├── .document ├── .gitignore ├── .rspec ├── .rubocop.yml ├── .travis.yml ├── ChangeLog.markdown ├── Gemfile ├── Guardfile ├── LICENSE ├── README.markdown ├── Rakefile ├── bin └── homesick ├── homesick.gemspec ├── lib ├── homesick.rb └── homesick │ ├── actions │ ├── file_actions.rb │ └── git_actions.rb │ ├── cli.rb │ ├── utils.rb │ └── version.rb └── spec ├── homesick_cli_spec.rb ├── spec.opts └── spec_helper.rb /.document: -------------------------------------------------------------------------------- 1 | README.rdoc 2 | lib/**/*.rb 3 | bin/* 4 | features/**/*.feature 5 | LICENSE 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # rcov generated 2 | coverage 3 | 4 | # rdoc generated 5 | rdoc 6 | 7 | # yard generated 8 | doc 9 | .yardoc 10 | 11 | # jeweler generated 12 | pkg 13 | 14 | .bundle 15 | 16 | # Have editor/IDE/OS specific files you need to ignore? Consider using a global gitignore: 17 | # 18 | # * Create a file at ~/.gitignore 19 | # * Include files you want ignored 20 | # * Run: git config --global core.excludesfile ~/.gitignore 21 | # 22 | # After doing this, these files will be ignored in all your git projects, 23 | # saving you from having to 'pollute' every project you touch with them 24 | # 25 | # 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) 26 | # 27 | # For MacOS: 28 | # 29 | .DS_Store 30 | # 31 | # For TextMate 32 | #*.tmproj 33 | #tmtags 34 | # 35 | # For emacs: 36 | *~ 37 | \#* 38 | .\#* 39 | # 40 | # For vim: 41 | *.swp 42 | # 43 | # For IDEA: 44 | .idea/ 45 | *.iml 46 | 47 | Gemfile.lock 48 | vendor/ 49 | 50 | homesick*.gem 51 | 52 | # rbenv configuration 53 | .ruby-version 54 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --color 2 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | # TODO: Eval is required for the .homesickrc feature. This should eventually be 2 | # removed if the feature is implemented in a more secure way. 3 | Eval: 4 | Enabled: false 5 | 6 | # TODO: The following settings disable reports about issues that can be fixed 7 | # through refactoring. Remove these as offenses are removed from the code base. 8 | 9 | ClassLength: 10 | Enabled: false 11 | 12 | CyclomaticComplexity: 13 | Max: 13 14 | 15 | LineLength: 16 | Enabled: false 17 | 18 | MethodLength: 19 | Max: 36 20 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: ruby 2 | rvm: 3 | - 2.5.0 4 | - 2.4.0 5 | - 2.3.3 6 | - 2.2.6 7 | sudo: false 8 | -------------------------------------------------------------------------------- /ChangeLog.markdown: -------------------------------------------------------------------------------- 1 | # 1.1.6 2 | * Makesure the FileUtils is imported correctly to avoid a potential error 3 | * Fixes an issue where comparing a diff would not use the content of the new file 4 | * Small documentation fixes 5 | 6 | # 1.1.5 7 | * Fixed problem with version number being incorrect. 8 | 9 | # 1.1.4 10 | * Make sure symlink conflicts are explicitly communicated to a user and symlinks are not silently overwritten 11 | * Use real paths of symlinks when linking a castle into home 12 | * Fix a problem when in a diff when asking a user to resolve a conflict 13 | * Some code refactoring and fixes 14 | 15 | # 1.1.3 16 | * Allow a destination to be passed when cloning a castle 17 | * Make sure `homesick edit` opens default editor in the root of the given castle 18 | * Fixed bug when diffing edited files 19 | * Fixed crashing bug when attempting to diff directories 20 | * Ensure that messages are escaped correctly on `git commit all` 21 | 22 | # 1.1.2 23 | * Added '--force' option to the rc command to bypass confirmation checks when running a .homesickrc file 24 | * 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. 25 | * Code refactoring and fixes. 26 | 27 | # 1.1.0 28 | * Added exec and exec_all commands to run commands inside one or all clones castles. 29 | * Code refactoring. 30 | 31 | # 1.0.0 32 | * Removed support for Ruby 1.8.7 33 | * Added a version command 34 | 35 | # 0.9.8 36 | * Introduce new commands 37 | * `homesick cd` 38 | * `homesick open` 39 | 40 | # 0.9.4 41 | * Use https protocol instead of git protocol 42 | * Introduce new commands 43 | * `homesick unlink` 44 | * `homesick rc` 45 | 46 | # 0.9.3 47 | * Add recursive option to `homesick clone` 48 | 49 | # 0.9.2 50 | * Set "dotfiles" as default castle name 51 | * Introduce new commands 52 | * `homesick show_path` 53 | * `homesick status` 54 | * `homesick diff` 55 | 56 | # 0.9.1 57 | * Fixed small bugs: #35, #40 58 | 59 | # 0.9.0 60 | * Introduce .homesick_subdir #39 61 | 62 | # 0.8.1 63 | * Fixed `homesick list` bug on ruby 2.0 #37 64 | 65 | # 0.8.0 66 | * Introduce commit & push command 67 | * commit changes in castle and push to remote 68 | * Enable recursive submodule update 69 | * Git add when track 70 | 71 | # 0.7.0 72 | * Fixed double-cloning #14 73 | * New option for pull command: --all 74 | * pulls each castle, instead of just one 75 | 76 | # 0.6.1 77 | 78 | * Add a license 79 | 80 | # 0.6.0 81 | 82 | * Introduce .homesickrc 83 | * Castles can now have a .homesickrc inside them 84 | * On clone, this is eval'd inside the destination directory 85 | * Introduce track command 86 | * Allows easily moving an existing file into a castle, and symlinking it back 87 | 88 | # 0.5.0 89 | 90 | * Fixed listing of castles cloned using `homesick clone /` (issue 3) 91 | * Added `homesick pull ` for updating castles (thanks Jorge Dias!) 92 | * Added a very basic `homesick generate ` 93 | 94 | # 0.4.1 95 | 96 | * Improved error message when a castle's home dir doesn't exist 97 | 98 | # 0.4.0 99 | 100 | * `homesick clone` can now take a path to a directory on the filesystem, which will be symlinked into place 101 | * `homesick clone` now tries to `git submodule init` and `git submodule update` if git submodules are defined for a cloned repo 102 | * Fixed missing dependency on thor and others 103 | * Use HOME environment variable for where to store files, instead of assuming ~ 104 | 105 | # 0.3.0 106 | 107 | * Renamed 'link' to 'symlink' 108 | * Fixed conflict resolution when symlink destination exists and is a normal file 109 | 110 | # 0.2.0 111 | 112 | * Better support for recognizing git urls (thanks jacobat!) 113 | * if it looks like a github user/repo, do that 114 | * otherwise hand off to git clone 115 | * Listing now displays in color, and show git remote 116 | * Support pretend, force, and quiet modes 117 | 118 | # 0.1.1 119 | 120 | * Fixed trying to link against castles that don't exist 121 | * Fixed linking, which tries to exclude . and .. from the list of files to 122 | link (thanks Martinos!) 123 | 124 | # 0.1.0 125 | 126 | * Initial release 127 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | this_ruby = Gem::Version.new(RUBY_VERSION) 4 | ruby_230 = Gem::Version.new('2.3.0') 5 | 6 | # Add dependencies required to use your gem here. 7 | gem 'thor', '>= 0.14.0' 8 | 9 | # Add dependencies to develop your gem here. 10 | # Include everything needed to run rake, tests, features, etc. 11 | group :development do 12 | gem 'capture-output', '~> 1.0.0' 13 | gem 'coveralls', require: false 14 | gem 'guard' 15 | gem 'guard-rspec' 16 | gem 'jeweler', '>= 1.6.2', '< 2.2' if this_ruby < ruby_230 17 | gem 'jeweler', '>= 1.6.2' if this_ruby >= ruby_230 18 | gem 'rake', '>= 0.8.7' 19 | gem 'rb-readline', '~> 0.5.0' 20 | gem 'rspec', '~> 3.5.0' 21 | gem 'rubocop' 22 | gem 'test_construct' 23 | 24 | install_if -> { RUBY_PLATFORM =~ /linux|freebsd|openbsd|sunos|solaris/ } do 25 | gem 'libnotify' 26 | end 27 | 28 | install_if -> { RUBY_PLATFORM =~ /darwin/ } do 29 | gem 'terminal-notifier-guard', '~> 1.7.0' 30 | end 31 | 32 | install_if -> { this_ruby < ruby_230 } do 33 | gem 'listen', '< 3' 34 | gem 'rack', '~> 2.0.6' 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /Guardfile: -------------------------------------------------------------------------------- 1 | guard :rspec, :cmd => 'bundle exec rspec' do 2 | watch(%r{^spec/.+_spec\.rb$}) 3 | watch(%r{^lib/(.+)\.rb$}) { |m| "spec/#{m[1]}_spec.rb" } 4 | watch(%r{^lib/homesick/.*\.rb}) { "spec" } 5 | watch('spec/spec_helper.rb') { "spec" } 6 | end 7 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2009 Joshua Nichols 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /README.markdown: -------------------------------------------------------------------------------- 1 | # homesick 2 | 3 | [![Gem Version](https://badge.fury.io/rb/homesick.svg)](http://badge.fury.io/rb/homesick) 4 | [![Build Status](https://travis-ci.org/technicalpickles/homesick.svg?branch=master)](https://travis-ci.org/technicalpickles/homesick) 5 | [![Dependency Status](https://gemnasium.com/technicalpickles/homesick.svg)](https://gemnasium.com/technicalpickles/homesick) 6 | [![Coverage Status](https://coveralls.io/repos/technicalpickles/homesick/badge.png)](https://coveralls.io/r/technicalpickles/homesick) 7 | [![Code Climate](https://codeclimate.com/github/technicalpickles/homesick.svg)](https://codeclimate.com/github/technicalpickles/homesick) 8 | [![Gitter chat](https://badges.gitter.im/technicalpickles/homesick.svg)](https://gitter.im/technicalpickles/homesick) 9 | 10 | Your home directory is your castle. Don't leave your dotfiles behind. 11 | 12 | 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. 13 | 14 | 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: 15 | 16 | * Contains a 'home' directory 17 | * 'home' contains any number of files and directories that begin with '.' 18 | 19 | To get started, install homesick first: 20 | 21 | gem install homesick 22 | 23 | Next, you use the homesick command to clone a castle: 24 | 25 | homesick clone git://github.com/technicalpickles/pickled-vim.git 26 | 27 | Alternatively, if it's on github, there's a slightly shorter way: 28 | 29 | homesick clone technicalpickles/pickled-vim 30 | 31 | With the castle cloned, you can now link its contents into your home dir: 32 | 33 | homesick link pickled-vim 34 | 35 | You can remove symlinks anytime when you don't need them anymore 36 | 37 | homesick unlink pickled-vim 38 | 39 | 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: 40 | 41 | homesick rc CASTLE 42 | 43 | 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`. 44 | 45 | If you're not sure what castles you have around, you can easily list them: 46 | 47 | homesick list 48 | 49 | To pull your castle (or all castles): 50 | 51 | homesick pull --all|CASTLE 52 | 53 | To commit your castle's changes: 54 | 55 | homesick commit CASTLE 56 | 57 | To push your castle: 58 | 59 | homesick push CASTLE 60 | 61 | To open a terminal in the root of a castle: 62 | 63 | homesick cd CASTLE 64 | 65 | To open your default editor in the root of a castle (the $EDITOR environment variable must be set): 66 | 67 | homesick open CASTLE 68 | 69 | To execute a shell command inside the root directory of a given castle: 70 | 71 | homesick exec CASTLE COMMAND 72 | 73 | To execute a shell command inside the root directory of every cloned castle: 74 | 75 | homesick exec_all COMMAND 76 | 77 | Not sure what else homesick has up its sleeve? There's always the built in help: 78 | 79 | homesick help 80 | 81 | If you ever want to see what version of homesick you have type: 82 | 83 | homesick version|-v|--version 84 | 85 | ## .homesick_subdir 86 | 87 | `homesick link` basically makes symlink to only first depth in `castle/home`. If you want to link nested files/directories, please use .homesick_subdir. 88 | 89 | For example, when you have castle like this: 90 | 91 | castle/home 92 | `-- .config 93 | `-- fooapp 94 | |-- config1 95 | |-- config2 96 | `-- config3 97 | 98 | and have home like this: 99 | 100 | $ tree -a 101 | ~ 102 | |-- .config 103 | | `-- barapp 104 | | |-- config1 105 | | |-- config2 106 | | `-- config3 107 | `-- .emacs.d 108 | |-- elisp 109 | `-- inits 110 | 111 | 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. 112 | 113 | castle/.homesick_subdir 114 | 115 | .config 116 | 117 | and run `homesick link CASTLE`. The result is: 118 | 119 | ~ 120 | |-- .config 121 | | |-- barapp 122 | | | |-- config1 123 | | | |-- config2 124 | | | `-- config3 125 | | `-- fooapp -> castle/home/.config/fooapp 126 | `-- .emacs.d 127 | |-- elisp 128 | `-- inits 129 | 130 | Or `homesick track NESTED_FILE CASTLE` adds a line automatically. For example: 131 | 132 | homesick track .emacs.d/elisp castle 133 | 134 | castle/.homesick_subdir 135 | 136 | .config 137 | .emacs.d 138 | 139 | home directory 140 | 141 | ~ 142 | |-- .config 143 | | |-- barapp 144 | | | |-- config1 145 | | | |-- config2 146 | | | `-- config3 147 | | `-- fooapp -> castle/home/.config/fooapp 148 | `-- .emacs.d 149 | |-- elisp -> castle/home/.emacs.d/elisp 150 | `-- inits 151 | 152 | and castle 153 | 154 | castle/home 155 | |-- .config 156 | | `-- fooapp 157 | | |-- config1 158 | | |-- config2 159 | | `-- config3 160 | `-- .emacs.d 161 | `-- elisp 162 | 163 | ## Supported Ruby Versions 164 | 165 | Homesick is tested on the following Ruby versions: 166 | 167 | * 2.2.6 168 | * 2.3.3 169 | * 2.4.0 170 | 171 | ## Note on Patches/Pull Requests 172 | 173 | * Fork the project. 174 | * Make your feature addition or bug fix. 175 | * Add tests for it. This is important so I don't break it in a future version unintentionally. 176 | * Commit, do not mess with rakefile, version, or history. (if you want to have your own version, that is fine but bump version in a commit by itself I can ignore when I pull) 177 | * Send me a pull request. Bonus points for topic branches. 178 | 179 | ## Need homesick without the ruby dependency? 180 | 181 | Check out [homeshick](https://github.com/andsens/homeshick). 182 | 183 | ## Copyright 184 | 185 | Copyright (c) 2010 Joshua Nichols. See LICENSE for details. 186 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require 'rubygems' 2 | require 'bundler' 3 | require_relative 'lib/homesick/version' 4 | begin 5 | Bundler.setup(:default, :development) 6 | rescue Bundler::BundlerError => e 7 | $stderr.puts e.message 8 | $stderr.puts "Run `bundle install` to install missing gems" 9 | exit e.status_code 10 | end 11 | require 'rake' 12 | 13 | require 'jeweler' 14 | Jeweler::Tasks.new do |gem| 15 | gem.name = "homesick" 16 | gem.summary = %Q{Your home directory is your castle. Don't leave your dotfiles behind.} 17 | gem.description = %Q{ 18 | Your home directory is your castle. Don't leave your dotfiles behind. 19 | 20 | 21 | 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. 22 | 23 | } 24 | gem.email = ["josh@technicalpickles.com", "info@muratayusuke.com"] 25 | gem.homepage = "http://github.com/technicalpickles/homesick" 26 | gem.authors = ["Joshua Nichols", "Yusuke Murata"] 27 | gem.version = Homesick::Version::STRING 28 | gem.license = "MIT" 29 | # Have dependencies? Add them to Gemfile 30 | 31 | # gem is a Gem::Specification... see http://www.rubygems.org/read/chapter/20 for additional settings 32 | end 33 | Jeweler::GemcutterTasks.new 34 | 35 | 36 | require 'rspec/core/rake_task' 37 | RSpec::Core::RakeTask.new(:spec) do |spec| 38 | spec.pattern = FileList['spec/**/*_spec.rb'] 39 | end 40 | 41 | RSpec::Core::RakeTask.new(:rcov) do |spec| 42 | spec.pattern = 'spec/**/*_spec.rb' 43 | spec.rcov = true 44 | end 45 | 46 | task :rubocop do 47 | if RUBY_VERSION >= '1.9.2' 48 | system('rubocop') 49 | end 50 | end 51 | 52 | task :test do 53 | Rake::Task['spec'].execute 54 | Rake::Task['rubocop'].execute 55 | end 56 | 57 | task :default => :test 58 | 59 | require 'rdoc/task' 60 | Rake::RDocTask.new do |rdoc| 61 | version = File.exist?('VERSION') ? File.read('VERSION') : "" 62 | 63 | rdoc.rdoc_dir = 'rdoc' 64 | rdoc.title = "homesick #{version}" 65 | rdoc.rdoc_files.include('README*') 66 | rdoc.rdoc_files.include('lib/**/*.rb') 67 | end 68 | 69 | -------------------------------------------------------------------------------- /bin/homesick: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require 'pathname' 4 | lib = Pathname.new(__FILE__).dirname.join('..', 'lib').expand_path 5 | $LOAD_PATH.unshift lib.to_s 6 | 7 | require 'homesick' 8 | 9 | Homesick::CLI.start 10 | -------------------------------------------------------------------------------- /homesick.gemspec: -------------------------------------------------------------------------------- 1 | # Generated by jeweler 2 | # DO NOT EDIT THIS FILE DIRECTLY 3 | # Instead, edit Jeweler::Tasks in Rakefile, and run 'rake gemspec' 4 | # -*- encoding: utf-8 -*- 5 | # stub: homesick 1.1.6 ruby lib 6 | 7 | Gem::Specification.new do |s| 8 | s.name = "homesick".freeze 9 | s.version = "1.1.6" 10 | 11 | s.required_rubygems_version = Gem::Requirement.new(">= 0".freeze) if s.respond_to? :required_rubygems_version= 12 | s.require_paths = ["lib".freeze] 13 | s.authors = ["Joshua Nichols".freeze, "Yusuke Murata".freeze] 14 | s.date = "2017-12-20" 15 | s.description = "\n Your home directory is your castle. Don't leave your dotfiles behind.\n \n\n 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. \n\n ".freeze 16 | s.email = ["josh@technicalpickles.com".freeze, "info@muratayusuke.com".freeze] 17 | s.executables = ["homesick".freeze] 18 | s.extra_rdoc_files = [ 19 | "ChangeLog.markdown", 20 | "LICENSE", 21 | "README.markdown" 22 | ] 23 | s.files = [ 24 | ".document", 25 | ".rspec", 26 | ".rubocop.yml", 27 | ".travis.yml", 28 | "ChangeLog.markdown", 29 | "Gemfile", 30 | "Guardfile", 31 | "LICENSE", 32 | "README.markdown", 33 | "Rakefile", 34 | "bin/homesick", 35 | "homesick.gemspec", 36 | "lib/homesick.rb", 37 | "lib/homesick/actions/file_actions.rb", 38 | "lib/homesick/actions/git_actions.rb", 39 | "lib/homesick/cli.rb", 40 | "lib/homesick/utils.rb", 41 | "lib/homesick/version.rb", 42 | "spec/homesick_cli_spec.rb", 43 | "spec/spec.opts", 44 | "spec/spec_helper.rb" 45 | ] 46 | s.homepage = "http://github.com/technicalpickles/homesick".freeze 47 | s.licenses = ["MIT".freeze] 48 | s.rubygems_version = "2.6.11".freeze 49 | s.summary = "Your home directory is your castle. Don't leave your dotfiles behind.".freeze 50 | 51 | if s.respond_to? :specification_version then 52 | s.specification_version = 4 53 | 54 | if Gem::Version.new(Gem::VERSION) >= Gem::Version.new('1.2.0') then 55 | s.add_runtime_dependency(%q.freeze, [">= 0.14.0"]) 56 | s.add_development_dependency(%q.freeze, ["~> 1.0.0"]) 57 | s.add_development_dependency(%q.freeze, [">= 0"]) 58 | s.add_development_dependency(%q.freeze, [">= 0"]) 59 | s.add_development_dependency(%q.freeze, [">= 0"]) 60 | s.add_development_dependency(%q.freeze, [">= 1.6.2"]) 61 | s.add_development_dependency(%q.freeze, [">= 0.8.7"]) 62 | s.add_development_dependency(%q.freeze, ["~> 0.5.0"]) 63 | s.add_development_dependency(%q.freeze, ["~> 3.5.0"]) 64 | s.add_development_dependency(%q.freeze, [">= 0"]) 65 | s.add_development_dependency(%q.freeze, [">= 0"]) 66 | s.add_development_dependency(%q.freeze, [">= 0"]) 67 | s.add_development_dependency(%q.freeze, ["~> 1.7.0"]) 68 | s.add_development_dependency(%q.freeze, ["< 3"]) 69 | s.add_development_dependency(%q.freeze, ["< 2"]) 70 | else 71 | s.add_dependency(%q.freeze, [">= 0.14.0"]) 72 | s.add_dependency(%q.freeze, ["~> 1.0.0"]) 73 | s.add_dependency(%q.freeze, [">= 0"]) 74 | s.add_dependency(%q.freeze, [">= 0"]) 75 | s.add_dependency(%q.freeze, [">= 0"]) 76 | s.add_dependency(%q.freeze, [">= 1.6.2"]) 77 | s.add_dependency(%q.freeze, [">= 0.8.7"]) 78 | s.add_dependency(%q.freeze, ["~> 0.5.0"]) 79 | s.add_dependency(%q.freeze, ["~> 3.5.0"]) 80 | s.add_dependency(%q.freeze, [">= 0"]) 81 | s.add_dependency(%q.freeze, [">= 0"]) 82 | s.add_dependency(%q.freeze, [">= 0"]) 83 | s.add_dependency(%q.freeze, ["~> 1.7.0"]) 84 | s.add_dependency(%q.freeze, ["< 3"]) 85 | s.add_dependency(%q.freeze, ["< 2"]) 86 | end 87 | else 88 | s.add_dependency(%q.freeze, [">= 0.14.0"]) 89 | s.add_dependency(%q.freeze, ["~> 1.0.0"]) 90 | s.add_dependency(%q.freeze, [">= 0"]) 91 | s.add_dependency(%q.freeze, [">= 0"]) 92 | s.add_dependency(%q.freeze, [">= 0"]) 93 | s.add_dependency(%q.freeze, [">= 1.6.2"]) 94 | s.add_dependency(%q.freeze, [">= 0.8.7"]) 95 | s.add_dependency(%q.freeze, ["~> 0.5.0"]) 96 | s.add_dependency(%q.freeze, ["~> 3.5.0"]) 97 | s.add_dependency(%q.freeze, [">= 0"]) 98 | s.add_dependency(%q.freeze, [">= 0"]) 99 | s.add_dependency(%q.freeze, [">= 0"]) 100 | s.add_dependency(%q.freeze, ["~> 1.7.0"]) 101 | s.add_dependency(%q.freeze, ["< 3"]) 102 | s.add_dependency(%q.freeze, ["< 2"]) 103 | end 104 | end 105 | 106 | -------------------------------------------------------------------------------- /lib/homesick.rb: -------------------------------------------------------------------------------- 1 | require 'homesick/actions/file_actions' 2 | require 'homesick/actions/git_actions' 3 | require 'homesick/version' 4 | require 'homesick/utils' 5 | require 'homesick/cli' 6 | require 'fileutils' 7 | 8 | # Homesick's top-level module 9 | module Homesick 10 | GITHUB_NAME_REPO_PATTERN = %r{\A([A-Za-z0-9_-]+/[A-Za-z0-9_-]+)\Z}.freeze 11 | SUBDIR_FILENAME = '.homesick_subdir'.freeze 12 | 13 | DEFAULT_CASTLE_NAME = 'dotfiles'.freeze 14 | QUIETABLE = [:say_status].freeze 15 | 16 | PRETENDABLE = [:system].freeze 17 | 18 | QUIETABLE.each do |method_name| 19 | define_method(method_name) do |*args| 20 | super(*args) unless options[:quiet] 21 | end 22 | end 23 | 24 | PRETENDABLE.each do |method_name| 25 | define_method(method_name) do |*args| 26 | super(*args) unless options[:pretend] 27 | end 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /lib/homesick/actions/file_actions.rb: -------------------------------------------------------------------------------- 1 | module Homesick 2 | module Actions 3 | # File-related helper methods for Homesick 4 | module FileActions 5 | protected 6 | 7 | def mv(source, destination) 8 | source = Pathname.new(source) 9 | destination = Pathname.new(destination + source.basename) 10 | say_status :conflict, "#{destination} exists", :red if destination.exist? && (options[:force] || shell.file_collision(destination) { source }) 11 | FileUtils.mv source, destination unless options[:pretend] 12 | end 13 | 14 | def rm_rf(dir) 15 | say_status "rm -rf #{dir}", '', :green 16 | FileUtils.rm_r dir, force: true 17 | end 18 | 19 | def rm_link(target) 20 | target = Pathname.new(target) 21 | 22 | if target.symlink? 23 | say_status :unlink, target.expand_path.to_s, :green 24 | FileUtils.rm_rf target 25 | else 26 | say_status :conflict, "#{target} is not a symlink", :red 27 | end 28 | end 29 | 30 | def rm(file) 31 | say_status "rm #{file}", '', :green 32 | FileUtils.rm file, force: true 33 | end 34 | 35 | def rm_r(dir) 36 | say_status "rm -r #{dir}", '', :green 37 | FileUtils.rm_r dir 38 | end 39 | 40 | def ln_s(source, destination) 41 | source = Pathname.new(source).realpath 42 | destination = Pathname.new(destination) 43 | FileUtils.mkdir_p destination.dirname 44 | 45 | action = :success 46 | action = :identical if destination.symlink? && destination.readlink == source 47 | action = :symlink_conflict if destination.symlink? 48 | action = :conflict if destination.exist? 49 | 50 | handle_symlink_action action, source, destination 51 | end 52 | 53 | def handle_symlink_action(action, source, destination) 54 | if action == :identical 55 | say_status :identical, destination.expand_path, :blue 56 | return 57 | end 58 | message = generate_symlink_message action, source, destination 59 | if %i[symlink_conflict conflict].include?(action) 60 | say_status :conflict, message, :red 61 | if collision_accepted?(destination, source) 62 | FileUtils.rm_r destination, force: true unless options[:pretend] 63 | end 64 | else 65 | say_status :symlink, message, :green 66 | end 67 | FileUtils.ln_s source, destination, force: true unless options[:pretend] 68 | end 69 | 70 | def generate_symlink_message(action, source, destination) 71 | message = "#{source.expand_path} to #{destination.expand_path}" 72 | message = "#{destination} exists and points to #{destination.readlink}" if action == :symlink_conflict 73 | message = "#{destination} exists" if action == :conflict 74 | message 75 | end 76 | end 77 | end 78 | end 79 | -------------------------------------------------------------------------------- /lib/homesick/actions/git_actions.rb: -------------------------------------------------------------------------------- 1 | module Homesick 2 | module Actions 3 | # Git-related helper methods for Homesick 4 | module GitActions 5 | # Information on the minimum git version required for Homesick 6 | MIN_VERSION = { 7 | major: 1, 8 | minor: 8, 9 | patch: 0 10 | }.freeze 11 | STRING = MIN_VERSION.values.join('.') 12 | 13 | def git_version_correct? 14 | info = `git --version`.scan(/(\d+)\.(\d+)\.(\d+)/).flatten.map(&:to_i) 15 | return false unless info.count == 3 16 | 17 | current_version = Hash[%i[major minor patch].zip(info)] 18 | major_equals = current_version.eql?(MIN_VERSION) 19 | major_greater = current_version[:major] > MIN_VERSION[:major] 20 | minor_greater = current_version[:major] == MIN_VERSION[:major] && current_version[:minor] > MIN_VERSION[:minor] 21 | patch_greater = current_version[:major] == MIN_VERSION[:major] && current_version[:minor] == MIN_VERSION[:minor] && current_version[:patch] >= MIN_VERSION[:patch] 22 | 23 | major_equals || major_greater || minor_greater || patch_greater 24 | end 25 | 26 | # TODO: move this to be more like thor's template, empty_directory, etc 27 | def git_clone(repo, config = {}) 28 | config ||= {} 29 | destination = config[:destination] || File.basename(repo, '.git') 30 | 31 | destination = Pathname.new(destination) unless destination.is_a?(Pathname) 32 | FileUtils.mkdir_p destination.dirname 33 | 34 | if destination.directory? 35 | say_status :exist, destination.expand_path, :blue 36 | else 37 | say_status 'git clone', 38 | "#{repo} to #{destination.expand_path}", 39 | :green 40 | system "git clone -q --config push.default=upstream --recursive #{repo} #{destination}" 41 | end 42 | end 43 | 44 | def git_init(path = '.') 45 | path = Pathname.new(path) 46 | 47 | inside path do 48 | if path.join('.git').exist? 49 | say_status 'git init', 'already initialized', :blue 50 | else 51 | say_status 'git init', '' 52 | system 'git init >/dev/null' 53 | end 54 | end 55 | end 56 | 57 | def git_remote_add(name, url) 58 | existing_remote = `git config remote.#{name}.url`.chomp 59 | existing_remote = nil if existing_remote == '' 60 | 61 | if existing_remote 62 | say_status 'git remote', "#{name} already exists", :blue 63 | else 64 | say_status 'git remote', "add #{name} #{url}" 65 | system "git remote add #{name} #{url}" 66 | end 67 | end 68 | 69 | def git_submodule_init 70 | say_status 'git submodule', 'init', :green 71 | system 'git submodule --quiet init' 72 | end 73 | 74 | def git_submodule_update 75 | say_status 'git submodule', 'update', :green 76 | system 'git submodule --quiet update --init --recursive >/dev/null 2>&1' 77 | end 78 | 79 | def git_pull 80 | say_status 'git pull', '', :green 81 | system 'git pull --quiet' 82 | end 83 | 84 | def git_push 85 | say_status 'git push', '', :green 86 | system 'git push' 87 | end 88 | 89 | def git_commit_all(config = {}) 90 | say_status 'git commit all', '', :green 91 | if config[:message] 92 | system %(git commit -a -m "#{config[:message]}") 93 | else 94 | system 'git commit -v -a' 95 | end 96 | end 97 | 98 | def git_add(file) 99 | say_status 'git add file', '', :green 100 | system "git add '#{file}'" 101 | end 102 | 103 | def git_status 104 | say_status 'git status', '', :green 105 | system 'git status' 106 | end 107 | 108 | def git_diff 109 | say_status 'git diff', '', :green 110 | system 'git diff' 111 | end 112 | end 113 | end 114 | end 115 | -------------------------------------------------------------------------------- /lib/homesick/cli.rb: -------------------------------------------------------------------------------- 1 | require 'fileutils' 2 | require 'thor' 3 | 4 | module Homesick 5 | # Homesick's command line interface 6 | class CLI < Thor 7 | include Thor::Actions 8 | include Homesick::Actions::FileActions 9 | include Homesick::Actions::GitActions 10 | include Homesick::Version 11 | include Homesick::Utils 12 | 13 | add_runtime_options! 14 | 15 | map '-v' => :version 16 | map '--version' => :version 17 | # Retain a mapped version of the symlink command for compatibility. 18 | map symlink: :link 19 | 20 | def initialize(args = [], options = {}, config = {}) 21 | super 22 | # Check if git is installed 23 | unless git_version_correct? 24 | say_status :error, "Git version >= #{Homesick::Actions::GitActions::STRING} must be installed to use Homesick", :red 25 | exit(1) 26 | end 27 | configure_symlinks_diff 28 | end 29 | 30 | desc 'clone URI CASTLE_NAME', 'Clone +uri+ as a castle with name CASTLE_NAME for homesick' 31 | def clone(uri, destination = nil) 32 | destination = Pathname.new(destination) unless destination.nil? 33 | 34 | inside repos_dir do 35 | if File.exist?(uri) 36 | uri = Pathname.new(uri).expand_path 37 | raise "Castle already cloned to #{uri}" if uri.to_s.start_with?(repos_dir.to_s) 38 | 39 | destination = uri.basename if destination.nil? 40 | 41 | ln_s uri, destination 42 | elsif uri =~ GITHUB_NAME_REPO_PATTERN 43 | destination = Pathname.new(uri).basename if destination.nil? 44 | git_clone "https://github.com/#{Regexp.last_match[1]}.git", 45 | destination: destination 46 | elsif uri =~ /%r([^%r]*?)(\.git)?\Z/ || uri =~ /[^:]+:([^:]+)(\.git)?\Z/ 47 | destination = Pathname.new(Regexp.last_match[1].gsub(/\.git$/, '')).basename if destination.nil? 48 | git_clone uri, destination: destination 49 | else 50 | raise "Unknown URI format: #{uri}" 51 | end 52 | 53 | setup_castle(destination) 54 | end 55 | end 56 | 57 | desc 'rc CASTLE', 'Run the .homesickrc for the specified castle' 58 | method_option :force, 59 | type: :boolean, 60 | default: false, 61 | desc: 'Evaluate .homesickrc without prompting.' 62 | def rc(name = DEFAULT_CASTLE_NAME) 63 | inside repos_dir do 64 | destination = Pathname.new(name) 65 | homesickrc = destination.join('.homesickrc').expand_path 66 | return unless homesickrc.exist? 67 | 68 | proceed = options[:force] || shell.yes?("#{name} has a .homesickrc. Proceed with evaling it? (This could be destructive)") 69 | return say_status 'eval skip', "not evaling #{homesickrc}, #{destination} may need manual configuration", :blue unless proceed 70 | 71 | say_status 'eval', homesickrc 72 | inside destination do 73 | eval homesickrc.read, binding, homesickrc.expand_path.to_s 74 | end 75 | end 76 | end 77 | 78 | desc 'pull CASTLE', 'Update the specified castle' 79 | method_option :all, 80 | type: :boolean, 81 | default: false, 82 | required: false, 83 | desc: 'Update all cloned castles' 84 | def pull(name = DEFAULT_CASTLE_NAME) 85 | if options[:all] 86 | inside_each_castle do |castle| 87 | say castle.to_s.gsub(repos_dir.to_s + '/', '') + ':' 88 | update_castle castle 89 | end 90 | else 91 | update_castle name 92 | end 93 | end 94 | 95 | desc 'commit CASTLE MESSAGE', "Commit the specified castle's changes" 96 | def commit(name = DEFAULT_CASTLE_NAME, message = nil) 97 | commit_castle name, message 98 | end 99 | 100 | desc 'push CASTLE', 'Push the specified castle' 101 | def push(name = DEFAULT_CASTLE_NAME) 102 | push_castle name 103 | end 104 | 105 | desc 'unlink CASTLE', 'Unsymlinks all dotfiles from the specified castle' 106 | def unlink(name = DEFAULT_CASTLE_NAME) 107 | check_castle_existance(name, 'symlink') 108 | 109 | inside castle_dir(name) do 110 | subdirs = subdirs(name) 111 | 112 | # unlink files 113 | unsymlink_each(name, castle_dir(name), subdirs) 114 | 115 | # unlink files in subdirs 116 | subdirs.each do |subdir| 117 | unsymlink_each(name, subdir, subdirs) 118 | end 119 | end 120 | end 121 | 122 | desc 'link CASTLE', 'Symlinks all dotfiles from the specified castle' 123 | method_option :force, 124 | type: :boolean, 125 | default: false, 126 | desc: 'Overwrite existing conflicting symlinks without prompting.' 127 | def link(name = DEFAULT_CASTLE_NAME) 128 | check_castle_existance(name, 'symlink') 129 | 130 | castle_path = castle_dir(name) 131 | inside castle_path do 132 | subdirs = subdirs(name) 133 | 134 | # link files 135 | symlink_each(name, castle_path, subdirs) 136 | 137 | # link files in subdirs 138 | subdirs.each do |subdir| 139 | symlink_each(name, subdir, subdirs) 140 | end 141 | end 142 | end 143 | 144 | desc 'track FILE CASTLE', 'add a file to a castle' 145 | def track(file, castle = DEFAULT_CASTLE_NAME) 146 | castle = Pathname.new(castle) 147 | file = Pathname.new(file.chomp('/')) 148 | check_castle_existance(castle, 'track') 149 | 150 | absolute_path = file.expand_path 151 | relative_dir = absolute_path.relative_path_from(home_dir).dirname 152 | castle_path = Pathname.new(castle_dir(castle)).join(relative_dir) 153 | FileUtils.mkdir_p castle_path 154 | 155 | # Are we already tracking this or anything inside it? 156 | target = Pathname.new(castle_path.join(file.basename)) 157 | if target.exist? 158 | if absolute_path.directory? 159 | move_dir_contents(target, absolute_path) 160 | absolute_path.rmtree 161 | subdir_remove(castle, relative_dir + file.basename) 162 | 163 | elsif more_recent? absolute_path, target 164 | target.delete 165 | mv absolute_path, castle_path 166 | else 167 | say_status(:track, 168 | "#{target} already exists, and is more recent than #{file}. Run 'homesick SYMLINK CASTLE' to create symlinks.", 169 | :blue) 170 | end 171 | else 172 | mv absolute_path, castle_path 173 | end 174 | 175 | inside home_dir do 176 | absolute_path = castle_path + file.basename 177 | home_path = home_dir + relative_dir + file.basename 178 | ln_s absolute_path, home_path 179 | end 180 | 181 | inside castle_path do 182 | git_add absolute_path 183 | end 184 | 185 | # are we tracking something nested? Add the parent dir to the manifest 186 | subdir_add(castle, relative_dir) unless relative_dir.eql?(Pathname.new('.')) 187 | end 188 | 189 | desc 'list', 'List cloned castles' 190 | def list 191 | inside_each_castle do |castle| 192 | say_status castle.relative_path_from(repos_dir).to_s, 193 | `git config remote.origin.url`.chomp, 194 | :cyan 195 | end 196 | end 197 | 198 | desc 'status CASTLE', 'Shows the git status of a castle' 199 | def status(castle = DEFAULT_CASTLE_NAME) 200 | check_castle_existance(castle, 'status') 201 | inside repos_dir.join(castle) do 202 | git_status 203 | end 204 | end 205 | 206 | desc 'diff CASTLE', 'Shows the git diff of uncommitted changes in a castle' 207 | def diff(castle = DEFAULT_CASTLE_NAME) 208 | check_castle_existance(castle, 'diff') 209 | inside repos_dir.join(castle) do 210 | git_diff 211 | end 212 | end 213 | 214 | desc 'show_path CASTLE', 'Prints the path of a castle' 215 | def show_path(castle = DEFAULT_CASTLE_NAME) 216 | check_castle_existance(castle, 'show_path') 217 | say repos_dir.join(castle) 218 | end 219 | 220 | desc 'generate PATH', 'generate a homesick-ready git repo at PATH' 221 | def generate(castle) 222 | castle = Pathname.new(castle).expand_path 223 | 224 | github_user = `git config github.user`.chomp 225 | github_user = nil if github_user == '' 226 | github_repo = castle.basename 227 | 228 | empty_directory castle 229 | inside castle do 230 | git_init 231 | if github_user 232 | url = "git@github.com:#{github_user}/#{github_repo}.git" 233 | git_remote_add 'origin', url 234 | end 235 | 236 | empty_directory 'home' 237 | end 238 | end 239 | 240 | desc 'destroy CASTLE', 'Delete all symlinks and remove the cloned repository' 241 | def destroy(name) 242 | check_castle_existance name, 'destroy' 243 | return unless shell.yes?('This will destroy your castle irreversible! Are you sure?') 244 | 245 | unlink(name) 246 | rm_rf repos_dir.join(name) 247 | end 248 | 249 | desc 'cd CASTLE', 'Open a new shell in the root of the given castle' 250 | def cd(castle = DEFAULT_CASTLE_NAME) 251 | check_castle_existance castle, 'cd' 252 | castle_dir = repos_dir.join(castle) 253 | say_status "cd #{castle_dir.realpath}", 254 | "Opening a new shell in castle '#{castle}'. To return to the original one exit from the new shell.", 255 | :green 256 | inside castle_dir do 257 | system(ENV['SHELL']) 258 | end 259 | end 260 | 261 | desc 'open CASTLE', 262 | 'Open your default editor in the root of the given castle' 263 | def open(castle = DEFAULT_CASTLE_NAME) 264 | unless ENV['EDITOR'] 265 | say_status :error, 266 | 'The $EDITOR environment variable must be set to use this command', 267 | :red 268 | 269 | exit(1) 270 | end 271 | check_castle_existance castle, 'open' 272 | castle_dir = repos_dir.join(castle) 273 | say_status "#{castle_dir.realpath}: #{ENV['EDITOR']} .", 274 | "Opening the root directory of castle '#{castle}' in editor '#{ENV['EDITOR']}'.", 275 | :green 276 | inside castle_dir do 277 | system("#{ENV['EDITOR']} .") 278 | end 279 | end 280 | 281 | desc 'exec CASTLE COMMAND', 282 | 'Execute a single shell command inside the root of a castle' 283 | def exec(castle, *args) 284 | check_castle_existance castle, 'exec' 285 | unless args.count > 0 286 | say_status :error, 287 | 'You must pass a shell command to execute', 288 | :red 289 | exit(1) 290 | end 291 | full_command = args.join(' ') 292 | say_status "exec '#{full_command}'", 293 | "#{options[:pretend] ? 'Would execute' : 'Executing command'} '#{full_command}' in castle '#{castle}'", 294 | :green 295 | inside repos_dir.join(castle) do 296 | system(full_command) 297 | end 298 | end 299 | 300 | desc 'exec_all COMMAND', 301 | 'Execute a single shell command inside the root of every cloned castle' 302 | def exec_all(*args) 303 | unless args.count > 0 304 | say_status :error, 305 | 'You must pass a shell command to execute', 306 | :red 307 | exit(1) 308 | end 309 | full_command = args.join(' ') 310 | inside_each_castle do |castle| 311 | say_status "exec '#{full_command}'", 312 | "#{options[:pretend] ? 'Would execute' : 'Executing command'} '#{full_command}' in castle '#{castle}'", 313 | :green 314 | system(full_command) 315 | end 316 | end 317 | 318 | desc 'version', 'Display the current version of homesick' 319 | def version 320 | say Homesick::Version::STRING 321 | end 322 | end 323 | end 324 | -------------------------------------------------------------------------------- /lib/homesick/utils.rb: -------------------------------------------------------------------------------- 1 | require 'pathname' 2 | 3 | module Homesick 4 | # Various utility methods that are used by Homesick 5 | module Utils 6 | protected 7 | 8 | def home_dir 9 | @home_dir ||= Pathname.new(ENV['HOME'] || '~').realpath 10 | end 11 | 12 | def repos_dir 13 | @repos_dir ||= home_dir.join('.homesick', 'repos').expand_path 14 | end 15 | 16 | def castle_dir(name) 17 | repos_dir.join(name, 'home') 18 | end 19 | 20 | def check_castle_existance(name, action) 21 | return if castle_dir(name).exist? 22 | 23 | say_status :error, 24 | "Could not #{action} #{name}, expected #{castle_dir(name)} to exist and contain dotfiles", 25 | :red 26 | exit(1) 27 | end 28 | 29 | def all_castles 30 | dirs = Pathname.glob("#{repos_dir}/**/.git", File::FNM_DOTMATCH) 31 | # reject paths that lie inside another castle, like git submodules 32 | dirs.reject do |dir| 33 | dirs.any? do |other| 34 | dir != other && dir.fnmatch(other.parent.join('*').to_s) 35 | end 36 | end 37 | end 38 | 39 | def inside_each_castle 40 | all_castles.each do |git_dir| 41 | castle = git_dir.dirname 42 | Dir.chdir castle do # so we can call git config from the right contxt 43 | yield castle 44 | end 45 | end 46 | end 47 | 48 | def update_castle(castle) 49 | check_castle_existance(castle, 'pull') 50 | inside repos_dir.join(castle) do 51 | git_pull 52 | git_submodule_init 53 | git_submodule_update 54 | end 55 | end 56 | 57 | def commit_castle(castle, message) 58 | check_castle_existance(castle, 'commit') 59 | inside repos_dir.join(castle) do 60 | git_commit_all message: message 61 | end 62 | end 63 | 64 | def push_castle(castle) 65 | check_castle_existance(castle, 'push') 66 | inside repos_dir.join(castle) do 67 | git_push 68 | end 69 | end 70 | 71 | def subdir_file(castle) 72 | repos_dir.join(castle, SUBDIR_FILENAME) 73 | end 74 | 75 | def subdirs(castle) 76 | subdir_filepath = subdir_file(castle) 77 | subdirs = [] 78 | if subdir_filepath.exist? 79 | subdir_filepath.readlines.each do |subdir| 80 | subdirs.push(subdir.chomp) 81 | end 82 | end 83 | subdirs 84 | end 85 | 86 | def subdir_add(castle, path) 87 | subdir_filepath = subdir_file(castle) 88 | File.open(subdir_filepath, 'a+') do |subdir| 89 | subdir.puts path unless subdir.readlines.reduce(false) do |memo, line| 90 | line.eql?("#{path}\n") || memo 91 | end 92 | end 93 | 94 | inside castle_dir(castle) do 95 | git_add subdir_filepath 96 | end 97 | end 98 | 99 | def subdir_remove(castle, path) 100 | subdir_filepath = subdir_file(castle) 101 | if subdir_filepath.exist? 102 | lines = IO.readlines(subdir_filepath).delete_if do |line| 103 | line == "#{path}\n" 104 | end 105 | File.open(subdir_filepath, 'w') { |manfile| manfile.puts lines } 106 | end 107 | 108 | inside castle_dir(castle) do 109 | git_add subdir_filepath 110 | end 111 | end 112 | 113 | def move_dir_contents(target, dir_path) 114 | child_files = dir_path.children 115 | child_files.each do |child| 116 | target_path = target.join(child.basename) 117 | if target_path.exist? 118 | if more_recent?(child, target_path) && target.file? 119 | target_path.delete 120 | mv child, target 121 | end 122 | next 123 | end 124 | 125 | mv child, target 126 | end 127 | end 128 | 129 | def more_recent?(first, second) 130 | first_p = Pathname.new(first) 131 | second_p = Pathname.new(second) 132 | first_p.mtime > second_p.mtime && !first_p.symlink? 133 | end 134 | 135 | def collision_accepted?(destination, source) 136 | raise "Arguments must be instances of Pathname, #{destination.class.name} and #{source.class.name} given" unless destination.instance_of?(Pathname) && source.instance_of?(Pathname) 137 | 138 | options[:force] || shell.file_collision(destination) { source } 139 | end 140 | 141 | def unsymlink_each(castle, basedir, subdirs) 142 | each_file(castle, basedir, subdirs) do |_absolute_path, home_path| 143 | rm_link home_path 144 | end 145 | end 146 | 147 | def symlink_each(castle, basedir, subdirs) 148 | each_file(castle, basedir, subdirs) do |absolute_path, home_path| 149 | ln_s absolute_path, home_path 150 | end 151 | end 152 | 153 | def setup_castle(path) 154 | if path.join('.gitmodules').exist? 155 | inside path do 156 | git_submodule_init 157 | git_submodule_update 158 | end 159 | end 160 | 161 | rc(path) 162 | end 163 | 164 | def each_file(castle, basedir, subdirs) 165 | absolute_basedir = Pathname.new(basedir).expand_path 166 | castle_home = castle_dir(castle) 167 | inside basedir do |destination_root| 168 | FileUtils.cd(destination_root) unless destination_root == FileUtils.pwd 169 | files = Pathname.glob('*', File::FNM_DOTMATCH) 170 | .reject { |a| ['.', '..'].include?(a.to_s) } 171 | .reject { |path| matches_ignored_dir? castle_home, path.expand_path, subdirs } 172 | files.each do |path| 173 | absolute_path = path.expand_path 174 | 175 | relative_dir = absolute_basedir.relative_path_from(castle_home) 176 | home_path = home_dir.join(relative_dir).join(path) 177 | 178 | yield(absolute_path, home_path) 179 | end 180 | end 181 | end 182 | 183 | def matches_ignored_dir?(castle_home, absolute_path, subdirs) 184 | # make ignore dirs 185 | ignore_dirs = [] 186 | subdirs.each do |subdir| 187 | # ignore all parent of each line in subdir file 188 | Pathname.new(subdir).ascend do |p| 189 | ignore_dirs.push(p) 190 | end 191 | end 192 | 193 | # ignore dirs written in subdir file 194 | ignore_dirs.uniq.each do |ignore_dir| 195 | return true if absolute_path == castle_home.join(ignore_dir) 196 | end 197 | false 198 | end 199 | 200 | def configure_symlinks_diff 201 | # Hack in support for diffing symlinks 202 | # Also adds support for checking if destination or content is a directory 203 | shell_metaclass = class << shell; self; end 204 | shell_metaclass.send(:define_method, :show_diff) do |destination, source| 205 | destination = Pathname.new(destination) 206 | source = Pathname.new(source) 207 | return 'Unable to create diff: destination or content is a directory' if destination.directory? || source.directory? 208 | return super(destination, File.binread(source)) unless destination.symlink? 209 | 210 | say "- #{destination.readlink}", :red, true 211 | say "+ #{source.expand_path}", :green, true 212 | end 213 | end 214 | end 215 | end 216 | -------------------------------------------------------------------------------- /lib/homesick/version.rb: -------------------------------------------------------------------------------- 1 | module Homesick 2 | # A representation of Homesick's version number in constants, including a 3 | # String of the entire version number 4 | module Version 5 | MAJOR = 1 6 | MINOR = 1 7 | PATCH = 6 8 | 9 | STRING = [MAJOR, MINOR, PATCH].compact.join('.') 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /spec/homesick_cli_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | require 'capture-output' 3 | require 'pathname' 4 | 5 | describe Homesick::CLI do 6 | let(:home) { create_construct } 7 | after { home.destroy! } 8 | 9 | let(:castles) { home.directory('.homesick/repos') } 10 | 11 | let(:homesick) { Homesick::CLI.new } 12 | 13 | before { allow(homesick).to receive(:repos_dir).and_return(castles) } 14 | 15 | describe 'smoke tests' do 16 | context 'when running bin/homesick' do 17 | before do 18 | bin_path = Pathname.new(__FILE__).parent.parent 19 | @output = `#{bin_path.expand_path}/bin/homesick` 20 | end 21 | it 'should output some text when bin/homesick is called' do 22 | expect(@output.length).to be > 0 23 | end 24 | end 25 | 26 | context 'when a git version that doesn\'t meet the minimum required is installed' do 27 | before do 28 | expect_any_instance_of(Homesick::Actions::GitActions).to receive(:`).and_return('git version 1.7.6') 29 | end 30 | it 'should raise an exception' do 31 | output = Capture.stdout { expect { Homesick::CLI.new }.to raise_error SystemExit } 32 | expect(output.chomp).to include(Homesick::Actions::GitActions::STRING) 33 | end 34 | end 35 | 36 | context 'when a git version that is the same as the minimum required is installed' do 37 | before do 38 | expect_any_instance_of(Homesick::Actions::GitActions).to receive(:`).at_least(:once).and_return("git version #{Homesick::Actions::GitActions::STRING}") 39 | end 40 | it 'should not raise an exception' do 41 | output = Capture.stdout { expect { Homesick::CLI.new }.not_to raise_error } 42 | expect(output.chomp).not_to include(Homesick::Actions::GitActions::STRING) 43 | end 44 | end 45 | 46 | context 'when a git version that is greater than the minimum required is installed' do 47 | before do 48 | expect_any_instance_of(Homesick::Actions::GitActions).to receive(:`).at_least(:once).and_return('git version 3.9.8') 49 | end 50 | it 'should not raise an exception' do 51 | output = Capture.stdout { expect { Homesick::CLI.new }.not_to raise_error } 52 | expect(output.chomp).not_to include(Homesick::Actions::GitActions::STRING) 53 | end 54 | end 55 | end 56 | 57 | describe 'clone' do 58 | context 'has a .homesickrc' do 59 | it 'runs the .homesickrc' do 60 | somewhere = create_construct 61 | local_repo = somewhere.directory('some_repo') 62 | local_repo.file('.homesickrc') do |file| 63 | file << "File.open(Dir.pwd + '/testing', 'w') do |f| 64 | f.print 'testing' 65 | end" 66 | end 67 | 68 | expect_any_instance_of(Thor::Shell::Basic).to receive(:yes?).with(be_a(String)).and_return(true) 69 | expect(homesick).to receive(:say_status).with('eval', kind_of(Pathname)) 70 | homesick.clone local_repo 71 | 72 | expect(castles.join('some_repo').join('testing')).to exist 73 | end 74 | end 75 | 76 | context 'of a file' do 77 | it 'symlinks existing directories' do 78 | somewhere = create_construct 79 | local_repo = somewhere.directory('wtf') 80 | 81 | homesick.clone local_repo 82 | 83 | expect(castles.join('wtf').readlink).to eq(local_repo) 84 | end 85 | 86 | context 'when it exists in a repo directory' do 87 | before do 88 | existing_castle = given_castle('existing_castle') 89 | @existing_dir = existing_castle.parent 90 | end 91 | 92 | it 'raises an error' do 93 | expect(homesick).not_to receive(:git_clone) 94 | expect { homesick.clone @existing_dir.to_s }.to raise_error(/already cloned/i) 95 | end 96 | end 97 | end 98 | 99 | it 'clones git repo like file:///path/to.git' do 100 | bare_repo = File.join(create_construct.to_s, 'dotfiles.git') 101 | system "git init --bare #{bare_repo} >/dev/null 2>&1" 102 | 103 | # Capture stderr to suppress message about cloning an empty repo. 104 | Capture.stderr do 105 | homesick.clone "file://#{bare_repo}" 106 | end 107 | expect(File.directory?(File.join(home.to_s, '.homesick/repos/dotfiles'))) 108 | .to be_truthy 109 | end 110 | 111 | it 'clones git repo like git://host/path/to.git' do 112 | expect(homesick).to receive(:git_clone) 113 | .with('git://github.com/technicalpickles/pickled-vim.git', destination: Pathname.new('pickled-vim')) 114 | 115 | homesick.clone 'git://github.com/technicalpickles/pickled-vim.git' 116 | end 117 | 118 | it 'clones git repo like git@host:path/to.git' do 119 | expect(homesick).to receive(:git_clone) 120 | .with('git@github.com:technicalpickles/pickled-vim.git', destination: Pathname.new('pickled-vim')) 121 | 122 | homesick.clone 'git@github.com:technicalpickles/pickled-vim.git' 123 | end 124 | 125 | it 'clones git repo like http://host/path/to.git' do 126 | expect(homesick).to receive(:git_clone) 127 | .with('http://github.com/technicalpickles/pickled-vim.git', destination: Pathname.new('pickled-vim')) 128 | 129 | homesick.clone 'http://github.com/technicalpickles/pickled-vim.git' 130 | end 131 | 132 | it 'clones git repo like http://host/path/to' do 133 | expect(homesick).to receive(:git_clone) 134 | .with('http://github.com/technicalpickles/pickled-vim', destination: Pathname.new('pickled-vim')) 135 | 136 | homesick.clone 'http://github.com/technicalpickles/pickled-vim' 137 | end 138 | 139 | it 'clones git repo like host-alias:repos.git' do 140 | expect(homesick).to receive(:git_clone).with('gitolite:pickled-vim.git', 141 | destination: Pathname.new('pickled-vim')) 142 | 143 | homesick.clone 'gitolite:pickled-vim.git' 144 | end 145 | 146 | it 'throws an exception when trying to clone a malformed uri like malformed' do 147 | expect(homesick).not_to receive(:git_clone) 148 | expect { homesick.clone 'malformed' }.to raise_error(RuntimeError) 149 | end 150 | 151 | it 'clones a github repo' do 152 | expect(homesick).to receive(:git_clone) 153 | .with('https://github.com/wfarr/dotfiles.git', destination: Pathname.new('dotfiles')) 154 | 155 | homesick.clone 'wfarr/dotfiles' 156 | end 157 | 158 | it 'accepts a destination', :focus do 159 | expect(homesick).to receive(:git_clone) 160 | .with('https://github.com/wfarr/dotfiles.git', 161 | destination: Pathname.new('other-name')) 162 | 163 | homesick.clone 'wfarr/dotfiles', 'other-name' 164 | end 165 | end 166 | 167 | describe 'rc' do 168 | let(:castle) { given_castle('glencairn') } 169 | 170 | context 'when told to do so' do 171 | before do 172 | expect_any_instance_of(Thor::Shell::Basic).to receive(:yes?).with(be_a(String)).and_return(true) 173 | end 174 | 175 | it 'executes the .homesickrc' do 176 | castle.file('.homesickrc') do |file| 177 | file << "File.open(Dir.pwd + '/testing', 'w') do |f| 178 | f.print 'testing' 179 | end" 180 | end 181 | 182 | expect(homesick).to receive(:say_status).with('eval', kind_of(Pathname)) 183 | homesick.rc castle 184 | 185 | expect(castle.join('testing')).to exist 186 | end 187 | end 188 | 189 | context 'when options[:force] == true' do 190 | let(:homesick) { Homesick::CLI.new [], force: true } 191 | before do 192 | expect_any_instance_of(Thor::Shell::Basic).to_not receive(:yes?) 193 | end 194 | 195 | it 'executes the .homesickrc' do 196 | castle.file('.homesickrc') do |file| 197 | file << "File.open(Dir.pwd + '/testing', 'w') do |f| 198 | f.print 'testing' 199 | end" 200 | end 201 | 202 | expect(homesick).to receive(:say_status).with('eval', kind_of(Pathname)) 203 | homesick.rc castle 204 | 205 | expect(castle.join('testing')).to exist 206 | end 207 | end 208 | 209 | context 'when told not to do so' do 210 | before do 211 | expect_any_instance_of(Thor::Shell::Basic).to receive(:yes?).with(be_a(String)).and_return(false) 212 | end 213 | 214 | it 'does not execute the .homesickrc' do 215 | castle.file('.homesickrc') do |file| 216 | file << "File.open(Dir.pwd + '/testing', 'w') do |f| 217 | f.print 'testing' 218 | end" 219 | end 220 | 221 | expect(homesick).to receive(:say_status).with('eval skip', /not evaling.+/, :blue) 222 | homesick.rc castle 223 | 224 | expect(castle.join('testing')).not_to exist 225 | end 226 | end 227 | end 228 | 229 | describe 'link_castle' do 230 | let(:castle) { given_castle('glencairn') } 231 | 232 | it 'links dotfiles from a castle to the home folder' do 233 | dotfile = castle.file('.some_dotfile') 234 | 235 | homesick.link('glencairn') 236 | 237 | expect(home.join('.some_dotfile').readlink).to eq(dotfile) 238 | end 239 | 240 | it 'links non-dotfiles from a castle to the home folder' do 241 | dotfile = castle.file('bin') 242 | 243 | homesick.link('glencairn') 244 | 245 | expect(home.join('bin').readlink).to eq(dotfile) 246 | end 247 | 248 | context 'when forced' do 249 | let(:homesick) { Homesick::CLI.new [], force: true } 250 | 251 | it 'can override symlinks to directories' do 252 | somewhere_else = create_construct 253 | existing_dotdir_link = home.join('.vim') 254 | FileUtils.ln_s somewhere_else, existing_dotdir_link 255 | 256 | dotdir = castle.directory('.vim') 257 | 258 | homesick.link('glencairn') 259 | 260 | expect(existing_dotdir_link.readlink).to eq(dotdir) 261 | end 262 | 263 | it 'can override existing directory' do 264 | existing_dotdir = home.directory('.vim') 265 | 266 | dotdir = castle.directory('.vim') 267 | 268 | homesick.link('glencairn') 269 | 270 | expect(existing_dotdir.readlink).to eq(dotdir) 271 | end 272 | end 273 | 274 | context "with '.config' in .homesick_subdir" do 275 | let(:castle) { given_castle('glencairn', ['.config']) } 276 | it 'can symlink in sub directory' do 277 | dotdir = castle.directory('.config') 278 | dotfile = dotdir.file('.some_dotfile') 279 | 280 | homesick.link('glencairn') 281 | 282 | home_dotdir = home.join('.config') 283 | expect(home_dotdir.symlink?).to eq(false) 284 | expect(home_dotdir.join('.some_dotfile').readlink).to eq(dotfile) 285 | end 286 | end 287 | 288 | context "with '.config/appA' in .homesick_subdir" do 289 | let(:castle) { given_castle('glencairn', ['.config/appA']) } 290 | it 'can symlink in nested sub directory' do 291 | dotdir = castle.directory('.config').directory('appA') 292 | dotfile = dotdir.file('.some_dotfile') 293 | 294 | homesick.link('glencairn') 295 | 296 | home_dotdir = home.join('.config').join('appA') 297 | expect(home_dotdir.symlink?).to eq(false) 298 | expect(home_dotdir.join('.some_dotfile').readlink).to eq(dotfile) 299 | end 300 | end 301 | 302 | context "with '.config' and '.config/someapp' in .homesick_subdir" do 303 | let(:castle) do 304 | given_castle('glencairn', ['.config', '.config/someapp']) 305 | end 306 | it 'can symlink under both of .config and .config/someapp' do 307 | config_dir = castle.directory('.config') 308 | config_dotfile = config_dir.file('.some_dotfile') 309 | someapp_dir = config_dir.directory('someapp') 310 | someapp_dotfile = someapp_dir.file('.some_appfile') 311 | 312 | homesick.link('glencairn') 313 | 314 | home_config_dir = home.join('.config') 315 | home_someapp_dir = home_config_dir.join('someapp') 316 | expect(home_config_dir.symlink?).to eq(false) 317 | expect(home_config_dir.join('.some_dotfile').readlink).to eq(config_dotfile) 318 | expect(home_someapp_dir.symlink?).to eq(false) 319 | expect(home_someapp_dir.join('.some_appfile').readlink).to eq(someapp_dotfile) 320 | end 321 | end 322 | 323 | context 'when call with no castle name' do 324 | let(:castle) { given_castle('dotfiles') } 325 | it 'using default castle name: "dotfiles"' do 326 | dotfile = castle.file('.some_dotfile') 327 | 328 | homesick.link 329 | 330 | expect(home.join('.some_dotfile').readlink).to eq(dotfile) 331 | end 332 | end 333 | 334 | context 'when call and some files conflict' do 335 | it 'shows differences for conflicting text files' do 336 | contents = { castle: 'castle has new content', home: 'home already has content' } 337 | 338 | dotfile = castle.file('text') 339 | File.open(dotfile.to_s, 'w') do |f| 340 | f.write contents[:castle] 341 | end 342 | File.open(home.join('text').to_s, 'w') do |f| 343 | f.write contents[:home] 344 | end 345 | message = Capture.stdout { homesick.shell.show_diff(home.join('text'), dotfile) } 346 | expect(message.b).to match(/- ?#{contents[:home]}\n.*\+ ?#{contents[:castle]}$/m) 347 | end 348 | it 'shows message or differences for conflicting binary files' do 349 | # content which contains NULL character, without any parentheses, braces, ... 350 | contents = { castle: (0..255).step(30).map(&:chr).join, home: (0..255).step(30).reverse_each.map(&:chr).join } 351 | 352 | dotfile = castle.file('binary') 353 | File.open(dotfile.to_s, 'w') do |f| 354 | f.write contents[:castle] 355 | end 356 | File.open(home.join('binary').to_s, 'w') do |f| 357 | f.write contents[:home] 358 | end 359 | message = Capture.stdout { homesick.shell.show_diff(home.join('binary'), dotfile) } 360 | if homesick.shell.is_a?(Thor::Shell::Color) 361 | expect(message.b).to match(/- ?#{contents[:home]}\n.*\+ ?#{contents[:castle]}$/m) 362 | elsif homesick.shell.is_a?(Thor::Shell::Basic) 363 | expect(message.b).to match(/^Binary files .+ differ$/) 364 | end 365 | end 366 | end 367 | end 368 | 369 | describe 'unlink' do 370 | let(:castle) { given_castle('glencairn') } 371 | 372 | it 'unlinks dotfiles in the home folder' do 373 | castle.file('.some_dotfile') 374 | 375 | homesick.link('glencairn') 376 | homesick.unlink('glencairn') 377 | 378 | expect(home.join('.some_dotfile')).not_to exist 379 | end 380 | 381 | it 'unlinks non-dotfiles from the home folder' do 382 | castle.file('bin') 383 | 384 | homesick.link('glencairn') 385 | homesick.unlink('glencairn') 386 | 387 | expect(home.join('bin')).not_to exist 388 | end 389 | 390 | context "with '.config' in .homesick_subdir" do 391 | let(:castle) { given_castle('glencairn', ['.config']) } 392 | 393 | it 'can unlink sub directories' do 394 | castle.directory('.config').file('.some_dotfile') 395 | 396 | homesick.link('glencairn') 397 | homesick.unlink('glencairn') 398 | 399 | home_dotdir = home.join('.config') 400 | expect(home_dotdir).to exist 401 | expect(home_dotdir.join('.some_dotfile')).not_to exist 402 | end 403 | end 404 | 405 | context "with '.config/appA' in .homesick_subdir" do 406 | let(:castle) { given_castle('glencairn', ['.config/appA']) } 407 | 408 | it 'can unsymlink in nested sub directory' do 409 | castle.directory('.config').directory('appA').file('.some_dotfile') 410 | 411 | homesick.link('glencairn') 412 | homesick.unlink('glencairn') 413 | 414 | home_dotdir = home.join('.config').join('appA') 415 | expect(home_dotdir).to exist 416 | expect(home_dotdir.join('.some_dotfile')).not_to exist 417 | end 418 | end 419 | 420 | context "with '.config' and '.config/someapp' in .homesick_subdir" do 421 | let(:castle) do 422 | given_castle('glencairn', ['.config', '.config/someapp']) 423 | end 424 | 425 | it 'can unsymlink under both of .config and .config/someapp' do 426 | config_dir = castle.directory('.config') 427 | config_dir.file('.some_dotfile') 428 | config_dir.directory('someapp').file('.some_appfile') 429 | 430 | homesick.link('glencairn') 431 | homesick.unlink('glencairn') 432 | 433 | home_config_dir = home.join('.config') 434 | home_someapp_dir = home_config_dir.join('someapp') 435 | expect(home_config_dir).to exist 436 | expect(home_config_dir.join('.some_dotfile')).not_to exist 437 | expect(home_someapp_dir).to exist 438 | expect(home_someapp_dir.join('.some_appfile')).not_to exist 439 | end 440 | end 441 | 442 | context 'when call with no castle name' do 443 | let(:castle) { given_castle('dotfiles') } 444 | 445 | it 'using default castle name: "dotfiles"' do 446 | castle.file('.some_dotfile') 447 | 448 | homesick.link 449 | homesick.unlink 450 | 451 | expect(home.join('.some_dotfile')).not_to exist 452 | end 453 | end 454 | end 455 | 456 | describe 'list' do 457 | it 'says each castle in the castle directory' do 458 | given_castle('zomg') 459 | given_castle('wtf/zomg') 460 | 461 | expect(homesick).to receive(:say_status) 462 | .with('zomg', 'git://github.com/technicalpickles/zomg.git', :cyan) 463 | expect(homesick).to receive(:say_status) 464 | .with('wtf/zomg', 'git://github.com/technicalpickles/zomg.git', :cyan) 465 | 466 | homesick.list 467 | end 468 | end 469 | 470 | describe 'status' do 471 | it 'says "nothing to commit" when there are no changes' do 472 | given_castle('castle_repo') 473 | text = Capture.stdout { homesick.status('castle_repo') } 474 | expect(text).to match(%r{nothing to commit \(create/copy files and use "git add" to track\)$}) 475 | end 476 | 477 | it 'says "Changes to be committed" when there are changes' do 478 | given_castle('castle_repo') 479 | some_rc_file = home.file '.some_rc_file' 480 | homesick.track(some_rc_file.to_s, 'castle_repo') 481 | text = Capture.stdout { homesick.status('castle_repo') } 482 | expect(text).to match(%r{Changes to be committed:.*new file:\s*home\/.some_rc_file}m) 483 | end 484 | end 485 | 486 | describe 'diff' do 487 | it 'outputs an empty message when there are no changes to commit' do 488 | given_castle('castle_repo') 489 | some_rc_file = home.file '.some_rc_file' 490 | homesick.track(some_rc_file.to_s, 'castle_repo') 491 | Capture.stdout do 492 | homesick.commit 'castle_repo', 'Adding a file to the test' 493 | end 494 | text = Capture.stdout { homesick.diff('castle_repo') } 495 | expect(text).to eq('') 496 | end 497 | 498 | it 'outputs a diff message when there are changes to commit' do 499 | given_castle('castle_repo') 500 | some_rc_file = home.file '.some_rc_file' 501 | homesick.track(some_rc_file.to_s, 'castle_repo') 502 | Capture.stdout do 503 | homesick.commit 'castle_repo', 'Adding a file to the test' 504 | end 505 | File.open(some_rc_file.to_s, 'w') do |file| 506 | file.puts 'Some test text' 507 | end 508 | text = Capture.stdout { homesick.diff('castle_repo') } 509 | expect(text).to match(/diff --git.+Some test text$/m) 510 | end 511 | end 512 | 513 | describe 'show_path' do 514 | it 'says the path of a castle' do 515 | castle = given_castle('castle_repo') 516 | 517 | expect(homesick).to receive(:say).with(castle.dirname) 518 | 519 | homesick.show_path('castle_repo') 520 | end 521 | end 522 | 523 | describe 'pull' do 524 | it 'performs a pull, submodule init and update when the given castle exists' do 525 | given_castle('castle_repo') 526 | allow(homesick).to receive(:system).once.with('git pull --quiet') 527 | allow(homesick).to receive(:system).once.with('git submodule --quiet init') 528 | allow(homesick).to receive(:system).once.with('git submodule --quiet update --init --recursive >/dev/null 2>&1') 529 | homesick.pull 'castle_repo' 530 | end 531 | 532 | it 'prints an error message when trying to pull a non-existant castle' do 533 | expect(homesick).to receive('say_status').once 534 | .with(:error, 535 | /Could not pull castle_repo, expected .* to exist and contain dotfiles/, 536 | :red) 537 | expect { homesick.pull 'castle_repo' }.to raise_error(SystemExit) 538 | end 539 | 540 | describe '--all' do 541 | it 'pulls each castle when invoked with --all' do 542 | given_castle('castle_repo') 543 | given_castle('glencairn') 544 | allow(homesick).to receive(:system).exactly(2).times.with('git pull --quiet') 545 | allow(homesick).to receive(:system).exactly(2).times 546 | .with('git submodule --quiet init') 547 | allow(homesick).to receive(:system).exactly(2).times 548 | .with('git submodule --quiet update --init --recursive >/dev/null 2>&1') 549 | Capture.stdout do 550 | Capture.stderr { homesick.invoke 'pull', [], all: true } 551 | end 552 | end 553 | end 554 | end 555 | 556 | describe 'push' do 557 | it 'performs a git push on the given castle' do 558 | given_castle('castle_repo') 559 | allow(homesick).to receive(:system).once.with('git push') 560 | homesick.push 'castle_repo' 561 | end 562 | 563 | it 'prints an error message when trying to push a non-existant castle' do 564 | expect(homesick).to receive('say_status').once 565 | .with(:error, /Could not push castle_repo, expected .* to exist and contain dotfiles/, :red) 566 | expect { homesick.push 'castle_repo' }.to raise_error(SystemExit) 567 | end 568 | end 569 | 570 | describe 'track' do 571 | it 'moves the tracked file into the castle' do 572 | castle = given_castle('castle_repo') 573 | 574 | some_rc_file = home.file '.some_rc_file' 575 | 576 | homesick.track(some_rc_file.to_s, 'castle_repo') 577 | 578 | tracked_file = castle.join('.some_rc_file') 579 | expect(tracked_file).to exist 580 | 581 | expect(some_rc_file.readlink).to eq(tracked_file) 582 | end 583 | 584 | it 'handles files with parens' do 585 | castle = given_castle('castle_repo') 586 | 587 | some_rc_file = home.file 'Default (Linux).sublime-keymap' 588 | 589 | homesick.track(some_rc_file.to_s, 'castle_repo') 590 | 591 | tracked_file = castle.join('Default (Linux).sublime-keymap') 592 | expect(tracked_file).to exist 593 | 594 | expect(some_rc_file.readlink).to eq(tracked_file) 595 | end 596 | 597 | it 'tracks a file in nested folder structure' do 598 | castle = given_castle('castle_repo') 599 | 600 | some_nested_file = home.file('some/nested/file.txt') 601 | homesick.track(some_nested_file.to_s, 'castle_repo') 602 | 603 | tracked_file = castle.join('some/nested/file.txt') 604 | expect(tracked_file).to exist 605 | expect(some_nested_file.readlink).to eq(tracked_file) 606 | end 607 | 608 | it 'tracks a nested directory' do 609 | castle = given_castle('castle_repo') 610 | 611 | some_nested_dir = home.directory('some/nested/directory/') 612 | homesick.track(some_nested_dir.to_s, 'castle_repo') 613 | 614 | tracked_file = castle.join('some/nested/directory/') 615 | expect(tracked_file).to exist 616 | expect(some_nested_dir.realpath).to eq(tracked_file.realpath) 617 | end 618 | 619 | context 'when call with no castle name' do 620 | it 'using default castle name: "dotfiles"' do 621 | castle = given_castle('dotfiles') 622 | 623 | some_rc_file = home.file '.some_rc_file' 624 | 625 | homesick.track(some_rc_file.to_s) 626 | 627 | tracked_file = castle.join('.some_rc_file') 628 | expect(tracked_file).to exist 629 | 630 | expect(some_rc_file.readlink).to eq(tracked_file) 631 | end 632 | end 633 | 634 | describe 'commit' do 635 | it 'has a commit message when the commit succeeds' do 636 | given_castle('castle_repo') 637 | some_rc_file = home.file '.a_random_rc_file' 638 | homesick.track(some_rc_file.to_s, 'castle_repo') 639 | text = Capture.stdout do 640 | homesick.commit('castle_repo', 'Test message') 641 | end 642 | expect(text).to match(/^\[master \(root-commit\) \w+\] Test message/) 643 | end 644 | end 645 | 646 | # Note that this is a test for the subdir_file related feature of track, 647 | # not for the subdir_file method itself. 648 | describe 'subdir_file' do 649 | it 'adds the nested files parent to the subdir_file' do 650 | castle = given_castle('castle_repo') 651 | 652 | some_nested_file = home.file('some/nested/file.txt') 653 | homesick.track(some_nested_file.to_s, 'castle_repo') 654 | 655 | subdir_file = castle.parent.join(Homesick::SUBDIR_FILENAME) 656 | File.open(subdir_file, 'r') do |f| 657 | expect(f.readline).to eq("some/nested\n") 658 | end 659 | end 660 | 661 | it 'does NOT add anything if the files parent is already listed' do 662 | castle = given_castle('castle_repo') 663 | 664 | some_nested_file = home.file('some/nested/file.txt') 665 | other_nested_file = home.file('some/nested/other.txt') 666 | homesick.track(some_nested_file.to_s, 'castle_repo') 667 | homesick.track(other_nested_file.to_s, 'castle_repo') 668 | 669 | subdir_file = castle.parent.join(Homesick::SUBDIR_FILENAME) 670 | File.open(subdir_file, 'r') do |f| 671 | expect(f.readlines.size).to eq(1) 672 | end 673 | end 674 | 675 | it 'removes the parent of a tracked file from the subdir_file if the parent itself is tracked' do 676 | castle = given_castle('castle_repo') 677 | 678 | some_nested_file = home.file('some/nested/file.txt') 679 | nested_parent = home.directory('some/nested/') 680 | homesick.track(some_nested_file.to_s, 'castle_repo') 681 | homesick.track(nested_parent.to_s, 'castle_repo') 682 | 683 | subdir_file = castle.parent.join(Homesick::SUBDIR_FILENAME) 684 | File.open(subdir_file, 'r') do |f| 685 | f.each_line { |line| expect(line).not_to eq("some/nested\n") } 686 | end 687 | end 688 | end 689 | end 690 | 691 | describe 'destroy' do 692 | it 'removes the symlink files' do 693 | expect_any_instance_of(Thor::Shell::Basic).to receive(:yes?).and_return('y') 694 | given_castle('stronghold') 695 | some_rc_file = home.file '.some_rc_file' 696 | homesick.track(some_rc_file.to_s, 'stronghold') 697 | homesick.destroy('stronghold') 698 | 699 | expect(some_rc_file).not_to be_exist 700 | end 701 | 702 | it 'deletes the cloned repository' do 703 | expect_any_instance_of(Thor::Shell::Basic).to receive(:yes?).and_return('y') 704 | castle = given_castle('stronghold') 705 | some_rc_file = home.file '.some_rc_file' 706 | homesick.track(some_rc_file.to_s, 'stronghold') 707 | homesick.destroy('stronghold') 708 | 709 | expect(castle).not_to be_exist 710 | end 711 | end 712 | 713 | describe 'cd' do 714 | it "cd's to the root directory of the given castle" do 715 | given_castle('castle_repo') 716 | expect(homesick).to receive('inside').once.with(kind_of(Pathname)).and_yield 717 | expect(homesick).to receive('system').once.with(ENV['SHELL']) 718 | Capture.stdout { homesick.cd 'castle_repo' } 719 | end 720 | 721 | it 'returns an error message when the given castle does not exist' do 722 | expect(homesick).to receive('say_status').once 723 | .with(:error, /Could not cd castle_repo, expected .* to exist and contain dotfiles/, :red) 724 | expect { homesick.cd 'castle_repo' }.to raise_error(SystemExit) 725 | end 726 | end 727 | 728 | describe 'open' do 729 | it 'opens the system default editor in the root of the given castle' do 730 | # Make sure calls to ENV use default values for most things... 731 | allow(ENV).to receive(:[]).and_call_original 732 | # Set a default value for 'EDITOR' just in case none is set 733 | allow(ENV).to receive(:[]).with('EDITOR').and_return('vim') 734 | given_castle 'castle_repo' 735 | expect(homesick).to receive('inside').once.with(kind_of(Pathname)).and_yield 736 | expect(homesick).to receive('system').once.with('vim .') 737 | Capture.stdout { homesick.open 'castle_repo' } 738 | end 739 | 740 | it 'returns an error message when the $EDITOR environment variable is not set' do 741 | # Return empty ENV, the test does not call it anyway 742 | allow(ENV).to receive(:[]).and_return(nil) 743 | # Set the default editor to make sure it fails. 744 | allow(ENV).to receive(:[]).with('EDITOR').and_return(nil) 745 | expect(homesick).to receive('say_status').once 746 | .with(:error, 'The $EDITOR environment variable must be set to use this command', :red) 747 | expect { homesick.open 'castle_repo' }.to raise_error(SystemExit) 748 | end 749 | 750 | it 'returns an error message when the given castle does not exist' do 751 | # Return empty ENV, the test does not call it anyway 752 | allow(ENV).to receive(:[]).and_return(nil) 753 | # Set a default just in case none is set 754 | allow(ENV).to receive(:[]).with('EDITOR').and_return('vim') 755 | allow(homesick).to receive('say_status').once 756 | .with(:error, /Could not open castle_repo, expected .* to exist and contain dotfiles/, :red) 757 | expect { homesick.open 'castle_repo' }.to raise_error(SystemExit) 758 | end 759 | end 760 | 761 | describe 'version' do 762 | it 'prints the current version of homesick' do 763 | text = Capture.stdout { homesick.version } 764 | expect(text.chomp).to match(/#{Regexp.escape(Homesick::Version::STRING)}/) 765 | end 766 | end 767 | 768 | describe 'exec' do 769 | before do 770 | given_castle 'castle_repo' 771 | end 772 | it 'executes a single command with no arguments inside a given castle' do 773 | allow(homesick).to receive('inside').once.with(kind_of(Pathname)).and_yield 774 | allow(homesick).to receive('say_status').once 775 | .with(be_a(String), be_a(String), :green) 776 | allow(homesick).to receive('system').once.with('ls') 777 | Capture.stdout { homesick.exec 'castle_repo', 'ls' } 778 | end 779 | 780 | it 'executes a single command with arguments inside a given castle' do 781 | allow(homesick).to receive('inside').once.with(kind_of(Pathname)).and_yield 782 | allow(homesick).to receive('say_status').once 783 | .with(be_a(String), be_a(String), :green) 784 | allow(homesick).to receive('system').once.with('ls -la') 785 | Capture.stdout { homesick.exec 'castle_repo', 'ls', '-la' } 786 | end 787 | 788 | it 'raises an error when the method is called without a command' do 789 | allow(homesick).to receive('say_status').once 790 | .with(:error, be_a(String), :red) 791 | allow(homesick).to receive('exit').once.with(1) 792 | Capture.stdout { homesick.exec 'castle_repo' } 793 | end 794 | 795 | context 'pretend' do 796 | it 'does not execute a command when the pretend option is passed' do 797 | allow(homesick).to receive('say_status').once 798 | .with(be_a(String), match(/.*Would execute.*/), :green) 799 | expect(homesick).to receive('system').never 800 | Capture.stdout { homesick.invoke 'exec', %w[castle_repo ls -la], pretend: true } 801 | end 802 | end 803 | 804 | context 'quiet' do 805 | it 'does not print status information when quiet is passed' do 806 | expect(homesick).to receive('say_status').never 807 | allow(homesick).to receive('system').once 808 | .with('ls -la') 809 | Capture.stdout { homesick.invoke 'exec', %w[castle_repo ls -la], quiet: true } 810 | end 811 | end 812 | end 813 | 814 | describe 'exec_all' do 815 | before do 816 | given_castle 'castle_repo' 817 | given_castle 'another_castle_repo' 818 | end 819 | 820 | it 'executes a command without arguments inside the root of each cloned castle' do 821 | allow(homesick).to receive('inside_each_castle').exactly(:twice).and_yield('castle_repo').and_yield('another_castle_repo') 822 | allow(homesick).to receive('say_status').at_least(:once) 823 | .with(be_a(String), be_a(String), :green) 824 | allow(homesick).to receive('system').at_least(:once).with('ls') 825 | Capture.stdout { homesick.exec_all 'ls' } 826 | end 827 | 828 | it 'executes a command with arguments inside the root of each cloned castle' do 829 | allow(homesick).to receive('inside_each_castle').exactly(:twice).and_yield('castle_repo').and_yield('another_castle_repo') 830 | allow(homesick).to receive('say_status').at_least(:once) 831 | .with(be_a(String), be_a(String), :green) 832 | allow(homesick).to receive('system').at_least(:once).with('ls -la') 833 | Capture.stdout { homesick.exec_all 'ls', '-la' } 834 | end 835 | 836 | it 'raises an error when the method is called without a command' do 837 | allow(homesick).to receive('say_status').once 838 | .with(:error, be_a(String), :red) 839 | allow(homesick).to receive('exit').once.with(1) 840 | Capture.stdout { homesick.exec_all } 841 | end 842 | 843 | context 'pretend' do 844 | it 'does not execute a command when the pretend option is passed' do 845 | allow(homesick).to receive('say_status').at_least(:once) 846 | .with(be_a(String), match(/.*Would execute.*/), :green) 847 | expect(homesick).to receive('system').never 848 | Capture.stdout { homesick.invoke 'exec_all', %w[ls -la], pretend: true } 849 | end 850 | end 851 | 852 | context 'quiet' do 853 | it 'does not print status information when quiet is passed' do 854 | expect(homesick).to receive('say_status').never 855 | allow(homesick).to receive('system').at_least(:once) 856 | .with('ls -la') 857 | Capture.stdout { homesick.invoke 'exec_all', %w[ls -la], quiet: true } 858 | end 859 | end 860 | end 861 | end 862 | -------------------------------------------------------------------------------- /spec/spec.opts: -------------------------------------------------------------------------------- 1 | --color 2 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | require 'coveralls' 2 | Coveralls.wear! 3 | 4 | $LOAD_PATH.unshift(File.dirname(__FILE__)) 5 | $LOAD_PATH.unshift(File.join(File.dirname(__FILE__), '..', 'lib')) 6 | require 'homesick' 7 | require 'rspec' 8 | require 'test_construct' 9 | require 'tempfile' 10 | 11 | RSpec.configure do |config| 12 | config.include TestConstruct::Helpers 13 | 14 | config.expect_with(:rspec) { |c| c.syntax = :expect } 15 | 16 | config.before { ENV['HOME'] = home.to_s } 17 | 18 | config.before { silence! } 19 | 20 | def silence! 21 | allow(homesick).to receive(:say_status) 22 | end 23 | 24 | def given_castle(path, subdirs = []) 25 | name = Pathname.new(path).basename 26 | castles.directory(path) do |castle| 27 | Dir.chdir(castle) do 28 | system 'git init >/dev/null 2>&1' 29 | system 'git config user.email "test@test.com"' 30 | system 'git config user.name "Test Name"' 31 | system "git remote add origin git://github.com/technicalpickles/#{name}.git >/dev/null 2>&1" 32 | if subdirs 33 | subdir_file = castle.join(Homesick::SUBDIR_FILENAME) 34 | subdirs.each do |subdir| 35 | File.open(subdir_file, 'a') { |file| file.write "\n#{subdir}\n" } 36 | end 37 | end 38 | return castle.directory('home') 39 | end 40 | end 41 | end 42 | end 43 | --------------------------------------------------------------------------------