pax_global_header00006660000000000000000000000064151577772320014532gustar00rootroot0000000000000052 comment=9b61b7885bcdd574d42ca80be3fb681208ce92da technicalpickles-homesick-9b61b78/000077500000000000000000000000001515777723200172035ustar00rootroot00000000000000technicalpickles-homesick-9b61b78/.document000066400000000000000000000000741515777723200210230ustar00rootroot00000000000000README.rdoc lib/**/*.rb bin/* features/**/*.feature LICENSE technicalpickles-homesick-9b61b78/.github/000077500000000000000000000000001515777723200205435ustar00rootroot00000000000000technicalpickles-homesick-9b61b78/.github/dependabot.yml000066400000000000000000000002741515777723200233760ustar00rootroot00000000000000version: 2 updates: - package-ecosystem: bundler directory: "/" schedule: interval: weekly groups: development-dependencies: dependency-type: development technicalpickles-homesick-9b61b78/.github/workflows/000077500000000000000000000000001515777723200226005ustar00rootroot00000000000000technicalpickles-homesick-9b61b78/.github/workflows/ci.yml000066400000000000000000000032541515777723200237220ustar00rootroot00000000000000name: CI on: push: branches: [master] pull_request: branches: [master] jobs: test: runs-on: ubuntu-latest strategy: matrix: ruby-version: ['3.2', '3.3', '3.4'] steps: - uses: actions/checkout@v5 - uses: ruby/setup-ruby@v1 with: ruby-version: ${{ matrix.ruby-version }} bundler-cache: true - name: Configure git identity for specs run: | git config --global user.email "test@example.com" git config --global user.name "Test User" - name: Run tests run: bundle exec rspec - name: Run RuboCop run: bundle exec rubocop - name: Audit dependencies run: | bundle exec bundler-audit update bundle exec bundler-audit check mutant: name: Mutation testing (changed code) runs-on: ubuntu-latest needs: test # Only meaningful on PRs - --since compares against the base branch if: github.event_name == 'pull_request' # Non-blocking: results are informational; the nightly job is authoritative. continue-on-error: true timeout-minutes: 30 steps: - uses: actions/checkout@v4 with: # Full history is required so --since can resolve the base ref fetch-depth: 0 - uses: ruby/setup-ruby@v1 with: ruby-version: '3.3' bundler-cache: true - name: Configure git identity for specs run: | git config --global user.email "test@example.com" git config --global user.name "Test User" - name: Run mutation tests on changed code run: bundle exec mutant run --since origin/${{ github.base_ref }} technicalpickles-homesick-9b61b78/.github/workflows/mutant-nightly.yml000066400000000000000000000013741515777723200263140ustar00rootroot00000000000000name: Mutation Testing (full) on: # Run weekly on Sunday at 02:00 UTC schedule: - cron: '0 2 * * 0' # Allow triggering manually from the Actions UI workflow_dispatch: jobs: mutant: name: Full mutation coverage runs-on: ubuntu-latest # No hard timeout - the suite currently takes ~5 h; allow up to 6 h. timeout-minutes: 360 steps: - uses: actions/checkout@v4 - uses: ruby/setup-ruby@v1 with: ruby-version: '3.3' bundler-cache: true - name: Configure git identity for specs run: | git config --global user.email "test@example.com" git config --global user.name "Test User" - name: Run full mutation test suite run: bundle exec mutant run technicalpickles-homesick-9b61b78/.github/workflows/release.yml000066400000000000000000000010141515777723200247370ustar00rootroot00000000000000name: Release on: push: tags: - 'v*.*.*' jobs: release: name: Build and publish gem runs-on: ubuntu-latest environment: rubygems permissions: contents: read # Required for RubyGems OIDC trusted publishing (no stored API key needed) id-token: write steps: - uses: actions/checkout@v4 - uses: ruby/setup-ruby@v1 with: ruby-version: '3.3' bundler-cache: true - name: Build and push gem uses: rubygems/release-gem@v1 technicalpickles-homesick-9b61b78/.gitignore000066400000000000000000000015011515777723200211700ustar00rootroot00000000000000# rcov generated coverage # rdoc generated rdoc # yard generated doc .yardoc # jeweler generated pkg .bundle # Have editor/IDE/OS specific files you need to ignore? Consider using a global gitignore: # # * Create a file at ~/.gitignore # * Include files you want ignored # * Run: git config --global core.excludesfile ~/.gitignore # # After doing this, these files will be ignored in all your git projects, # saving you from having to 'pollute' every project you touch with them # # Not sure what to needs to be ignored for particular editors/OSes? Here's some ideas to get you started. (Remember, remove the leading # of the line) # # For MacOS: # .DS_Store # # For TextMate #*.tmproj #tmtags # # For emacs: *~ \#* .\#* # # For vim: *.swp # # For IDEA: .idea/ *.iml vendor/ homesick*.gem # rbenv configuration .ruby-version technicalpickles-homesick-9b61b78/.mutant.yml000066400000000000000000000006731515777723200213220ustar00rootroot00000000000000# Mutant mutation testing configuration. # Run with: mutant run [-- SubjectPattern] # Examples: # mutant run # all Homesick subjects # mutant run -- Homesick::RC* # only the RC namespace # # This project is open source; the 'opensource' usage type is free. usage: opensource integration: name: rspec includes: - lib requires: - homesick matcher: subjects: - Homesick* jobs: 4 mutation: timeout: 30.0 technicalpickles-homesick-9b61b78/.rspec000066400000000000000000000000101515777723200203070ustar00rootroot00000000000000--color technicalpickles-homesick-9b61b78/.rubocop.yml000066400000000000000000000011351515777723200214550ustar00rootroot00000000000000plugins: - rubocop-rake AllCops: NewCops: enable TargetRubyVersion: 3.2 # Explicit maxes that reflect the current code. Reduce these incrementally # as methods are refactored further. Metrics/AbcSize: Max: 35 Metrics/BlockLength: Exclude: - 'spec/**/*' Metrics/ClassLength: Max: 255 Metrics/CyclomaticComplexity: Max: 10 Layout/LineLength: Max: 120 Exclude: - 'spec/**/*' Metrics/MethodLength: Max: 25 Metrics/ModuleLength: Max: 210 Metrics/ParameterLists: Max: 6 Metrics/PerceivedComplexity: Max: 11 Gemspec/DevelopmentDependencies: EnforcedStyle: gemspec technicalpickles-homesick-9b61b78/ChangeLog.markdown000066400000000000000000000111631515777723200226000ustar00rootroot00000000000000# 2.0.0 **Breaking changes** — see migration notes below before upgrading. * Require Ruby >= 3.2; dropped support for Ruby 2.x and 3.0/3.1 * Pin Thor dependency to `~> 1.0`; Thor 0.x is no longer supported * `.homesickrc` files must now use the `Homesick::RC::Context` DSL instead of being evaluated in the CLI scope. Replace any direct CLI method calls with the two DSL methods: `castle_path` (returns the castle's path) and `run` (executes a shell command). See README for examples. * `git clone` now respects the `--pretend` flag consistently with other actions * Switch CI from Travis CI to GitHub Actions; added mutation testing, dependency auditing, and automated gem release via RubyGems trusted publishing **Migration from 1.x** If your `.homesickrc` calls methods directly on the Homesick CLI object, update it to use the new DSL. For example: ```ruby # Before (1.x) FileUtils.ln_s "#{self.class.source_root}/file", "#{ENV['HOME']}/file" # After (2.0) run "ln -s #{castle_path}/file #{ENV['HOME']}/file" ``` # 1.1.6 * Makesure the FileUtils is imported correctly to avoid a potential error * Fixes an issue where comparing a diff would not use the content of the new file * Small documentation fixes # 1.1.5 * Fixed problem with version number being incorrect. # 1.1.4 * Make sure symlink conflicts are explicitly communicated to a user and symlinks are not silently overwritten * Use real paths of symlinks when linking a castle into home * Fix a problem when in a diff when asking a user to resolve a conflict * Some code refactoring and fixes # 1.1.3 * Allow a destination to be passed when cloning a castle * Make sure `homesick edit` opens default editor in the root of the given castle * Fixed bug when diffing edited files * Fixed crashing bug when attempting to diff directories * Ensure that messages are escaped correctly on `git commit all` # 1.1.2 * Added '--force' option to the rc command to bypass confirmation checks when running a .homesickrc file * Added a check to make sure that a minimum of Git 1.8.0 is installed. This stops Homesick failing silently if Git is not installed. * Code refactoring and fixes. # 1.1.0 * Added exec and exec_all commands to run commands inside one or all clones castles. * Code refactoring. # 1.0.0 * Removed support for Ruby 1.8.7 * Added a version command # 0.9.8 * Introduce new commands * `homesick cd` * `homesick open` # 0.9.4 * Use https protocol instead of git protocol * Introduce new commands * `homesick unlink` * `homesick rc` # 0.9.3 * Add recursive option to `homesick clone` # 0.9.2 * Set "dotfiles" as default castle name * Introduce new commands * `homesick show_path` * `homesick status` * `homesick diff` # 0.9.1 * Fixed small bugs: #35, #40 # 0.9.0 * Introduce .homesick_subdir #39 # 0.8.1 * Fixed `homesick list` bug on ruby 2.0 #37 # 0.8.0 * Introduce commit & push command * commit changes in castle and push to remote * Enable recursive submodule update * Git add when track # 0.7.0 * Fixed double-cloning #14 * New option for pull command: --all * pulls each castle, instead of just one # 0.6.1 * Add a license # 0.6.0 * Introduce .homesickrc * Castles can now have a .homesickrc inside them * On clone, this is eval'd inside the destination directory * Introduce track command * Allows easily moving an existing file into a castle, and symlinking it back # 0.5.0 * Fixed listing of castles cloned using `homesick clone /` (issue 3) * Added `homesick pull ` for updating castles (thanks Jorge Dias!) * Added a very basic `homesick generate ` # 0.4.1 * Improved error message when a castle's home dir doesn't exist # 0.4.0 * `homesick clone` can now take a path to a directory on the filesystem, which will be symlinked into place * `homesick clone` now tries to `git submodule init` and `git submodule update` if git submodules are defined for a cloned repo * Fixed missing dependency on thor and others * Use HOME environment variable for where to store files, instead of assuming ~ # 0.3.0 * Renamed 'link' to 'symlink' * Fixed conflict resolution when symlink destination exists and is a normal file # 0.2.0 * Better support for recognizing git urls (thanks jacobat!) * if it looks like a github user/repo, do that * otherwise hand off to git clone * Listing now displays in color, and show git remote * Support pretend, force, and quiet modes # 0.1.1 * Fixed trying to link against castles that don't exist * Fixed linking, which tries to exclude . and .. from the list of files to link (thanks Martinos!) # 0.1.0 * Initial release technicalpickles-homesick-9b61b78/Gemfile000066400000000000000000000001061515777723200204730ustar00rootroot00000000000000# frozen_string_literal: true source 'https://rubygems.org' gemspec technicalpickles-homesick-9b61b78/Gemfile.lock000066400000000000000000000054171515777723200214340ustar00rootroot00000000000000PATH remote: . specs: homesick (2.0.0) thor (~> 1.0) GEM remote: https://rubygems.org/ specs: addressable (2.8.9) public_suffix (>= 2.0.2, < 8.0) ast (2.4.3) bigdecimal (4.0.1) bundler-audit (0.9.3) bundler (>= 1.2.0) thor (~> 1.0) capture-output (1.0.0) date (3.5.1) diff-lcs (1.6.2) erb (6.0.2) io-console (0.8.2) irb (1.17.0) pp (>= 0.6.0) prism (>= 1.3.0) rdoc (>= 4.0.0) reline (>= 0.4.2) json (2.19.2) json-schema (6.2.0) addressable (~> 2.8) bigdecimal (>= 3.1, < 5) language_server-protocol (3.17.0.5) lint_roller (1.1.0) mcp (0.8.0) json-schema (>= 4.1) mutant (0.15.1) diff-lcs (>= 1.6, < 3) irb (~> 1.15) parser (~> 3.3.10) regexp_parser (~> 2.10) sorbet-runtime (~> 0.6.0) unparser (~> 0.8.2) mutant-rspec (0.15.1) mutant (= 0.15.1) rspec-core (>= 3.8.0, < 5.0.0) parallel (1.27.0) parser (3.3.10.2) ast (~> 2.4.1) racc pp (0.6.3) prettyprint prettyprint (0.2.0) prism (1.9.0) psych (5.3.1) date stringio public_suffix (7.0.5) racc (1.8.1) rainbow (3.1.1) rake (13.3.1) rdoc (7.2.0) erb psych (>= 4.0.0) tsort regexp_parser (2.11.3) reline (0.6.3) io-console (~> 0.5) rspec (3.13.2) rspec-core (~> 3.13.0) rspec-expectations (~> 3.13.0) rspec-mocks (~> 3.13.0) rspec-core (3.13.6) rspec-support (~> 3.13.0) rspec-expectations (3.13.5) diff-lcs (>= 1.2.0, < 2.0) rspec-support (~> 3.13.0) rspec-mocks (3.13.8) diff-lcs (>= 1.2.0, < 2.0) rspec-support (~> 3.13.0) rspec-support (3.13.7) rubocop (1.85.1) json (~> 2.3) language_server-protocol (~> 3.17.0.2) lint_roller (~> 1.1.0) mcp (~> 0.6) parallel (~> 1.10) parser (>= 3.3.0.2) rainbow (>= 2.2.2, < 4.0) regexp_parser (>= 2.9.3, < 3.0) rubocop-ast (>= 1.49.0, < 2.0) ruby-progressbar (~> 1.7) unicode-display_width (>= 2.4.0, < 4.0) rubocop-ast (1.49.1) parser (>= 3.3.7.2) prism (~> 1.7) rubocop-rake (0.7.1) lint_roller (~> 1.1) rubocop (>= 1.72.1) ruby-progressbar (1.13.0) sorbet-runtime (0.6.13055) stringio (3.2.0) test_construct (2.0.2) thor (1.5.0) tsort (0.2.0) unicode-display_width (3.2.0) unicode-emoji (~> 4.1) unicode-emoji (4.2.0) unparser (0.8.2) diff-lcs (>= 1.6, < 3) parser (>= 3.3.0) prism (>= 1.5.1) PLATFORMS ruby x86_64-linux DEPENDENCIES bundler-audit (~> 0.9) capture-output (~> 1.0) homesick! mutant-rspec rake rspec (~> 3.0) rubocop rubocop-rake test_construct BUNDLED WITH 2.5.22 technicalpickles-homesick-9b61b78/Guardfile000066400000000000000000000003771515777723200210370ustar00rootroot00000000000000# frozen_string_literal: true guard :rspec, cmd: 'bundle exec rspec' do watch(%r{^spec/.+_spec\.rb$}) watch(%r{^lib/(.+)\.rb$}) { |m| "spec/#{m[1]}_spec.rb" } watch(%r{^lib/homesick/.*\.rb}) { 'spec' } watch('spec/spec_helper.rb') { 'spec' } end technicalpickles-homesick-9b61b78/LICENSE000066400000000000000000000020421515777723200202060ustar00rootroot00000000000000Copyright (c) 2009 Joshua Nichols Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. technicalpickles-homesick-9b61b78/README.markdown000066400000000000000000000124561515777723200217140ustar00rootroot00000000000000# homesick [![Gem Version](https://badge.fury.io/rb/homesick.svg)](http://badge.fury.io/rb/homesick) [![CI](https://github.com/technicalpickles/homesick/actions/workflows/ci.yml/badge.svg)](https://github.com/technicalpickles/homesick/actions/workflows/ci.yml) Your home directory is your castle. Don't leave your dotfiles behind. Homesick is sorta like [rip](http://github.com/defunkt/rip), but for dotfiles. It uses git to clone a repository containing dotfiles, and saves them in `~/.homesick`. It then allows you to symlink all the dotfiles into place with a single command. We call a repository that is compatible with homesick to be a 'castle'. To act as a castle, a repository must be organized like so: * Contains a 'home' directory * 'home' contains any number of files and directories that begin with '.' To get started, install homesick first: gem install homesick Next, you use the homesick command to clone a castle: homesick clone git://github.com/technicalpickles/pickled-vim.git Alternatively, if it's on github, there's a slightly shorter way: homesick clone technicalpickles/pickled-vim With the castle cloned, you can now link its contents into your home dir: homesick link pickled-vim You can remove symlinks anytime when you don't need them anymore homesick unlink pickled-vim If you need to add further configuration steps you can add these in a file called '.homesickrc' in the root of a castle. Once you've cloned a castle with a .homesickrc run the configuration with: homesick rc CASTLE The contents of the .homesickrc file must be valid Ruby code as the file will be executed with Ruby's eval construct. The .homesickrc is also passed the current homesick object during its execution and this is available within the .homesickrc file as the 'self' variable. As the rc operation can be destructive the command normally asks for confirmation before proceeding. You can bypass this by passing the '--force' option, for example `homesick rc --force CASTLE`. If you're not sure what castles you have around, you can easily list them: homesick list To pull your castle (or all castles): homesick pull --all|CASTLE To commit your castle's changes: homesick commit CASTLE To push your castle: homesick push CASTLE To open a terminal in the root of a castle: homesick cd CASTLE To open your default editor in the root of a castle (the $EDITOR environment variable must be set): homesick open CASTLE To execute a shell command inside the root directory of a given castle: homesick exec CASTLE COMMAND To execute a shell command inside the root directory of every cloned castle: homesick exec_all COMMAND Not sure what else homesick has up its sleeve? There's always the built in help: homesick help If you ever want to see what version of homesick you have type: homesick version|-v|--version ## .homesick_subdir `homesick link` basically makes symlink to only first depth in `castle/home`. If you want to link nested files/directories, please use .homesick_subdir. For example, when you have castle like this: castle/home `-- .config `-- fooapp |-- config1 |-- config2 `-- config3 and have home like this: $ tree -a ~ |-- .config | `-- barapp | |-- config1 | |-- config2 | `-- config3 `-- .emacs.d |-- elisp `-- inits You may want to symlink only to `castle/home/.config/fooapp` instead of `castle/home/.config` because you already have `~/.config/barapp`. In this case, you can use .homesick_subdir. Please write "directories you want to look up sub directories (instead of just first depth)" in this file. castle/.homesick_subdir .config and run `homesick link CASTLE`. The result is: ~ |-- .config | |-- barapp | | |-- config1 | | |-- config2 | | `-- config3 | `-- fooapp -> castle/home/.config/fooapp `-- .emacs.d |-- elisp `-- inits Or `homesick track NESTED_FILE CASTLE` adds a line automatically. For example: homesick track .emacs.d/elisp castle castle/.homesick_subdir .config .emacs.d home directory ~ |-- .config | |-- barapp | | |-- config1 | | |-- config2 | | `-- config3 | `-- fooapp -> castle/home/.config/fooapp `-- .emacs.d |-- elisp -> castle/home/.emacs.d/elisp `-- inits and castle castle/home |-- .config | `-- fooapp | |-- config1 | |-- config2 | `-- config3 `-- .emacs.d `-- elisp ## Supported Ruby Versions Homesick is tested on the following Ruby versions: * 3.2 * 3.3 * 3.4 ## Note on Patches/Pull Requests * Fork the project. * Make your feature addition or bug fix. * Add tests for it. This is important so it doesn't get broken unintentionally in a future version. * Commit, but do not touch the rakefile, version, or history. (If you want your own version, that is fine — just bump the version in a separate commit that can be ignored when merging.) * Open a pull request. Bonus points for topic branches. ## Need homesick without the ruby dependency? Check out [homeshick](https://github.com/andsens/homeshick). ## Copyright Copyright (c) 2010 Joshua Nichols. See LICENSE for details. technicalpickles-homesick-9b61b78/Rakefile000066400000000000000000000004171515777723200206520ustar00rootroot00000000000000# frozen_string_literal: true require 'rspec/core/rake_task' require 'rubocop/rake_task' RSpec::Core::RakeTask.new(:spec) RuboCop::RakeTask.new task default: %i[spec rubocop] desc 'Run mutation tests (requires mutant-rspec gem)' task :mutant do sh 'mutant run' end technicalpickles-homesick-9b61b78/bin/000077500000000000000000000000001515777723200177535ustar00rootroot00000000000000technicalpickles-homesick-9b61b78/bin/homesick000077500000000000000000000003161515777723200215030ustar00rootroot00000000000000#!/usr/bin/env ruby # frozen_string_literal: true require 'pathname' lib = Pathname.new(__FILE__).dirname.join('..', 'lib').expand_path $LOAD_PATH.unshift lib.to_s require 'homesick' Homesick::CLI.start technicalpickles-homesick-9b61b78/homesick.gemspec000066400000000000000000000025531515777723200223570ustar00rootroot00000000000000# frozen_string_literal: true require_relative 'lib/homesick/version' Gem::Specification.new do |s| s.name = 'homesick' s.version = Homesick::Version::STRING s.authors = ['Joshua Nichols', 'Yusuke Murata'] s.email = ['josh@technicalpickles.com', 'info@muratayusuke.com'] s.homepage = 'http://github.com/technicalpickles/homesick' s.summary = "Your home directory is your castle. Don't leave your dotfiles behind." s.description = 'Homesick is sorta like rip, but for dotfiles. It uses git to clone a ' \ 'repository containing dotfiles, and saves them in ~/.homesick. It then ' \ 'allows you to symlink all the dotfiles into place with a single command.' s.license = 'MIT' s.metadata = { 'rubygems_mfa_required' => 'true' } s.required_ruby_version = '>= 3.2' s.files = `git ls-files`.split("\n") s.executables = ['homesick'] s.require_paths = ['lib'] s.add_dependency 'thor', '~> 1.0' s.add_development_dependency 'bundler-audit', '~> 0.9' s.add_development_dependency 'capture-output', '~> 1.0' s.add_development_dependency 'mutant-rspec' s.add_development_dependency 'rake' s.add_development_dependency 'rspec', '~> 3.0' s.add_development_dependency 'rubocop' s.add_development_dependency 'rubocop-rake' s.add_development_dependency 'test_construct' end technicalpickles-homesick-9b61b78/lib/000077500000000000000000000000001515777723200177515ustar00rootroot00000000000000technicalpickles-homesick-9b61b78/lib/homesick.rb000066400000000000000000000013711515777723200221020ustar00rootroot00000000000000# frozen_string_literal: true require 'homesick/actions/file_actions' require 'homesick/actions/git_actions' require 'homesick/version' require 'homesick/rc' require 'homesick/utils' require 'homesick/cli' require 'fileutils' # Homesick's top-level module module Homesick GITHUB_NAME_REPO_PATTERN = %r{\A([A-Za-z0-9_-]+/[A-Za-z0-9_-]+)\Z} SUBDIR_FILENAME = '.homesick_subdir' DEFAULT_CASTLE_NAME = 'dotfiles' QUIETABLE = [:say_status].freeze PRETENDABLE = [:system].freeze QUIETABLE.each do |method_name| define_method(method_name) do |*args| super(*args) unless options[:quiet] end end PRETENDABLE.each do |method_name| define_method(method_name) do |*args| super(*args) unless options[:pretend] end end end technicalpickles-homesick-9b61b78/lib/homesick/000077500000000000000000000000001515777723200215535ustar00rootroot00000000000000technicalpickles-homesick-9b61b78/lib/homesick/actions/000077500000000000000000000000001515777723200232135ustar00rootroot00000000000000technicalpickles-homesick-9b61b78/lib/homesick/actions/file_actions.rb000066400000000000000000000051471515777723200262060ustar00rootroot00000000000000# frozen_string_literal: true module Homesick module Actions # File-related helper methods for Homesick module FileActions protected def mv(source, destination) source = Pathname.new(source) destination = Pathname.new(destination + source.basename) collision = destination.exist? && (options[:force] || shell.file_collision(destination) { source }) say_status :conflict, "#{destination} exists", :red if collision FileUtils.mv source, destination unless options[:pretend] end def rm_rf(dir) say_status "rm -rf #{dir}", '', :green FileUtils.rm_r dir, force: true end def rm_link(target) target = Pathname.new(target) if target.symlink? say_status :unlink, target.expand_path.to_s, :green FileUtils.rm_rf target else say_status :conflict, "#{target} is not a symlink", :red end end def rm(file) say_status "rm #{file}", '', :green FileUtils.rm file, force: true end def rm_r(dir) say_status "rm -r #{dir}", '', :green FileUtils.rm_r dir end def ln_s(source, destination) source = Pathname.new(source).realpath destination = Pathname.new(destination) FileUtils.mkdir_p destination.dirname action = :success action = :identical if destination.symlink? && destination.readlink == source action = :symlink_conflict if destination.symlink? action = :conflict if destination.exist? handle_symlink_action action, source, destination end def handle_symlink_action(action, source, destination) if action == :identical say_status :identical, destination.expand_path, :blue return end message = generate_symlink_message action, source, destination if %i[symlink_conflict conflict].include?(action) say_status :conflict, message, :red return unless collision_accepted?(destination, source) FileUtils.rm_r destination, force: true unless options[:pretend] else say_status :symlink, message, :green end FileUtils.ln_s source, destination, force: true unless options[:pretend] end def generate_symlink_message(action, source, destination) message = "#{source.expand_path} to #{destination.expand_path}" message = "#{destination} exists and points to #{destination.readlink}" if action == :symlink_conflict message = "#{destination} exists" if action == :conflict message end end end end technicalpickles-homesick-9b61b78/lib/homesick/actions/git_actions.rb000066400000000000000000000067241515777723200260540ustar00rootroot00000000000000# frozen_string_literal: true module Homesick module Actions # Git-related helper methods for Homesick module GitActions # Information on the minimum git version required for Homesick MIN_VERSION = { major: 1, minor: 8, patch: 0 }.freeze STRING = MIN_VERSION.values.join('.') def git_version_correct? info = `git --version`.scan(/(\d+)\.(\d+)\.(\d+)/).flatten.map(&:to_i) return false unless info.count == 3 current_version = %i[major minor patch].zip(info).to_h major_equals = current_version.eql?(MIN_VERSION) major_greater = current_version[:major] > MIN_VERSION[:major] minor_greater = current_version[:major] == MIN_VERSION[:major] && current_version[:minor] > MIN_VERSION[:minor] patch_greater = current_version[:major] == MIN_VERSION[:major] && current_version[:minor] == MIN_VERSION[:minor] && current_version[:patch] >= MIN_VERSION[:patch] major_equals || major_greater || minor_greater || patch_greater end def git_clone(repo, config = {}) destination = config[:destination] || File.basename(repo, '.git') destination = Pathname.new(destination) unless destination.is_a?(Pathname) FileUtils.mkdir_p destination.dirname if destination.directory? say_status :exist, destination.expand_path, :blue else say_status 'git clone', "#{repo} to #{destination.expand_path}", :green unless options[:pretend] system "git clone -q --config push.default=upstream --recursive #{repo} #{destination}" end end end def git_init(path = '.') path = Pathname.new(path) inside path do if path.join('.git').exist? say_status 'git init', 'already initialized', :blue else say_status 'git init', '' system 'git init >/dev/null' end end end def git_remote_add(name, url) existing_remote = `git config remote.#{name}.url`.chomp existing_remote = nil if existing_remote == '' if existing_remote say_status 'git remote', "#{name} already exists", :blue else say_status 'git remote', "add #{name} #{url}" system "git remote add #{name} #{url}" end end def git_submodule_init say_status 'git submodule', 'init', :green system 'git submodule --quiet init' end def git_submodule_update say_status 'git submodule', 'update', :green system 'git submodule --quiet update --init --recursive >/dev/null 2>&1' end def git_pull say_status 'git pull', '', :green system 'git pull --quiet' end def git_push say_status 'git push', '', :green system 'git push' end def git_commit_all(config = {}) say_status 'git commit all', '', :green if config[:message] system %(git commit -a -m "#{config[:message]}") else system 'git commit -v -a' end end def git_add(file) say_status 'git add file', '', :green system "git add '#{file}'" end def git_status say_status 'git status', '', :green system 'git status' end def git_diff say_status 'git diff', '', :green system 'git diff' end end end end technicalpickles-homesick-9b61b78/lib/homesick/cli.rb000066400000000000000000000226451515777723200226600ustar00rootroot00000000000000# frozen_string_literal: true require 'fileutils' require 'thor' module Homesick # Homesick's command line interface class CLI < Thor include Thor::Actions include Homesick::Actions::FileActions include Homesick::Actions::GitActions include Homesick::Version include Homesick::Utils add_runtime_options! def self.exit_on_failure? true end map '-v' => :version map '--version' => :version # Retain a mapped version of the symlink command for compatibility. map symlink: :link def initialize(args = [], options = {}, config = {}) super # Check if git is installed unless git_version_correct? say_status :error, "Git version >= #{Homesick::Actions::GitActions::STRING} must be installed to use Homesick", :red exit(1) end configure_symlinks_diff end desc 'clone URI CASTLE_NAME', 'Clone +uri+ as a castle with name CASTLE_NAME for homesick' def clone(uri, destination = nil) destination = Pathname.new(destination) unless destination.nil? inside repos_dir do destination = clone_from_uri(uri, destination) setup_castle(destination) end end desc 'rc CASTLE', 'Run the .homesickrc for the specified castle' method_option :force, type: :boolean, default: false, desc: 'Evaluate .homesickrc without prompting.' def rc(name = DEFAULT_CASTLE_NAME) inside repos_dir do destination = Pathname.new(name) homesickrc = destination.join('.homesickrc').expand_path return unless homesickrc.exist? proceed = options[:force] || shell.yes?("#{name} has a .homesickrc. Proceed with evaling it? (This could be destructive)") unless proceed return say_status 'eval skip', "not evaling #{homesickrc}, #{destination} may need manual configuration", :blue end say_status 'eval', homesickrc inside destination do ctx = Homesick::RC::Context.new(destination.expand_path) ctx.instance_eval(homesickrc.read, homesickrc.expand_path.to_s) end end end desc 'pull CASTLE', 'Update the specified castle' method_option :all, type: :boolean, default: false, required: false, desc: 'Update all cloned castles' def pull(name = DEFAULT_CASTLE_NAME) if options[:all] inside_each_castle do |castle| say "#{castle.to_s.gsub("#{repos_dir}/", '')}:" update_castle castle end else update_castle name end end desc 'commit CASTLE MESSAGE', "Commit the specified castle's changes" def commit(name = DEFAULT_CASTLE_NAME, message = nil) commit_castle name, message end desc 'push CASTLE', 'Push the specified castle' def push(name = DEFAULT_CASTLE_NAME) push_castle name end desc 'unlink CASTLE', 'Unsymlinks all dotfiles from the specified castle' def unlink(name = DEFAULT_CASTLE_NAME) check_castle_existance(name, 'symlink') inside castle_dir(name) do subdirs = subdirs(name) # unlink files unsymlink_each(name, castle_dir(name), subdirs) # unlink files in subdirs subdirs.each do |subdir| unsymlink_each(name, subdir, subdirs) end end end desc 'link CASTLE', 'Symlinks all dotfiles from the specified castle' method_option :force, type: :boolean, default: false, desc: 'Overwrite existing conflicting symlinks without prompting.' def link(name = DEFAULT_CASTLE_NAME) check_castle_existance(name, 'symlink') castle_path = castle_dir(name) inside castle_path do subdirs = subdirs(name) # link files symlink_each(name, castle_path, subdirs) # link files in subdirs subdirs.each do |subdir| symlink_each(name, subdir, subdirs) end end end desc 'track FILE CASTLE', 'add a file to a castle' def track(file, castle = DEFAULT_CASTLE_NAME) castle = Pathname.new(castle) file = Pathname.new(file.chomp('/')) check_castle_existance(castle, 'track') absolute_path = file.expand_path relative_dir = absolute_path.relative_path_from(home_dir).dirname castle_path = Pathname.new(castle_dir(castle)).join(relative_dir) FileUtils.mkdir_p castle_path target = Pathname.new(castle_path.join(file.basename)) if target.exist? handle_existing_track_target(castle, absolute_path, castle_path, relative_dir, file, target) else mv absolute_path, castle_path end inside home_dir do absolute_path = castle_path + file.basename home_path = home_dir + relative_dir + file.basename ln_s absolute_path, home_path end inside castle_path do git_add absolute_path end subdir_add(castle, relative_dir) unless relative_dir.eql?(Pathname.new('.')) end desc 'list', 'List cloned castles' def list inside_each_castle do |castle| say_status castle.relative_path_from(repos_dir).to_s, `git config remote.origin.url`.chomp, :cyan end end desc 'status CASTLE', 'Shows the git status of a castle' def status(castle = DEFAULT_CASTLE_NAME) check_castle_existance(castle, 'status') inside repos_dir.join(castle) do git_status end end desc 'diff CASTLE', 'Shows the git diff of uncommitted changes in a castle' def diff(castle = DEFAULT_CASTLE_NAME) check_castle_existance(castle, 'diff') inside repos_dir.join(castle) do git_diff end end desc 'show_path CASTLE', 'Prints the path of a castle' def show_path(castle = DEFAULT_CASTLE_NAME) check_castle_existance(castle, 'show_path') say repos_dir.join(castle) end desc 'generate PATH', 'generate a homesick-ready git repo at PATH' def generate(castle) castle = Pathname.new(castle).expand_path github_user = `git config github.user`.chomp github_user = nil if github_user == '' github_repo = castle.basename empty_directory castle inside castle do git_init if github_user url = "git@github.com:#{github_user}/#{github_repo}.git" git_remote_add 'origin', url end empty_directory 'home' end end desc 'destroy CASTLE', 'Delete all symlinks and remove the cloned repository' def destroy(name) check_castle_existance name, 'destroy' return unless shell.yes?('This will destroy your castle irreversible! Are you sure?') unlink(name) rm_rf repos_dir.join(name) end desc 'cd CASTLE', 'Open a new shell in the root of the given castle' def cd(castle = DEFAULT_CASTLE_NAME) check_castle_existance castle, 'cd' castle_dir = repos_dir.join(castle) say_status "cd #{castle_dir.realpath}", "Opening a new shell in castle '#{castle}'. To return to the original one exit from the new shell.", :green inside castle_dir do system(ENV.fetch('SHELL', nil)) end end desc 'open CASTLE', 'Open your default editor in the root of the given castle' def open(castle = DEFAULT_CASTLE_NAME) unless ENV.fetch('EDITOR', nil) say_status :error, 'The $EDITOR environment variable must be set to use this command', :red exit(1) end check_castle_existance castle, 'open' castle_dir = repos_dir.join(castle) say_status "#{castle_dir.realpath}: #{ENV.fetch('EDITOR', nil)} .", "Opening the root directory of castle '#{castle}' in editor '#{ENV.fetch('EDITOR', nil)}'.", :green inside castle_dir do system("#{ENV.fetch('EDITOR', nil)} .") end end desc 'exec CASTLE COMMAND', 'Execute a single shell command inside the root of a castle' def exec(castle, *args) check_castle_existance castle, 'exec' unless args.any? say_status :error, 'You must pass a shell command to execute', :red exit(1) end full_command = args.join(' ') action = options[:pretend] ? 'Would execute' : 'Executing command' say_status "exec '#{full_command}'", "#{action} '#{full_command}' in castle '#{castle}'", :green inside repos_dir.join(castle) do system(full_command) end end desc 'exec_all COMMAND', 'Execute a single shell command inside the root of every cloned castle' def exec_all(*args) unless args.any? say_status :error, 'You must pass a shell command to execute', :red exit(1) end full_command = args.join(' ') inside_each_castle do |castle| action = options[:pretend] ? 'Would execute' : 'Executing command' say_status "exec '#{full_command}'", "#{action} '#{full_command}' in castle '#{castle}'", :green system(full_command) end end desc 'version', 'Display the current version of homesick' def version say Homesick::Version::STRING end end end technicalpickles-homesick-9b61b78/lib/homesick/rc.rb000066400000000000000000000015171515777723200225100ustar00rootroot00000000000000# frozen_string_literal: true require 'pathname' module Homesick module RC # Evaluation context for .homesickrc scripts. # # Runs castle setup scripts in a clean object rather than in the # Homesick::CLI binding, so scripts cannot access CLI internals. # Standard Ruby library methods (File, Dir, system, etc.) remain # available to scripts. class Context # @param castle_path [Pathname, String] absolute path to the castle root def initialize(castle_path) @castle_path = Pathname.new(castle_path) end # The absolute path of the castle being configured. attr_reader :castle_path # Execute a shell command. # @param command [String] # @return [Boolean] true if the command succeeded def run(command) system(command) end end end end technicalpickles-homesick-9b61b78/lib/homesick/utils.rb000066400000000000000000000171221515777723200232430ustar00rootroot00000000000000# frozen_string_literal: true require 'pathname' module Homesick # Various utility methods that are used by Homesick module Utils protected def home_dir @home_dir ||= Pathname.new(Dir.home).realpath end def repos_dir @repos_dir ||= home_dir.join('.homesick', 'repos').expand_path end def castle_dir(name) repos_dir.join(name, 'home') end def check_castle_existance(name, action) return if castle_dir(name).exist? say_status :error, "Could not #{action} #{name}, expected #{castle_dir(name)} to exist and contain dotfiles", :red exit(1) end def all_castles dirs = Pathname.glob("#{repos_dir}/**/.git", File::FNM_DOTMATCH) # reject paths that lie inside another castle, like git submodules dirs.reject do |dir| dirs.any? do |other| dir != other && dir.fnmatch(other.parent.join('*').to_s) end end end def inside_each_castle all_castles.each do |git_dir| castle = git_dir.dirname Dir.chdir castle do # so we can call git config from the right contxt yield castle end end end def update_castle(castle) check_castle_existance(castle, 'pull') inside repos_dir.join(castle) do git_pull git_submodule_init git_submodule_update end end def commit_castle(castle, message) check_castle_existance(castle, 'commit') inside repos_dir.join(castle) do git_commit_all message: message end end def push_castle(castle) check_castle_existance(castle, 'push') inside repos_dir.join(castle) do git_push end end def subdir_file(castle) repos_dir.join(castle, SUBDIR_FILENAME) end def subdirs(castle) subdir_filepath = subdir_file(castle) subdirs = [] if subdir_filepath.exist? subdir_filepath.readlines.each do |subdir| subdirs.push(subdir.chomp) end end subdirs end def subdir_add(castle, path) subdir_filepath = subdir_file(castle) File.open(subdir_filepath, 'a+') do |subdir| subdir.puts path unless subdir.readlines.reduce(false) do |memo, line| line.eql?("#{path}\n") || memo end end inside castle_dir(castle) do git_add subdir_filepath end end def subdir_remove(castle, path) subdir_filepath = subdir_file(castle) if subdir_filepath.exist? lines = File.readlines(subdir_filepath).delete_if do |line| line == "#{path}\n" end File.open(subdir_filepath, 'w') { |manfile| manfile.puts lines } end inside castle_dir(castle) do git_add subdir_filepath end end def move_dir_contents(target, dir_path) child_files = dir_path.children child_files.each do |child| target_path = target.join(child.basename) if target_path.exist? if more_recent?(child, target_path) && target.file? target_path.delete mv child, target end next end mv child, target end end def more_recent?(first, second) first_p = Pathname.new(first) second_p = Pathname.new(second) first_p.mtime > second_p.mtime && !first_p.symlink? end def collision_accepted?(destination, source) unless destination.instance_of?(Pathname) && source.instance_of?(Pathname) raise 'Arguments must be instances of Pathname, ' \ "#{destination.class.name} and #{source.class.name} given" end options[:force] || shell.file_collision(destination) { source } end def clone_from_uri(uri, destination) if File.exist?(uri) uri_path = Pathname.new(uri).expand_path raise "Castle already cloned to #{uri_path}" if uri_path.to_s.start_with?(repos_dir.to_s) destination = uri_path.basename if destination.nil? ln_s uri_path, destination elsif uri =~ GITHUB_NAME_REPO_PATTERN destination = Pathname.new(uri).basename if destination.nil? git_clone "https://github.com/#{Regexp.last_match[1]}.git", destination: destination elsif uri =~ /%r([^%r]*?)(\.git)?\Z/ || uri =~ /[^:]+:([^:]+)(\.git)?\Z/ destination = Pathname.new(Regexp.last_match[1].gsub(/\.git$/, '')).basename if destination.nil? git_clone uri, destination: destination else raise "Unknown URI format: #{uri}" end destination end def handle_existing_track_target(castle, absolute_path, castle_path, relative_dir, file, target) if absolute_path.directory? move_dir_contents(target, absolute_path) absolute_path.rmtree subdir_remove(castle, relative_dir + file.basename) elsif more_recent? absolute_path, target target.delete mv absolute_path, castle_path else say_status(:track, "#{target} already exists, and is more recent than #{file}. " \ "Run 'homesick SYMLINK CASTLE' to create symlinks.", :blue) end end def unsymlink_each(castle, basedir, subdirs) each_file(castle, basedir, subdirs) do |_absolute_path, home_path| rm_link home_path end end def symlink_each(castle, basedir, subdirs) each_file(castle, basedir, subdirs) do |absolute_path, home_path| ln_s absolute_path, home_path end end def setup_castle(path) if path.join('.gitmodules').exist? inside path do git_submodule_init git_submodule_update end end rc(path) end def each_file(castle, basedir, subdirs) absolute_basedir = Pathname.new(basedir).expand_path castle_home = castle_dir(castle) inside basedir do |destination_root| FileUtils.cd(destination_root) unless destination_root == FileUtils.pwd files = Pathname.glob('*', File::FNM_DOTMATCH) .reject { |a| ['.', '..'].include?(a.to_s) } .reject { |path| matches_ignored_dir? castle_home, path.expand_path, subdirs } files.each do |path| absolute_path = path.expand_path relative_dir = absolute_basedir.relative_path_from(castle_home) home_path = home_dir.join(relative_dir).join(path) yield(absolute_path, home_path) end end end def matches_ignored_dir?(castle_home, absolute_path, subdirs) # make ignore dirs ignore_dirs = [] subdirs.each do |subdir| # ignore all parent of each line in subdir file Pathname.new(subdir).ascend do |p| ignore_dirs.push(p) end end # ignore dirs written in subdir file ignore_dirs.uniq.each do |ignore_dir| return true if absolute_path == castle_home.join(ignore_dir) end false end def configure_symlinks_diff # Hack in support for diffing symlinks # Also adds support for checking if destination or content is a directory shell_metaclass = class << shell; self; end shell_metaclass.send(:define_method, :show_diff) do |destination, source| destination = Pathname.new(destination) source = Pathname.new(source) return 'Unable to create diff: destination or content is a directory' \ if destination.directory? || source.directory? return super(destination, File.binread(source)) unless destination.symlink? say "- #{destination.readlink}", :red, true say "+ #{source.expand_path}", :green, true end end end end technicalpickles-homesick-9b61b78/lib/homesick/version.rb000066400000000000000000000004361515777723200235700ustar00rootroot00000000000000# frozen_string_literal: true module Homesick # A representation of Homesick's version number in constants, including a # String of the entire version number module Version MAJOR = 2 MINOR = 0 PATCH = 0 STRING = [MAJOR, MINOR, PATCH].compact.join('.') end end technicalpickles-homesick-9b61b78/spec/000077500000000000000000000000001515777723200201355ustar00rootroot00000000000000technicalpickles-homesick-9b61b78/spec/homesick_cli_spec.rb000066400000000000000000000767721515777723200241500ustar00rootroot00000000000000# frozen_string_literal: true require 'spec_helper' require 'capture-output' require 'pathname' describe Homesick::CLI do let(:home) { create_construct } after { home.destroy! } let(:castles) { home.directory('.homesick/repos') } let(:homesick) { Homesick::CLI.new } before { allow(homesick).to receive(:repos_dir).and_return(castles) } describe 'smoke tests' do context 'when running bin/homesick' do before do bin_path = Pathname.new(__FILE__).parent.parent @output = `#{bin_path.expand_path}/bin/homesick` end it 'should output some text when bin/homesick is called' do expect(@output.length).to be > 0 end end context 'when a git version that doesn\'t meet the minimum required is installed' do before do expect_any_instance_of(Homesick::Actions::GitActions).to receive(:`).and_return('git version 1.7.6') end it 'should raise an exception' do output = Capture.stdout { expect { Homesick::CLI.new }.to raise_error SystemExit } expect(output.chomp).to include(Homesick::Actions::GitActions::STRING) end end context 'when a git version that is the same as the minimum required is installed' do before do expect_any_instance_of(Homesick::Actions::GitActions).to receive(:`).at_least(:once).and_return("git version #{Homesick::Actions::GitActions::STRING}") end it 'should not raise an exception' do output = Capture.stdout { expect { Homesick::CLI.new }.not_to raise_error } expect(output.chomp).not_to include(Homesick::Actions::GitActions::STRING) end end context 'when a git version that is greater than the minimum required is installed' do before do expect_any_instance_of(Homesick::Actions::GitActions).to receive(:`).at_least(:once).and_return('git version 3.9.8') end it 'should not raise an exception' do output = Capture.stdout { expect { Homesick::CLI.new }.not_to raise_error } expect(output.chomp).not_to include(Homesick::Actions::GitActions::STRING) end end end describe 'clone' do context 'has a .homesickrc' do it 'runs the .homesickrc' do somewhere = create_construct local_repo = somewhere.directory('some_repo') local_repo.file('.homesickrc') do |file| file << "File.open(Dir.pwd + '/testing', 'w') do |f| f.print 'testing' end" end expect_any_instance_of(Thor::Shell::Basic).to receive(:yes?).with(be_a(String)).and_return(true) expect(homesick).to receive(:say_status).with('eval', kind_of(Pathname)) homesick.clone local_repo expect(castles.join('some_repo').join('testing')).to exist end end context 'of a file' do it 'symlinks existing directories' do somewhere = create_construct local_repo = somewhere.directory('wtf') homesick.clone local_repo expect(castles.join('wtf').readlink).to eq(local_repo) end context 'when it exists in a repo directory' do before do existing_castle = given_castle('existing_castle') @existing_dir = existing_castle.parent end it 'raises an error' do expect(homesick).not_to receive(:git_clone) expect { homesick.clone @existing_dir.to_s }.to raise_error(/already cloned/i) end end end it 'clones git repo like file:///path/to.git' do bare_repo = File.join(create_construct.to_s, 'dotfiles.git') system "git init --bare #{bare_repo} >/dev/null 2>&1" # Capture stderr to suppress message about cloning an empty repo. Capture.stderr do homesick.clone "file://#{bare_repo}" end expect(File.directory?(File.join(home.to_s, '.homesick/repos/dotfiles'))) .to be_truthy end it 'clones git repo like git://host/path/to.git' do expect(homesick).to receive(:git_clone) .with('git://github.com/technicalpickles/pickled-vim.git', destination: Pathname.new('pickled-vim')) homesick.clone 'git://github.com/technicalpickles/pickled-vim.git' end it 'clones git repo like git@host:path/to.git' do expect(homesick).to receive(:git_clone) .with('git@github.com:technicalpickles/pickled-vim.git', destination: Pathname.new('pickled-vim')) homesick.clone 'git@github.com:technicalpickles/pickled-vim.git' end it 'clones git repo like http://host/path/to.git' do expect(homesick).to receive(:git_clone) .with('http://github.com/technicalpickles/pickled-vim.git', destination: Pathname.new('pickled-vim')) homesick.clone 'http://github.com/technicalpickles/pickled-vim.git' end it 'clones git repo like http://host/path/to' do expect(homesick).to receive(:git_clone) .with('http://github.com/technicalpickles/pickled-vim', destination: Pathname.new('pickled-vim')) homesick.clone 'http://github.com/technicalpickles/pickled-vim' end it 'clones git repo like host-alias:repos.git' do expect(homesick).to receive(:git_clone).with('gitolite:pickled-vim.git', destination: Pathname.new('pickled-vim')) homesick.clone 'gitolite:pickled-vim.git' end it 'throws an exception when trying to clone a malformed uri like malformed' do expect(homesick).not_to receive(:git_clone) expect { homesick.clone 'malformed' }.to raise_error(RuntimeError) end it 'clones a github repo' do expect(homesick).to receive(:git_clone) .with('https://github.com/wfarr/dotfiles.git', destination: Pathname.new('dotfiles')) homesick.clone 'wfarr/dotfiles' end it 'accepts a destination', :focus do expect(homesick).to receive(:git_clone) .with('https://github.com/wfarr/dotfiles.git', destination: Pathname.new('other-name')) homesick.clone 'wfarr/dotfiles', 'other-name' end end describe 'rc' do let(:castle) { given_castle('glencairn') } context 'when no .homesickrc is present' do it 'does nothing' do expect_any_instance_of(Thor::Shell::Basic).not_to receive(:yes?) expect(homesick).not_to receive(:say_status).with('eval', anything) homesick.rc castle end end context 'when told to do so' do before do expect_any_instance_of(Thor::Shell::Basic).to receive(:yes?).with(be_a(String)).and_return(true) end it 'executes the .homesickrc' do castle.file('.homesickrc') do |file| file << "File.open(Dir.pwd + '/testing', 'w') do |f| f.print 'testing' end" end expect(homesick).to receive(:say_status).with('eval', kind_of(Pathname)) homesick.rc castle expect(castle.join('testing')).to exist end end context 'when options[:force] == true' do let(:homesick) { Homesick::CLI.new [], force: true } before do expect_any_instance_of(Thor::Shell::Basic).to_not receive(:yes?) end it 'executes the .homesickrc' do castle.file('.homesickrc') do |file| file << "File.open(Dir.pwd + '/testing', 'w') do |f| f.print 'testing' end" end expect(homesick).to receive(:say_status).with('eval', kind_of(Pathname)) homesick.rc castle expect(castle.join('testing')).to exist end end context 'when told not to do so' do before do expect_any_instance_of(Thor::Shell::Basic).to receive(:yes?).with(be_a(String)).and_return(false) end it 'does not execute the .homesickrc' do castle.file('.homesickrc') do |file| file << "File.open(Dir.pwd + '/testing', 'w') do |f| f.print 'testing' end" end expect(homesick).to receive(:say_status).with('eval skip', /not evaling.+/, :blue) homesick.rc castle expect(castle.join('testing')).not_to exist end end end describe 'link_castle' do let(:castle) { given_castle('glencairn') } it 'links dotfiles from a castle to the home folder' do dotfile = castle.file('.some_dotfile') homesick.link('glencairn') expect(home.join('.some_dotfile').readlink).to eq(dotfile) end it 'links non-dotfiles from a castle to the home folder' do dotfile = castle.file('bin') homesick.link('glencairn') expect(home.join('bin').readlink).to eq(dotfile) end context 'when a conflict exists' do it 'does not replace the existing file when collision is declined' do dotfile = castle.file('.some_dotfile') existing = home.file('.some_dotfile', 'original content') allow(homesick.shell).to receive(:file_collision).and_return(false) homesick.link('glencairn') expect(existing.symlink?).to eq(false) expect(existing.read).to eq('original content') _ = dotfile # ensure castle file exists end it 'replaces the existing file when collision is accepted' do dotfile = castle.file('.some_dotfile') existing = home.file('.some_dotfile', 'original content') allow(homesick.shell).to receive(:file_collision).and_return(true) homesick.link('glencairn') expect(home.join('.some_dotfile').symlink?).to eq(true) expect(home.join('.some_dotfile').readlink).to eq(dotfile) _ = existing # ensure conflicting file was created end end context 'when forced' do let(:homesick) { Homesick::CLI.new [], force: true } it 'can override symlinks to directories' do somewhere_else = create_construct existing_dotdir_link = home.join('.vim') FileUtils.ln_s somewhere_else, existing_dotdir_link dotdir = castle.directory('.vim') homesick.link('glencairn') expect(existing_dotdir_link.readlink).to eq(dotdir) end it 'can override existing directory' do existing_dotdir = home.directory('.vim') dotdir = castle.directory('.vim') homesick.link('glencairn') expect(existing_dotdir.readlink).to eq(dotdir) end end context "with '.config' in .homesick_subdir" do let(:castle) { given_castle('glencairn', ['.config']) } it 'can symlink in sub directory' do dotdir = castle.directory('.config') dotfile = dotdir.file('.some_dotfile') homesick.link('glencairn') home_dotdir = home.join('.config') expect(home_dotdir.symlink?).to eq(false) expect(home_dotdir.join('.some_dotfile').readlink).to eq(dotfile) end end context "with '.config/appA' in .homesick_subdir" do let(:castle) { given_castle('glencairn', ['.config/appA']) } it 'can symlink in nested sub directory' do dotdir = castle.directory('.config').directory('appA') dotfile = dotdir.file('.some_dotfile') homesick.link('glencairn') home_dotdir = home.join('.config').join('appA') expect(home_dotdir.symlink?).to eq(false) expect(home_dotdir.join('.some_dotfile').readlink).to eq(dotfile) end end context "with '.config' and '.config/someapp' in .homesick_subdir" do let(:castle) do given_castle('glencairn', ['.config', '.config/someapp']) end it 'can symlink under both of .config and .config/someapp' do config_dir = castle.directory('.config') config_dotfile = config_dir.file('.some_dotfile') someapp_dir = config_dir.directory('someapp') someapp_dotfile = someapp_dir.file('.some_appfile') homesick.link('glencairn') home_config_dir = home.join('.config') home_someapp_dir = home_config_dir.join('someapp') expect(home_config_dir.symlink?).to eq(false) expect(home_config_dir.join('.some_dotfile').readlink).to eq(config_dotfile) expect(home_someapp_dir.symlink?).to eq(false) expect(home_someapp_dir.join('.some_appfile').readlink).to eq(someapp_dotfile) end end context 'when call with no castle name' do let(:castle) { given_castle('dotfiles') } it 'using default castle name: "dotfiles"' do dotfile = castle.file('.some_dotfile') homesick.link expect(home.join('.some_dotfile').readlink).to eq(dotfile) end end context 'when call and some files conflict' do it 'shows differences for conflicting text files' do contents = { castle: 'castle has new content', home: 'home already has content' } dotfile = castle.file('text') File.write(dotfile.to_s, contents[:castle]) File.write(home.join('text').to_s, contents[:home]) message = Capture.stdout { homesick.shell.show_diff(home.join('text'), dotfile) } expect(message.b).to match(/- ?#{contents[:home]}\n.*\+ ?#{contents[:castle]}$/m) end it 'shows message or differences for conflicting binary files' do # content which contains NULL character, without any parentheses, braces, ... contents = { castle: (0..255).step(30).map(&:chr).join, home: (0..255).step(30).reverse_each.map(&:chr).join } dotfile = castle.file('binary') File.write(dotfile.to_s, contents[:castle]) File.write(home.join('binary').to_s, contents[:home]) message = Capture.stdout { homesick.shell.show_diff(home.join('binary'), dotfile) } if homesick.shell.is_a?(Thor::Shell::Color) expect(message.b).to match(/- ?#{contents[:home]}\n.*\+ ?#{contents[:castle]}$/m) elsif homesick.shell.is_a?(Thor::Shell::Basic) expect(message.b).to match(/^Binary files .+ differ$/) end end end end describe 'unlink' do let(:castle) { given_castle('glencairn') } it 'unlinks dotfiles in the home folder' do castle.file('.some_dotfile') homesick.link('glencairn') homesick.unlink('glencairn') expect(home.join('.some_dotfile')).not_to exist end it 'unlinks non-dotfiles from the home folder' do castle.file('bin') homesick.link('glencairn') homesick.unlink('glencairn') expect(home.join('bin')).not_to exist end context "with '.config' in .homesick_subdir" do let(:castle) { given_castle('glencairn', ['.config']) } it 'can unlink sub directories' do castle.directory('.config').file('.some_dotfile') homesick.link('glencairn') homesick.unlink('glencairn') home_dotdir = home.join('.config') expect(home_dotdir).to exist expect(home_dotdir.join('.some_dotfile')).not_to exist end end context "with '.config/appA' in .homesick_subdir" do let(:castle) { given_castle('glencairn', ['.config/appA']) } it 'can unsymlink in nested sub directory' do castle.directory('.config').directory('appA').file('.some_dotfile') homesick.link('glencairn') homesick.unlink('glencairn') home_dotdir = home.join('.config').join('appA') expect(home_dotdir).to exist expect(home_dotdir.join('.some_dotfile')).not_to exist end end context "with '.config' and '.config/someapp' in .homesick_subdir" do let(:castle) do given_castle('glencairn', ['.config', '.config/someapp']) end it 'can unsymlink under both of .config and .config/someapp' do config_dir = castle.directory('.config') config_dir.file('.some_dotfile') config_dir.directory('someapp').file('.some_appfile') homesick.link('glencairn') homesick.unlink('glencairn') home_config_dir = home.join('.config') home_someapp_dir = home_config_dir.join('someapp') expect(home_config_dir).to exist expect(home_config_dir.join('.some_dotfile')).not_to exist expect(home_someapp_dir).to exist expect(home_someapp_dir.join('.some_appfile')).not_to exist end end context 'when call with no castle name' do let(:castle) { given_castle('dotfiles') } it 'using default castle name: "dotfiles"' do castle.file('.some_dotfile') homesick.link homesick.unlink expect(home.join('.some_dotfile')).not_to exist end end end describe 'list' do it 'says each castle in the castle directory' do given_castle('zomg') given_castle('wtf/zomg') expect(homesick).to receive(:say_status) .with('zomg', 'git://github.com/technicalpickles/zomg.git', :cyan) expect(homesick).to receive(:say_status) .with('wtf/zomg', 'git://github.com/technicalpickles/zomg.git', :cyan) homesick.list end end describe 'status' do it 'says "nothing to commit" when there are no changes' do given_castle('castle_repo') text = Capture.stdout { homesick.status('castle_repo') } expect(text).to match(%r{nothing to commit \(create/copy files and use "git add" to track\)$}) end it 'says "Changes to be committed" when there are changes' do given_castle('castle_repo') some_rc_file = home.file '.some_rc_file' homesick.track(some_rc_file.to_s, 'castle_repo') text = Capture.stdout { homesick.status('castle_repo') } expect(text).to match(%r{Changes to be committed:.*new file:\s*home/.some_rc_file}m) end end describe 'diff' do it 'outputs an empty message when there are no changes to commit' do given_castle('castle_repo') some_rc_file = home.file '.some_rc_file' homesick.track(some_rc_file.to_s, 'castle_repo') Capture.stdout do homesick.commit 'castle_repo', 'Adding a file to the test' end text = Capture.stdout { homesick.diff('castle_repo') } expect(text).to eq('') end it 'outputs a diff message when there are changes to commit' do given_castle('castle_repo') some_rc_file = home.file '.some_rc_file' homesick.track(some_rc_file.to_s, 'castle_repo') Capture.stdout do homesick.commit 'castle_repo', 'Adding a file to the test' end File.open(some_rc_file.to_s, 'w') do |file| file.puts 'Some test text' end text = Capture.stdout { homesick.diff('castle_repo') } expect(text).to match(/diff --git.+Some test text$/m) end end describe 'show_path' do it 'says the path of a castle' do castle = given_castle('castle_repo') expect(homesick).to receive(:say).with(castle.dirname) homesick.show_path('castle_repo') end end describe 'pull' do it 'performs a pull, submodule init and update when the given castle exists' do given_castle('castle_repo') allow(homesick).to receive(:system).once.with('git pull --quiet') allow(homesick).to receive(:system).once.with('git submodule --quiet init') allow(homesick).to receive(:system).once.with('git submodule --quiet update --init --recursive >/dev/null 2>&1') homesick.pull 'castle_repo' end it 'prints an error message when trying to pull a non-existant castle' do expect(homesick).to receive('say_status').once .with(:error, /Could not pull castle_repo, expected .* to exist and contain dotfiles/, :red) expect { homesick.pull 'castle_repo' }.to raise_error(SystemExit) end describe '--all' do it 'pulls each castle when invoked with --all' do given_castle('castle_repo') given_castle('glencairn') allow(homesick).to receive(:system).exactly(2).times.with('git pull --quiet') allow(homesick).to receive(:system).exactly(2).times .with('git submodule --quiet init') allow(homesick).to receive(:system).exactly(2).times .with('git submodule --quiet update --init --recursive >/dev/null 2>&1') Capture.stdout do Capture.stderr { homesick.invoke 'pull', [], all: true } end end end end describe 'push' do it 'performs a git push on the given castle' do given_castle('castle_repo') allow(homesick).to receive(:system).once.with('git push') homesick.push 'castle_repo' end it 'prints an error message when trying to push a non-existant castle' do expect(homesick).to receive('say_status').once .with(:error, /Could not push castle_repo, expected .* to exist and contain dotfiles/, :red) expect { homesick.push 'castle_repo' }.to raise_error(SystemExit) end end describe 'track' do it 'moves the tracked file into the castle' do castle = given_castle('castle_repo') some_rc_file = home.file '.some_rc_file' homesick.track(some_rc_file.to_s, 'castle_repo') tracked_file = castle.join('.some_rc_file') expect(tracked_file).to exist expect(some_rc_file.readlink).to eq(tracked_file) end it 'handles files with parens' do castle = given_castle('castle_repo') some_rc_file = home.file 'Default (Linux).sublime-keymap' homesick.track(some_rc_file.to_s, 'castle_repo') tracked_file = castle.join('Default (Linux).sublime-keymap') expect(tracked_file).to exist expect(some_rc_file.readlink).to eq(tracked_file) end it 'tracks a file in nested folder structure' do castle = given_castle('castle_repo') some_nested_file = home.file('some/nested/file.txt') homesick.track(some_nested_file.to_s, 'castle_repo') tracked_file = castle.join('some/nested/file.txt') expect(tracked_file).to exist expect(some_nested_file.readlink).to eq(tracked_file) end it 'tracks a nested directory' do castle = given_castle('castle_repo') some_nested_dir = home.directory('some/nested/directory/') homesick.track(some_nested_dir.to_s, 'castle_repo') tracked_file = castle.join('some/nested/directory/') expect(tracked_file).to exist expect(some_nested_dir.realpath).to eq(tracked_file.realpath) end context 'when call with no castle name' do it 'using default castle name: "dotfiles"' do castle = given_castle('dotfiles') some_rc_file = home.file '.some_rc_file' homesick.track(some_rc_file.to_s) tracked_file = castle.join('.some_rc_file') expect(tracked_file).to exist expect(some_rc_file.readlink).to eq(tracked_file) end end describe 'commit' do it 'has a commit message when the commit succeeds' do given_castle('castle_repo') some_rc_file = home.file '.a_random_rc_file' homesick.track(some_rc_file.to_s, 'castle_repo') text = Capture.stdout do homesick.commit('castle_repo', 'Test message') end expect(text).to match(/^\[master \(root-commit\) \w+\] Test message/) end end # Note that this is a test for the subdir_file related feature of track, # not for the subdir_file method itself. describe 'subdir_file' do it 'adds the nested files parent to the subdir_file' do castle = given_castle('castle_repo') some_nested_file = home.file('some/nested/file.txt') homesick.track(some_nested_file.to_s, 'castle_repo') subdir_file = castle.parent.join(Homesick::SUBDIR_FILENAME) File.open(subdir_file, 'r') do |f| expect(f.readline).to eq("some/nested\n") end end it 'does NOT add anything if the files parent is already listed' do castle = given_castle('castle_repo') some_nested_file = home.file('some/nested/file.txt') other_nested_file = home.file('some/nested/other.txt') homesick.track(some_nested_file.to_s, 'castle_repo') homesick.track(other_nested_file.to_s, 'castle_repo') subdir_file = castle.parent.join(Homesick::SUBDIR_FILENAME) File.open(subdir_file, 'r') do |f| expect(f.readlines.size).to eq(1) end end it 'removes the parent of a tracked file from the subdir_file if the parent itself is tracked' do castle = given_castle('castle_repo') some_nested_file = home.file('some/nested/file.txt') nested_parent = home.directory('some/nested/') homesick.track(some_nested_file.to_s, 'castle_repo') homesick.track(nested_parent.to_s, 'castle_repo') subdir_file = castle.parent.join(Homesick::SUBDIR_FILENAME) File.open(subdir_file, 'r') do |f| f.each_line { |line| expect(line).not_to eq("some/nested\n") } end end end end describe 'destroy' do it 'removes the symlink files' do expect_any_instance_of(Thor::Shell::Basic).to receive(:yes?).and_return('y') given_castle('stronghold') some_rc_file = home.file '.some_rc_file' homesick.track(some_rc_file.to_s, 'stronghold') homesick.destroy('stronghold') expect(some_rc_file).not_to be_exist end it 'deletes the cloned repository' do expect_any_instance_of(Thor::Shell::Basic).to receive(:yes?).and_return('y') castle = given_castle('stronghold') some_rc_file = home.file '.some_rc_file' homesick.track(some_rc_file.to_s, 'stronghold') homesick.destroy('stronghold') expect(castle).not_to be_exist end end describe 'cd' do it "cd's to the root directory of the given castle" do given_castle('castle_repo') expect(homesick).to receive('inside').once.with(kind_of(Pathname)).and_yield expect(homesick).to receive('system').once.with(ENV.fetch('SHELL', nil)) Capture.stdout { homesick.cd 'castle_repo' } end it 'returns an error message when the given castle does not exist' do expect(homesick).to receive('say_status').once .with(:error, /Could not cd castle_repo, expected .* to exist and contain dotfiles/, :red) expect { homesick.cd 'castle_repo' }.to raise_error(SystemExit) end end describe 'open' do it 'opens the system default editor in the root of the given castle' do # Set a default value for 'EDITOR' just in case none is set allow(ENV).to receive(:fetch).with('EDITOR', nil).and_return('vim') given_castle 'castle_repo' expect(homesick).to receive('inside').once.with(kind_of(Pathname)).and_yield expect(homesick).to receive('system').once.with('vim .') Capture.stdout { homesick.open 'castle_repo' } end it 'returns an error message when the $EDITOR environment variable is not set' do # Set the default editor to make sure it fails. allow(ENV).to receive(:fetch).with('EDITOR', nil).and_return(nil) expect(homesick).to receive('say_status').once .with(:error, 'The $EDITOR environment variable must be set to use this command', :red) expect { homesick.open 'castle_repo' }.to raise_error(SystemExit) end it 'returns an error message when the given castle does not exist' do # Set a default just in case none is set allow(ENV).to receive(:fetch).with('EDITOR', nil).and_return('vim') allow(homesick).to receive('say_status').once .with(:error, /Could not open castle_repo, expected .* to exist and contain dotfiles/, :red) expect { homesick.open 'castle_repo' }.to raise_error(SystemExit) end end describe 'version' do it 'prints the current version of homesick' do text = Capture.stdout { homesick.version } expect(text.chomp).to match(/#{Regexp.escape(Homesick::Version::STRING)}/) end end describe 'exec' do before do given_castle 'castle_repo' end it 'executes a single command with no arguments inside a given castle' do allow(homesick).to receive('inside').once.with(kind_of(Pathname)).and_yield allow(homesick).to receive('say_status').once .with(be_a(String), be_a(String), :green) allow(homesick).to receive('system').once.with('ls') Capture.stdout { homesick.exec 'castle_repo', 'ls' } end it 'executes a single command with arguments inside a given castle' do allow(homesick).to receive('inside').once.with(kind_of(Pathname)).and_yield allow(homesick).to receive('say_status').once .with(be_a(String), be_a(String), :green) allow(homesick).to receive('system').once.with('ls -la') Capture.stdout { homesick.exec 'castle_repo', 'ls', '-la' } end it 'raises an error when the method is called without a command' do allow(homesick).to receive('say_status').once .with(:error, be_a(String), :red) allow(homesick).to receive('exit').once.with(1) Capture.stdout { homesick.exec 'castle_repo' } end context 'pretend' do it 'does not execute a command when the pretend option is passed' do allow(homesick).to receive('say_status').once .with(be_a(String), match(/.*Would execute.*/), :green) expect(homesick).to receive('system').never Capture.stdout { homesick.invoke 'exec', %w[castle_repo ls -la], pretend: true } end end context 'quiet' do it 'does not print status information when quiet is passed' do expect(homesick).to receive('say_status').never allow(homesick).to receive('system').once .with('ls -la') Capture.stdout { homesick.invoke 'exec', %w[castle_repo ls -la], quiet: true } end end end describe 'exec_all' do before do given_castle 'castle_repo' given_castle 'another_castle_repo' end it 'executes a command without arguments inside the root of each cloned castle' do allow(homesick).to receive('inside_each_castle').exactly(:twice).and_yield('castle_repo').and_yield('another_castle_repo') allow(homesick).to receive('say_status').at_least(:once) .with(be_a(String), be_a(String), :green) allow(homesick).to receive('system').at_least(:once).with('ls') Capture.stdout { homesick.exec_all 'ls' } end it 'executes a command with arguments inside the root of each cloned castle' do allow(homesick).to receive('inside_each_castle').exactly(:twice).and_yield('castle_repo').and_yield('another_castle_repo') allow(homesick).to receive('say_status').at_least(:once) .with(be_a(String), be_a(String), :green) allow(homesick).to receive('system').at_least(:once).with('ls -la') Capture.stdout { homesick.exec_all 'ls', '-la' } end it 'raises an error when the method is called without a command' do allow(homesick).to receive('say_status').once .with(:error, be_a(String), :red) allow(homesick).to receive('exit').once.with(1) Capture.stdout { homesick.exec_all } end context 'pretend' do it 'does not execute a command when the pretend option is passed' do allow(homesick).to receive('say_status').at_least(:once) .with(be_a(String), match(/.*Would execute.*/), :green) expect(homesick).to receive('system').never Capture.stdout { homesick.invoke 'exec_all', %w[ls -la], pretend: true } end end context 'quiet' do it 'does not print status information when quiet is passed' do expect(homesick).to receive('say_status').never allow(homesick).to receive('system').at_least(:once) .with('ls -la') Capture.stdout { homesick.invoke 'exec_all', %w[ls -la], quiet: true } end end end end technicalpickles-homesick-9b61b78/spec/homesick_rc_spec.rb000066400000000000000000000033271515777723200237670ustar00rootroot00000000000000# frozen_string_literal: true require 'spec_helper' require 'tmpdir' require 'pathname' describe Homesick::RC::Context do let(:tmpdir) { Pathname.new(Dir.mktmpdir) } let(:home) { tmpdir } # satisfies spec_helper's global ENV['HOME'] hook after { tmpdir.rmtree } # RC::Context does not emit say_status; suppress the global silence! hook. def silence!; end subject(:context) { described_class.new(tmpdir) } describe '#castle_path' do it 'returns a Pathname equal to the given castle path' do expect(context.castle_path).to eq(tmpdir) end it 'returns a Pathname instance' do expect(context.castle_path).to be_a(Pathname) end it 'accepts a string path and coerces it to Pathname' do ctx = described_class.new(tmpdir.to_s) expect(ctx.castle_path).to eq(tmpdir) end end describe '#run' do it 'executes the given shell command and returns true on success' do marker = tmpdir.join('ran') result = context.run("touch #{marker}") expect(result).to be(true) expect(marker).to exist end it 'returns false when the command fails' do result = context.run('false') expect(result).to be(false) end end describe 'instance_eval isolation' do it 'does not expose Homesick::CLI methods to the script' do # check_castle_existance is a CLI method; the context should not have it expect { context.instance_eval('check_castle_existance("x", "y")', __FILE__, __LINE__) } .to raise_error(NoMethodError) end it 'allows standard Ruby file operations' do marker = tmpdir.join('from_script') context.instance_eval { File.write(marker.to_s, 'ok') } expect(marker.read).to eq('ok') end end end technicalpickles-homesick-9b61b78/spec/spec.opts000066400000000000000000000000101515777723200217650ustar00rootroot00000000000000--color technicalpickles-homesick-9b61b78/spec/spec_helper.rb000066400000000000000000000022221515777723200227510ustar00rootroot00000000000000# frozen_string_literal: true $LOAD_PATH.unshift(File.dirname(__FILE__)) $LOAD_PATH.unshift(File.join(File.dirname(__FILE__), '..', 'lib')) require 'homesick' require 'rspec' require 'test_construct' require 'tempfile' RSpec.configure do |config| config.include TestConstruct::Helpers config.expect_with(:rspec) { |c| c.syntax = :expect } config.before { ENV['HOME'] = home.to_s } config.before { silence! } def silence! allow(homesick).to receive(:say_status) end def given_castle(path, subdirs = []) name = Pathname.new(path).basename castles.directory(path) do |castle| Dir.chdir(castle) do system 'git init >/dev/null 2>&1' system 'git config user.email "test@test.com"' system 'git config user.name "Test Name"' system "git remote add origin git://github.com/technicalpickles/#{name}.git >/dev/null 2>&1" if subdirs subdir_file = castle.join(Homesick::SUBDIR_FILENAME) subdirs.each do |subdir| File.open(subdir_file, 'a') { |file| file.write "\n#{subdir}\n" } end end return castle.directory('home') end end end end