10 Commits

Author SHA1 Message Date
Micheal Wilkinson
0dfacc31d4 chore(build): rename binary to gosick 2026-03-19 16:33:45 +00:00
Micheal Wilkinson
1d26594010 docs(changelog): add unreleased migration notes 2026-03-19 16:29:39 +00:00
Micheal Wilkinson
c10ff251d5 docs(changelog): update formatting 2026-03-19 16:29:34 +00:00
Micheal Wilkinson
8d34674415 chore: remove Ruby implementation and tooling 2026-03-19 16:17:54 +00:00
Micheal Wilkinson
8174c6a983 refactor(cli): use kong for command parsing 2026-03-19 14:51:47 +00:00
Micheal Wilkinson
1d4c088edc test(cli): add parser coverage for kong refactor 2026-03-19 14:40:08 +00:00
Micheal Wilkinson
040bf31b56 fix(core): route status and diff output through app writers 2026-03-19 14:29:52 +00:00
Micheal Wilkinson
4355e7fd9d test(core): add status diff and version suites 2026-03-19 14:29:03 +00:00
Micheal Wilkinson
b7c353553a test(core): add dedicated list and show_path suites 2026-03-19 14:25:37 +00:00
Micheal Wilkinson
2f45d28acb feat(core,cli): implement track command with go-git staging 2026-03-19 14:21:15 +00:00
36 changed files with 1062 additions and 2400 deletions

View File

@@ -1,5 +0,0 @@
README.rdoc
lib/**/*.rb
bin/*
features/**/*.feature
LICENSE

21
.gitignore vendored
View File

@@ -1,17 +1,3 @@
# 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: # Have editor/IDE/OS specific files you need to ignore? Consider using a global gitignore:
# #
@@ -44,17 +30,10 @@ pkg
.idea/ .idea/
*.iml *.iml
Gemfile.lock
vendor/
homesick*.gem
# Go scaffolding artifacts # Go scaffolding artifacts
dist/ dist/
*.test *.test
*.out *.out
# rbenv configuration
.ruby-version
.github/* .github/*

1
.rspec
View File

@@ -1 +0,0 @@
--color

View File

@@ -1,19 +0,0 @@
# TODO: Eval is required for the .homesickrc feature. This should eventually be
# removed if the feature is implemented in a more secure way.
Eval:
Enabled: false
# TODO: The following settings disable reports about issues that can be fixed
# through refactoring. Remove these as offenses are removed from the code base.
ClassLength:
Enabled: false
CyclomaticComplexity:
Max: 13
LineLength:
Enabled: false
MethodLength:
Max: 36

View File

@@ -1,7 +0,0 @@
language: ruby
rvm:
- 2.5.0
- 2.4.0
- 2.3.3
- 2.2.6
sudo: false

View File

@@ -1,126 +0,0 @@
# 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 <github-user>/<github-repo>` (issue 3)
* Added `homesick pull <CASTLE>` for updating castles (thanks Jorge Dias!)
* Added a very basic `homesick generate <CASTLE>`
# 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

36
Gemfile
View File

@@ -1,36 +0,0 @@
source 'https://rubygems.org'
this_ruby = Gem::Version.new(RUBY_VERSION)
ruby_230 = Gem::Version.new('2.3.0')
# Add dependencies required to use your gem here.
gem 'thor', '>= 0.14.0'
# Add dependencies to develop your gem here.
# Include everything needed to run rake, tests, features, etc.
group :development do
gem 'capture-output', '~> 1.0.0'
gem 'coveralls', require: false
gem 'guard'
gem 'guard-rspec'
gem 'jeweler', '>= 1.6.2', '< 2.2' if this_ruby < ruby_230
gem 'jeweler', '>= 1.6.2' if this_ruby >= ruby_230
gem 'rake', '>= 0.8.7'
gem 'rb-readline', '~> 0.5.0'
gem 'rspec', '~> 3.5.0'
gem 'rubocop'
gem 'test_construct'
install_if -> { RUBY_PLATFORM =~ /linux|freebsd|openbsd|sunos|solaris/ } do
gem 'libnotify'
end
install_if -> { RUBY_PLATFORM =~ /darwin/ } do
gem 'terminal-notifier-guard', '~> 1.7.0'
end
install_if -> { this_ruby < ruby_230 } do
gem 'listen', '< 3'
gem 'rack', '~> 2.0.6'
end
end

View File

@@ -1,6 +0,0 @@
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

250
README.md
View File

@@ -1,228 +1,74 @@
# homesick # homesick
[![Gem Version](https://badge.fury.io/rb/homesick.svg)](http://badge.fury.io/rb/homesick)
[![Build Status](https://travis-ci.org/technicalpickles/homesick.svg?branch=master)](https://travis-ci.org/technicalpickles/homesick)
[![Dependency Status](https://gemnasium.com/technicalpickles/homesick.svg)](https://gemnasium.com/technicalpickles/homesick)
[![Coverage Status](https://coveralls.io/repos/technicalpickles/homesick/badge.png)](https://coveralls.io/r/technicalpickles/homesick)
[![Code Climate](https://codeclimate.com/github/technicalpickles/homesick.svg)](https://codeclimate.com/github/technicalpickles/homesick)
[![Gitter chat](https://badges.gitter.im/technicalpickles/homesick.svg)](https://gitter.im/technicalpickles/homesick)
Your home directory is your castle. Don't leave your dotfiles behind. 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. This repository now contains a Go implementation of Homesick. A dotfiles repository is called a castle and should contain a `home/` directory with files to link into your `$HOME`.
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: ## Build
- Contains a 'home' directory Build with just:
- '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
## Docker Behavior Validation (Ruby vs Go)
To preserve behavior while migrating from Ruby to Go, this repository includes a containerized behavior suite that runs Homesick commands in a clean environment and validates filesystem and git outcomes.
The suite creates a dedicated git repository inside the container to act as a test castle fixture, then validates behavior for:
- clone
- link / unlink
- track
- list / show_path
- status / diff
- version
Run against the current Ruby implementation:
./script/run-behavior-suite-docker.sh
Show full command output (including internal Homesick status lines) when needed:
./script/run-behavior-suite-docker.sh --verbose
This runner now builds an Alpine-based container and installs runtime dependencies with `apk`, so behavior validation is not tied to a Ruby base image.
Run against a future Go binary (example path):
HOMESICK_CMD="/workspace/dist/homesick" ./script/run-behavior-suite-docker.sh
The command under test is controlled with the `HOMESICK_CMD` environment variable. By running the same suite for both implementations, you can verify parity at the behavior level.
## Go Scaffold
Initial Go scaffolding now lives under [cmd/homesick](cmd/homesick) and [internal/homesick](internal/homesick).
Build the Go binary:
```bash
just go-build just go-build
```
Run behavior validation against the Go binary: Or directly with Go:
HOMESICK_CMD="/workspace/dist/homesick-go" just behavior-go ```bash
go build -o dist/gosick ./cmd/homesick
```
At this stage, the Go implementation includes a subset of commands (`clone`, `list`, `show_path`, `status`, `diff`, `version`) and intentionally reports clear errors for commands that are not ported yet. ## Commands
## .homesick_subdir Implemented commands:
`homesick link` basically makes symlink to only first depth in `castle/home`. If you want to link nested files/directories, please use .homesick_subdir. - `clone URI [CASTLE_NAME]`
- `list`
- `show_path [CASTLE]`
- `status [CASTLE]`
- `diff [CASTLE]`
- `link [CASTLE]`
- `unlink [CASTLE]`
- `track FILE [CASTLE]`
- `version`
For example, when you have castle like this: Not yet implemented:
castle/home - `pull`
`-- .config - `push`
`-- fooapp - `commit`
|-- config1 - `destroy`
|-- config2 - `cd`
`-- config3 - `open`
- `exec`
- `exec_all`
- `rc`
- `generate`
and have home like this: ## Behavior Suite
$ tree -a The repository includes a Docker-based behavior suite that validates filesystem and git outcomes for the implemented commands.
~
|-- .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. Run behavior suite:
castle/.homesick_subdir ```bash
just behavior
```
.config Verbose behavior suite output:
and run `homesick link CASTLE`. The result is: ```bash
just behavior-verbose
```
~ ## Testing
|-- .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: Run all Go tests:
homesick track .emacs.d/elisp castle ```bash
just go-test
```
castle/.homesick_subdir ## License
.config See `LICENSE`.
.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:
- 2.2.6
- 2.3.3
- 2.4.0
## Note on Patches/Pull Requests
- Fork the project.
- Make your feature addition or bug fix.
- Add tests for it. This is important so I don't break it in a future version unintentionally.
- 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)
- Send me 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.

View File

@@ -1,68 +0,0 @@
require 'rubygems'
require 'bundler'
require_relative 'lib/homesick/version'
begin
Bundler.setup(:default, :development)
rescue Bundler::BundlerError => e
$stderr.puts e.message
$stderr.puts "Run `bundle install` to install missing gems"
exit e.status_code
end
require 'rake'
require 'jeweler'
Jeweler::Tasks.new do |gem|
gem.name = "homesick"
gem.summary = %Q{Your home directory is your castle. Don't leave your dotfiles behind.}
gem.description = %Q{
Your home directory is your castle. Don't leave your dotfiles behind.
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.
}
gem.email = ["josh@technicalpickles.com", "info@muratayusuke.com"]
gem.homepage = "http://github.com/technicalpickles/homesick"
gem.authors = ["Joshua Nichols", "Yusuke Murata"]
gem.version = Homesick::Version::STRING
gem.license = "MIT"
# Have dependencies? Add them to Gemfile
# gem is a Gem::Specification... see http://www.rubygems.org/read/chapter/20 for additional settings
end
Jeweler::GemcutterTasks.new
require 'rspec/core/rake_task'
RSpec::Core::RakeTask.new(:spec) do |spec|
spec.pattern = FileList['spec/**/*_spec.rb']
end
RSpec::Core::RakeTask.new(:rcov) do |spec|
spec.pattern = 'spec/**/*_spec.rb'
spec.rcov = true
end
task :rubocop do
if RUBY_VERSION >= '1.9.2'
system('rubocop')
end
end
task :test do
Rake::Task['spec'].execute
Rake::Task['rubocop'].execute
end
task :default => :test
require 'rdoc/task'
Rake::RDocTask.new do |rdoc|
version = File.exist?('VERSION') ? File.read('VERSION') : ""
rdoc.rdoc_dir = 'rdoc'
rdoc.title = "homesick #{version}"
rdoc.rdoc_files.include('README*')
rdoc.rdoc_files.include('lib/**/*.rb')
end

