118 Commits

Author SHA1 Message Date
Micheal Wilkinson
904c1be192 chore(go): Adding fun comment 2026-03-19 14:20:15 +00:00
Micheal Wilkinson
f443e96f9e test(core): add failing track behavior suite 2026-03-19 14:19:29 +00:00
Micheal Wilkinson
0076588e1f chore(git): updating ignore to split irrelevant files out 2026-03-19 14:16:15 +00:00
Micheal Wilkinson
919f033c8b feat(go): implement unlink 2026-03-19 14:11:49 +00:00
Micheal Wilkinson
dbc77a1b34 feat(core): reimplement clone with go-git 2026-03-19 14:05:50 +00:00
Micheal Wilkinson
d02d118b28 test(core): add failing clone suite for go-git migration 2026-03-19 13:58:25 +00:00
Micheal Wilkinson
a952c4f6bf chore(just): build linux binary for behavior-go 2026-03-19 13:48:26 +00:00
Micheal Wilkinson
e733dff818 feat(go): implement link with subdir and force handling 2026-03-19 13:46:48 +00:00
Micheal Wilkinson
41584dec6a chore(go): scaffold module and add failing link tests 2026-03-19 13:44:02 +00:00
Micheal Wilkinson
005209703e Adding a set of behavioural tests 2026-03-19 10:57:25 +00:00
Jeremy Cook
ee4388b0f4 Moved code to a more logical home. 2019-01-19 23:21:27 -05:00
Jeremy Cook
a44a514007 Reduced visibility of methods. 2019-01-19 18:59:46 -05:00
Jeremy Cook
9431cb78af Moved code to utils to reduce method complexity. 2019-01-19 18:55:02 -05:00
Jeremy Cook
46c52769a6 Fixed issue where using pretend option would not evaluate files
correctly.
2019-01-19 15:03:28 -05:00
Jeremy Cook
fdb57cd846 Apply fixes suggested by Rubocop. 2019-01-19 11:50:20 -05:00
Jeremy Cook
ff387280d5 Merge branch 'master' of github.com:technicalpickles/homesick 2019-01-18 23:33:41 -05:00
Jeremy Cook
f09c62d922 Minor fixes suggested by rubocop. 2019-01-18 23:32:31 -05:00
Balint Reczey
dd7d52a25d Run Travis tests on Ruby 2.5.0, too 2019-01-18 17:02:57 -05:00
Balint Reczey
f1630ece79 Fix tests on Ruby 2.5 2019-01-18 17:02:57 -05:00
Denny Schäfer
11ee8cdc0d Fix markdown typo 2019-01-18 17:02:57 -05:00
Jeremy Cook
ceb08cbe22 Update Gemfile to remove reference to outdated version of rack. 2019-01-18 17:02:52 -05:00
Jeremy Cook
057e1cfc59 Regenerate gemspec for version 1.1.6 2019-01-18 17:02:52 -05:00
Jeremy Cook
89f3000d8b Prepare for release of new version 2019-01-18 17:02:51 -05:00
Diego Rabatone Oliveira
36e3cb6bbf Require fileutils correctly
Fix #165
2019-01-18 17:02:51 -05:00
mail6543210
9ebae75e7d Add testcase 2019-01-18 17:02:51 -05:00
mail6543210
35e1909790 Real fix for #148 2019-01-18 17:02:51 -05:00
mail6543210
3b633ed326 Rename content to source
It is a instance of Pathname, not binary content
2019-01-18 17:02:51 -05:00
mail6543210
fdf2da84dd Revert "Use source content instead of source path (fixes: #148)"
This reverts commit ed397bdaf8.
2019-01-18 17:02:51 -05:00
Jeremy Cook
e561566b46 Merge pull request #172 from rbalint/master
Fix tests on Ruby 2.5
2018-03-08 06:37:30 -05:00
Balint Reczey
dcef34c17d Run Travis tests on Ruby 2.5.0, too 2018-03-08 08:46:36 +01:00
Balint Reczey
72d11c4a47 Fix tests on Ruby 2.5 2018-03-07 18:06:20 +01:00
Jeremy Cook
c2457bae9f Merge pull request #171 from tuxinaut/master
Fix markdown typo
2018-01-11 07:41:01 -05:00
Denny Schäfer
001bd32bb3 Fix markdown typo 2018-01-11 00:20:23 +01:00
Jeremy Cook
7080321081 Regenerate gemspec for version 1.1.6 2017-12-20 16:03:17 -05:00
Jeremy Cook
9d9cf66de6 Prepare for release of new version 2017-12-20 16:01:20 -05:00
Jeremy Cook
9e9a940825 Merge pull request #170 from diraol/fix-fileutils
Require fileutils correctly
2017-12-18 10:48:14 -05:00
Diego Rabatone Oliveira
257e974c38 Require fileutils correctly
Fix #165
2017-12-18 13:40:44 -02:00
Jeremy Cook
615e31428c Merge pull request #167 from mail6543210/master
Fix "diff" action on binary files
2017-10-13 17:59:36 -04:00
mail6543210
8c2a1d0f84 Add testcase 2017-09-23 00:41:33 +08:00
mail6543210
62c934774b Real fix for #148 2017-09-19 20:30:15 +08:00
mail6543210
d3d6974b7b Rename content to source
It is a instance of Pathname, not binary content
2017-09-19 20:29:06 +08:00
mail6543210
474d69da0b Revert "Use source content instead of source path (fixes: #148)"
This reverts commit ed397bdaf8.
2017-09-19 20:26:01 +08:00
Jeremy Cook
db6a513d1d Merge pull request #166 from MainShayne233/master
Enhancement/Add 'to' to error message.
2017-09-02 19:59:40 -04:00
Shayne Tremblay
ae343c4cab Add 'to' to error message. 2017-09-02 13:06:46 -07:00
Jeremy Cook
a2b365fb6f Merge pull request #164 from fuzzbomb/update-README-symlink-command
Rename symlink command to link in README.
2017-08-28 12:55:50 -04:00
Andrew Macpherson
4cb2006f41 Rename symlink command to link in README.
symlink command was previously renamed to link, but references in the README
were not updated.
2017-06-12 17:37:45 +01:00
Jeremy Cook
66347d307f Merge pull request #161 from philoserf/patch-1
Update ChangeLog.markdown
2017-03-23 17:33:31 -04:00
Mark Ayers
8f92a1b4f0 Update ChangeLog.markdown
Add the space that markdown requires between the header marker `#` and the header text.
2017-03-23 13:44:33 -07:00
Jeremy Cook
7a2df591c0 Regenerate gemspec for version 1.1.5 2017-03-23 08:37:44 -04:00
Jeremy Cook
4923265dea Updated homesick version and changelog due to problem with version
number in release.
2017-03-23 08:34:47 -04:00
Jeremy Cook
79421580e9 Merge branch 'master' of github.com:technicalpickles/homesick 2017-03-22 17:24:28 -04:00
Jeremy Cook
cabde9e5f1 Updated change log for new version. 2017-03-22 17:23:49 -04:00
Jeremy Cook
0d60ae9d1a Regenerate gemspec for version 1.1.4 2017-03-22 17:14:09 -04:00
Jeremy Cook
d5317b8e17 Bumped version in preparation for release. 2017-03-22 17:10:23 -04:00
Jeremy Cook
3b8a5b4be4 Merge pull request #159 from mruwek/refactor-conflict-actions
Wrap symlink and regular conflicts into one case
2017-03-20 16:07:19 -04:00
Jacek Sowiński
6590a1eeff Wrap symlink and regular conflicts into one case
This way we're not duplicating collision-related code.
2017-03-20 20:26:14 +01:00
Jeremy Cook
693ae5f05e Merge pull request #157 from mruwek/verbose-symlink-conflicts
Don't overwrite silently on symlink conflicts
2017-03-19 22:02:41 -04:00
Jeremy Cook
da3002f199 Merge pull request #154 from singular0/symlink_realpath
Use real paths of symlinks when linking castle into home
2017-03-19 22:02:01 -04:00
Jeremy Cook
feaaab2fa4 Merge pull request #158 from JCook21/master
Minor updates
2017-03-19 22:00:34 -04:00
Jeremy Cook
59f75711a4 Changed strings to use symbols. 2017-03-19 15:57:44 -04:00
Jeremy Cook
f24030b51f Updates to Gemfile to clean it up. 2017-03-19 15:57:22 -04:00
Jeremy Cook
71bb120a12 Update to dependency. 2017-03-19 14:44:44 -04:00
Jacek Sowiński
85f46e01b1 Don't overwrite silently on symlink conflicts
Symlink conflicts are now handled in similar fashion as normal
file-conflicts.
2017-03-17 19:31:23 +01:00
Jeremy Cook
c5b24b9b38 Merge pull request #155 from danielbayerlein/ruby-2.4.0
Add support for Ruby 2.4.0
2016-12-26 07:08:38 -05:00
Daniel Bayerlein
68460af45e Add support for Ruby 2.4.0 2016-12-26 10:31:34 +01:00
Denis Yantarev
5614b6b8b3 Use real paths of symlinks when linking castle into home 2016-12-25 18:34:33 +03:00
Jeremy Cook
570b063632 Merge pull request #152 from singular0/master
Thanks for taking care of this.
2016-12-24 12:13:55 -05:00
Jeremy Cook
1d398587d0 Remove config for removed ruby versions.
Deleted config for unused ruby versions.
2016-12-24 12:11:01 -05:00
Jeremy Cook
085853faaa Merge branch 'master' into master 2016-12-24 12:05:54 -05:00
Jeremy Cook
21b4e344a9 Merge pull request #153 from danielbayerlein/ruby-version
Looks good to me, thanks!
2016-12-24 12:03:51 -05:00
Daniel Bayerlein
a6194dfe8b Update RSpec 2016-12-23 11:59:44 +01:00
Daniel Bayerlein
5692194fa2 Add support for Ruby 2.2.6 and 2.3.3 2016-12-23 11:37:47 +01:00
Daniel Bayerlein
11745098c2 Support Ruby 2.1.0, 2.2.0, 2.3.0 2016-12-23 11:23:11 +01:00
Denis Yantarev
b1bb0c996c Add Ruby 2.2 & 2.3 to Travis config and fix GEM dependencies 2016-12-05 03:34:57 +03:00
Denis Yantarev
a62039da50 Ignore rbenv configuration files 2016-12-05 03:34:14 +03:00
Denis Yantarev
4bfd1c60c2 Fix default option value type warning 2016-12-03 15:56:22 +03:00
Jeremy Cook
f0e11abb5b Merge pull request #149 from mail6543210/master
Use source content instead of source path (fixes: #148). Thanks for fixing this!
2016-01-21 20:05:33 -05:00
mail6543210
ed397bdaf8 Use source content instead of source path (fixes: #148) 2016-01-21 18:36:52 +08:00
Jeremy Cook
2f5e20d963 Fixed formatting in Changelog file. 2015-10-31 09:54:41 -04:00
Jeremy Cook
cc83a4e1fa Preparing for 1.1.3 release. 2015-10-31 09:48:10 -04:00
Jeremy Cook
dcc5cb0bc1 Merge pull request #146 from JCook21/issue134
Fix for issue134
2015-10-25 15:21:26 -04:00
Jeremy Cook
978416d1e4 Fixing diff problems by providing source block and checking for
directories in diff
2015-10-20 21:47:43 -04:00
Jeremy Cook
1c12c73e4b Merge branch 'rweng-named_castles' 2015-10-14 22:31:20 -04:00
Jeremy Cook
1016002638 Merge branch 'named_castles' of https://github.com/rweng/homesick into rweng-named_castles 2015-10-14 22:30:46 -04:00
Jeremy Cook
6431a864ad Merge pull request #145 from JCook21/master
Maintenance fixes
2015-10-13 22:43:07 -04:00
Jeremy Cook
42f661cfbf Update to use new travis container infrastructure (see
http://docs.travis-ci.com/user/migrating-from-legacy/?utm_source=legacy-notice&utm_medium=banner&utm_campaign=legacy-upgrade)
2015-10-12 19:30:29 -04:00
Jeremy Cook
7632591681 Coding standards fixes based off of Rubocop and minor edits to make
logic flow easier to understand.
2015-10-12 19:30:29 -04:00
Jeremy Cook
a9a5b81dc5 Adding system notifications to development gems. 2015-10-12 19:30:29 -04:00
Jeremy Cook
721c10cffd Merge pull request #143 from rweng/fix-open
Fix homesick open
2015-10-12 11:06:10 -04:00
Robin Wenglewski
332aad8ad0 change 'homesick open' to run '$EDITOR .' instead of '$EDITOR' in castle_dir #142 2015-10-12 16:39:52 +02:00
Robin Wenglewski
171b4c1fb8 add option to pass in destination to homesick clone 2015-10-11 18:21:22 +02:00
Jeremy Cook
60d4458bbc Merge pull request #124 from shioyama/clone_destination
Pass destination when cloning url.
2015-03-22 14:12:41 -04:00
Chris Salzberg
9ad171ab78 Pass destination when cloning url. 2015-03-05 22:26:41 +09:00
Jeremy Cook
5918746059 Merge pull request #137 from gerasiov/master
Thanks!
2015-02-23 23:00:22 -05:00
Alexander GQ Gerasiov
4641843ffd Add "requite 'pathname'" to lib/homesick/utils.rb
Since Pathname is used in lib/homesick/utils.rb, it should require this module
itself.

Signed-off-by: Alexander GQ Gerasiov <gq@cs.msu.su>
2015-02-22 23:09:18 +03:00
Jeremy Cook
1a181b907c Merge pull request #136 from williamboman/patch-1
Thanks for fixing this!
2015-02-20 09:19:15 -05:00
William Boman
fb7595d254 Escape message correctly on git_commit_all. 2015-02-19 16:51:53 +01:00
Jeremy Cook
c8f0999035 Preparing for new release. 2015-01-02 00:06:20 -05:00
Jeremy Cook
46faec7857 Bug fix to make sure git check works properly. 2015-01-01 21:21:27 -05:00
Jeremy Cook
e35d3fe6ba Merge pull request #128 from wireframe/force-rc 2014-12-13 08:12:04 -05:00
Jeremy Cook
ba620e0f7f Merge pull request #127 from JCook21/CheckGit
In lieu of other comments I'm going to go ahead and merge this.
2014-12-02 20:53:42 -05:00
Ryan Sonnek
5700f55dc3 Add --force option to homesick rc command
Support automatically eval-ing .homesickrc file without prompting for user input.
This is particularly useful for headless scripts that do not support
user input.
2014-12-01 13:38:43 -06:00
Jeremy Cook
2c92010093 Fix to make tests pass in Ruby 1.9.3 2014-11-25 21:18:13 -05:00
Jeremy Cook
03490531d8 Changed name of git check method to be more descriptive. 2014-11-24 08:49:17 -05:00
Jeremy Cook
7bd9759e81 Added tests for a minimumGit version of 1.8.0. 2014-11-23 22:22:44 -05:00
Jeremy Cook
a808f56caf Tightened up git checking to check for a minimum installed version of
Git.
2014-11-23 14:32:47 -05:00
Jeremy Cook
b7e2b45e69 Added simple implementation to check if git is installed before
executing commands.
2014-11-20 21:25:47 -05:00
Jeremy Cook
63c45d7c3a Removed unneeded config line. 2014-11-09 11:44:09 -05:00
Jeremy Cook
096067ac62 Merge pull request #125 from JCook21/master
Updated to use Rspec 3.1
2014-10-01 21:37:44 -04:00
Jeremy Cook
8d6bf4c0c5 Updated to use Rspec 3.1 2014-09-30 19:39:16 -04:00
Jeremy Cook
882b862780 Merge pull request #123 from JCook21/SymlinkedHomes
I'm merging this as it seems to be harmless and hasn't elicited any responses.
2014-09-27 17:37:21 -04:00
Jeremy Cook
e06a5d6300 Merge pull request #122 from JCook21/SmokeTest
Added basic smoke test
2014-09-19 14:38:44 -04:00
Jeremy Cook
7451e8c739 Bug fix to cover cases where homes are symlinked. 2014-09-19 14:36:32 -04:00
Jeremy Cook
f034f773c5 Added a smoke test to ensure that calling bin/homesick outputs some text 2014-09-19 14:19:09 -04:00
Jeremy Cook
681fd98dc3 Merge pull request #121 from PeterDaveHello/patch-1
Looks good to me, thanks.
2014-09-19 12:10:34 -04:00
Jeremy Cook
e57b139e32 Merge pull request #117 from JCook21/Shell
Merging as there doesn't seem to be any comments or issues with this.
2014-09-19 12:08:54 -04:00
Peter Dave Hello
b64bfe2bb6 Use svg instead of png to get better image quality 2014-09-08 15:42:54 +08:00
Jeremy Cook
ee04b5788a Removed the homesick shell module and folded its code in elsewhere. 2014-06-14 15:03:57 -04:00
30 changed files with 1992 additions and 344 deletions

14
.gitignore vendored
View File

@@ -13,7 +13,7 @@ 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:
#
# * Create a file at ~/.gitignore
# * Include files you want ignored
@@ -46,3 +46,15 @@ pkg
Gemfile.lock
vendor/
homesick*.gem
# Go scaffolding artifacts
dist/
*.test
*.out
# rbenv configuration
.ruby-version
.github/*

View File

@@ -1,5 +1,7 @@
language: ruby
rvm:
- 2.1.0
- 2.0.0
- 1.9.3
- 2.5.0
- 2.4.0
- 2.3.3
- 2.2.6
sudo: false

View File

@@ -1,8 +1,34 @@
#1.1.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
# 1.0.0
* Removed support for Ruby 1.8.7
* Added a version command
@@ -29,19 +55,19 @@
# 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
* Fixed `homesick list` bug on ruby 2.0 #37
# 0.8.0
* Introduce commit & push command
* commit changes in castle and push to remote
* 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

38
Gemfile
View File

@@ -1,26 +1,36 @@
require 'rbconfig'
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"
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 "rake", ">= 0.8.7"
gem "rspec", "~> 2.10"
gem "guard"
gem "guard-rspec"
gem "rb-readline", "~> 0.5.0"
gem "jeweler", ">= 1.6.2"
#gem "simplecov"
gem 'capture-output', '~> 1.0.0'
gem 'coveralls', require: false
gem "test_construct"
gem "capture-output", "~> 1.0.0"
if RbConfig::CONFIG['host_os'] =~ /linux|freebsd|openbsd|sunos|solaris/
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
if RUBY_VERSION >= '1.9.2'
gem "rubocop"
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,11 +1,11 @@
# homesick
[![Gem Version](https://badge.fury.io/rb/homesick.png)](http://badge.fury.io/rb/homesick)
[![Build Status](https://travis-ci.org/technicalpickles/homesick.png?branch=master)](https://travis-ci.org/technicalpickles/homesick)
[![Dependency Status](https://gemnasium.com/technicalpickles/homesick.png)](https://gemnasium.com/technicalpickles/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.png)](https://codeclimate.com/github/technicalpickles/homesick)
[![Gitter chat](https://badges.gitter.im/technicalpickles/homesick.png)](https://gitter.im/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.
@@ -13,8 +13,8 @@ Homesick is sorta like [rip](http://github.com/defunkt/rip), but for dotfiles. I
We call a repository that is compatible with homesick to be a 'castle'. To act as a castle, a repository must be organized like so:
* Contains a 'home' directory
* 'home' contains any number of files and directories that begin with '.'
- Contains a 'home' directory
- 'home' contains any number of files and directories that begin with '.'
To get started, install homesick first:
@@ -30,7 +30,7 @@ Alternatively, if it's on github, there's a slightly shorter way:
With the castle cloned, you can now link its contents into your home dir:
homesick symlink pickled-vim
homesick link pickled-vim
You can remove symlinks anytime when you don't need them anymore
@@ -40,7 +40,7 @@ If you need to add further configuration steps you can add these in a file calle
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.
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:
@@ -82,9 +82,52 @@ 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:
just go-build
Run behavior validation against the Go binary:
HOMESICK_CMD="/workspace/dist/homesick-go" just behavior-go
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.
## .homesick_subdir
`homesick symlink` basically makes symlink to only first depth in `castle/home`. If you want to link nested files/directories, please use .homesick_subdir.
`homesick link` basically makes symlink to only first depth in `castle/home`. If you want to link nested files/directories, please use .homesick_subdir.
For example, when you have castle like this:
@@ -114,7 +157,7 @@ castle/.homesick_subdir
.config
and run `homesick symlink CASTLE`. The result is:
and run `homesick link CASTLE`. The result is:
~
|-- .config
@@ -134,7 +177,7 @@ Or `homesick track NESTED_FILE CASTLE` adds a line automatically. For example:
castle/.homesick_subdir
.config
.emacs.d
.emacs.d
home directory
@@ -164,17 +207,17 @@ and castle
Homesick is tested on the following Ruby versions:
* 1.9.3
* 2.0.0
* 2.1.0
- 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.
- 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?

12
cmd/homesick/main.go Normal file
View File

@@ -0,0 +1,12 @@
package main
import (
"os"
"git.hrafn.xyz/aether/gosick/internal/homesick/cli"
)
func main() {
exitCode := cli.Run(os.Args[1:], os.Stdout, os.Stderr)
os.Exit(exitCode)
}

View File

@@ -0,0 +1,13 @@
FROM alpine:3.21
RUN apk add --no-cache \
bash \
ca-certificates \
git \
ruby \
ruby-thor
WORKDIR /workspace
COPY . /workspace
ENTRYPOINT ["/workspace/test/behavior/behavior_suite.sh"]

34
go.mod Normal file
View File

@@ -0,0 +1,34 @@
module git.hrafn.xyz/aether/gosick
go 1.26
toolchain go1.26.1
require github.com/stretchr/testify v1.10.0
require github.com/go-git/go-git/v5 v5.14.0
require (
dario.cat/mergo v1.0.0 // indirect
github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/ProtonMail/go-crypto v1.1.5 // indirect
github.com/cloudflare/circl v1.6.0 // indirect
github.com/cyphar/filepath-securejoin v0.4.1 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/emirpasic/gods v1.18.1 // indirect
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
github.com/go-git/go-billy/v5 v5.6.2 // indirect
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
github.com/kevinburke/ssh_config v1.2.0 // indirect
github.com/pjbgf/sha1cd v0.3.2 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect
github.com/skeema/knownhosts v1.3.1 // indirect
github.com/xanzy/ssh-agent v0.3.3 // indirect
golang.org/x/crypto v0.35.0 // indirect
golang.org/x/net v0.35.0 // indirect
golang.org/x/sys v0.30.0 // indirect
gopkg.in/warnings.v0 v0.1.2 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

102
go.sum Normal file
View File

@@ -0,0 +1,102 @@
dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk=
dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk=
github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
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/go.mod h1:rA3QumHc/FZ8pAHreoekgiAbzpNsfQAosU5td4SnOrE=
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/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio=
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs=
github.com/cloudflare/circl v1.6.0 h1:cr5JKic4HI+LkINy2lg3W2jF8sHCVTBncJr5gIIq7qk=
github.com/cloudflare/circl v1.6.0/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs=
github.com/cyphar/filepath-securejoin v0.4.1 h1:JyxxyPEaktOD+GAnqIqTf9A8tHyAG22rowi7HkoSU1s=
github.com/cyphar/filepath-securejoin v0.4.1/go.mod h1:Sdj7gXlvMcPZsbhwhQ33GguGLDGQL7h7bg04C/+u9jI=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o=
github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE=
github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc=
github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ=
github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c=
github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU=
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI=
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic=
github.com/go-git/go-billy/v5 v5.6.2 h1:6Q86EsPXMa7c3YZ3aLAQsMA0VlWmy43r6FHqa/UNbRM=
github.com/go-git/go-billy/v5 v5.6.2/go.mod h1:rcFC2rAsp/erv7CMz9GczHcuD0D32fWzH+MJAU+jaUU=
github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMje31YglSBqCdIqdhKBW8lokaMrL3uTkpGYlE2OOT4=
github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII=
github.com/go-git/go-git/v5 v5.14.0 h1:/MD3lCrGjCen5WfEAzKg00MJJffKhC8gzS80ycmCi60=
github.com/go-git/go-git/v5 v5.14.0/go.mod h1:Z5Xhoia5PcWA3NF8vRLURn9E5FRhSl7dGj9ItW3Wk5k=
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ=
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A=
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo=
github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4=
github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k=
github.com/onsi/gomega v1.34.1/go.mod h1:kU1QgUvBDLXBJq618Xvm2LUX6rSAfRaFRTcdOeDLwwY=
github.com/pjbgf/sha1cd v0.3.2 h1:a9wb0bp1oC2TGwStyn0Umc/IGKQnEgF0vVaZ8QF8eo4=
github.com/pjbgf/sha1cd v0.3.2/go.mod h1:zQWigSxVmsHEZow5qaLtPYxpcKMMQpa09ixqBxuCS6A=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8=
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
github.com/skeema/knownhosts v1.3.1 h1:X2osQ+RAjK76shCbvhHHHVl3ZlgDm8apHEHFqRjnBY8=
github.com/skeema/knownhosts v1.3.1/go.mod h1:r7KTdC8l4uxWRyK2TpQZ/1o5HaSzh06ePQNxPwTcfiY=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM=
github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw=
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.35.0 h1:b15kiHdrGCHrP6LvwaQ3c03kgNhhiMgvlhxHQhmg2Xs=
golang.org/x/crypto v0.35.0/go.mod h1:dy7dXNW32cAb/6/PRuTNsix8T+vJAqvuIy5Bli/x0YQ=
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8=
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY=
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8=
golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk=
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.29.0 h1:L6pJp37ocefwRRtYPKSWOWzOtWSxVajvz2ldH/xi3iU=
golang.org/x/term v0.29.0/go.mod h1:6bl4lRlvVuDgSf3179VpIxBF0o10JUpXWOnI7nErv7s=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME=
gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@@ -2,19 +2,19 @@
# DO NOT EDIT THIS FILE DIRECTLY
# Instead, edit Jeweler::Tasks in Rakefile, and run 'rake gemspec'
# -*- encoding: utf-8 -*-
# stub: homesick 1.1.1 ruby lib
# stub: homesick 1.1.6 ruby lib
Gem::Specification.new do |s|
s.name = "homesick"
s.version = "1.1.1"
s.name = "homesick".freeze
s.version = "1.1.6"
s.required_rubygems_version = Gem::Requirement.new(">= 0") if s.respond_to? :required_rubygems_version=
s.require_paths = ["lib"]
s.authors = ["Joshua Nichols", "Yusuke Murata"]
s.date = "2014-05-22"
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 "
s.email = ["josh@technicalpickles.com", "info@muratayusuke.com"]
s.executables = ["homesick"]
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",
@@ -37,58 +37,69 @@ Gem::Specification.new do |s|
"lib/homesick/actions/file_actions.rb",
"lib/homesick/actions/git_actions.rb",
"lib/homesick/cli.rb",
"lib/homesick/shell.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"
s.licenses = ["MIT"]
s.rubygems_version = "2.2.2"
s.summary = "Your home directory is your castle. Don't leave your dotfiles behind."
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>, [">= 0.14.0"])
s.add_development_dependency(%q<rake>, [">= 0.8.7"])
s.add_development_dependency(%q<rspec>, ["~> 2.10"])
s.add_development_dependency(%q<guard>, [">= 0"])
s.add_development_dependency(%q<guard-rspec>, [">= 0"])
s.add_development_dependency(%q<rb-readline>, ["~> 0.5.0"])
s.add_development_dependency(%q<jeweler>, [">= 1.6.2"])
s.add_development_dependency(%q<coveralls>, [">= 0"])
s.add_development_dependency(%q<test_construct>, [">= 0"])
s.add_development_dependency(%q<capture-output>, ["~> 1.0.0"])
s.add_development_dependency(%q<rubocop>, [">= 0"])
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>, [">= 0.14.0"])
s.add_dependency(%q<rake>, [">= 0.8.7"])
s.add_dependency(%q<rspec>, ["~> 2.10"])
s.add_dependency(%q<guard>, [">= 0"])
s.add_dependency(%q<guard-rspec>, [">= 0"])
s.add_dependency(%q<rb-readline>, ["~> 0.5.0"])
s.add_dependency(%q<jeweler>, [">= 1.6.2"])
s.add_dependency(%q<coveralls>, [">= 0"])
s.add_dependency(%q<test_construct>, [">= 0"])
s.add_dependency(%q<capture-output>, ["~> 1.0.0"])
s.add_dependency(%q<rubocop>, [">= 0"])
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>, [">= 0.14.0"])
s.add_dependency(%q<rake>, [">= 0.8.7"])
s.add_dependency(%q<rspec>, ["~> 2.10"])
s.add_dependency(%q<guard>, [">= 0"])
s.add_dependency(%q<guard-rspec>, [">= 0"])
s.add_dependency(%q<rb-readline>, ["~> 0.5.0"])
s.add_dependency(%q<jeweler>, [">= 1.6.2"])
s.add_dependency(%q<coveralls>, [">= 0"])
s.add_dependency(%q<test_construct>, [">= 0"])
s.add_dependency(%q<capture-output>, ["~> 1.0.0"])
s.add_dependency(%q<rubocop>, [">= 0"])
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

@@ -0,0 +1,129 @@
package cli
import (
"fmt"
"io"
"os"
"strings"
"git.hrafn.xyz/aether/gosick/internal/homesick/core"
"git.hrafn.xyz/aether/gosick/internal/homesick/version"
)
func Run(args []string, stdout io.Writer, stderr io.Writer) int {
app, err := core.New(stdout, stderr)
if err != nil {
_, _ = fmt.Fprintf(stderr, "error: %v\n", err)
return 1
}
if len(args) == 0 {
printHelp(stdout)
return 0
}
command := args[0]
switch command {
case "-v", "--version", "version":
if err := app.Version(version.String); err != nil {
_, _ = fmt.Fprintf(stderr, "error: %v\n", err)
return 1
}
return 0
case "help", "--help", "-h":
printHelp(stdout)
return 0
case "clone":
if len(args) < 2 {
_, _ = fmt.Fprintln(stderr, "error: clone requires URI")
return 1
}
destination := ""
if len(args) > 2 {
destination = args[2]
}
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 {
if len(args) > 1 && strings.TrimSpace(args[1]) != "" {
return args[1]
}
return "dotfiles"
}
func printHelp(w io.Writer) {
_, _ = fmt.Fprintln(w, "homesick (Go scaffold)")
_, _ = fmt.Fprintln(w, "")
_, _ = fmt.Fprintln(w, "Implemented commands:")
_, _ = fmt.Fprintln(w, " clone URI [CASTLE_NAME]")
_, _ = fmt.Fprintln(w, " list")
_, _ = fmt.Fprintln(w, " show_path [CASTLE]")
_, _ = fmt.Fprintln(w, " status [CASTLE]")
_, _ = fmt.Fprintln(w, " diff [CASTLE]")
_, _ = fmt.Fprintln(w, " link [CASTLE]")
_, _ = fmt.Fprintln(w, " unlink [CASTLE]")
_, _ = fmt.Fprintln(w, " version | -v | --version")
_, _ = fmt.Fprintln(w, "")
_, _ = fmt.Fprintln(w, "Not implemented yet:")
_, _ = fmt.Fprintln(w, " track, pull, push, commit, destroy, cd, open, exec, exec_all, rc, generate")
_, _ = fmt.Fprintln(w, "")
_, _ = fmt.Fprintln(w, "Build: go build -o dist/homesick-go ./cmd/homesick")
}
func init() {
_ = os.Setenv("GIT_TERMINAL_PROMPT", "0")
}

View File

@@ -0,0 +1,103 @@
package core_test
import (
"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/config"
"github.com/go-git/go-git/v5/plumbing/object"
"github.com/stretchr/testify/require"
"github.com/stretchr/testify/suite"
)
type CloneSuite struct {
suite.Suite
tmpDir string
homeDir string
reposDir string
app *core.App
}
func TestCloneSuite(t *testing.T) {
suite.Run(t, new(CloneSuite))
}
func (s *CloneSuite) 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.app = &core.App{
HomeDir: s.homeDir,
ReposDir: s.reposDir,
Stdout: io.Discard,
Stderr: io.Discard,
}
}
func (s *CloneSuite) createBareRemote(name string) string {
remotePath := filepath.Join(s.tmpDir, name+".git")
_, err := git.PlainInit(remotePath, true)
require.NoError(s.T(), err)
workPath := filepath.Join(s.tmpDir, name+"-work")
repo, err := git.PlainInit(workPath, false)
require.NoError(s.T(), err)
castleFile := filepath.Join(workPath, "home", ".vimrc")
require.NoError(s.T(), os.MkdirAll(filepath.Dir(castleFile), 0o755))
require.NoError(s.T(), os.WriteFile(castleFile, []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)
_, err = repo.CreateRemote(&config.RemoteConfig{Name: "origin", URLs: []string{remotePath}})
require.NoError(s.T(), err)
require.NoError(s.T(), repo.Push(&git.PushOptions{RemoteName: "origin"}))
return remotePath
}
func (s *CloneSuite) TestClone_FileURLWorks() {
remotePath := s.createBareRemote("castle")
err := s.app.Clone("file://"+remotePath, "parity-castle")
require.NoError(s.T(), err)
require.FileExists(s.T(), filepath.Join(s.reposDir, "parity-castle", "home", ".vimrc"))
}
func (s *CloneSuite) TestClone_DerivesDestinationWhenMissing() {
remotePath := s.createBareRemote("dotfiles")
err := s.app.Clone("file://"+remotePath, "")
require.NoError(s.T(), err)
require.DirExists(s.T(), filepath.Join(s.reposDir, "dotfiles"))
}
func (s *CloneSuite) TestClone_LocalPathSymlinksDirectory() {
localCastle := filepath.Join(s.tmpDir, "local-castle")
require.NoError(s.T(), os.MkdirAll(filepath.Join(localCastle, "home"), 0o755))
err := s.app.Clone(localCastle, "")
require.NoError(s.T(), err)
destination := filepath.Join(s.reposDir, "local-castle")
info, err := os.Lstat(destination)
require.NoError(s.T(), err)
require.True(s.T(), info.Mode()&os.ModeSymlink != 0)
}

View File

@@ -0,0 +1,440 @@
package core
import (
"errors"
"fmt"
"io"
"io/fs"
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
git "github.com/go-git/go-git/v5"
)
type App struct {
HomeDir string
ReposDir string
Stdout io.Writer
Stderr io.Writer
Verbose bool
Force bool
}
func New(stdout io.Writer, stderr io.Writer) (*App, error) {
home, err := os.UserHomeDir()
if err != nil {
return nil, fmt.Errorf("resolve home directory: %w", err)
}
return &App{
HomeDir: home,
ReposDir: filepath.Join(home, ".homesick", "repos"),
Stdout: stdout,
Stderr: stderr,
}, nil
}
func (a *App) Version(version string) error {
_, err := fmt.Fprintln(a.Stdout, version)
return err
}
func (a *App) ShowPath(castle string) error {
_, err := fmt.Fprintln(a.Stdout, filepath.Join(a.ReposDir, castle))
return err
}
func (a *App) Clone(uri string, destination string) error {
if uri == "" {
return errors.New("clone requires URI")
}
if destination == "" {
destination = deriveDestination(uri)
}
if destination == "" {
return fmt.Errorf("unable to derive destination from uri %q", uri)
}
if err := os.MkdirAll(a.ReposDir, 0o755); err != nil {
return fmt.Errorf("create repos directory: %w", err)
}
destinationPath := filepath.Join(a.ReposDir, destination)
if fi, err := os.Stat(uri); err == nil && fi.IsDir() {
if err := os.Symlink(uri, destinationPath); err != nil {
return fmt.Errorf("symlink local castle: %w", err)
}
return nil
}
_, err := git.PlainClone(destinationPath, false, &git.CloneOptions{
URL: uri,
RecurseSubmodules: git.DefaultSubmoduleRecursionDepth,
})
if err != nil {
return fmt.Errorf("clone %q into %q failed: %w", uri, destinationPath, err)
}
return nil
}
func (a *App) List() error {
if err := os.MkdirAll(a.ReposDir, 0o755); err != nil {
return err
}
var castles []string
err := filepath.WalkDir(a.ReposDir, func(path string, d fs.DirEntry, walkErr error) error {
if walkErr != nil {
return walkErr
}
if !d.IsDir() || d.Name() != ".git" {
return nil
}
castleRoot := filepath.Dir(path)
rel, err := filepath.Rel(a.ReposDir, castleRoot)
if err != nil {
return err
}
castles = append(castles, rel)
return filepath.SkipDir
})
if err != nil {
return err
}
sort.Strings(castles)
for _, castle := range castles {
castleRoot := filepath.Join(a.ReposDir, castle)
remote, remoteErr := gitOutput(castleRoot, "config", "remote.origin.url")
if remoteErr != nil {
remote = ""
}
_, writeErr := fmt.Fprintf(a.Stdout, "%s %s\n", castle, strings.TrimSpace(remote))
if writeErr != nil {
return writeErr
}
}
return nil
}
func (a *App) Status(castle string) error {
return runGit(filepath.Join(a.ReposDir, castle), "status")
}
func (a *App) Diff(castle string) error {
return runGit(filepath.Join(a.ReposDir, castle), "diff")
}
func (a *App) Link(castle string) error {
if strings.TrimSpace(castle) == "" {
castle = "dotfiles"
}
return a.LinkCastle(castle)
}
func (a *App) LinkCastle(castle string) error {
castleHome := filepath.Join(a.ReposDir, castle, "home")
info, err := os.Stat(castleHome)
if err != nil || !info.IsDir() {
return fmt.Errorf("could not symlink %s, expected %s to exist and contain dotfiles", castle, castleHome)
}
subdirs, err := readSubdirs(filepath.Join(a.ReposDir, castle, ".homesick_subdir"))
if err != nil {
return err
}
if err := a.linkEach(castleHome, castleHome, subdirs); err != nil {
return err
}
for _, subdir := range subdirs {
base := filepath.Join(castleHome, subdir)
if _, err := os.Stat(base); err != nil {
if errors.Is(err, os.ErrNotExist) {
continue
}
return err
}
if err := a.linkEach(castleHome, base, subdirs); err != nil {
return err
}
}
return nil
}
func (a *App) Unlink(castle string) error {
if strings.TrimSpace(castle) == "" {
castle = "dotfiles"
}
return a.UnlinkCastle(castle)
}
func (a *App) UnlinkCastle(castle string) error {
castleHome := filepath.Join(a.ReposDir, castle, "home")
info, err := os.Stat(castleHome)
if err != nil || !info.IsDir() {
return fmt.Errorf("could not symlink %s, expected %s to exist and contain dotfiles", castle, castleHome)
}
subdirs, err := readSubdirs(filepath.Join(a.ReposDir, castle, ".homesick_subdir"))
if err != nil {
return err
}
if err := a.unlinkEach(castleHome, castleHome, subdirs); err != nil {
return err
}
for _, subdir := range subdirs {
base := filepath.Join(castleHome, subdir)
if _, err := os.Stat(base); err != nil {
if errors.Is(err, os.ErrNotExist) {
continue
}
return err
}
if err := a.unlinkEach(castleHome, base, subdirs); err != nil {
return err
}
}
return nil
}
func (a *App) Track(string, string) error {
return errors.New("track is not implemented in Go yet")
}
func (a *App) linkEach(castleHome string, baseDir string, subdirs []string) error {
entries, err := os.ReadDir(baseDir)
if err != nil {
return err
}
for _, entry := range entries {
name := entry.Name()
if name == "." || name == ".." {
continue
}
source := filepath.Join(baseDir, name)
ignore, err := matchesIgnoredDir(castleHome, source, subdirs)
if err != nil {
return err
}
if ignore {
continue
}
relDir, err := filepath.Rel(castleHome, baseDir)
if err != nil {
return err
}
destination := filepath.Join(a.HomeDir, relDir, name)
if relDir == "." {
destination = filepath.Join(a.HomeDir, name)
}
if err := a.linkPath(source, destination); err != nil {
return err
}
}
return nil
}
func (a *App) unlinkEach(castleHome string, baseDir string, subdirs []string) error {
entries, err := os.ReadDir(baseDir)
if err != nil {
return err
}
for _, entry := range entries {
name := entry.Name()
if name == "." || name == ".." {
continue
}
source := filepath.Join(baseDir, name)
ignore, err := matchesIgnoredDir(castleHome, source, subdirs)
if err != nil {
return err
}
if ignore {
continue
}
relDir, err := filepath.Rel(castleHome, baseDir)
if err != nil {
return err
}
destination := filepath.Join(a.HomeDir, relDir, name)
if relDir == "." {
destination = filepath.Join(a.HomeDir, name)
}
if err := unlinkPath(destination); err != nil {
return err
}
}
return nil
}
func unlinkPath(destination string) error {
info, err := os.Lstat(destination)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return nil
}
return err
}
if info.Mode()&os.ModeSymlink == 0 {
return nil
}
return os.Remove(destination)
}
func (a *App) linkPath(source string, destination string) error {
absSource, err := filepath.Abs(source)
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(destination), 0o755); err != nil {
return err
}
info, err := os.Lstat(destination)
if err == nil {
if info.Mode()&os.ModeSymlink != 0 {
target, readErr := os.Readlink(destination)
if readErr == nil && target == absSource {
return nil
}
}
if !a.Force {
return fmt.Errorf("%s exists", destination)
}
if rmErr := os.RemoveAll(destination); rmErr != nil {
return rmErr
}
} else if !errors.Is(err, os.ErrNotExist) {
return err
}
if err := os.Symlink(absSource, destination); err != nil {
return err
}
return nil
}
func readSubdirs(path string) ([]string, error) {
data, err := os.ReadFile(path)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return []string{}, nil
}
return nil, err
}
lines := strings.Split(string(data), "\n")
result := make([]string, 0, len(lines))
for _, line := range lines {
trimmed := strings.TrimSpace(line)
if trimmed == "" {
continue
}
result = append(result, filepath.Clean(trimmed))
}
return result, nil
}
func matchesIgnoredDir(castleHome string, candidate string, subdirs []string) (bool, error) {
absCandidate, err := filepath.Abs(candidate)
if err != nil {
return false, err
}
ignoreSet := map[string]struct{}{}
for _, subdir := range subdirs {
clean := filepath.Clean(subdir)
for clean != "." && clean != string(filepath.Separator) {
ignoreSet[filepath.Join(castleHome, clean)] = struct{}{}
next := filepath.Dir(clean)
if next == clean {
break
}
clean = next
}
}
_, ok := ignoreSet[absCandidate]
return ok, nil
}
func runGit(dir string, args ...string) error {
cmd := exec.Command("git", args...)
cmd.Dir = dir
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
return fmt.Errorf("git %s failed: %w", strings.Join(args, " "), err)
}
return nil
}
func gitOutput(dir string, args ...string) (string, error) {
cmd := exec.Command("git", args...)
cmd.Dir = dir
out, err := cmd.Output()
if err != nil {
return "", err
}
return string(out), nil
}
func deriveDestination(uri string) string {
candidate := strings.TrimSpace(uri)
candidate = strings.TrimPrefix(candidate, "https://github.com/")
candidate = strings.TrimPrefix(candidate, "http://github.com/")
candidate = strings.TrimPrefix(candidate, "git://github.com/")
if strings.HasPrefix(candidate, "file://") {
candidate = strings.TrimPrefix(candidate, "file://")
}
candidate = strings.TrimSuffix(candidate, ".git")
candidate = strings.TrimSuffix(candidate, "/")
if candidate == "" {
return ""
}
parts := strings.Split(candidate, "/")
last := parts[len(parts)-1]
if strings.Contains(last, ":") {
a := strings.Split(last, ":")
last = a[len(a)-1]
}
return last
}

View File

@@ -0,0 +1,24 @@
package core
import "testing"
func TestDeriveDestination(t *testing.T) {
tests := []struct {
name string
uri string
want string
}{
{name: "github short", uri: "technicalpickles/pickled-vim", want: "pickled-vim"},
{name: "https", uri: "https://github.com/technicalpickles/pickled-vim.git", want: "pickled-vim"},
{name: "git ssh", uri: "git@github.com:technicalpickles/pickled-vim.git", want: "pickled-vim"},
{name: "file", uri: "file:///tmp/dotfiles.git", want: "dotfiles"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := deriveDestination(tt.uri); got != tt.want {
t.Fatalf("deriveDestination(%q) = %q, want %q", tt.uri, got, tt.want)
}
})
}
}

View File

@@ -0,0 +1,118 @@
package core_test
import (
"io"
"os"
"path/filepath"
"testing"
"git.hrafn.xyz/aether/gosick/internal/homesick/core"
"github.com/stretchr/testify/require"
"github.com/stretchr/testify/suite"
)
type LinkSuite struct {
suite.Suite
tmpDir string
homeDir string
reposDir string
app *core.App
}
func TestLinkSuite(t *testing.T) {
suite.Run(t, new(LinkSuite))
}
func (s *LinkSuite) 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.app = &core.App{
HomeDir: s.homeDir,
ReposDir: s.reposDir,
Stdout: io.Discard,
Stderr: io.Discard,
}
}
func (s *LinkSuite) createCastle(castle string) string {
castleHome := filepath.Join(s.reposDir, castle, "home")
require.NoError(s.T(), os.MkdirAll(castleHome, 0o755))
return castleHome
}
func (s *LinkSuite) writeFile(path string, content string) {
require.NoError(s.T(), os.MkdirAll(filepath.Dir(path), 0o755))
require.NoError(s.T(), os.WriteFile(path, []byte(content), 0o644))
}
func (s *LinkSuite) TestLink_SymlinksTopLevelFiles() {
castleHome := s.createCastle("glencairn")
dotfile := filepath.Join(castleHome, ".vimrc")
s.writeFile(dotfile, "set number\n")
err := s.app.Link("glencairn")
require.NoError(s.T(), err)
homePath := filepath.Join(s.homeDir, ".vimrc")
info, err := os.Lstat(homePath)
require.NoError(s.T(), err)
require.True(s.T(), info.Mode()&os.ModeSymlink != 0)
target, err := os.Readlink(homePath)
require.NoError(s.T(), err)
require.Equal(s.T(), dotfile, target)
}
func (s *LinkSuite) TestLink_RespectsHomesickSubdir() {
castleHome := s.createCastle("glencairn")
configDir := filepath.Join(castleHome, ".config")
appDir := filepath.Join(configDir, "myapp")
s.writeFile(filepath.Join(appDir, "config.toml"), "ok=true\n")
s.writeFile(filepath.Join(s.reposDir, "glencairn", ".homesick_subdir"), ".config\n")
err := s.app.Link("glencairn")
require.NoError(s.T(), err)
configInfo, err := os.Lstat(filepath.Join(s.homeDir, ".config"))
require.NoError(s.T(), err)
require.False(s.T(), configInfo.Mode()&os.ModeSymlink != 0)
homeApp := filepath.Join(s.homeDir, ".config", "myapp")
appInfo, err := os.Lstat(homeApp)
require.NoError(s.T(), err)
require.True(s.T(), appInfo.Mode()&os.ModeSymlink != 0)
target, err := os.Readlink(homeApp)
require.NoError(s.T(), err)
require.Equal(s.T(), appDir, target)
}
func (s *LinkSuite) TestLink_ForceReplacesExistingFile() {
castleHome := s.createCastle("glencairn")
dotfile := filepath.Join(castleHome, ".zshrc")
s.writeFile(dotfile, "export EDITOR=vim\n")
s.writeFile(filepath.Join(s.homeDir, ".zshrc"), "existing\n")
s.app.Force = true
err := s.app.Link("glencairn")
require.NoError(s.T(), err)
homePath := filepath.Join(s.homeDir, ".zshrc")
info, err := os.Lstat(homePath)
require.NoError(s.T(), err)
require.True(s.T(), info.Mode()&os.ModeSymlink != 0)
}
func (s *LinkSuite) TestLink_NoForceErrorsOnConflict() {
castleHome := s.createCastle("glencairn")
dotfile := filepath.Join(castleHome, ".gitconfig")
s.writeFile(dotfile, "[user]\n")
s.writeFile(filepath.Join(s.homeDir, ".gitconfig"), "local\n")
err := s.app.Link("glencairn")
require.Error(s.T(), err)
}

View File

@@ -0,0 +1,101 @@
package core_test
import (
"io"
"os"
"path/filepath"
"testing"
"git.hrafn.xyz/aether/gosick/internal/homesick/core"
git "github.com/go-git/go-git/v5"
"github.com/stretchr/testify/require"
"github.com/stretchr/testify/suite"
)
type TrackSuite struct {
suite.Suite
tmpDir string
homeDir string
reposDir string
app *core.App
}
//NB: this has nothing to do with jogging
func TestTrackSuite(t *testing.T) {
suite.Run(t, new(TrackSuite))
}
func (s *TrackSuite) 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.app = &core.App{
HomeDir: s.homeDir,
ReposDir: s.reposDir,
Stdout: io.Discard,
Stderr: io.Discard,
}
}
func (s *TrackSuite) createCastleRepo(castle string) string {
castleRoot := filepath.Join(s.reposDir, castle)
require.NoError(s.T(), os.MkdirAll(filepath.Join(castleRoot, "home"), 0o755))
_, err := git.PlainInit(castleRoot, false)
require.NoError(s.T(), err)
return castleRoot
}
func (s *TrackSuite) writeFile(path string, content string) {
require.NoError(s.T(), os.MkdirAll(filepath.Dir(path), 0o755))
require.NoError(s.T(), os.WriteFile(path, []byte(content), 0o644))
}
func (s *TrackSuite) TestTrack_AfterRelinkTracksFileAndUpdatesSubdir() {
castleRoot := s.createCastleRepo("parity-castle")
castleHome := filepath.Join(castleRoot, "home")
s.writeFile(filepath.Join(castleHome, ".vimrc"), "set number\n")
s.writeFile(filepath.Join(castleRoot, ".homesick_subdir"), ".config\n")
s.writeFile(filepath.Join(castleHome, ".config", "myapp", "config.toml"), "ok=true\n")
require.NoError(s.T(), s.app.Link("parity-castle"))
require.NoError(s.T(), s.app.Unlink("parity-castle"))
require.NoError(s.T(), s.app.Link("parity-castle"))
toolPath := filepath.Join(s.homeDir, ".local", "bin", "tool")
s.writeFile(toolPath, "#!/usr/bin/env bash\n")
require.NoError(s.T(), s.app.Track(toolPath, "parity-castle"))
expectedTarget := filepath.Join(castleHome, ".local", "bin", "tool")
info, err := os.Lstat(toolPath)
require.NoError(s.T(), err)
require.True(s.T(), info.Mode()&os.ModeSymlink != 0)
target, err := os.Readlink(toolPath)
require.NoError(s.T(), err)
require.Equal(s.T(), expectedTarget, target)
subdirData, err := os.ReadFile(filepath.Join(castleRoot, ".homesick_subdir"))
require.NoError(s.T(), err)
require.Contains(s.T(), string(subdirData), ".local/bin\n")
}
func (s *TrackSuite) TestTrack_DefaultCastleName() {
castleRoot := s.createCastleRepo("dotfiles")
castleHome := filepath.Join(castleRoot, "home")
filePath := filepath.Join(s.homeDir, ".tmux.conf")
s.writeFile(filePath, "set -g mouse on\n")
require.NoError(s.T(), s.app.Track(filePath, ""))
expectedTarget := filepath.Join(castleHome, ".tmux.conf")
require.FileExists(s.T(), expectedTarget)
linkTarget, err := os.Readlink(filePath)
require.NoError(s.T(), err)
require.Equal(s.T(), expectedTarget, linkTarget)
}

View File

@@ -0,0 +1,106 @@
package core_test
import (
"io"
"os"
"path/filepath"
"testing"
"git.hrafn.xyz/aether/gosick/internal/homesick/core"
"github.com/stretchr/testify/require"
"github.com/stretchr/testify/suite"
)
type UnlinkSuite struct {
suite.Suite
tmpDir string
homeDir string
reposDir string
app *core.App
}
func TestUnlinkSuite(t *testing.T) {
suite.Run(t, new(UnlinkSuite))
}
func (s *UnlinkSuite) 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.app = &core.App{
HomeDir: s.homeDir,
ReposDir: s.reposDir,
Stdout: io.Discard,
Stderr: io.Discard,
}
}
func (s *UnlinkSuite) createCastle(castle string) string {
castleHome := filepath.Join(s.reposDir, castle, "home")
require.NoError(s.T(), os.MkdirAll(castleHome, 0o755))
return castleHome
}
func (s *UnlinkSuite) writeFile(path string, content string) {
require.NoError(s.T(), os.MkdirAll(filepath.Dir(path), 0o755))
require.NoError(s.T(), os.WriteFile(path, []byte(content), 0o644))
}
func (s *UnlinkSuite) TestUnlink_RemovesTopLevelSymlinks() {
castleHome := s.createCastle("glencairn")
dotfile := filepath.Join(castleHome, ".vimrc")
s.writeFile(dotfile, "set number\n")
require.NoError(s.T(), s.app.Link("glencairn"))
require.NoError(s.T(), s.app.Unlink("glencairn"))
require.NoFileExists(s.T(), filepath.Join(s.homeDir, ".vimrc"))
}
func (s *UnlinkSuite) TestUnlink_RemovesNonDotfileSymlinks() {
castleHome := s.createCastle("glencairn")
binFile := filepath.Join(castleHome, "bin")
s.writeFile(binFile, "#!/usr/bin/env bash\n")
require.NoError(s.T(), s.app.Link("glencairn"))
require.NoError(s.T(), s.app.Unlink("glencairn"))
require.NoFileExists(s.T(), filepath.Join(s.homeDir, "bin"))
}
func (s *UnlinkSuite) TestUnlink_RespectsHomesickSubdir() {
castleHome := s.createCastle("glencairn")
appDir := filepath.Join(castleHome, ".config", "myapp")
s.writeFile(filepath.Join(appDir, "config.toml"), "ok=true\n")
s.writeFile(filepath.Join(s.reposDir, "glencairn", ".homesick_subdir"), ".config\n")
require.NoError(s.T(), s.app.Link("glencairn"))
require.NoError(s.T(), s.app.Unlink("glencairn"))
require.DirExists(s.T(), filepath.Join(s.homeDir, ".config"))
require.NoFileExists(s.T(), filepath.Join(s.homeDir, ".config", "myapp"))
}
func (s *UnlinkSuite) TestUnlink_DefaultCastleName() {
castleHome := s.createCastle("dotfiles")
dotfile := filepath.Join(castleHome, ".zshrc")
s.writeFile(dotfile, "export EDITOR=vim\n")
require.NoError(s.T(), s.app.Link("dotfiles"))
require.NoError(s.T(), s.app.Unlink(""))
require.NoFileExists(s.T(), filepath.Join(s.homeDir, ".zshrc"))
}
func (s *UnlinkSuite) TestUnlink_IgnoresNonSymlinkDestination() {
castleHome := s.createCastle("glencairn")
dotfile := filepath.Join(castleHome, ".gitconfig")
s.writeFile(dotfile, "[user]\n")
s.writeFile(filepath.Join(s.homeDir, ".gitconfig"), "local\n")
require.NoError(s.T(), s.app.Unlink("glencairn"))
require.FileExists(s.T(), filepath.Join(s.homeDir, ".gitconfig"))
}

View File

@@ -0,0 +1,3 @@
package version
const String = "1.1.6"

27
justfile Normal file
View File

@@ -0,0 +1,27 @@
set shell := ["bash", "-eu", "-o", "pipefail", "-c"]
default:
@just --list
go-build:
@mkdir -p dist
go build -o dist/homesick-go ./cmd/homesick
go-build-linux:
@mkdir -p dist
GOOS=linux GOARCH="$(go env GOARCH)" go build -o dist/homesick-go ./cmd/homesick
go-test:
go test ./...
behavior-ruby:
./script/run-behavior-suite-docker.sh
behavior-go: go-build-linux
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

View File

@@ -1,15 +1,29 @@
# -*- encoding : utf-8 -*-
require 'homesick/shell'
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 = /\A([A-Za-z0-9_-]+\/[A-Za-z0-9_-]+)\Z/
SUBDIR_FILENAME = '.homesick_subdir'
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'
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,20 +1,14 @@
# -*- encoding : utf-8 -*-
module Homesick
module Actions
# File-related helper methods for Homesick
module FileActions
def mv(source, destination, config = {})
protected
def mv(source, destination)
source = Pathname.new(source)
destination = Pathname.new(destination + source.basename)
if destination.exist?
say_status :conflict, "#{destination} exists", :red
FileUtils.mv source, destination if (options[:force] || shell.file_collision(destination) { source }) && !options[:pretend]
else
# this needs some sort of message here.
FileUtils.mv source, destination unless options[:pretend]
end
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)
@@ -26,7 +20,7 @@ module Homesick
target = Pathname.new(target)
if target.symlink?
say_status :unlink, "#{target.expand_path}", :green
say_status :unlink, target.expand_path.to_s, :green
FileUtils.rm_rf target
else
say_status :conflict, "#{target} is not a symlink", :red
@@ -43,48 +37,41 @@ module Homesick
FileUtils.rm_r dir
end
def ln_s(source, destination, config = {})
source = Pathname.new(source)
def ln_s(source, destination)
source = Pathname.new(source).realpath
destination = Pathname.new(destination)
FileUtils.mkdir_p destination.dirname
action = if destination.symlink? && destination.readlink == source
:identical
elsif destination.symlink?
:symlink_conflict
elsif destination.exist?
:conflict
else
:success
end
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)
case action
when :identical
if action == :identical
say_status :identical, destination.expand_path, :blue
when :symlink_conflict
say_status :conflict,
"#{destination} exists and points to #{destination.readlink}",
:red
FileUtils.rm destination
FileUtils.ln_s source, destination, force: true unless options[:pretend]
when :conflict
say_status :conflict, "#{destination} exists", :red
if collision_accepted?(destination)
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]
FileUtils.ln_s source, destination, force: true unless options[:pretend]
end
else
say_status :symlink,
"#{source.expand_path} to #{destination.expand_path}",
:green
FileUtils.ln_s source, destination unless options[:pretend]
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

View File

@@ -1,14 +1,34 @@
# -*- encoding : utf-8 -*-
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.kind_of?(Pathname)
destination = Pathname.new(destination) unless destination.is_a?(Pathname)
FileUtils.mkdir_p destination.dirname
if destination.directory?
@@ -46,22 +66,22 @@ module Homesick
end
end
def git_submodule_init(config = {})
def git_submodule_init
say_status 'git submodule', 'init', :green
system 'git submodule --quiet init'
end
def git_submodule_update(config = {})
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(config = {})
def git_pull
say_status 'git pull', '', :green
system 'git pull --quiet'
end
def git_push(config = {})
def git_push
say_status 'git push', '', :green
system 'git push'
end
@@ -69,23 +89,23 @@ module Homesick
def git_commit_all(config = {})
say_status 'git commit all', '', :green
if config[:message]
system "git commit -a -m '#{config[:message]}'"
system %(git commit -a -m "#{config[:message]}")
else
system 'git commit -v -a'
end
end
def git_add(file, config = {})
def git_add(file)
say_status 'git add file', '', :green
system "git add '#{file}'"
end
def git_status(config = {})
def git_status
say_status 'git status', '', :green
system 'git status'
end
def git_diff(config = {})
def git_diff
say_status 'git diff', '', :green
system 'git diff'
end

View File

@@ -1,4 +1,4 @@
# -*- encoding : utf-8 -*-
require 'fileutils'
require 'thor'
module Homesick
@@ -19,29 +19,35 @@ module Homesick
def initialize(args = [], options = {}, config = {})
super
self.shell = Homesick::Shell.new
# 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', 'Clone +uri+ as a castle for homesick'
def clone(uri)
desc 'clone URI CASTLE_NAME', 'Clone +uri+ as a castle with name CASTLE_NAME for homesick'
def clone(uri, destination = nil)
destination = Pathname.new(destination) unless destination.nil?
inside repos_dir do
destination = nil
if File.exist?(uri)
uri = Pathname.new(uri).expand_path
fail "Castle already cloned to #{uri}" if uri.to_s.start_with?(repos_dir.to_s)
raise "Castle already cloned to #{uri}" if uri.to_s.start_with?(repos_dir.to_s)
destination = uri.basename
destination = uri.basename if destination.nil?
ln_s uri, destination
elsif uri =~ GITHUB_NAME_REPO_PATTERN
destination = Pathname.new(uri).basename
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])
git_clone uri
destination = Pathname.new(Regexp.last_match[1].gsub(/\.git$/, '')).basename if destination.nil?
git_clone uri, destination: destination
else
fail "Unknown URI format: #{uri}"
raise "Unknown URI format: #{uri}"
end
setup_castle(destination)
@@ -49,22 +55,22 @@ module Homesick
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
if homesickrc.exist?
proceed = shell.yes?("#{name} has a .homesickrc. Proceed with evaling it? (This could be destructive)")
if proceed
say_status 'eval', homesickrc
inside destination do
eval homesickrc.read, binding, homesickrc.expand_path.to_s
end
else
say_status 'eval skip',
"not evaling #{homesickrc}, #{destination} may need manual configuration",
:blue
end
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
@@ -115,16 +121,18 @@ module Homesick
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')
inside castle_dir(name) do
castle_path = castle_dir(name)
inside castle_path do
subdirs = subdirs(name)
# link files
symlink_each(name, castle_dir(name), subdirs)
symlink_each(name, castle_path, subdirs)
# link files in subdirs
subdirs.each do |subdir|
@@ -232,11 +240,10 @@ module Homesick
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?')
if shell.yes?('This will destroy your castle irreversible! Are you sure?')
unlink(name)
rm_rf repos_dir.join(name)
end
unlink(name)
rm_rf repos_dir.join(name)
end
desc 'cd CASTLE', 'Open a new shell in the root of the given castle'
@@ -263,11 +270,11 @@ module Homesick
end
check_castle_existance castle, 'open'
castle_dir = repos_dir.join(castle)
say_status "#{ENV['EDITOR']} #{castle_dir.realpath}",
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'])
system("#{ENV['EDITOR']} .")
end
end

View File

@@ -1,17 +0,0 @@
require 'thor'
module Homesick
# Hack in support for diffing symlinks
class Shell < Thor::Shell::Color
def show_diff(destination, content)
destination = Pathname.new(destination)
if destination.symlink?
say "- #{destination.readlink}", :red, true
say "+ #{content.expand_path}", :green, true
else
super
end
end
end
end

View File

@@ -1,27 +1,12 @@
# -*- encoding : utf-8 -*-
require 'pathname'
module Homesick
# Various utility methods that are used by Homesick
module Utils
QUIETABLE = ['say_status']
PRETENDABLE = ['system']
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
protected
def home_dir
@home_dir ||= Pathname.new(ENV['HOME'] || '~').expand_path
@home_dir ||= Pathname.new(ENV['HOME'] || '~').realpath
end
def repos_dir
@@ -33,12 +18,12 @@ module Homesick
end
def check_castle_existance(name, action)
unless castle_dir(name).exist?
say_status :error,
"Could not #{action} #{name}, expected #{castle_dir(name)} exist and contain dotfiles",
:red
exit(1)
end
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
@@ -51,7 +36,7 @@ module Homesick
end
end
def inside_each_castle(&block)
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
@@ -128,7 +113,6 @@ module Homesick
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?
@@ -148,50 +132,14 @@ module Homesick
first_p.mtime > second_p.mtime && !first_p.symlink?
end
def collision_accepted?(destination)
fail "Argument must be an instance of Pathname, #{destination.class.name} given" unless destination.instance_of?(Pathname)
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 each_file(castle, basedir, subdirs)
absolute_basedir = Pathname.new(basedir).expand_path
inside basedir do
files = Pathname.glob('{.*,*}').reject do |a|
['.', '..'].include?(a.to_s)
end
files.each do |path|
absolute_path = path.expand_path
castle_home = castle_dir(castle)
# 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
matched = false
ignore_dirs.uniq.each do |ignore_dir|
if absolute_path == castle_home.join(ignore_dir)
matched = true
break
end
end
next if matched
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 unsymlink_each(castle, basedir, subdirs)
each_file(castle, basedir, subdirs) do |absolute_path, home_path|
each_file(castle, basedir, subdirs) do |_absolute_path, home_path|
rm_link home_path
end
end
@@ -212,5 +160,56 @@ module Homesick
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 +1,10 @@
# -*- encoding : utf-8 -*-
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 = 1
PATCH = 6
STRING = [MAJOR, MINOR, PATCH].compact.join('.')
end

View File

@@ -0,0 +1,57 @@
#!/usr/bin/env bash
set -euo pipefail
: "${HOMESICK_CMD:=ruby /workspace/bin/homesick}"
behavior_verbose="${BEHAVIOR_VERBOSE:-0}"
while [[ $# -gt 0 ]]; do
case "$1" in
-v|--verbose)
echo "Enabling verbose output for behavior suite"
behavior_verbose=1
;;
*)
echo "Unknown argument: $1" >&2
echo "Usage: $0 [--verbose]" >&2
exit 1
;;
esac
shift
done
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
repo_root="$(cd "$script_dir/.." && pwd)"
run_docker_build() {
echo "Building Docker image for behavior suite..."
local build_log
local -a build_cmd
if docker buildx version >/dev/null 2>&1; then
build_cmd=(docker buildx build --load -f "$repo_root/docker/behavior/Dockerfile" -t homesick-behavior:latest "$repo_root")
else
build_cmd=(docker build -f "$repo_root/docker/behavior/Dockerfile" -t homesick-behavior:latest "$repo_root")
fi
if [[ "$behavior_verbose" == "1" ]]; then
"${build_cmd[@]}"
return
fi
build_log="$(mktemp)"
if ! "${build_cmd[@]}" >"$build_log" 2>&1; then
cat "$build_log" >&2
rm -f "$build_log"
exit 1
fi
rm -f "$build_log"
}
run_docker_build
echo "Running behavior suite in Docker container..."
docker run --rm \
-e HOMESICK_CMD="$HOMESICK_CMD" \
-e BEHAVIOR_VERBOSE="$behavior_verbose" \
homesick-behavior:latest

View File

@@ -1,6 +1,6 @@
# -*- encoding : utf-8 -*-
require 'spec_helper'
require 'capture-output'
require 'pathname'
describe Homesick::CLI do
let(:home) { create_construct }
@@ -12,6 +12,48 @@ describe Homesick::CLI do
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
@@ -63,55 +105,63 @@ describe Homesick::CLI do
homesick.clone "file://#{bare_repo}"
end
expect(File.directory?(File.join(home.to_s, '.homesick/repos/dotfiles')))
.to be_true
.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')
.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')
.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')
.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')
.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')
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
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'))
.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
@@ -136,6 +186,26 @@ describe Homesick::CLI do
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)
@@ -156,7 +226,7 @@ describe Homesick::CLI do
end
end
describe 'link' do
describe 'link_castle' do
let(:castle) { given_castle('glencairn') }
it 'links dotfiles from a castle to the home folder' do
@@ -244,11 +314,9 @@ describe Homesick::CLI do
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_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)
expect(home_someapp_dir.join('.some_appfile').readlink).to eq(someapp_dotfile)
end
end
@@ -262,6 +330,40 @@ describe Homesick::CLI do
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
@@ -357,13 +459,9 @@ describe Homesick::CLI do
given_castle('wtf/zomg')
expect(homesick).to receive(:say_status)
.with('zomg',
'git://github.com/technicalpickles/zomg.git',
:cyan)
.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)
.with('wtf/zomg', 'git://github.com/technicalpickles/zomg.git', :cyan)
homesick.list
end
@@ -373,7 +471,7 @@ describe Homesick::CLI 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(/nothing to commit \(create\/copy files and use "git add" to track\)$/)
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
@@ -381,9 +479,7 @@ describe Homesick::CLI do
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(
/Changes to be committed:.*new file:\s*home\/.some_rc_file/m
)
expect(text).to match(%r{Changes to be committed:.*new file:\s*home\/.some_rc_file}m)
end
end
@@ -435,9 +531,9 @@ describe Homesick::CLI do
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 .* exist and contain dotfiles/,
:red)
.with(:error,
/Could not pull castle_repo, expected .* to exist and contain dotfiles/,
:red)
expect { homesick.pull 'castle_repo' }.to raise_error(SystemExit)
end
@@ -447,15 +543,14 @@ describe Homesick::CLI do
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')
.with('git submodule --quiet init')
allow(homesick).to receive(:system).exactly(2).times
.with('git submodule --quiet update --init --recursive >/dev/null 2>&1')
.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
@@ -467,9 +562,7 @@ describe Homesick::CLI do
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 .* exist and contain dotfiles/,
:red)
.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
@@ -553,7 +646,6 @@ describe Homesick::CLI do
# 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')
@@ -628,9 +720,7 @@ describe Homesick::CLI do
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 .* exist and contain dotfiles/,
:red)
.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
@@ -643,27 +733,27 @@ describe Homesick::CLI do
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')
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)
.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 .* exist and contain dotfiles/,
:red)
.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
@@ -671,7 +761,7 @@ describe Homesick::CLI do
describe 'version' do
it 'prints the current version of homesick' do
text = Capture.stdout { homesick.version }
expect(text.chomp).to match(/\d+\.\d+\.\d+/)
expect(text.chomp).to match(/#{Regexp.escape(Homesick::Version::STRING)}/)
end
end
@@ -682,9 +772,7 @@ describe Homesick::CLI do
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)
.with(be_a(String), be_a(String), :green)
allow(homesick).to receive('system').once.with('ls')
Capture.stdout { homesick.exec 'castle_repo', 'ls' }
end
@@ -692,18 +780,14 @@ describe Homesick::CLI do
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)
.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)
.with(:error, be_a(String), :red)
allow(homesick).to receive('exit').once.with(1)
Capture.stdout { homesick.exec 'castle_repo' }
end
@@ -711,11 +795,9 @@ describe Homesick::CLI do
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)
.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 }
Capture.stdout { homesick.invoke 'exec', %w[castle_repo ls -la], pretend: true }
end
end
@@ -723,8 +805,8 @@ describe Homesick::CLI 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 }
.with('ls -la')
Capture.stdout { homesick.invoke 'exec', %w[castle_repo ls -la], quiet: true }
end
end
end
@@ -738,9 +820,7 @@ describe Homesick::CLI do
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)
.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
@@ -748,18 +828,14 @@ describe Homesick::CLI do
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)
.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)
.with(:error, be_a(String), :red)
allow(homesick).to receive('exit').once.with(1)
Capture.stdout { homesick.exec_all }
end
@@ -767,11 +843,9 @@ describe Homesick::CLI do
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)
.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 }
Capture.stdout { homesick.invoke 'exec_all', %w[ls -la], pretend: true }
end
end
@@ -779,8 +853,8 @@ describe Homesick::CLI 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 }
.with('ls -la')
Capture.stdout { homesick.invoke 'exec_all', %w[ls -la], quiet: true }
end
end
end

View File

@@ -5,7 +5,6 @@ $LOAD_PATH.unshift(File.dirname(__FILE__))
$LOAD_PATH.unshift(File.join(File.dirname(__FILE__), '..', 'lib'))
require 'homesick'
require 'rspec'
require 'rspec/autorun'
require 'test_construct'
require 'tempfile'

193
test/behavior/behavior_suite.sh Executable file
View File

@@ -0,0 +1,193 @@
#!/usr/bin/env bash
set -euo pipefail
: "${HOMESICK_CMD:=ruby /workspace/bin/homesick}"
: "${BEHAVIOR_VERBOSE:=0}"
RUN_OUTPUT=""
fail() {
echo "FAIL: $*" >&2
exit 1
}
pass() {
if [[ -t 1 ]]; then
printf ' \033[32mPassed\033[0m\n'
else
echo " Passed"
fi
}
parse_args() {
while [[ $# -gt 0 ]]; do
case "$1" in
-v|--verbose)
BEHAVIOR_VERBOSE=1
;;
*)
fail "unknown argument: $1"
;;
esac
shift
done
}
run_git() {
if [[ "$BEHAVIOR_VERBOSE" == "1" ]]; then
git "$@"
else
git "$@" >/dev/null 2>&1
fi
}
assert_path_exists() {
local path="$1"
[[ -e "$path" ]] || fail "expected path to exist: $path"
}
assert_path_missing() {
local path="$1"
[[ ! -e "$path" ]] || fail "expected path to be missing: $path"
}
assert_symlink_target() {
local link_path="$1"
local expected_target="$2"
[[ -L "$link_path" ]] || fail "expected symlink: $link_path"
local actual_target
actual_target="$(readlink "$link_path")"
[[ "$actual_target" == "$expected_target" ]] || fail "expected symlink target '$expected_target' but got '$actual_target'"
}
run_homesick() {
local out_file
local output
out_file="$(mktemp)"
if ! bash -lc "$HOMESICK_CMD $*" >"$out_file" 2>&1; then
cat "$out_file" >&2
rm -f "$out_file"
fail "homesick command failed: $*"
fi
output="$(cat "$out_file")"
RUN_OUTPUT="$output"
if [[ "$BEHAVIOR_VERBOSE" == "1" && -n "$output" ]]; then
printf '%s\n' "$output"
fi
rm -f "$out_file"
}
setup_remote_castle() {
local remote_dir="$1"
local work_dir="$2"
mkdir -p "$remote_dir"
run_git init --bare "$remote_dir/base.git"
mkdir -p "$work_dir/base"
pushd "$work_dir/base" >/dev/null
run_git init
run_git config user.email "behavior@test.local"
run_git config user.name "Behavior Test"
mkdir -p home/.config/myapp
echo "set number" > home/.vimrc
echo "export PATH=\"$PATH:$HOME/bin\"" > home/.zshrc
echo "option=true" > home/.config/myapp/config.toml
printf '.config\n' > .homesick_subdir
run_git add .
run_git commit -m "initial castle"
run_git remote add origin "$remote_dir/base.git"
run_git push -u origin master
popd >/dev/null
}
setup_local_test_file() {
mkdir -p "$HOME/.local/bin"
echo "#!/usr/bin/env bash" > "$HOME/.local/bin/tool"
chmod +x "$HOME/.local/bin/tool"
}
run_suite() {
local tmp_root
tmp_root="$(mktemp -d)"
trap "rm -rf '$tmp_root'" EXIT
export HOME="$tmp_root/home"
mkdir -p "$HOME"
local remote_root="$tmp_root/remote"
local work_root="$tmp_root/work"
setup_remote_castle "$remote_root" "$work_root"
echo "[1/7] clone"
run_homesick "clone file://$remote_root/base.git parity-castle"
assert_path_exists "$HOME/.homesick/repos/parity-castle/.git"
assert_path_exists "$HOME/.homesick/repos/parity-castle/home/.vimrc"
pass
echo "[2/7] link"
run_homesick "link parity-castle"
assert_symlink_target "$HOME/.vimrc" "$HOME/.homesick/repos/parity-castle/home/.vimrc"
assert_symlink_target "$HOME/.zshrc" "$HOME/.homesick/repos/parity-castle/home/.zshrc"
assert_path_exists "$HOME/.config/myapp"
assert_symlink_target "$HOME/.config/myapp" "$HOME/.homesick/repos/parity-castle/home/.config/myapp"
pass
echo "[3/7] unlink"
run_homesick "unlink parity-castle"
assert_path_missing "$HOME/.vimrc"
assert_path_missing "$HOME/.zshrc"
assert_path_exists "$HOME/.config"
assert_path_missing "$HOME/.config/myapp"
pass
echo "[4/7] relink + track"
run_homesick "link parity-castle"
setup_local_test_file
run_homesick "track $HOME/.local/bin/tool parity-castle"
assert_symlink_target "$HOME/.local/bin/tool" "$HOME/.homesick/repos/parity-castle/home/.local/bin/tool"
assert_path_exists "$HOME/.homesick/repos/parity-castle/.homesick_subdir"
grep -q "^.local/bin$" "$HOME/.homesick/repos/parity-castle/.homesick_subdir" || fail "expected .homesick_subdir to contain .local/bin"
pass
echo "[5/7] list and show_path"
local list_output
run_homesick "list"
list_output="$RUN_OUTPUT"
[[ "$list_output" == *"parity-castle"* ]] || fail "expected list output to include parity-castle"
local show_path_output
run_homesick "show_path parity-castle"
show_path_output="$RUN_OUTPUT"
[[ "$show_path_output" == *"$HOME/.homesick/repos/parity-castle"* ]] || fail "expected show_path output to include parity-castle path"
pass
echo "[6/7] status and diff"
echo "change" >> "$HOME/.vimrc"
local status_output
run_homesick "status parity-castle"
status_output="$RUN_OUTPUT"
[[ "$status_output" == *"modified:"* ]] || fail "expected status output to include modified file"
local diff_output
run_homesick "diff parity-castle"
diff_output="$RUN_OUTPUT"
[[ "$diff_output" == *"diff --git"* ]] || fail "expected diff output to include git diff"
pass
echo "[7/7] version"
local version_output
run_homesick "version"
version_output="$RUN_OUTPUT"
[[ "$version_output" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || fail "expected semantic version output, got: $version_output"
pass
echo "PASS: behavior suite completed for command: $HOMESICK_CMD"
}
parse_args "$@"
run_suite