View File

@@ -1,9 +1,11 @@
#!/usr/bin/env ruby #!/usr/bin/env bash
set -euo pipefail
require 'pathname' script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
lib = Pathname.new(__FILE__).dirname.join('..', 'lib').expand_path repo_root="$(cd "$script_dir/.." && pwd)"
$LOAD_PATH.unshift lib.to_s
require 'homesick' if [[ -x "$repo_root/dist/gosick" ]]; then
exec "$repo_root/dist/gosick" "$@"
fi
Homesick::CLI.start exec go run "$repo_root/cmd/homesick" "$@"

262
changelog.md Normal file
View File

@@ -0,0 +1,262 @@
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
### Added
- Native Go implementations for `clone`, `link`, `unlink`, and `track`.
- Containerized behavior test suite for command parity validation.
- Dedicated test suites for `list`, `show_path`, `status`, `diff`, and `version`.
- Just workflow support for building and running the Linux behavior binary.
### Changed
- CLI argument parsing migrated to Kong.
- Git operations for clone and track migrated to `go-git`.
- Build and behavior workflows now produce and run the `gosick` binary name.
- Release notes standardized to Keep a Changelog format.
### Fixed
- `status` and `diff` now consistently write through configured app output writers.
### Removed
- Legacy Ruby implementation and Ruby toolchain.
## [1.1.6] - 2017-12-20
### Fixed
- Ensure `FileUtils` is imported correctly to avoid a potential error.
- Fix an issue where comparing a diff did not use the content of the new file.
### Changed
- Small documentation fixes.
## [1.1.5] - 2017-03-23
### Fixed
- Problem with version number being incorrect.
## [1.1.4] - 2017-03-22
### Fixed
- Ensure symlink conflicts are explicitly communicated to users and symlinks are not silently overwritten.
- Fix a problem in diff when asking a user to resolve a conflict.
### Changed
- Use real paths of symlinks when linking a castle into home.
- Code refactoring and fixes.
## [1.1.3] - 2015-10-31
### Added
- Allow a destination to be passed when cloning a castle.
### Fixed
- Make sure `homesick edit` opens the default editor in the root of the given castle.
- Bug when diffing edited files.
- Crashing bug when attempting to diff directories.
- Ensure that messages are escaped correctly on `git commit all`.
## [1.1.2] - 2015-01-02
### Added
- `--force` option to the rc command to bypass confirmation checks when running a `.homesickrc` file.
- Check to ensure that at least Git 1.8.0 is installed.
### Fixed
- Stop Homesick failing silently when Git is not installed.
### Changed
- Code refactoring and fixes.
## [1.1.0] - 2014-04-28
### Added
- `exec` and `exec_all` commands to run commands inside one or all cloned castles.
### Changed
- Code refactoring.
## [1.0.0] - 2014-01-15
### Added
- `version` command.
### Removed
- Support for Ruby 1.8.7.
## [0.9.8] - 2014-01-02
### Added
- `homesick cd` command.
- `homesick open` command.
## [0.9.4] - 2013-07-31
### Added
- `homesick unlink` command.
- `homesick rc` command.
### Changed
- Use HTTPS protocol instead of git protocol.
## [0.9.3] - 2013-07-07
### Added
- Recursive option to `homesick clone`.
## [0.9.2] - 2013-06-27
### Added
- `homesick show_path` command.
- `homesick status` command.
- `homesick diff` command.
### Changed
- Set `dotfiles` as default castle name.
## [0.9.1] - 2013-06-17
### Fixed
- Small bugs: #35, #40.
## [0.9.0] - 2013-06-06
### Added
- `.homesick_subdir` (#39).
## [0.8.1] - 2013-05-19
### Fixed
- `homesick list` bug on Ruby 2.0 (#37).
## [0.8.0] - 2013-04-06
### Added
- `commit` and `push` command.
- Commit changes in a castle and push to remote.
- Enable recursive submodule update.
- Git add when using track.
## [0.7.0] - 2012-05-28
### Added
- New option for pull command: `--all`.
- Pull each castle instead of just one.
### Fixed
- Double-cloning (#14).
## [0.6.1] - 2010-11-13
### Added
- License.
## [0.6.0] - 2010-10-27
### Added
- `.homesickrc` support.
- Castles can now have a `.homesickrc` inside them.
- On clone, this is eval'd inside the destination directory.
- `track` command.
- Allows easily moving an existing file into a castle and symlinking it back.
## [0.5.0] - 2010-05-18
### Added
- `homesick pull <CASTLE>` for updating castles (thanks Jorge Dias).
- A very basic `homesick generate <CASTLE>`.
### Fixed
- Listing of castles cloned using `homesick clone <github-user>/<github-repo>` (issue 3).
## [0.4.1] - 2010-04-02
### Fixed
- Improve error message when a castle's home dir does not exist.
## [0.4.0] - 2010-04-01
### Added
- `homesick clone` can take a path to a directory on the filesystem, which is symlinked into place.
- `homesick clone` tries to run `git submodule init` and `git submodule update` if git submodules are defined for a cloned repo.
### Changed
- Use `HOME` environment variable for where to store files, instead of assuming `~`.
### Fixed
- Missing dependency on thor and others.
## [0.3.0] - 2010-04-01
### Changed
- Rename `link` to `symlink`.
### Fixed
- Conflict resolution when symlink destination exists and is a normal file.
## [0.2.0] - 2010-03-19
### Added
- Better support for recognizing git URLs (thanks jacobat).
- If it looks like a GitHub user/repo, use that.
- Otherwise hand off to git clone.
- Listing now displays in color and shows git remote.
- Support pretend, force, and quiet modes.
## [0.1.1] - 2010-03-17
### Fixed
- Trying to link against castles that do not exist.
- Linking now excludes `.` and `..` from the list of files to link (thanks Martinos).
## [0.1.0] - 2010-03-10
### Added
- Initial release.

View File

@@ -1,13 +1,21 @@
FROM golang:1.26-alpine AS builder
WORKDIR /workspace
COPY go.mod go.sum /workspace/
RUN go mod download
COPY . /workspace
RUN mkdir -p /workspace/dist && \
go build -o /workspace/dist/gosick ./cmd/homesick
FROM alpine:3.21 FROM alpine:3.21
RUN apk add --no-cache \ RUN apk add --no-cache \
bash \ bash \
ca-certificates \ ca-certificates \
git \ git
ruby \
ruby-thor
WORKDIR /workspace WORKDIR /workspace
COPY . /workspace COPY . /workspace
COPY --from=builder /workspace/dist/gosick /workspace/dist/gosick
ENTRYPOINT ["/workspace/test/behavior/behavior_suite.sh"] ENTRYPOINT ["/workspace/test/behavior/behavior_suite.sh"]

1
go.mod
View File

@@ -12,6 +12,7 @@ require (
dario.cat/mergo v1.0.0 // indirect dario.cat/mergo v1.0.0 // indirect
github.com/Microsoft/go-winio v0.6.2 // indirect github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/ProtonMail/go-crypto v1.1.5 // indirect github.com/ProtonMail/go-crypto v1.1.5 // indirect
github.com/alecthomas/kong v1.12.1 // indirect
github.com/cloudflare/circl v1.6.0 // indirect github.com/cloudflare/circl v1.6.0 // indirect
github.com/cyphar/filepath-securejoin v0.4.1 // indirect github.com/cyphar/filepath-securejoin v0.4.1 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect github.com/davecgh/go-spew v1.1.1 // indirect

2
go.sum
View File

@@ -5,6 +5,8 @@ github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERo
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/ProtonMail/go-crypto v1.1.5 h1:eoAQfK2dwL+tFSFpr7TbOaPNUbPiJj4fLYwwGE1FQO4= github.com/ProtonMail/go-crypto v1.1.5 h1:eoAQfK2dwL+tFSFpr7TbOaPNUbPiJj4fLYwwGE1FQO4=
github.com/ProtonMail/go-crypto v1.1.5/go.mod h1:rA3QumHc/FZ8pAHreoekgiAbzpNsfQAosU5td4SnOrE= github.com/ProtonMail/go-crypto v1.1.5/go.mod h1:rA3QumHc/FZ8pAHreoekgiAbzpNsfQAosU5td4SnOrE=
github.com/alecthomas/kong v1.12.1 h1:iq6aMJDcFYP9uFrLdsiZQ2ZMmcshduyGv4Pek0MQPW0=
github.com/alecthomas/kong v1.12.1/go.mod h1:p2vqieVMeTAnaC83txKtXe8FLke2X07aruPWXyMPQrU=
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8= github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8=
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4= github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4=
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio=

View File

@@ -1,105 +0,0 @@
# Generated by jeweler
# DO NOT EDIT THIS FILE DIRECTLY
# Instead, edit Jeweler::Tasks in Rakefile, and run 'rake gemspec'
# -*- encoding: utf-8 -*-
# stub: homesick 1.1.6 ruby lib
Gem::Specification.new do |s|
s.name = "homesick".freeze
s.version = "1.1.6"
s.required_rubygems_version = Gem::Requirement.new(">= 0".freeze) if s.respond_to? :required_rubygems_version=
s.require_paths = ["lib".freeze]
s.authors = ["Joshua Nichols".freeze, "Yusuke Murata".freeze]
s.date = "2017-12-20"
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
s.email = ["josh@technicalpickles.com".freeze, "info@muratayusuke.com".freeze]
s.executables = ["homesick".freeze]
s.extra_rdoc_files = [
"ChangeLog.markdown",
"LICENSE",
"README.markdown"
]
s.files = [
".document",
".rspec",
".rubocop.yml",
".travis.yml",
"ChangeLog.markdown",
"Gemfile",
"Guardfile",
"LICENSE",
"README.markdown",
"Rakefile",
"bin/homesick",
"homesick.gemspec",
"lib/homesick.rb",
"lib/homesick/actions/file_actions.rb",
"lib/homesick/actions/git_actions.rb",
"lib/homesick/cli.rb",
"lib/homesick/utils.rb",
"lib/homesick/version.rb",
"spec/homesick_cli_spec.rb",
"spec/spec.opts",
"spec/spec_helper.rb"
]
s.homepage = "http://github.com/technicalpickles/homesick".freeze
s.licenses = ["MIT".freeze]
s.rubygems_version = "2.6.11".freeze
s.summary = "Your home directory is your castle. Don't leave your dotfiles behind.".freeze
if s.respond_to? :specification_version then
s.specification_version = 4
if Gem::Version.new(Gem::VERSION) >= Gem::Version.new('1.2.0') then
s.add_runtime_dependency(%q<thor>.freeze, [">= 0.14.0"])
s.add_development_dependency(%q<capture-output>.freeze, ["~> 1.0.0"])
s.add_development_dependency(%q<coveralls>.freeze, [">= 0"])
s.add_development_dependency(%q<guard>.freeze, [">= 0"])
s.add_development_dependency(%q<guard-rspec>.freeze, [">= 0"])
s.add_development_dependency(%q<jeweler>.freeze, [">= 1.6.2"])
s.add_development_dependency(%q<rake>.freeze, [">= 0.8.7"])
s.add_development_dependency(%q<rb-readline>.freeze, ["~> 0.5.0"])
s.add_development_dependency(%q<rspec>.freeze, ["~> 3.5.0"])
s.add_development_dependency(%q<rubocop>.freeze, [">= 0"])
s.add_development_dependency(%q<test_construct>.freeze, [">= 0"])
s.add_development_dependency(%q<libnotify>.freeze, [">= 0"])
s.add_development_dependency(%q<terminal-notifier-guard>.freeze, ["~> 1.7.0"])
s.add_development_dependency(%q<listen>.freeze, ["< 3"])
s.add_development_dependency(%q<rack>.freeze, ["< 2"])
else
s.add_dependency(%q<thor>.freeze, [">= 0.14.0"])
s.add_dependency(%q<capture-output>.freeze, ["~> 1.0.0"])
s.add_dependency(%q<coveralls>.freeze, [">= 0"])
s.add_dependency(%q<guard>.freeze, [">= 0"])
s.add_dependency(%q<guard-rspec>.freeze, [">= 0"])
s.add_dependency(%q<jeweler>.freeze, [">= 1.6.2"])
s.add_dependency(%q<rake>.freeze, [">= 0.8.7"])
s.add_dependency(%q<rb-readline>.freeze, ["~> 0.5.0"])
s.add_dependency(%q<rspec>.freeze, ["~> 3.5.0"])
s.add_dependency(%q<rubocop>.freeze, [">= 0"])
s.add_dependency(%q<test_construct>.freeze, [">= 0"])
s.add_dependency(%q<libnotify>.freeze, [">= 0"])
s.add_dependency(%q<terminal-notifier-guard>.freeze, ["~> 1.7.0"])
s.add_dependency(%q<listen>.freeze, ["< 3"])
s.add_dependency(%q<rack>.freeze, ["< 2"])
end
else
s.add_dependency(%q<thor>.freeze, [">= 0.14.0"])
s.add_dependency(%q<capture-output>.freeze, ["~> 1.0.0"])
s.add_dependency(%q<coveralls>.freeze, [">= 0"])
s.add_dependency(%q<guard>.freeze, [">= 0"])
s.add_dependency(%q<guard-rspec>.freeze, [">= 0"])
s.add_dependency(%q<jeweler>.freeze, [">= 1.6.2"])
s.add_dependency(%q<rake>.freeze, [">= 0.8.7"])
s.add_dependency(%q<rb-readline>.freeze, ["~> 0.5.0"])
s.add_dependency(%q<rspec>.freeze, ["~> 3.5.0"])
s.add_dependency(%q<rubocop>.freeze, [">= 0"])
s.add_dependency(%q<test_construct>.freeze, [">= 0"])
s.add_dependency(%q<libnotify>.freeze, [">= 0"])
s.add_dependency(%q<terminal-notifier-guard>.freeze, ["~> 1.7.0"])
s.add_dependency(%q<listen>.freeze, ["< 3"])
s.add_dependency(%q<rack>.freeze, ["< 2"])
end
end

View File

@@ -1,6 +1,7 @@
package cli package cli
import ( import (
"errors"
"fmt" "fmt"
"io" "io"
"os" "os"
@@ -8,6 +9,7 @@ import (
"git.hrafn.xyz/aether/gosick/internal/homesick/core" "git.hrafn.xyz/aether/gosick/internal/homesick/core"
"git.hrafn.xyz/aether/gosick/internal/homesick/version" "git.hrafn.xyz/aether/gosick/internal/homesick/version"
"github.com/alecthomas/kong"
) )
func Run(args []string, stdout io.Writer, stderr io.Writer) int { func Run(args []string, stdout io.Writer, stderr io.Writer) int {
@@ -17,111 +19,219 @@ func Run(args []string, stdout io.Writer, stderr io.Writer) int {
return 1 return 1
} }
if len(args) == 0 { parser, err := kong.New(
printHelp(stdout) &cliModel{},
kong.Name("homesick"),
kong.Description("Go scaffold"),
kong.Writers(stdout, stderr),
kong.Exit(func(int) {}),
kong.ConfigureHelp(kong.HelpOptions{Compact: true}),
)
if err != nil {
_, _ = fmt.Fprintf(stderr, "error: %v\n", err)
return 1
}
normalizedArgs := normalizeArgs(args)
ctx, err := parser.Parse(normalizedArgs)
if err != nil {
var parseErr *kong.ParseError
if errors.As(err, &parseErr) {
if parseErr.ExitCode() == 0 || isHelpRequest(normalizedArgs) {
return 0
}
_, _ = fmt.Fprintf(stderr, "error: %v\n", err)
if parseErr.Context != nil {
_ = parseErr.Context.PrintUsage(false)
}
return parseErr.ExitCode()
}
_, _ = fmt.Fprintf(stderr, "error: %v\n", err)
return 1
}
if err := ctx.Run(app); err != nil {
var exitErr *cliExitError
if errors.As(err, &exitErr) {
_, _ = fmt.Fprintf(stderr, "error: %v\n", exitErr.err)
return exitErr.code
}
_, _ = fmt.Fprintf(stderr, "error: %v\n", err)
return 1
}
return 0 return 0
} }
command := args[0] type cliModel struct {
switch command { Clone cloneCmd `cmd:"" help:"Clone a castle."`
case "-v", "--version", "version": List listCmd `cmd:"" help:"List castles."`
if err := app.Version(version.String); err != nil { ShowPath showPathCmd `cmd:"" name:"show_path" help:"Show the path of a castle."`
_, _ = fmt.Fprintf(stderr, "error: %v\n", err) Status statusCmd `cmd:"" help:"Show git status for a castle."`
return 1 Diff diffCmd `cmd:"" help:"Show git diff for a castle."`
} Link linkCmd `cmd:"" help:"Symlink all dotfiles from a castle."`
return 0 Unlink unlinkCmd `cmd:"" help:"Unsymlink all dotfiles from a castle."`
case "help", "--help", "-h": Track trackCmd `cmd:"" help:"Track a file in a castle."`
printHelp(stdout) Version versionCmd `cmd:"" help:"Display the current version."`
return 0 Pull pullCmd `cmd:"" help:"Pull the specified castle."`
case "clone": Push pushCmd `cmd:"" help:"Push the specified castle."`
if len(args) < 2 { Commit commitCmd `cmd:"" help:"Commit the specified castle's changes."`
_, _ = fmt.Fprintln(stderr, "error: clone requires URI") Destroy destroyCmd `cmd:"" help:"Destroy a castle."`
return 1 Cd cdCmd `cmd:"" help:"Print the path to a castle."`
} Open openCmd `cmd:"" help:"Open a castle."`
destination := "" Exec execCmd `cmd:"" help:"Execute a command in a castle."`
if len(args) > 2 { ExecAll execAllCmd `cmd:"" name:"exec_all" help:"Execute a command in every castle."`
destination = args[2] Rc rcCmd `cmd:"" help:"Run rc hooks for a castle."`
} Generate generateCmd `cmd:"" help:"Generate a castle."`
if err := app.Clone(args[1], destination); err != nil {
_, _ = fmt.Fprintf(stderr, "error: %v\n", err)
return 1
}
return 0
case "list":
if err := app.List(); err != nil {
_, _ = fmt.Fprintf(stderr, "error: %v\n", err)
return 1
}
return 0
case "show_path":
castle := defaultCastle(args)
if err := app.ShowPath(castle); err != nil {
_, _ = fmt.Fprintf(stderr, "error: %v\n", err)
return 1
}
return 0
case "status":
castle := defaultCastle(args)
if err := app.Status(castle); err != nil {
_, _ = fmt.Fprintf(stderr, "error: %v\n", err)
return 1
}
return 0
case "diff":
castle := defaultCastle(args)
if err := app.Diff(castle); err != nil {
_, _ = fmt.Fprintf(stderr, "error: %v\n", err)
return 1
}
return 0
case "link":
castle := defaultCastle(args)
if err := app.LinkCastle(castle); err != nil {
_, _ = fmt.Fprintf(stderr, "error: %v\n", err)
return 1
}
return 0
case "unlink":
castle := defaultCastle(args)
if err := app.Unlink(castle); err != nil {
_, _ = fmt.Fprintf(stderr, "error: %v\n", err)
return 1
}
return 0
case "track", "pull", "push", "commit", "destroy", "cd", "open", "exec", "exec_all", "rc", "generate":
_, _ = fmt.Fprintf(stderr, "error: %s is not implemented in Go yet\n", command)
return 2
default:
_, _ = fmt.Fprintf(stderr, "error: unknown command %q\n\n", command)
printHelp(stderr)
return 1
}
} }
func defaultCastle(args []string) string { type cloneCmd struct {
if len(args) > 1 && strings.TrimSpace(args[1]) != "" { URI string `arg:"" name:"URI" help:"Castle URI to clone."`
return args[1] Destination string `arg:"" optional:"" name:"CASTLE_NAME" help:"Optional local castle name."`
} }
func (c *cloneCmd) Run(app *core.App) error {
return app.Clone(c.URI, c.Destination)
}
type listCmd struct{}
func (c *listCmd) Run(app *core.App) error {
return app.List()
}
type showPathCmd struct {
Castle string `arg:"" optional:"" name:"CASTLE" help:"Castle name."`
}
func (c *showPathCmd) Run(app *core.App) error {
return app.ShowPath(defaultCastle(c.Castle))
}
type statusCmd struct {
Castle string `arg:"" optional:"" name:"CASTLE" help:"Castle name."`
}
func (c *statusCmd) Run(app *core.App) error {
return app.Status(defaultCastle(c.Castle))
}
type diffCmd struct {
Castle string `arg:"" optional:"" name:"CASTLE" help:"Castle name."`
}
func (c *diffCmd) Run(app *core.App) error {
return app.Diff(defaultCastle(c.Castle))
}
type linkCmd struct {
Castle string `arg:"" optional:"" name:"CASTLE" help:"Castle name."`
}
func (c *linkCmd) Run(app *core.App) error {
return app.LinkCastle(defaultCastle(c.Castle))
}
type unlinkCmd struct {
Castle string `arg:"" optional:"" name:"CASTLE" help:"Castle name."`
}
func (c *unlinkCmd) Run(app *core.App) error {
return app.Unlink(defaultCastle(c.Castle))
}
type trackCmd struct {
File string `arg:"" name:"FILE" help:"File to track."`
Castle string `arg:"" optional:"" name:"CASTLE" help:"Castle name."`
}
func (c *trackCmd) Run(app *core.App) error {
return app.Track(c.File, defaultCastle(c.Castle))
}
type versionCmd struct{}
func (c *versionCmd) Run(app *core.App) error {
return app.Version(version.String)
}
type pullCmd struct{}
type pushCmd struct{}
type commitCmd struct{}
type destroyCmd struct{}
type cdCmd struct{}
type openCmd struct{}
type execCmd struct{}
type execAllCmd struct{}
type rcCmd struct{}
type generateCmd struct{}
func (c *pullCmd) Run() error { return notImplemented("pull") }
func (c *pushCmd) Run() error { return notImplemented("push") }
func (c *commitCmd) Run() error { return notImplemented("commit") }
func (c *destroyCmd) Run() error { return notImplemented("destroy") }
func (c *cdCmd) Run() error { return notImplemented("cd") }
func (c *openCmd) Run() error { return notImplemented("open") }
func (c *execCmd) Run() error { return notImplemented("exec") }
func (c *execAllCmd) Run() error { return notImplemented("exec_all") }
func (c *rcCmd) Run() error { return notImplemented("rc") }
func (c *generateCmd) Run() error { return notImplemented("generate") }
func defaultCastle(castle string) string {
if strings.TrimSpace(castle) == "" {
return "dotfiles" return "dotfiles"
} }
return castle
}
func printHelp(w io.Writer) { func normalizeArgs(args []string) []string {
_, _ = fmt.Fprintln(w, "homesick (Go scaffold)") if len(args) == 0 {
_, _ = fmt.Fprintln(w, "") return []string{"--help"}
_, _ = fmt.Fprintln(w, "Implemented commands:") }
_, _ = fmt.Fprintln(w, " clone URI [CASTLE_NAME]")
_, _ = fmt.Fprintln(w, " list") switch args[0] {
_, _ = fmt.Fprintln(w, " show_path [CASTLE]") case "-h", "--help":
_, _ = fmt.Fprintln(w, " status [CASTLE]") return []string{"--help"}
_, _ = fmt.Fprintln(w, " diff [CASTLE]") case "help":
_, _ = fmt.Fprintln(w, " link [CASTLE]") if len(args) == 1 {
_, _ = fmt.Fprintln(w, " unlink [CASTLE]") return []string{"--help"}
_, _ = fmt.Fprintln(w, " version | -v | --version") }
_, _ = fmt.Fprintln(w, "") return append(args[1:], "--help")
_, _ = fmt.Fprintln(w, "Not implemented yet:") case "-v", "--version":
_, _ = fmt.Fprintln(w, " track, pull, push, commit, destroy, cd, open, exec, exec_all, rc, generate") return []string{"version"}
_, _ = fmt.Fprintln(w, "") default:
_, _ = fmt.Fprintln(w, "Build: go build -o dist/homesick-go ./cmd/homesick") return args
}
}
func isHelpRequest(args []string) bool {
for _, arg := range args {
if arg == "-h" || arg == "--help" {
return true
}
}
return false
}
type cliExitError struct {
code int
err error
}
func (e *cliExitError) Error() string {
return e.err.Error()
}
func notImplemented(command string) error {
return &cliExitError{code: 2, err: fmt.Errorf("%s is not implemented in Go yet", command)}
} }
func init() { func init() {

View File

@@ -0,0 +1,62 @@
package cli_test
import (
"bytes"
"os"
"path/filepath"
"testing"
"git.hrafn.xyz/aether/gosick/internal/homesick/cli"
"git.hrafn.xyz/aether/gosick/internal/homesick/version"
"github.com/stretchr/testify/require"
"github.com/stretchr/testify/suite"
)
type CLISuite struct {
suite.Suite
homeDir string
stdout *bytes.Buffer
stderr *bytes.Buffer
}
func TestCLISuite(t *testing.T) {
suite.Run(t, new(CLISuite))
}
func (s *CLISuite) SetupTest() {
s.homeDir = filepath.Join(s.T().TempDir(), "home")
require.NoError(s.T(), os.MkdirAll(s.homeDir, 0o755))
require.NoError(s.T(), os.Setenv("HOME", s.homeDir))
s.stdout = &bytes.Buffer{}
s.stderr = &bytes.Buffer{}
}
func (s *CLISuite) TestRun_VersionAliases() {
for _, args := range [][]string{{"-v"}, {"--version"}, {"version"}} {
s.stdout.Reset()
s.stderr.Reset()
exitCode := cli.Run(args, s.stdout, s.stderr)
require.Equal(s.T(), 0, exitCode)
require.Equal(s.T(), version.String+"\n", s.stdout.String())
require.Empty(s.T(), s.stderr.String())
}
}
func (s *CLISuite) TestRun_ShowPath_DefaultCastle() {
exitCode := cli.Run([]string{"show_path"}, s.stdout, s.stderr)
require.Equal(s.T(), 0, exitCode)
require.Equal(s.T(), filepath.Join(s.homeDir, ".homesick", "repos", "dotfiles")+"\n", s.stdout.String())
require.Empty(s.T(), s.stderr.String())
}
func (s *CLISuite) TestRun_CloneSubcommandHelp() {
exitCode := cli.Run([]string{"clone", "--help"}, s.stdout, s.stderr)
require.Equal(s.T(), 0, exitCode)
require.Contains(s.T(), s.stdout.String(), "clone")
require.Contains(s.T(), s.stdout.String(), "URI")
require.Empty(s.T(), s.stderr.String())
}

View File

@@ -126,11 +126,11 @@ func (a *App) List() error {
} }
func (a *App) Status(castle string) error { func (a *App) Status(castle string) error {
return runGit(filepath.Join(a.ReposDir, castle), "status") return runGitWithIO(filepath.Join(a.ReposDir, castle), a.Stdout, a.Stderr, "status")
} }
func (a *App) Diff(castle string) error { func (a *App) Diff(castle string) error {
return runGit(filepath.Join(a.ReposDir, castle), "diff") return runGitWithIO(filepath.Join(a.ReposDir, castle), a.Stdout, a.Stderr, "diff")
} }
func (a *App) Link(castle string) error { func (a *App) Link(castle string) error {
@@ -213,8 +213,125 @@ func (a *App) UnlinkCastle(castle string) error {
return nil return nil
} }
func (a *App) Track(string, string) error { func (a *App) Track(filePath string, castle string) error {
return errors.New("track is not implemented in Go yet") return a.TrackPath(filePath, castle)
}
func (a *App) TrackPath(filePath string, castle string) error {
if strings.TrimSpace(castle) == "" {
castle = "dotfiles"
}
trimmedFile := strings.TrimSpace(filePath)
if trimmedFile == "" {
return errors.New("track requires FILE")
}
castleRoot := filepath.Join(a.ReposDir, castle)
castleHome := filepath.Join(castleRoot, "home")
info, err := os.Stat(castleHome)
if err != nil || !info.IsDir() {
return fmt.Errorf("could not track %s, expected %s to exist and contain dotfiles", castle, castleHome)
}
absolutePath, err := filepath.Abs(strings.TrimRight(trimmedFile, string(filepath.Separator)))
if err != nil {
return err
}
if _, err := os.Lstat(absolutePath); err != nil {
return err
}
relativeDir, err := filepath.Rel(a.HomeDir, filepath.Dir(absolutePath))
if err != nil {
return err
}
if relativeDir == ".." || strings.HasPrefix(relativeDir, ".."+string(filepath.Separator)) {
return fmt.Errorf("track requires file under %s", a.HomeDir)
}
castleTargetDir := filepath.Join(castleHome, relativeDir)
if relativeDir == "." {
castleTargetDir = castleHome
}
if err := os.MkdirAll(castleTargetDir, 0o755); err != nil {
return err
}
trackedPath := filepath.Join(castleTargetDir, filepath.Base(absolutePath))
if _, err := os.Lstat(trackedPath); err == nil {
return fmt.Errorf("%s already exists", trackedPath)
} else if !errors.Is(err, os.ErrNotExist) {
return err
}
if err := os.Rename(absolutePath, trackedPath); err != nil {
return err
}
subdirChanged := false
if relativeDir != "." {
subdirPath := filepath.Join(castleRoot, ".homesick_subdir")
subdirChanged, err = appendUniqueSubdir(subdirPath, relativeDir)
if err != nil {
return err
}
}
if err := a.linkPath(trackedPath, absolutePath); err != nil {
return err
}
repo, err := git.PlainOpen(castleRoot)
if err != nil {
return err
}
worktree, err := repo.Worktree()
if err != nil {
return err
}
trackedRelativePath := filepath.Join("home", relativeDir, filepath.Base(absolutePath))
if relativeDir == "." {
trackedRelativePath = filepath.Join("home", filepath.Base(absolutePath))
}
if _, err := worktree.Add(filepath.ToSlash(filepath.Clean(trackedRelativePath))); err != nil {
return err
}
if subdirChanged {
if _, err := worktree.Add(".homesick_subdir"); err != nil {
return err
}
}
return nil
}
func appendUniqueSubdir(path string, subdir string) (bool, error) {
existing, err := readSubdirs(path)
if err != nil {
return false, err
}
cleanSubdir := filepath.Clean(subdir)
for _, line := range existing {
if filepath.Clean(line) == cleanSubdir {
return false, nil
}
}
file, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644)
if err != nil {
return false, err
}
defer file.Close()
if _, err := file.WriteString(cleanSubdir + "\n"); err != nil {
return false, err
}
return true, nil
} }
func (a *App) linkEach(castleHome string, baseDir string, subdirs []string) error { func (a *App) linkEach(castleHome string, baseDir string, subdirs []string) error {
@@ -394,10 +511,14 @@ func matchesIgnoredDir(castleHome string, candidate string, subdirs []string) (b
} }
func runGit(dir string, args ...string) error { func runGit(dir string, args ...string) error {
return runGitWithIO(dir, os.Stdout, os.Stderr, args...)
}
func runGitWithIO(dir string, stdout io.Writer, stderr io.Writer, args ...string) error {
cmd := exec.Command("git", args...) cmd := exec.Command("git", args...)
cmd.Dir = dir cmd.Dir = dir
cmd.Stdout = os.Stdout cmd.Stdout = stdout
cmd.Stderr = os.Stderr cmd.Stderr = stderr
if err := cmd.Run(); err != nil { if err := cmd.Run(); err != nil {
return fmt.Errorf("git %s failed: %w", strings.Join(args, " "), err) return fmt.Errorf("git %s failed: %w", strings.Join(args, " "), err)
} }
@@ -420,9 +541,7 @@ func deriveDestination(uri string) string {
candidate = strings.TrimPrefix(candidate, "http://github.com/") candidate = strings.TrimPrefix(candidate, "http://github.com/")
candidate = strings.TrimPrefix(candidate, "git://github.com/") candidate = strings.TrimPrefix(candidate, "git://github.com/")
if strings.HasPrefix(candidate, "file://") {
candidate = strings.TrimPrefix(candidate, "file://") candidate = strings.TrimPrefix(candidate, "file://")
}
candidate = strings.TrimSuffix(candidate, ".git") candidate = strings.TrimSuffix(candidate, ".git")
candidate = strings.TrimSuffix(candidate, "/") candidate = strings.TrimSuffix(candidate, "/")

View File

@@ -0,0 +1,76 @@
package core_test
import (
"bytes"
"os"
"path/filepath"
"testing"
"time"
"git.hrafn.xyz/aether/gosick/internal/homesick/core"
git "github.com/go-git/go-git/v5"
"github.com/go-git/go-git/v5/plumbing/object"
"github.com/stretchr/testify/require"
"github.com/stretchr/testify/suite"
)
type DiffSuite struct {
suite.Suite
tmpDir string
homeDir string
reposDir string
stdout *bytes.Buffer
stderr *bytes.Buffer
app *core.App
}
func TestDiffSuite(t *testing.T) {
suite.Run(t, new(DiffSuite))
}
func (s *DiffSuite) SetupTest() {
s.tmpDir = s.T().TempDir()
s.homeDir = filepath.Join(s.tmpDir, "home")
s.reposDir = filepath.Join(s.homeDir, ".homesick", "repos")
require.NoError(s.T(), os.MkdirAll(s.reposDir, 0o755))
s.stdout = &bytes.Buffer{}
s.stderr = &bytes.Buffer{}
s.app = &core.App{
HomeDir: s.homeDir,
ReposDir: s.reposDir,
Stdout: s.stdout,
Stderr: s.stderr,
}
}
func (s *DiffSuite) createCastleRepo(castle string) string {
castleRoot := filepath.Join(s.reposDir, castle)
repo, err := git.PlainInit(castleRoot, false)
require.NoError(s.T(), err)
filePath := filepath.Join(castleRoot, "home", ".vimrc")
require.NoError(s.T(), os.MkdirAll(filepath.Dir(filePath), 0o755))
require.NoError(s.T(), os.WriteFile(filePath, []byte("set number\n"), 0o644))
wt, err := repo.Worktree()
require.NoError(s.T(), err)
_, err = wt.Add("home/.vimrc")
require.NoError(s.T(), err)
_, err = wt.Commit("initial", &git.CommitOptions{Author: &object.Signature{
Name: "Behavior Test",
Email: "behavior@test.local",
When: time.Now(),
}})
require.NoError(s.T(), err)
return castleRoot
}
func (s *DiffSuite) TestDiff_WritesGitDiffToAppStdout() {
castleRoot := s.createCastleRepo("castle_repo")
require.NoError(s.T(), os.WriteFile(filepath.Join(castleRoot, "home", ".vimrc"), []byte("changed\n"), 0o644))
require.NoError(s.T(), s.app.Diff("castle_repo"))
require.Contains(s.T(), s.stdout.String(), "diff --git")
}

View File

@@ -0,0 +1,72 @@
package core_test
import (
"bytes"
"io"
"os"
"path/filepath"
"testing"
"git.hrafn.xyz/aether/gosick/internal/homesick/core"
git "github.com/go-git/go-git/v5"
"github.com/go-git/go-git/v5/config"
"github.com/stretchr/testify/require"
"github.com/stretchr/testify/suite"
)
type ListSuite struct {
suite.Suite
tmpDir string
homeDir string
reposDir string
stdout *bytes.Buffer
app *core.App
}
func TestListSuite(t *testing.T) {
suite.Run(t, new(ListSuite))
}
func (s *ListSuite) SetupTest() {
s.tmpDir = s.T().TempDir()
s.homeDir = filepath.Join(s.tmpDir, "home")
s.reposDir = filepath.Join(s.homeDir, ".homesick", "repos")
require.NoError(s.T(), os.MkdirAll(s.reposDir, 0o755))
s.stdout = &bytes.Buffer{}
s.app = &core.App{
HomeDir: s.homeDir,
ReposDir: s.reposDir,
Stdout: s.stdout,
Stderr: io.Discard,
}
}
func (s *ListSuite) createCastleRepo(castle string, remoteURL string) {
castleRoot := filepath.Join(s.reposDir, filepath.FromSlash(castle))
require.NoError(s.T(), os.MkdirAll(castleRoot, 0o755))
repo, err := git.PlainInit(castleRoot, false)
require.NoError(s.T(), err)
if remoteURL != "" {
_, err = repo.CreateRemote(&config.RemoteConfig{Name: "origin", URLs: []string{remoteURL}})
require.NoError(s.T(), err)
}
}
func (s *ListSuite) TestList_OutputsSortedCastlesWithRemoteURLs() {
s.createCastleRepo("zomg", "git://github.com/technicalpickles/zomg.git")
s.createCastleRepo("wtf/zomg", "git://github.com/technicalpickles/wtf-zomg.git")
s.createCastleRepo("alpha", "git://github.com/technicalpickles/alpha.git")
require.NoError(s.T(), s.app.List())
require.Equal(
s.T(),
"alpha git://github.com/technicalpickles/alpha.git\n"+
"wtf/zomg git://github.com/technicalpickles/wtf-zomg.git\n"+
"zomg git://github.com/technicalpickles/zomg.git\n",
s.stdout.String(),
)
}

View File

@@ -0,0 +1,51 @@
package core_test
import (
"bytes"
"io"
"os"
"path/filepath"
"testing"
"git.hrafn.xyz/aether/gosick/internal/homesick/core"
"github.com/stretchr/testify/require"
"github.com/stretchr/testify/suite"
)
type ShowPathSuite struct {
suite.Suite
tmpDir string
homeDir string
reposDir string
stdout *bytes.Buffer
app *core.App
}
func TestShowPathSuite(t *testing.T) {
suite.Run(t, new(ShowPathSuite))
}
func (s *ShowPathSuite) SetupTest() {
s.tmpDir = s.T().TempDir()
s.homeDir = filepath.Join(s.tmpDir, "home")
s.reposDir = filepath.Join(s.homeDir, ".homesick", "repos")
require.NoError(s.T(), os.MkdirAll(s.reposDir, 0o755))
s.stdout = &bytes.Buffer{}
s.app = &core.App{
HomeDir: s.homeDir,
ReposDir: s.reposDir,
Stdout: s.stdout,
Stderr: io.Discard,
}
}
func (s *ShowPathSuite) TestShowPath_OutputsCastlePath() {
require.NoError(s.T(), s.app.ShowPath("castle_repo"))
require.Equal(
s.T(),
filepath.Join(s.reposDir, "castle_repo")+"\n",
s.stdout.String(),
)
}

View File

@@ -0,0 +1,79 @@
package core_test
import (
"bytes"
"io"
"os"
"path/filepath"
"testing"
"time"
"git.hrafn.xyz/aether/gosick/internal/homesick/core"
git "github.com/go-git/go-git/v5"
"github.com/go-git/go-git/v5/plumbing/object"
"github.com/stretchr/testify/require"
"github.com/stretchr/testify/suite"
)
type StatusSuite struct {
suite.Suite
tmpDir string
homeDir string
reposDir string
stdout *bytes.Buffer
stderr *bytes.Buffer
app *core.App
}
func TestStatusSuite(t *testing.T) {
suite.Run(t, new(StatusSuite))
}
func (s *StatusSuite) SetupTest() {
s.tmpDir = s.T().TempDir()
s.homeDir = filepath.Join(s.tmpDir, "home")
s.reposDir = filepath.Join(s.homeDir, ".homesick", "repos")
require.NoError(s.T(), os.MkdirAll(s.reposDir, 0o755))
s.stdout = &bytes.Buffer{}
s.stderr = &bytes.Buffer{}
s.app = &core.App{
HomeDir: s.homeDir,
ReposDir: s.reposDir,
Stdout: s.stdout,
Stderr: s.stderr,
}
}
func (s *StatusSuite) createCastleRepo(castle string) string {
castleRoot := filepath.Join(s.reposDir, castle)
repo, err := git.PlainInit(castleRoot, false)
require.NoError(s.T(), err)
filePath := filepath.Join(castleRoot, "home", ".vimrc")
require.NoError(s.T(), os.MkdirAll(filepath.Dir(filePath), 0o755))
require.NoError(s.T(), os.WriteFile(filePath, []byte("set number\n"), 0o644))
wt, err := repo.Worktree()
require.NoError(s.T(), err)
_, err = wt.Add("home/.vimrc")
require.NoError(s.T(), err)
_, err = wt.Commit("initial", &git.CommitOptions{Author: &object.Signature{
Name: "Behavior Test",
Email: "behavior@test.local",
When: time.Now(),
}})
require.NoError(s.T(), err)
return castleRoot
}
func (s *StatusSuite) TestStatus_WritesGitStatusToAppStdout() {
castleRoot := s.createCastleRepo("castle_repo")
require.NoError(s.T(), os.WriteFile(filepath.Join(castleRoot, "home", ".vimrc"), []byte("changed\n"), 0o644))
require.NoError(s.T(), s.app.Status("castle_repo"))
require.Contains(s.T(), s.stdout.String(), "modified:")
}
var _ io.Writer

View File

@@ -0,0 +1,46 @@
package core_test
import (
"bytes"
"io"
"os"
"path/filepath"
"testing"
"git.hrafn.xyz/aether/gosick/internal/homesick/core"
"github.com/stretchr/testify/require"
"github.com/stretchr/testify/suite"
)
type VersionSuite struct {
suite.Suite
tmpDir string
homeDir string
reposDir string
stdout *bytes.Buffer
app *core.App
}
func TestVersionSuite(t *testing.T) {
suite.Run(t, new(VersionSuite))
}
func (s *VersionSuite) SetupTest() {
s.tmpDir = s.T().TempDir()
s.homeDir = filepath.Join(s.tmpDir, "home")
s.reposDir = filepath.Join(s.homeDir, ".homesick", "repos")
require.NoError(s.T(), os.MkdirAll(s.reposDir, 0o755))
s.stdout = &bytes.Buffer{}
s.app = &core.App{
HomeDir: s.homeDir,
ReposDir: s.reposDir,
Stdout: s.stdout,
Stderr: io.Discard,
}
}
func (s *VersionSuite) TestVersion_WritesVersionToAppStdout() {
require.NoError(s.T(), s.app.Version("1.2.3"))
require.Equal(s.T(), "1.2.3\n", s.stdout.String())
}

View File

@@ -5,23 +5,17 @@ default:
go-build: go-build:
@mkdir -p dist @mkdir -p dist
go build -o dist/homesick-go ./cmd/homesick go build -o dist/gosick ./cmd/homesick
go-build-linux: go-build-linux:
@mkdir -p dist @mkdir -p dist
GOOS=linux GOARCH="$(go env GOARCH)" go build -o dist/homesick-go ./cmd/homesick GOOS=linux GOARCH="$(go env GOARCH)" go build -o dist/gosick ./cmd/homesick
go-test: go-test:
go test ./... go test ./...
behavior-ruby: behavior:
./script/run-behavior-suite-docker.sh ./script/run-behavior-suite-docker.sh
behavior-go: go-build-linux behavior-verbose:
HOMESICK_CMD="/workspace/dist/homesick-go" ./script/run-behavior-suite-docker.sh
behavior-go-verbose: go-build-linux
HOMESICK_CMD="/workspace/dist/homesick-go" ./script/run-behavior-suite-docker.sh --verbose
behavior-ruby-verbose:
./script/run-behavior-suite-docker.sh --verbose ./script/run-behavior-suite-docker.sh --verbose

View File

@@ -1,29 +0,0 @@
require 'homesick/actions/file_actions'
require 'homesick/actions/git_actions'
require 'homesick/version'
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}.freeze
SUBDIR_FILENAME = '.homesick_subdir'.freeze
DEFAULT_CASTLE_NAME = 'dotfiles'.freeze
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

View File

@@ -1,78 +0,0 @@
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)
say_status :conflict, "#{destination} exists", :red if destination.exist? && (options[:force] || shell.file_collision(destination) { source })
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
if collision_accepted?(destination, source)
FileUtils.rm_r destination, force: true unless options[:pretend]
end
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

View File

@@ -1,114 +0,0 @@
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 = Hash[%i[major minor patch].zip(info)]
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
# TODO: move this to be more like thor's template, empty_directory, etc
def git_clone(repo, config = {})
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
system "git clone -q --config push.default=upstream --recursive #{repo} #{destination}"
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

View File

@@ -1,323 +0,0 @@
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!
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
if File.exist?(uri)
uri = Pathname.new(uri).expand_path
raise "Castle already cloned to #{uri}" if uri.to_s.start_with?(repos_dir.to_s)
destination = uri.basename if destination.nil?
ln_s uri, 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
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)")
return say_status 'eval skip', "not evaling #{homesickrc}, #{destination} may need manual configuration", :blue unless proceed
say_status 'eval', homesickrc
inside destination do
eval homesickrc.read, binding, 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.to_s + '/', '') + ':'
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
# Are we already tracking this or anything inside it?
target = Pathname.new(castle_path.join(file.basename))
if target.exist?
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
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
# are we tracking something nested? Add the parent dir to the manifest
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['SHELL'])
end
end
desc 'open CASTLE',
'Open your default editor in the root of the given castle'
def open(castle = DEFAULT_CASTLE_NAME)
unless ENV['EDITOR']
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['EDITOR']} .",
"Opening the root directory of castle '#{castle}' in editor '#{ENV['EDITOR']}'.",
:green
inside castle_dir do
system("#{ENV['EDITOR']} .")
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.count > 0
say_status :error,
'You must pass a shell command to execute',
:red
exit(1)
end
full_command = args.join(' ')
say_status "exec '#{full_command}'",
"#{options[:pretend] ? 'Would execute' : 'Executing command'} '#{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.count > 0
say_status :error,
'You must pass a shell command to execute',
:red
exit(1)
end
full_command = args.join(' ')
inside_each_castle do |castle|
say_status "exec '#{full_command}'",
"#{options[:pretend] ? 'Would execute' : 'Executing command'} '#{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

View File

@@ -1,215 +0,0 @@
require 'pathname'
module Homesick
# Various utility methods that are used by Homesick
module Utils
protected
def home_dir
@home_dir ||= Pathname.new(ENV['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 = IO.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)
raise "Arguments must be instances of Pathname, #{destination.class.name} and #{source.class.name} given" unless destination.instance_of?(Pathname) && source.instance_of?(Pathname)
options[:force] || shell.file_collision(destination) { source }
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

View File

@@ -1,11 +0,0 @@
module Homesick
# A representation of Homesick's version number in constants, including a
# String of the entire version number
module Version
MAJOR = 1
MINOR = 1
PATCH = 6
STRING = [MAJOR, MINOR, PATCH].compact.join('.')
end
end

View File

@@ -1,7 +1,7 @@
#!/usr/bin/env bash #!/usr/bin/env bash
set -euo pipefail set -euo pipefail
: "${HOMESICK_CMD:=ruby /workspace/bin/homesick}" : "${HOMESICK_CMD:=/workspace/dist/gosick}"
behavior_verbose="${BEHAVIOR_VERBOSE:-0}" behavior_verbose="${BEHAVIOR_VERBOSE:-0}"
while [[ $# -gt 0 ]]; do while [[ $# -gt 0 ]]; do

View File

@@ -1,861 +0,0 @@
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 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 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.open(dotfile.to_s, 'w') do |f|
f.write contents[:castle]
end
File.open(home.join('text').to_s, 'w') do |f|
f.write contents[:home]
end
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.open(dotfile.to_s, 'w') do |f|
f.write contents[:castle]
end
File.open(home.join('binary').to_s, 'w') do |f|
f.write contents[:home]
end
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['SHELL'])
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
# Make sure calls to ENV use default values for most things...
allow(ENV).to receive(:[]).and_call_original
# Set a default value for 'EDITOR' just in case none is set
allow(ENV).to receive(:[]).with('EDITOR').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
# Return empty ENV, the test does not call it anyway
allow(ENV).to receive(:[]).and_return(nil)
# Set the default editor to make sure it fails.
allow(ENV).to receive(:[]).with('EDITOR').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
# Return empty ENV, the test does not call it anyway
allow(ENV).to receive(:[]).and_return(nil)
# Set a default just in case none is set
allow(ENV).to receive(:[]).with('EDITOR').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

View File

@@ -1 +0,0 @@
--color

View File

@@ -1,42 +0,0 @@
require 'coveralls'
Coveralls.wear!
$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

View File

@@ -1,7 +1,7 @@
#!/usr/bin/env bash #!/usr/bin/env bash
set -euo pipefail set -euo pipefail
: "${HOMESICK_CMD:=ruby /workspace/bin/homesick}" : "${HOMESICK_CMD:=/workspace/dist/gosick}"
: "${BEHAVIOR_VERBOSE:=0}" : "${BEHAVIOR_VERBOSE:=0}"
RUN_OUTPUT="" RUN_OUTPUT=""