171 Commits

Author SHA1 Message Date
Micheal Wilkinson
cf183d9bb0 docs: update changelog for badge upload guard
Some checks failed
Pull Request Validation / validate (pull_request) Failing after 2m32s
Push Validation / validate (push) Successful in 3m3s
2026-03-21 21:07:43 +00:00
Micheal Wilkinson
65d0a95968 ci(pr-validation): guard badge upload on coverage file 2026-03-21 21:07:43 +00:00
Micheal Wilkinson
7fbbb442a0 ci(pr-validation): always run badge upload and PR decoration
Some checks failed
Push Validation / validate (push) Successful in 4m41s
Pull Request Validation / validate (pull_request) Failing after 3m30s
2026-03-21 20:59:39 +00:00
Micheal Wilkinson
a316723cfc docs: update changelog for gosec scanner fix
Some checks failed
Push Validation / validate (push) Has been cancelled
Pull Request Validation / validate (pull_request) Failing after 4m5s
2026-03-21 20:58:17 +00:00
Micheal Wilkinson
7405044fb5 chore(go): annotate intentional command execution for gosec 2026-03-21 20:58:17 +00:00
Micheal Wilkinson
4fc9401741 docs: update changelog for core error wrapping
Some checks failed
Push Validation / validate (push) Failing after 1m31s
Pull Request Validation / validate (pull_request) Failing after 1m53s
2026-03-21 20:52:13 +00:00
Micheal Wilkinson
c793925828 chore(go): wrap core filesystem errors with context 2026-03-21 20:52:13 +00:00
Micheal Wilkinson
bc0a6747b8 docs: update changelog for parity fixes 2026-03-21 20:45:05 +00:00
Micheal Wilkinson
d642870a66 chore(go): inject stdin and pass rc force explicitly 2026-03-21 20:45:05 +00:00
Micheal Wilkinson
038b109e7b ci: align govulncheck action inputs with workflow standard 2026-03-21 20:45:05 +00:00
Micheal Wilkinson
519c6703d2 docs: update changelog for vociferate v1.1.0 bump 2026-03-21 20:18:25 +00:00
Micheal Wilkinson
8a3fde8e07 ci: bump vociferate prepare and publish to v1.1.0 2026-03-21 20:18:25 +00:00
Micheal Wilkinson
3fa377efe2 docs: update changelog for CI security hardening and badge URL fix 2026-03-21 20:16:24 +00:00
Micheal Wilkinson
02eebb02fe docs: fix badge link target to use actions/runs/latest per workflow standards 2026-03-21 20:15:30 +00:00
Micheal Wilkinson
dd1d802605 ci: replace gosec action with direct invocation, pin govulncheck to v1.0.4
Per security scanning requirements in project instructions:
- Replace securego/gosec@v2.22.3 action with go install + gosec run step
  in both push-validation and pr-validation to avoid compatibility issues
  with Go 1.26.1
- Pin golang/govulncheck-action from @v1 to @v1.0.4 in both workflows;
  major-version tags do not resolve reliably in Gitea API
- Move GOTOOLCHAIN=auto from per-step env to job-level env in both workflows
- Bump coverage-badge in push-validation from v1.0.1 to v1.1.0
2026-03-21 20:15:08 +00:00
Micheal Wilkinson
a65f62ea9d docs: update changelog for coverage test improvements and vociferate PR gate migration 2026-03-21 20:13:58 +00:00
Micheal Wilkinson
014b330931 ci(pr-validation): replace manual badge/gate logic with vociferate actions
- Remove manual changelog validation shell script
- Remove AWS CLI install and jq tooling steps
- Remove hand-rolled SVG badge generation, S3 upload, and PR comment steps
- Replace with coverage-badge@v1.1.0 for coverage artefact upload
- Replace with decorate-pr@v1.1.0 for PR comment and changelog gate
  (enable-changelog-gate: true, changelog-gate-mode: strict)
- Retain per-package coverage gate awk logic (Aether threshold enforcement)
2026-03-21 20:13:40 +00:00
Micheal Wilkinson
5b37057b61 test(coverage): add targeted tests to raise per-package coverage gates
- internal/homesick/version: new version_test.go covers String constant
  and semver format validation
- internal/homesick/cli: add list, generate, clone, status, diff, and
  git-repo helper tests; coverage raised from 62.5% to 71.2%
- internal/homesick/core: new helpers_test.go covers runGit pretend,
  actionVerb, sayStatus, unlinkPath, linkPath, readSubdirs,
  matchesIgnoredDir, confirmDestroy, ExecAll edge cases, and
  Link/Unlink default castle wrappers; core_test.go and pull_test.go
  extended with New constructor and PullAll quiet-mode tests;
  exec_test.go extended with ExecAll no-repos-dir and error-wrap tests;
  coverage raised from 75.6% to 80.2%
2026-03-21 20:13:31 +00:00
Micheal Wilkinson
4b54a45a76 docs: note scanner toolchain compatibility fix
All checks were successful
Push Validation / validate (push) Successful in 3m29s
2026-03-21 13:54:11 +00:00
Micheal Wilkinson
eb63da9354 chore(ci): allow scanner actions to auto-select Go toolchain 2026-03-21 13:54:11 +00:00
Micheal Wilkinson
494eea998d docs: record gofmt remediation for core tests
Some checks failed
Push Validation / validate (push) Failing after 1m40s
2026-03-21 13:49:18 +00:00
Micheal Wilkinson
15f05a1999 chore(go): run go fmt on core test files 2026-03-21 13:49:14 +00:00
Micheal Wilkinson
a01a2171ff docs: correct gosec action reference in changelog
Some checks failed
Push Validation / validate (push) Failing after 1m32s
2026-03-21 13:37:41 +00:00
Micheal Wilkinson
f134361b6e docs: note gitea gosec action source fix 2026-03-21 13:34:46 +00:00
Micheal Wilkinson
ecda12fc49 chore(ci): fix gosec action source for gitea runners 2026-03-21 13:34:37 +00:00
Micheal Wilkinson
be14cfdc29 chore(ci): include go bin directory in cache for cli tools
Some checks failed
Push Validation / validate (push) Failing after 10s
2026-03-21 13:23:16 +00:00
Micheal Wilkinson
302acbe9bb docs: document ci marketplace actions and formatting check 2026-03-21 13:22:34 +00:00
Micheal Wilkinson
3cc90ff54e chore(ci): replace manual security tools with marketplace actions and add go fmt check
- Replace `go install` of gosec/govulncheck with secureCodeBox/gosec-action and golang/govulncheck-action
- Actions handle their own caching; remove explicit security tools cache step
- Add code formatting check using `go fmt ./...` to reject pushes/PRs with incorrect formatting
- Formatting check runs before security scanning for faster feedback
2026-03-21 13:22:25 +00:00
Micheal Wilkinson
c36b738240 docs: document dependency security updates 2026-03-21 13:15:12 +00:00
Micheal Wilkinson
2cf5851231 chore(deps): update vulnerable dependencies to patched versions
- cloudflare/circl v1.6.0 → v1.6.3 (fixes GO-2026-4550, GO-2025-3754)
- go-git/go-git/v5 v5.14.0 → v5.17.0 (fixes GO-2026-4473)
- golang.org/x/crypto v0.35.0 → v0.49.0 (fixes GO-2025-4116)
- golang.org/x/net v0.35.0 → v0.52.0 (fixes GO-2025-3503)
2026-03-21 13:15:04 +00:00
Micheal Wilkinson
4cfda23187 docs: document ci caching improvements 2026-03-21 13:13:43 +00:00
Micheal Wilkinson
fb4b3f7ed1 chore(ci): add explicit go modules cache to validation workflows 2026-03-21 13:12:42 +00:00
Micheal Wilkinson
a92ab1a29c docs: document security hardening improvements
Some checks failed
Push Validation / validate (push) Failing after 3m49s
2026-03-21 13:08:24 +00:00
Micheal Wilkinson
0d3c9b5214 chore(security): resolve gosec findings with permission fixes and #nosec suppressions
Some checks failed
Push Validation / validate (push) Has been cancelled
2026-03-21 13:05:08 +00:00
Micheal Wilkinson
106e45d16b chore(ci): cache security tool binaries in validation workflows 2026-03-21 12:52:34 +00:00
Micheal Wilkinson
332de3a3f6 chore(go): prepare ci runtime for rc and commit tests
Some checks failed
Push Validation / validate (push) Failing after 3m6s
2026-03-21 12:18:35 +00:00
Micheal Wilkinson
19c9e5485b chore(go): tidy module metadata
Some checks failed
Push Validation / validate (push) Failing after 3m0s
2026-03-21 12:02:52 +00:00
Micheal Wilkinson
fc9a30fed1 chore(go): use explicit self-hosted action urls
Some checks failed
Push Validation / validate (push) Failing after 43s
2026-03-21 11:52:50 +00:00
Micheal Wilkinson
b235c6ca45 chore(go): wire coverage-badge action inputs
Some checks failed
Push Validation / validate (push) Failing after 10s
2026-03-21 11:31:52 +00:00
Micheal Wilkinson
5ecbad8f27 chore(go): keep changelog filename compatibility 2026-03-21 11:24:24 +00:00
Micheal Wilkinson
ef554dde2d docs: rename changelog file 2026-03-21 11:21:56 +00:00
Micheal Wilkinson
55867df599 docs: align changelog with current workflows 2026-03-21 11:20:23 +00:00
Micheal Wilkinson
cd92a961bd chore(go): harden ci process workflows 2026-03-21 11:20:09 +00:00
Micheal Wilkinson
7bc7ee4746 chore(go): validate core constructor dependencies 2026-03-21 11:18:45 +00:00
Micheal Wilkinson
8a6a21811a chore(go): add failing core constructor tests 2026-03-21 11:18:10 +00:00
Micheal Wilkinson
001983b76e docs: document coverage gate enforcement 2026-03-21 11:14:43 +00:00
Micheal Wilkinson
ad5196420e chore(go): enforce package coverage gates 2026-03-21 11:14:40 +00:00
Micheal Wilkinson
692e205a63 update vociferate version 2026-03-21 11:11:32 +00:00
Micheal Wilkinson
ca3215f2c4 docs: document vociferate release migration 2026-03-21 11:09:40 +00:00
Micheal Wilkinson
0112d9a0a6 chore(go): replace releaseprep with vociferate flows 2026-03-21 11:09:36 +00:00
Micheal Wilkinson
e68575f15a docs: fix main validation badge link 2026-03-21 11:07:35 +00:00
Micheal Wilkinson
ce1d253814 docs(changelog): record behavior suite parity fixes 2026-03-21 10:58:41 +00:00
Micheal Wilkinson
8f51cf368a refactor(core): extract destroy confirmation response helper 2026-03-21 10:58:34 +00:00
Micheal Wilkinson
d73049baa4 fix(parity): restore updated behavior suite compatibility 2026-03-21 10:58:15 +00:00
Micheal Wilkinson
abfd6b817b test(parity): add behavior suite regression coverage 2026-03-21 10:58:08 +00:00
Micheal Wilkinson
bbe41a6d72 test: Expanded behaviour suite 2026-03-20 21:48:30 +00:00
Micheal Wilkinson
310979d799 docs(changelog): note legacy releaseprep removal 2026-03-20 18:49:23 +00:00
Micheal Wilkinson
f7af294d30 chore(release): remove legacy in-repo releaseprep implementation 2026-03-20 18:49:07 +00:00
Micheal Wilkinson
1abf298c47 docs(changelog): note vociferate releaseprep adoption 2026-03-20 18:47:07 +00:00
Micheal Wilkinson
28ba4aab70 ci(release): use vociferate releaseprep in gosick 2026-03-20 18:46:55 +00:00
Micheal Wilkinson
665a488c3b test(release): assert prepare script uses vociferate 2026-03-20 18:46:16 +00:00
Micheal Wilkinson
3dc7924de5 docs: remove completed feature checklist 2026-03-20 18:12:20 +00:00
Micheal Wilkinson
bbc64eb756 docs: document pretend quiet and dry-run flags 2026-03-20 18:10:06 +00:00
Micheal Wilkinson
ad8ec1bd6c feat(cli): add global pretend quiet and dry-run alias 2026-03-20 18:09:54 +00:00
Micheal Wilkinson
7f8a5d24e3 test(cli): add failing pretend quiet dry-run coverage 2026-03-20 18:08:37 +00:00
Micheal Wilkinson
5307e4d35f docs: document rc --force parity behavior 2026-03-20 18:05:19 +00:00
Micheal Wilkinson
b070267bde feat(rc): add --force guard for legacy homesickrc 2026-03-20 18:05:07 +00:00
Micheal Wilkinson
cd2258e267 test(rc): add failing --force parity coverage 2026-03-20 18:04:27 +00:00
Micheal Wilkinson
c887a573e0 docs: record pull --all parity support 2026-03-20 18:02:12 +00:00
Micheal Wilkinson
9e6f98948e feat(pull): add --all support across cloned castles 2026-03-20 18:01:59 +00:00
Micheal Wilkinson
edd1c4357a test(pull): add failing pull --all parity coverage 2026-03-20 18:01:17 +00:00
Micheal Wilkinson
2fc3f3d006 docs: update exec and exec_all parity notes 2026-03-20 18:00:13 +00:00
Micheal Wilkinson
58f70860ee feat(cli): implement exec and exec_all commands 2026-03-20 18:00:05 +00:00
Micheal Wilkinson
79d4577083 docs: mark open and generate parity complete 2026-03-20 17:53:51 +00:00
Micheal Wilkinson
59caa62ac6 feat(open,generate): implement command parity 2026-03-20 17:53:39 +00:00
Micheal Wilkinson
043b859a42 test(open,generate): add failing parity tests 2026-03-20 17:52:58 +00:00
Micheal Wilkinson
c36cae2e33 docs: mark cd parity complete 2026-03-20 17:51:16 +00:00
Micheal Wilkinson
5fe37a7f12 feat(cd): implement cd command parity 2026-03-20 17:51:09 +00:00
Micheal Wilkinson
7f46ab43ac test(cd): add failing CLI parity tests 2026-03-20 17:50:52 +00:00
Micheal Wilkinson
82dde43f24 docs: mark destroy parity complete 2026-03-20 17:50:06 +00:00
Micheal Wilkinson
88b07ea934 feat(destroy): implement destroy command parity 2026-03-20 17:49:55 +00:00
Micheal Wilkinson
4901f7b664 test(destroy): add failing destroy parity tests 2026-03-20 17:49:26 +00:00
Micheal Wilkinson
f186286a7e docs: mark commit parity complete 2026-03-20 17:48:16 +00:00
Micheal Wilkinson
d8eaf4d058 feat(commit): implement commit command parity 2026-03-20 17:48:04 +00:00
Micheal Wilkinson
eeeb9f7d8e test(commit): add failing commit parity tests 2026-03-20 17:47:33 +00:00
Micheal Wilkinson
f0dc55159b docs: mark push parity complete 2026-03-20 17:46:17 +00:00
Micheal Wilkinson
8a451cbaee feat(push): implement push command parity 2026-03-20 17:46:07 +00:00
Micheal Wilkinson
e60000680b test(push): add failing push parity tests 2026-03-20 17:45:41 +00:00
Micheal Wilkinson
4a2f0ff0b8 docs: update parity checklist and changelog for pull 2026-03-20 17:44:35 +00:00
Micheal Wilkinson
4fb028cd81 feat(pull): implement pull command parity 2026-03-20 17:44:13 +00:00
Micheal Wilkinson
4a422bd241 test(pull): add failing pull parity tests 2026-03-20 17:43:39 +00:00
Micheal Wilkinson
6719fb170b docs(readme): document rc implementation and parity checklist 2026-03-20 17:43:12 +00:00
Micheal Wilkinson
f3b1a7707a docs(changelog): document rc command implementation 2026-03-20 15:30:13 +00:00
Micheal Wilkinson
a381746cef feat(rc): implement rc command with .homesick.d script execution
- App.Rc runs all executable files in <castle>/.homesick.d in sorted
  (lexicographic) order with the castle root as cwd
- Non-executable files are skipped
- stdout/stderr from scripts forward to App writers
- If .homesickrc exists and parity.rb does not yet exist in .homesick.d,
  a Ruby wrapper (parity.rb) is generated before execution
- Existing parity.rb is never overwritten
- Wire rcCmd in CLI with optional CASTLE argument (defaults to dotfiles)
2026-03-20 15:29:58 +00:00
Micheal Wilkinson
75f636f9ba test(rc): add failing tests for Rc command
Some checks failed
Push Validation / validate (push) Failing after 1m32s
2026-03-20 15:26:50 +00:00
Micheal Wilkinson
1e5de20a41 docs(changelog): document breaking change section convention 2026-03-20 15:06:33 +00:00
Micheal Wilkinson
07d73660eb docs(changelog): seed unreleased section with breaking heading 2026-03-20 15:05:20 +00:00
Micheal Wilkinson
029175cb55 docs(changelog): note breaking release section 2026-03-20 15:04:25 +00:00
Micheal Wilkinson
38f649e99b feat(release): support breaking changelog notes 2026-03-20 15:04:15 +00:00
Micheal Wilkinson
af491aa267 test(release): treat breaking notes as major bump 2026-03-20 15:03:30 +00:00
Micheal Wilkinson
0dd38e5267 docs(changelog): note release tag recommendation guard 2026-03-20 14:59:57 +00:00
Micheal Wilkinson
93918f3a39 feat(release): guard empty notes and recommend next tag 2026-03-20 14:59:46 +00:00
Micheal Wilkinson
3b8dadbd29 test(release): guard empty notes and suggest next tag 2026-03-20 14:58:31 +00:00
Micheal Wilkinson
f9c853a4e9 docs(changelog): note release preparation automation 2026-03-20 14:55:16 +00:00
Micheal Wilkinson
799c8d167d feat(release): automate release preparation 2026-03-20 14:54:57 +00:00
Micheal Wilkinson
feb8ca3434 test(release): use external package for release prep tests 2026-03-20 14:51:23 +00:00
Micheal Wilkinson
dbb6c82562 test(release): cover automated release preparation 2026-03-20 14:49:42 +00:00
Micheal Wilkinson
c3f809a586 chore(release): add UPX compression for linux artifacts 2026-03-20 14:46:54 +00:00
Micheal Wilkinson
8fc831dfdf chore(ci): re-enable Go module caching and add coverage badge to README
All checks were successful
Push Validation / validate (push) Successful in 1m52s
2026-03-20 13:55:09 +00:00
Micheal Wilkinson
7e32cd83c5 chore(ci): install aws cli via setup action
All checks were successful
Push Validation / validate (push) Successful in 1m46s
2026-03-20 13:42:23 +00:00
Micheal Wilkinson
3d71433630 chore(ci): pin Go toolchain to 1.26.1 in workflows
Some checks failed
Push Validation / validate (push) Failing after 1m30s
2026-03-20 13:30:19 +00:00
Micheal Wilkinson
c6c382afce chore(ci): add bash as default shell for workflows
Some checks failed
Push Validation / validate (push) Failing after 1m17s
2026-03-20 13:20:24 +00:00
Micheal Wilkinson
665401f2bd chore(ci): use catthehacker/ubuntu container for better tool availability
Some checks failed
Push Validation / validate (push) Failing after 1m38s
2026-03-20 13:16:11 +00:00
Micheal Wilkinson
d084abd636 chore(ci): remove Go module caching to eliminate artifact cache timeouts 2026-03-20 13:13:53 +00:00
Micheal Wilkinson
a6034ce470 chore(bash): remove redundant bash script 2026-03-20 13:06:46 +00:00
Micheal Wilkinson
484db0781b ci(gitea): use pipx for awscli installation
Some checks failed
Push Validation / validate (push) Failing after 6m34s
2026-03-20 13:04:30 +00:00
Micheal Wilkinson
4a8ef7e1f6 ci(gitea): use pip for awscli installation
Some checks failed
Push Validation / validate (push) Failing after 5m59s
2026-03-20 12:53:09 +00:00
Micheal Wilkinson
b3f66e9e2e docs(changelog): note go cache in gitea pipelines 2026-03-20 12:07:18 +00:00
Micheal Wilkinson
9d6dacb0f8 ci: cache go modules and build outputs in workflows 2026-03-20 12:07:10 +00:00
Micheal Wilkinson
195b936de6 docs(changelog): note coverage artefact publishing
Some checks failed
Push Validation / validate (push) Failing after 6m32s
2026-03-20 11:46:12 +00:00
Micheal Wilkinson
f6b5186f31 ci(gitea): publish coverage reports to artefact storage 2026-03-20 11:46:05 +00:00
Micheal Wilkinson
ea16ba8430 chore(go): Removing unused function 2026-03-20 09:57:40 +00:00
Micheal Wilkinson
96ce572792 docs(changelog): note CLI help messaging improvements 2026-03-20 09:56:16 +00:00
Micheal Wilkinson
d638f201fe fix(cli): improve help name and description 2026-03-20 09:54:42 +00:00
Micheal Wilkinson
e09bdd78c2 docs(changelog): note unified push validation workflow 2026-03-20 09:50:12 +00:00
Micheal Wilkinson
0034a6f4e2 ci(gitea): unify push and merged-pr validation 2026-03-20 09:50:00 +00:00
Micheal Wilkinson
aa66695665 docs(readme): add workflow status badges 2026-03-20 09:41:28 +00:00
Micheal Wilkinson
a7e4c501e4 ci(gitea): add validation and release workflows 2026-03-20 09:37:09 +00:00
Micheal Wilkinson
0dfacc31d4 chore(build): rename binary to gosick 2026-03-19 16:33:45 +00:00
Micheal Wilkinson
1d26594010 docs(changelog): add unreleased migration notes 2026-03-19 16:29:39 +00:00
Micheal Wilkinson
c10ff251d5 docs(changelog): update formatting 2026-03-19 16:29:34 +00:00
Micheal Wilkinson
8d34674415 chore: remove Ruby implementation and tooling 2026-03-19 16:17:54 +00:00
Micheal Wilkinson
8174c6a983 refactor(cli): use kong for command parsing 2026-03-19 14:51:47 +00:00
Micheal Wilkinson
1d4c088edc test(cli): add parser coverage for kong refactor 2026-03-19 14:40:08 +00:00
Micheal Wilkinson
040bf31b56 fix(core): route status and diff output through app writers 2026-03-19 14:29:52 +00:00
Micheal Wilkinson
4355e7fd9d test(core): add status diff and version suites 2026-03-19 14:29:03 +00:00
Micheal Wilkinson
b7c353553a test(core): add dedicated list and show_path suites 2026-03-19 14:25:37 +00:00
Micheal Wilkinson
2f45d28acb feat(core,cli): implement track command with go-git staging 2026-03-19 14:21:15 +00:00
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
59 changed files with 5318 additions and 2259 deletions

View File

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

View File

@@ -0,0 +1,190 @@
name: Pull Request Validation
on:
pull_request:
types:
- opened
- synchronize
- reopened
jobs:
validate:
runs-on: ubuntu-latest
container: docker.io/catthehacker/ubuntu:act-latest
defaults:
run:
shell: bash
env:
ARTEFACT_BUCKET_NAME: ${{ vars.ARTEFACT_BUCKET_NAME }}
ARTEFACT_BUCKET_ENDPONT: ${{ vars.ARTEFACT_BUCKET_ENDPONT }}
ARTEFACT_BUCKET_REGION: ${{ vars.ARTEFACT_BUCKET_REGION }}
AWS_ACCESS_KEY_ID: ${{ secrets.ARTEFACT_BUCKET_WRITE_ACCESS_KEY }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.ARTEFACT_BUCKET_WRITE_ACCESS_SECRET }}
AWS_DEFAULT_REGION: ${{ vars.ARTEFACT_BUCKET_REGION }}
AWS_EC2_METADATA_DISABLED: true
GOTOOLCHAIN: auto
SUMMARY_FILE: ${{ runner.temp }}/summary.md
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: '1.26.1'
check-latest: true
cache: true
cache-dependency-path: go.sum
- name: Cache Go modules and build cache
uses: actions/cache@v4
with:
path: |
~/go/pkg/mod
~/.cache/go-build
~/go/bin
key: ${{ runner.os }}-go-cache-${{ hashFiles('**/go.mod', '**/go.sum') }}
restore-keys: |
${{ runner.os }}-go-cache-
- name: Verify module hygiene
run: |
set -euo pipefail
go mod tidy
git diff --exit-code go.mod go.sum
go mod verify
- name: Prepare test runtime
run: |
set -euo pipefail
apt-get update
apt-get install -y ruby
git config --global user.name "gitea-actions[bot]"
git config --global user.email "gitea-actions[bot]@users.noreply.local"
- name: Run full unit test suite with coverage
id: coverage
run: |
set -euo pipefail
go test -covermode=atomic -coverprofile=coverage.out ./... | tee go-test-coverage.log
set +e
awk '
/^ok[[:space:]]/ && /coverage: [0-9.]+% of statements/ {
pkg = $2
cov = $0
sub(/^.*coverage: /, "", cov)
sub(/% of statements.*$/, "", cov)
status = "target"
if (cov + 0 < 50) {
status = "fail"
fail = 1
} else if (cov + 0 < 65) {
status = "high-risk"
} else if (cov + 0 < 80) {
status = "warning"
}
printf "%s %.1f %s\n", pkg, cov + 0, status
}
END {
if (fail) {
exit 2
}
}
' go-test-coverage.log > coverage-packages.raw
package_gate_status=$?
set -e
{
echo '| Package | Coverage | Status |'
echo '| --- | ---: | --- |'
} > coverage-packages.md
while read -r pkg cov status; do
case "$status" in
fail)
pretty='FAIL (<50%)'
;;
high-risk)
pretty='High risk (50%-64.99%)'
;;
warning)
pretty='Warning (65%-79.99%)'
;;
*)
pretty='Target (>=80%)'
;;
esac
printf '| `%s` | %.1f%% | %s |\n' "$pkg" "$cov" "$pretty" >> coverage-packages.md
done < coverage-packages.raw
if [[ "$package_gate_status" -ne 0 ]]; then
echo "Per-package coverage gate failed: one or more packages are below 50%." >&2
exit 1
fi
- name: Check code formatting
run: |
set -euo pipefail
fmt_output=$(go fmt ./...)
if [[ -n "$fmt_output" ]]; then
echo "Code formatting check failed. The following files need formatting:" >&2
echo "$fmt_output" >&2
exit 1
fi
- name: Run Gosec Security Scanner
run: |
set -euo pipefail
go install github.com/securego/gosec/v2/cmd/gosec@latest
gosec ./...
- name: Run Go Vulnerability Check
uses: golang/govulncheck-action@v1.0.4
with:
go-package: ./...
cache: true
cache-dependency-path: go.sum
- name: Upload coverage badge
id: badge
if: ${{ always() && hashFiles('coverage.out') != '' }}
uses: https://git.hrafn.xyz/aether/vociferate/coverage-badge@v1.1.0
with:
artefact-bucket-name: ${{ vars.ARTEFACT_BUCKET_NAME }}
artefact-bucket-endpoint: ${{ vars.ARTEFACT_BUCKET_ENDPONT }}
- name: Decorate PR
if: ${{ always() }}
uses: https://git.hrafn.xyz/aether/vociferate/decorate-pr@v1.1.0
with:
coverage-percentage: ${{ steps.badge.outputs.total }}
badge-url: ${{ steps.badge.outputs.badge-url }}
enable-changelog-gate: 'true'
changelog-gate-mode: strict
- name: Add coverage summary
run: |
{
echo '## Coverage'
echo
echo '- Total: `${{ steps.badge.outputs.total }}%`'
echo '- Report: ${{ steps.badge.outputs.report-url }}'
echo '- Badge: ${{ steps.badge.outputs.badge-url }}'
echo
echo '### Package Coverage'
cat coverage-packages.md
} >> "$SUMMARY_FILE"
- name: Run behavior suite
run: ./script/run-behavior-suite-docker.sh
- name: Summary
if: ${{ always() }}
run: |
if [[ -f "$SUMMARY_FILE" ]]; then
cat "$SUMMARY_FILE" >> "$GITHUB_STEP_SUMMARY"
fi

View File

@@ -0,0 +1,46 @@
name: Release
on:
push:
branches: [main]
permissions:
contents: write
jobs:
prepare:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Provide lowercase changelog compatibility
run: |
set -euo pipefail
if [[ -f CHANGELOG.md && ! -e changelog.md ]]; then
ln -s CHANGELOG.md changelog.md
fi
- name: Vociferate prepare
uses: https://git.hrafn.xyz/aether/vociferate/prepare@v1.1.0
publish:
needs: prepare
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Provide lowercase changelog compatibility
run: |
set -euo pipefail
if [[ -f CHANGELOG.md && ! -e changelog.md ]]; then
ln -s CHANGELOG.md changelog.md
fi
- name: Vociferate publish
uses: https://git.hrafn.xyz/aether/vociferate/publish@v1.1.0

View File

@@ -0,0 +1,184 @@
name: Push Validation
on:
push:
branches:
- "**"
tags-ignore:
- "*"
jobs:
validate:
runs-on: ubuntu-latest
container: docker.io/catthehacker/ubuntu:act-latest
defaults:
run:
shell: bash
env:
ARTEFACT_BUCKET_NAME: ${{ vars.ARTEFACT_BUCKET_NAME }}
ARTEFACT_BUCKET_ENDPONT: ${{ vars.ARTEFACT_BUCKET_ENDPONT }}
ARTEFACT_BUCKET_REGION: ${{ vars.ARTEFACT_BUCKET_REGION }}
AWS_ACCESS_KEY_ID: ${{ secrets.ARTEFACT_BUCKET_WRITE_ACCESS_KEY }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.ARTEFACT_BUCKET_WRITE_ACCESS_SECRET }}
AWS_DEFAULT_REGION: ${{ vars.ARTEFACT_BUCKET_REGION }}
AWS_EC2_METADATA_DISABLED: true
GOTOOLCHAIN: auto
SUMMARY_FILE: ${{ runner.temp }}/summary.md
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: '1.26.1'
check-latest: true
cache: true
cache-dependency-path: go.sum
- name: Cache Go modules and build cache
uses: actions/cache@v4
with:
path: |
~/go/pkg/mod
~/.cache/go-build
~/go/bin
key: ${{ runner.os }}-go-cache-${{ hashFiles('**/go.mod', '**/go.sum') }}
restore-keys: |
${{ runner.os }}-go-cache-
- name: Verify module hygiene
run: |
set -euo pipefail
go mod tidy
git diff --exit-code go.mod go.sum
go mod verify
- name: Check code formatting
run: |
set -euo pipefail
fmt_output=$(go fmt ./...)
if [[ -n "$fmt_output" ]]; then
echo "Code formatting check failed. The following files need formatting:" >&2
echo "$fmt_output" >&2
exit 1
fi
- name: Run Gosec Security Scanner
run: |
set -euo pipefail
go install github.com/securego/gosec/v2/cmd/gosec@latest
gosec ./...
- name: Run Go Vulnerability Check
uses: golang/govulncheck-action@v1.0.4
with:
go-package: ./...
cache: true
cache-dependency-path: go.sum
- name: Install AWS CLI v2
uses: ankurk91/install-aws-cli-action@v1
- name: Verify AWS CLI
run: aws --version
- name: Prepare test runtime
run: |
set -euo pipefail
apt-get update
apt-get install -y ruby
git config --global user.name "gitea-actions[bot]"
git config --global user.email "gitea-actions[bot]@users.noreply.local"
- name: Run full unit test suite with coverage
id: coverage-tests
run: |
set -euo pipefail
go test -covermode=atomic -coverprofile=coverage.out ./... | tee go-test-coverage.log
go tool cover -html=coverage.out -o coverage.html
total="$(go tool cover -func=coverage.out | awk '/^total:/ {sub(/%/, "", $3); print $3}')"
printf '{\n "total": "%s"\n}\n' "$total" > coverage-summary.json
printf 'total=%s\n' "$total" >> "$GITHUB_OUTPUT"
set +e
awk '
/^ok[[:space:]]/ && /coverage: [0-9.]+% of statements/ {
pkg = $2
cov = $0
sub(/^.*coverage: /, "", cov)
sub(/% of statements.*$/, "", cov)
status = "target"
if (cov + 0 < 50) {
status = "fail"
fail = 1
} else if (cov + 0 < 65) {
status = "high-risk"
} else if (cov + 0 < 80) {
status = "warning"
}
printf "%s %.1f %s\n", pkg, cov + 0, status
}
END {
if (fail) {
exit 2
}
}
' go-test-coverage.log > coverage-packages.raw
package_gate_status=$?
set -e
{
echo '| Package | Coverage | Status |'
echo '| --- | ---: | --- |'
} > coverage-packages.md
while read -r pkg cov status; do
case "$status" in
fail)
pretty='FAIL (<50%)'
;;
high-risk)
pretty='High risk (50%-64.99%)'
;;
warning)
pretty='Warning (65%-79.99%)'
;;
*)
pretty='Target (>=80%)'
;;
esac
printf '| `%s` | %.1f%% | %s |\n' "$pkg" "$cov" "$pretty" >> coverage-packages.md
done < coverage-packages.raw
if [[ "$package_gate_status" -ne 0 ]]; then
echo "Per-package coverage gate failed: one or more packages are below 50%." >&2
exit 1
fi
- name: Publish coverage artefacts
id: coverage-badge
uses: https://git.hrafn.xyz/aether/vociferate/coverage-badge@v1.1.0
with:
coverage-profile: coverage.out
coverage-html: coverage.html
coverage-badge: coverage-badge.svg
coverage-summary: coverage-summary.json
artefact-bucket-name: ${{ vars.ARTEFACT_BUCKET_NAME }}
artefact-bucket-endpoint: ${{ vars.ARTEFACT_BUCKET_ENDPONT }}
branch-name: ${{ github.ref_name }}
repository-name: ${{ github.repository }}
summary-file: ${{ env.SUMMARY_FILE }}
- name: Run behavior suite on main pushes
if: ${{ github.ref == 'refs/heads/main' }}
run: ./script/run-behavior-suite-docker.sh
- name: Summary
if: ${{ always() }}
run: |
if [[ -f "$SUMMARY_FILE" ]]; then
cat "$SUMMARY_FILE" >> "$GITHUB_STEP_SUMMARY"
fi

View File

@@ -0,0 +1,93 @@
name: Tag Build Artifacts
on:
push:
tags:
- "v*"
jobs:
build:
runs-on: ubuntu-latest
container: docker.io/catthehacker/ubuntu:act-latest
defaults:
run:
shell: bash
env:
RUNNER_TOOL_CACHE: /cache/tools
strategy:
fail-fast: false
matrix:
include:
- goos: linux
goarch: amd64
- goos: linux
goarch: arm64
- goos: darwin
goarch: amd64
- goos: darwin
goarch: arm64
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: '1.26.1'
check-latest: true
cache: true
cache-dependency-path: go.sum
- name: Install UPX
uses: crazy-max/ghaction-upx@v3
with:
install-only: true
- name: Build binary
run: |
mkdir -p dist
output="dist/gosick_${{ matrix.goos }}_${{ matrix.goarch }}"
GOOS=${{ matrix.goos }} GOARCH=${{ matrix.goarch }} CGO_ENABLED=0 \
go build -o "$output" ./cmd/homesick
- name: Compress binary with UPX
if: ${{ matrix.goos == 'linux' }}
run: |
set -euo pipefail
output="dist/gosick_${{ matrix.goos }}_${{ matrix.goarch }}"
if ! upx --best --lzma "$output"; then
echo "::warning::UPX compression failed for ${output}; continuing with uncompressed binary"
fi
- name: Package artifact
run: |
cd dist
tar -czf gosick_${{ matrix.goos }}_${{ matrix.goarch }}.tar.gz gosick_${{ matrix.goos }}_${{ matrix.goarch }}
- name: Publish workflow artifact
uses: actions/upload-artifact@v4
with:
name: gosick_${{ matrix.goos }}_${{ matrix.goarch }}
path: dist/gosick_${{ matrix.goos }}_${{ matrix.goarch }}.tar.gz
release:
runs-on: ubuntu-latest
needs: build
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Provide lowercase changelog compatibility
run: |
set -euo pipefail
if [[ -f CHANGELOG.md && ! -e changelog.md ]]; then
ln -s CHANGELOG.md changelog.md
fi
- name: Vociferate publish
uses: https://git.hrafn.xyz/aether/vociferate/publish@v1.1.0

24
.gitignore vendored
View File

@@ -1,17 +1,3 @@
# rcov generated
coverage
# rdoc generated
rdoc
# yard generated
doc
.yardoc
# jeweler generated
pkg
.bundle
# Have editor/IDE/OS specific files you need to ignore? Consider using a global gitignore:
#
@@ -44,10 +30,10 @@ pkg
.idea/
*.iml
Gemfile.lock
vendor/
homesick*.gem
# Go scaffolding artifacts
dist/
*.test
*.out
# rbenv configuration
.ruby-version
.github/*

1
.rspec
View File

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

View File

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

View File

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

321
CHANGELOG.md Normal file
View File

@@ -0,0 +1,321 @@
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
A `### Breaking` section is used in addition to Keep a Changelog's standard sections to explicitly document changes that are backwards-incompatible but would otherwise appear under `### Changed`. Entries under `### Breaking` trigger a major version bump in the automated release tooling.
## [Unreleased]
### Breaking
### Added
- `internal/homesick/version`: added `version_test.go` covering the `String` constant and semver format validation.
- `internal/homesick/cli`: added targeted tests for `list`, `generate`, `clone`, `status`, and `diff` CLI commands; coverage raised from 62.5% to 71.2%.
- `internal/homesick/core`: added `helpers_test.go` covering `runGit` pretend mode, `actionVerb`, `sayStatus`, `unlinkPath`, `linkPath`, `readSubdirs`, `matchesIgnoredDir`, `confirmDestroy` responses and read errors, `ExecAll` empty-command and no-repos-dir edge cases, and `Link`/`Unlink` default-castle wrappers; existing suites extended with `New` constructor and `PullAll` quiet-mode tests; coverage raised from 75.6% to 80.2%.
- PR validation now uses `vociferate/coverage-badge@v1.1.0` for coverage artefact upload and `vociferate/decorate-pr@v1.1.0` for PR comment decoration and changelog gate enforcement.
### Changed
- `gosec` security scanning in CI now invoked directly via `go install + gosec ./...` instead of the `securego/gosec` action, resolving compatibility issues with Go 1.26.1.
- `golang/govulncheck-action` pinned from `@v1` to `@v1.0.4` in push and PR validation; major-version tags do not resolve reliably in Gitea API.
- `GOTOOLCHAIN=auto` moved from per-step env to job-level env in push and PR validation workflows.
- Push validation `vociferate/coverage-badge` bumped from `v1.0.1` to `v1.1.0` for version consistency with PR validation.
- `vociferate/prepare` and `vociferate/publish` in `prepare-release.yml` and `tag-build-artifacts.yml` bumped from `v1.0.1` to `v1.1.0` for cross-workflow version consistency.
- `golang/govulncheck-action` in push and PR validation now passes explicit `go-package`, cache enablement, and `cache-dependency-path` inputs to match the required workflow pattern.
- CLI/core wiring now injects `stdin` through `core.NewApp`, `main` owns the `GIT_TERMINAL_PROMPT=0` side effect, and `Rc` force handling is passed per call instead of mutating shared app state.
- Core filesystem and git error paths now wrap underlying failures with command-specific context across listing, generation, tracking, linking, rc hook execution, and destroy confirmation flows.
- Gosec compliance updated for intentional command execution paths: `Open()` now documents both `G702` and `G204` suppression rationale, and fixed-`git` helper invocations include explicit `G204` justifications.
- PR validation badge upload now runs only when `coverage.out` exists, preventing downstream badge artefact failures while still allowing PR decoration to run on failed jobs.
- README badge link target updated to `actions/runs/latest?workflow=...` format per workflow standards.
- CI security scanning now uses GitHub Marketplace actions (`securego/gosec` and `golang/govulncheck-action`) instead of manual tool installation, improving reliability and caching.
- CI setup compatibility fix: gosec scanner now references the correct public action source (`securego/gosec`), resolving action clone failures in Gitea runners.
- CI security scanner compatibility: gosec and govulncheck action steps now set `GOTOOLCHAIN=auto` so repositories requiring newer Go versions are analyzed successfully.
- Code formatting validation added to CI pipelines: pushes and pull requests with code not matching `go fmt ./...` output will be rejected.
- Applied `go fmt` normalization to core tests (`list_test.go` and `track_test.go`) to satisfy the new formatting gate.
- Dependencies updated to resolve security vulnerabilities: `cloudflare/circl` to v1.6.3, `go-git/v5` to v5.17.0, `golang.org/x/crypto` to v0.49.0, and `golang.org/x/net` to v0.52.0.
- CI workflows now include explicit caching for Go modules and build artifacts to reduce pipeline execution time.
- Security hardening: file and directory creation now uses restrictive permissions (`0o750` for directories, `0o600` for files) instead of world-accessible defaults. Executable wrapper scripts are created with restricted permissions and then explicitly made executable via `chmod`.
- Security: `Open()` now executes the editor directly without shell intermediary to prevent injection through the `$EDITOR` environment variable.
- CI validation now runs `gosec` and `govulncheck` security scanning on push and pull request workflows.
- `cmd/homesick` now includes entrypoint-focused tests that exercise both the CLI run path and `main` process path.
- `rc` command: executes all executable scripts inside a castle's `.homesick.d/` directory in sorted order, with the castle root as the working directory. stdout/stderr from each script is forwarded to the caller.
- `rc` command: when a `.homesickrc` file exists and no `parity.rb` wrapper is present in `.homesick.d/`, a Ruby wrapper script (`parity.rb`) is generated automatically to preserve backwards compatibility. An existing `parity.rb` is never overwritten.
- `exec` command: runs a shell command inside the target castle root directory.
- `exec_all` command: runs a shell command inside each cloned castle root directory in sorted order.
- `pull --all` support: pulls updates for every cloned castle in sorted order.
- `rc --force` support: legacy `.homesickrc` compatibility hooks now require explicit force mode before execution.
- Global command flags restored: `--pretend` (with `--dry-run` alias) and `--quiet`.
- Native Go implementations for `clone`, `link`, `unlink`, `track`, `pull`, `push`, `commit`, `destroy`, `cd`, `open`, and `generate`.
- Containerized behavior test suite for command parity validation.
- Dedicated test suites for `list`, `show_path`, `status`, `diff`, and `version`.
- Just workflow support for building and running the Linux behavior binary.
- Coverage reports and badges published to shared object storage for branches and pull requests.
- Pull requests now receive coverage report links in CI comments.
- Automated release orchestration now runs through vociferate prepare and publish workflows.
- `symlink` command alias compatibility for `link`.
### Changed
- Release automation now uses `aether/vociferate` `prepare` and `publish` actions (pinned to `v1.0.1`) instead of repository-local releaseprep wrappers.
- Push and pull request validation now enforce per-package coverage gates (fail below 50%) and publish package-level coverage status tables in workflow summaries.
- Push and pull request validation now verify module hygiene (`go mod tidy`, `go mod verify`) and use a dedicated summary-file pattern with a final always-run summary step.
- CLI argument parsing migrated to Kong.
- Git operations for clone and track migrated to `go-git`.
- Build and behavior workflows now produce and run the `gosick` binary name.
- CI validation is unified into push events, running behavior tests only on `main` pushes.
- Gitea CI workflows now cache Go modules and build artifacts using a shared runner tool cache.
- Gitea workflow and README badge updated from `push-unit-tests` to `push-validation`.
- CLI help now uses the invoked binary name (defaulting to `gosick`) in usage output.
- CLI help description now reflects Homesick's purpose for managing precious dotfiles.
- Release notes standardized to Keep a Changelog format.
- `commit` command now accepts legacy positional form `commit <castle> <message>` in addition to `-m`.
- `destroy` now prompts for confirmation by default and preserves the castle when declined.
### Fixed
- `status` and `diff` now consistently write through configured app output writers.
- `pull --all` output now includes per-castle prefixes to match behavior expectations.
- Behavior-suite container now includes Ruby so `.homesickrc` parity wrapper execution works under `rc --force`.
### Removed
- Legacy `script/prepare-release.sh` releaseprep wrapper and its dedicated script test.
- Legacy Ruby implementation and Ruby toolchain.
- Legacy in-repository `releaseprep` package and command implementation, now superseded by the standalone `vociferate` tool.
## [1.1.6] - 2017-12-20
### Fixed
- Ensure `FileUtils` is imported correctly to avoid a potential error.
- Fix an issue where comparing a diff did not use the content of the new file.
### Changed
- Small documentation fixes.
## [1.1.5] - 2017-03-23
### Fixed
- Problem with version number being incorrect.
## [1.1.4] - 2017-03-22
### Fixed
- Ensure symlink conflicts are explicitly communicated to users and symlinks are not silently overwritten.
- Fix a problem in diff when asking a user to resolve a conflict.
### Changed
- Use real paths of symlinks when linking a castle into home.
- Code refactoring and fixes.
## [1.1.3] - 2015-10-31
### Added
- Allow a destination to be passed when cloning a castle.
### Fixed
- Make sure `homesick edit` opens the default editor in the root of the given castle.
- Bug when diffing edited files.
- Crashing bug when attempting to diff directories.
- Ensure that messages are escaped correctly on `git commit all`.
## [1.1.2] - 2015-01-02
### Added
- `--force` option to the rc command to bypass confirmation checks when running a `.homesickrc` file.
- Check to ensure that at least Git 1.8.0 is installed.
### Fixed
- Stop Homesick failing silently when Git is not installed.
### Changed
- Code refactoring and fixes.
## [1.1.0] - 2014-04-28
### Added
- `exec` and `exec_all` commands to run commands inside one or all cloned castles.
### Changed
- Code refactoring.
## [1.0.0] - 2014-01-15
### Added
- `version` command.
### Removed
- Support for Ruby 1.8.7.
## [0.9.8] - 2014-01-02
### Added
- `homesick cd` command.
- `homesick open` command.
## [0.9.4] - 2013-07-31
### Added
- `homesick unlink` command.
- `homesick rc` command.
### Changed
- Use HTTPS protocol instead of git protocol.
## [0.9.3] - 2013-07-07
### Added
- Recursive option to `homesick clone`.
## [0.9.2] - 2013-06-27
### Added
- `homesick show_path` command.
- `homesick status` command.
- `homesick diff` command.
### Changed
- Set `dotfiles` as default castle name.
## [0.9.1] - 2013-06-17
### Fixed
- Small bugs: #35, #40.
## [0.9.0] - 2013-06-06
### Added
- `.homesick_subdir` (#39).
## [0.8.1] - 2013-05-19
### Fixed
- `homesick list` bug on Ruby 2.0 (#37).
## [0.8.0] - 2013-04-06
### Added
- `commit` and `push` command.
- Commit changes in a castle and push to remote.
- Enable recursive submodule update.
- Git add when using track.
## [0.7.0] - 2012-05-28
### Added
- New option for pull command: `--all`.
- Pull each castle instead of just one.
### Fixed
- Double-cloning (#14).
## [0.6.1] - 2010-11-13
### Added
- License.
## [0.6.0] - 2010-10-27
### Added
- `.homesickrc` support.
- Castles can now have a `.homesickrc` inside them.
- On clone, this is eval'd inside the destination directory.
- `track` command.
- Allows easily moving an existing file into a castle and symlinking it back.
## [0.5.0] - 2010-05-18
### Added
- `homesick pull <CASTLE>` for updating castles (thanks Jorge Dias).
- A very basic `homesick generate <CASTLE>`.
### Fixed
- Listing of castles cloned using `homesick clone <github-user>/<github-repo>` (issue 3).
## [0.4.1] - 2010-04-02
### Fixed
- Improve error message when a castle's home dir does not exist.
## [0.4.0] - 2010-04-01
### Added
- `homesick clone` can take a path to a directory on the filesystem, which is symlinked into place.
- `homesick clone` tries to run `git submodule init` and `git submodule update` if git submodules are defined for a cloned repo.
### Changed
- Use `HOME` environment variable for where to store files, instead of assuming `~`.
### Fixed
- Missing dependency on thor and others.
## [0.3.0] - 2010-04-01
### Changed
- Rename `link` to `symlink`.
### Fixed
- Conflict resolution when symlink destination exists and is a normal file.
## [0.2.0] - 2010-03-19
### Added
- Better support for recognizing git URLs (thanks jacobat).
- If it looks like a GitHub user/repo, use that.
- Otherwise hand off to git clone.
- Listing now displays in color and shows git remote.
- Support pretend, force, and quiet modes.
## [0.1.1] - 2010-03-17
### Fixed
- Trying to link against castles that do not exist.
- Linking now excludes `.` and `..` from the list of files to link (thanks Martinos).
## [0.1.0] - 2010-03-10
### Added
- Initial release.

View File

@@ -1,126 +0,0 @@
#1.1.6
* Makesure the FileUtils is imported correctly to avoid a potential error
* Fixes an issue where comparing a diff would not use the content of the new file
* Small documentation fixes
# 1.1.5
* Fixed problem with version number being incorrect.
# 1.1.4
* Make sure symlink conflicts are explicitly communicated to a user and symlinks are not silently overwritten
* Use real paths of symlinks when linking a castle into home
* Fix a problem when in a diff when asking a user to resolve a conflict
* Some code refactoring and fixes
# 1.1.3
* Allow a destination to be passed when cloning a castle
* Make sure `homesick edit` opens default editor in the root of the given castle
* Fixed bug when diffing edited files
* Fixed crashing bug when attempting to diff directories
* Ensure that messages are escaped correctly on `git commit all`
# 1.1.2
* Added '--force' option to the rc command to bypass confirmation checks when running a .homesickrc file
* Added a check to make sure that a minimum of Git 1.8.0 is installed. This stops Homesick failing silently if Git is not installed.
* Code refactoring and fixes.
# 1.1.0
* Added exec and exec_all commands to run commands inside one or all clones castles.
* Code refactoring.
# 1.0.0
* Removed support for Ruby 1.8.7
* Added a version command
# 0.9.8
* Introduce new commands
* `homesick cd`
* `homesick open`
# 0.9.4
* Use https protocol instead of git protocol
* Introduce new commands
* `homesick unlink`
* `homesick rc`
# 0.9.3
* Add recursive option to `homesick clone`
# 0.9.2
* Set "dotfiles" as default castle name
* Introduce new commands
* `homesick show_path`
* `homesick status`
* `homesick diff`
# 0.9.1
* Fixed small bugs: #35, #40
# 0.9.0
* Introduce .homesick_subdir #39
# 0.8.1
* Fixed `homesick list` bug on ruby 2.0 #37
# 0.8.0
* Introduce commit & push command
* commit changes in castle and push to remote
* Enable recursive submodule update
* Git add when track
# 0.7.0
* Fixed double-cloning #14
* New option for pull command: --all
* pulls each castle, instead of just one
# 0.6.1
* Add a license
# 0.6.0
* Introduce .homesickrc
* Castles can now have a .homesickrc inside them
* On clone, this is eval'd inside the destination directory
* Introduce track command
* Allows easily moving an existing file into a castle, and symlinking it back
# 0.5.0
* Fixed listing of castles cloned using `homesick clone <github-user>/<github-repo>` (issue 3)
* Added `homesick pull <CASTLE>` for updating castles (thanks Jorge Dias!)
* Added a very basic `homesick generate <CASTLE>`
# 0.4.1
* Improved error message when a castle's home dir doesn't exist
# 0.4.0
* `homesick clone` can now take a path to a directory on the filesystem, which will be symlinked into place
* `homesick clone` now tries to `git submodule init` and `git submodule update` if git submodules are defined for a cloned repo
* Fixed missing dependency on thor and others
* Use HOME environment variable for where to store files, instead of assuming ~
# 0.3.0
* Renamed 'link' to 'symlink'
* Fixed conflict resolution when symlink destination exists and is a normal file
# 0.2.0
* Better support for recognizing git urls (thanks jacobat!)
* if it looks like a github user/repo, do that
* otherwise hand off to git clone
* Listing now displays in color, and show git remote
* Support pretend, force, and quiet modes
# 0.1.1
* Fixed trying to link against castles that don't exist
* Fixed linking, which tries to exclude . and .. from the list of files to
link (thanks Martinos!)
# 0.1.0
* Initial release

36
Gemfile
View File

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

View File

@@ -1,6 +0,0 @@
guard :rspec, :cmd => 'bundle exec rspec' do
watch(%r{^spec/.+_spec\.rb$})
watch(%r{^lib/(.+)\.rb$}) { |m| "spec/#{m[1]}_spec.rb" }
watch(%r{^lib/homesick/.*\.rb}) { "spec" }
watch('spec/spec_helper.rb') { "spec" }
end

View File

@@ -1,185 +0,0 @@
# homesick
[![Gem Version](https://badge.fury.io/rb/homesick.svg)](http://badge.fury.io/rb/homesick)
[![Build Status](https://travis-ci.org/technicalpickles/homesick.svg?branch=master)](https://travis-ci.org/technicalpickles/homesick)
[![Dependency Status](https://gemnasium.com/technicalpickles/homesick.svg)](https://gemnasium.com/technicalpickles/homesick)
[![Coverage Status](https://coveralls.io/repos/technicalpickles/homesick/badge.png)](https://coveralls.io/r/technicalpickles/homesick)
[![Code Climate](https://codeclimate.com/github/technicalpickles/homesick.svg)](https://codeclimate.com/github/technicalpickles/homesick)
[![Gitter chat](https://badges.gitter.im/technicalpickles/homesick.svg)](https://gitter.im/technicalpickles/homesick)
Your home directory is your castle. Don't leave your dotfiles behind.
Homesick is sorta like [rip](http://github.com/defunkt/rip), but for dotfiles. It uses git to clone a repository containing dotfiles, and saves them in `~/.homesick`. It then allows you to symlink all the dotfiles into place with a single command.
We call a repository that is compatible with homesick to be a 'castle'. To act as a castle, a repository must be organized like so:
* Contains a 'home' directory
* 'home' contains any number of files and directories that begin with '.'
To get started, install homesick first:
gem install homesick
Next, you use the homesick command to clone a castle:
homesick clone git://github.com/technicalpickles/pickled-vim.git
Alternatively, if it's on github, there's a slightly shorter way:
homesick clone technicalpickles/pickled-vim
With the castle cloned, you can now link its contents into your home dir:
homesick link pickled-vim
You can remove symlinks anytime when you don't need them anymore
homesick unlink pickled-vim
If you need to add further configuration steps you can add these in a file called '.homesickrc' in the root of a castle. Once you've cloned a castle with a .homesickrc run the configuration with:
homesick rc CASTLE
The contents of the .homesickrc file must be valid Ruby code as the file will be executed with Ruby's eval construct. The .homesickrc is also passed the current homesick object during its execution and this is available within the .homesickrc file as the 'self' variable. As the rc operation can be destructive the command normally asks for confirmation before proceeding. You can bypass this by passing the '--force' option, for example `homesick rc --force CASTLE`.
If you're not sure what castles you have around, you can easily list them:
homesick list
To pull your castle (or all castles):
homesick pull --all|CASTLE
To commit your castle's changes:
homesick commit CASTLE
To push your castle:
homesick push CASTLE
To open a terminal in the root of a castle:
homesick cd CASTLE
To open your default editor in the root of a castle (the $EDITOR environment variable must be set):
homesick open CASTLE
To execute a shell command inside the root directory of a given castle:
homesick exec CASTLE COMMAND
To execute a shell command inside the root directory of every cloned castle:
homesick exec_all COMMAND
Not sure what else homesick has up its sleeve? There's always the built in help:
homesick help
If you ever want to see what version of homesick you have type:
homesick version|-v|--version
## .homesick_subdir
`homesick link` basically makes symlink to only first depth in `castle/home`. If you want to link nested files/directories, please use .homesick_subdir.
For example, when you have castle like this:
castle/home
`-- .config
`-- fooapp
|-- config1
|-- config2
`-- config3
and have home like this:
$ tree -a
~
|-- .config
| `-- barapp
| |-- config1
| |-- config2
| `-- config3
`-- .emacs.d
|-- elisp
`-- inits
You may want to symlink only to `castle/home/.config/fooapp` instead of `castle/home/.config` because you already have `~/.config/barapp`. In this case, you can use .homesick_subdir. Please write "directories you want to look up sub directories (instead of just first depth)" in this file.
castle/.homesick_subdir
.config
and run `homesick link CASTLE`. The result is:
~
|-- .config
| |-- barapp
| | |-- config1
| | |-- config2
| | `-- config3
| `-- fooapp -> castle/home/.config/fooapp
`-- .emacs.d
|-- elisp
`-- inits
Or `homesick track NESTED_FILE CASTLE` adds a line automatically. For example:
homesick track .emacs.d/elisp castle
castle/.homesick_subdir
.config
.emacs.d
home directory
~
|-- .config
| |-- barapp
| | |-- config1
| | |-- config2
| | `-- config3
| `-- fooapp -> castle/home/.config/fooapp
`-- .emacs.d
|-- elisp -> castle/home/.emacs.d/elisp
`-- inits
and castle
castle/home
|-- .config
| `-- fooapp
| |-- config1
| |-- config2
| `-- config3
`-- .emacs.d
`-- elisp
## Supported Ruby Versions
Homesick is tested on the following Ruby versions:
* 2.2.6
* 2.3.3
* 2.4.0
## Note on Patches/Pull Requests
* Fork the project.
* Make your feature addition or bug fix.
* Add tests for it. This is important so I don't break it in a future version unintentionally.
* Commit, do not mess with rakefile, version, or history. (if you want to have your own version, that is fine but bump version in a commit by itself I can ignore when I pull)
* Send me a pull request. Bonus points for topic branches.
## Need homesick without the ruby dependency?
Check out [homeshick](https://github.com/andsens/homeshick).
## Copyright
Copyright (c) 2010 Joshua Nichols. See LICENSE for details.

94
README.md Normal file
View File

@@ -0,0 +1,94 @@
# homesick
[![Main Validation](https://git.hrafn.xyz/aether/gosick/actions/workflows/push-validation.yml/badge.svg?branch=main&event=push)](https://git.hrafn.xyz/aether/gosick/actions/runs/latest?workflow=push-validation.yml&branch=main&event=push)
[![Coverage](https://s3.hrafn.xyz/aether-workflow-report-artefacts/gosick/branch/main/coverage-badge.svg)](https://s3.hrafn.xyz/aether-workflow-report-artefacts/gosick/branch/main/coverage.html)
Your home directory is your castle. Don't leave your dotfiles behind.
This repository now contains a Go implementation of Homesick. A dotfiles repository is called a castle and should contain a `home/` directory with files to link into your `$HOME`.
## Build
Build with just:
```bash
just go-build
```
Or directly with Go:
```bash
go build -o dist/gosick ./cmd/homesick
```
## Commands
Implemented commands:
- `clone URI [CASTLE_NAME]`
- `list`
- `show_path [CASTLE]`
- `status [CASTLE]`
- `diff [CASTLE]`
- `link [CASTLE]`
- `unlink [CASTLE]`
- `track FILE [CASTLE]`
- `pull [--all|CASTLE]`
- `push [CASTLE]`
- `commit -m MESSAGE [CASTLE]`
- `destroy [CASTLE]`
- `cd [CASTLE]`
- `open [CASTLE]`
- `exec CASTLE COMMAND...`
- `exec_all COMMAND...`
- `generate PATH`
- `rc [--force] [CASTLE]`
- `version`
Global options:
- `--pretend` simulates command execution for shell/git-backed operations.
- `--dry-run` is an alias for `--pretend`.
- `--quiet` suppresses status output.
### rc behavior
- Runs executable scripts in `<castle>/.homesick.d/` in lexicographic order.
- Executes scripts with the castle root as the current working directory.
- Forwards script stdout/stderr to command output.
- If `<castle>/.homesickrc` exists, `--force` is required before legacy Ruby compatibility hooks are run.
- If `<castle>/.homesickrc` exists and `<castle>/.homesick.d/parity.rb` does not, generates `parity.rb` before execution.
- Never overwrites an existing `parity.rb` wrapper.
### exec behavior
- `exec CASTLE COMMAND...` runs the shell command inside the target castle root.
- `exec_all COMMAND...` runs the same shell command inside each cloned castle root in sorted order.
## Behavior Suite
The repository includes a Docker-based behavior suite that validates filesystem and git outcomes for the implemented commands.
Run behavior suite:
```bash
just behavior
```
Verbose behavior suite output:
```bash
just behavior-verbose
```
## Testing
Run all Go tests:
```bash
just go-test
```
## License
See `LICENSE`.

View File

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

View File

@@ -1,9 +0,0 @@
#!/usr/bin/env ruby
require 'pathname'
lib = Pathname.new(__FILE__).dirname.join('..', 'lib').expand_path
$LOAD_PATH.unshift lib.to_s
require 'homesick'
Homesick::CLI.start

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

@@ -0,0 +1,18 @@
package main
import (
"io"
"os"
"git.hrafn.xyz/aether/gosick/internal/homesick/cli"
)
func main() {
_ = os.Setenv("GIT_TERMINAL_PROMPT", "0")
exitCode := run(os.Args[1:], os.Stdin, os.Stdout, os.Stderr)
os.Exit(exitCode)
}
func run(args []string, stdin io.Reader, stdout io.Writer, stderr io.Writer) int {
return cli.Run(args, stdin, stdout, stderr)
}

53
cmd/homesick/main_test.go Normal file
View File

@@ -0,0 +1,53 @@
package main
import (
"bytes"
"os"
"os/exec"
"testing"
"git.hrafn.xyz/aether/gosick/internal/homesick/version"
)
func TestRunVersionCommand(t *testing.T) {
stdout := &bytes.Buffer{}
stderr := &bytes.Buffer{}
exitCode := run([]string{"version"}, bytes.NewBuffer(nil), stdout, stderr)
if exitCode != 0 {
t.Fatalf("run(version) exit code = %d, want 0", exitCode)
}
if got := stdout.String(); got != version.String+"\n" {
t.Fatalf("stdout = %q, want %q", got, version.String+"\n")
}
if got := stderr.String(); got != "" {
t.Fatalf("stderr = %q, want empty", got)
}
}
func TestMainVersionCommand(t *testing.T) {
if os.Getenv("GO_WANT_HELPER_PROCESS") == "1" {
os.Args = []string{"gosick", "version"}
main()
return
}
cmd := exec.Command(os.Args[0], "-test.run=TestMainVersionCommand")
cmd.Env = append(os.Environ(), "GO_WANT_HELPER_PROCESS=1")
stdout := &bytes.Buffer{}
stderr := &bytes.Buffer{}
cmd.Stdout = stdout
cmd.Stderr = stderr
if err := cmd.Run(); err != nil {
t.Fatalf("helper process failed: %v, stderr: %s", err, stderr.String())
}
if got := stdout.String(); got != version.String+"\n" {
t.Fatalf("stdout = %q, want %q", got, version.String+"\n")
}
if got := stderr.String(); got != "" {
t.Fatalf("stderr = %q, want empty", got)
}
}

View File

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

38
go.mod Normal file
View File

@@ -0,0 +1,38 @@
module git.hrafn.xyz/aether/gosick
go 1.26
toolchain go1.26.1
require github.com/stretchr/testify v1.11.1
require (
github.com/alecthomas/kong v1.12.1
github.com/go-git/go-git/v5 v5.17.0
)
require (
dario.cat/mergo v1.0.2 // indirect
github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/ProtonMail/go-crypto v1.4.1 // indirect
github.com/cloudflare/circl v1.6.3 // indirect
github.com/cyphar/filepath-securejoin v0.6.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.8.0 // 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.6.0 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/pjbgf/sha1cd v0.5.0 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/sergi/go-diff v1.4.0 // indirect
github.com/skeema/knownhosts v1.3.2 // indirect
github.com/xanzy/ssh-agent v0.3.3 // indirect
golang.org/x/crypto v0.49.0 // indirect
golang.org/x/net v0.52.0 // indirect
golang.org/x/sys v0.42.0 // indirect
gopkg.in/warnings.v0 v0.1.2 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

112
go.sum Normal file
View File

@@ -0,0 +1,112 @@
dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=
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.4.1 h1:9RfcZHqEQUvP8RzecWEUafnZVtEvrBVL9BiF67IQOfM=
github.com/ProtonMail/go-crypto v1.4.1/go.mod h1:e1OaTyu5SYVrO9gKOEhTc+5UcXtTUa+P3uLudwcgPqo=
github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
github.com/alecthomas/kong v1.12.1 h1:iq6aMJDcFYP9uFrLdsiZQ2ZMmcshduyGv4Pek0MQPW0=
github.com/alecthomas/kong v1.12.1/go.mod h1:p2vqieVMeTAnaC83txKtXe8FLke2X07aruPWXyMPQrU=
github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc=
github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
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.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8=
github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4=
github.com/cyphar/filepath-securejoin v0.6.1 h1:5CeZ1jPXEiYt3+Z6zqprSAgSWiggmpVyciv8syjIpVE=
github.com/cyphar/filepath-securejoin v0.6.1/go.mod h1:A8hd4EnAeyujCJRrICiOWqjS1AX0a9kM5XL+NwKoYSc=
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.8.0 h1:I8hjc3LbBlXTtVuFNJuwYuMiHvQJDq1AT6u4DwDzZG0=
github.com/go-git/go-billy/v5 v5.8.0/go.mod h1:RpvI/rw4Vr5QA+Z60c6d6LXH0rYJo0uD5SqfmrrheCY=
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.17.0 h1:AbyI4xf+7DsjINHMu35quAh4wJygKBKBuXVjV/pxesM=
github.com/go-git/go-git/v5 v5.17.0/go.mod h1:f82C4YiLx+Lhi8eHxltLeGC5uBTXSFa6PC5WW9o4SjI=
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/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
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.6.0 h1:J1FBfmuVosPHf5GRdltRLhPJtJpTlMdKTBjRgTaQBFY=
github.com/kevinburke/ssh_config v1.6.0/go.mod h1:q2RIzfka+BXARoNexmF9gkxEX7DmvbW9P4hIVx2Kg4M=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
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.5.0 h1:a+UkboSi1znleCDUNT3M5YxjOnN1fz2FhN48FlwCxs0=
github.com/pjbgf/sha1cd v0.5.0/go.mod h1:lhpGlyHLpQZoxMv8HcgXvZEhcGs0PG/vsZnEJ7H0iCM=
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.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw=
github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
github.com/skeema/knownhosts v1.3.2 h1:EDL9mgf4NzwMXCTfaxSD/o/a5fxDw/xL9nkU28JjdBg=
github.com/skeema/knownhosts v1.3.2/go.mod h1:bEg3iQAuw+jyiw+484wwFJoKSLwcfd7fqRy+N0QTiow=
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.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
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.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
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.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
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.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU=
golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
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

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

View File

@@ -0,0 +1,327 @@
package cli
import (
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"git.hrafn.xyz/aether/gosick/internal/homesick/core"
"git.hrafn.xyz/aether/gosick/internal/homesick/version"
"github.com/alecthomas/kong"
)
func Run(args []string, stdin io.Reader, stdout io.Writer, stderr io.Writer) int {
model := &cliModel{}
app, err := core.NewApp(stdin, stdout, stderr)
if err != nil {
_, _ = fmt.Fprintf(stderr, "error: %v\n", err)
return 1
}
parser, err := kong.New(
model,
kong.Name(programName()),
kong.Description("Your home is your castle. Don't leave your precious dotfiles behind."),
kong.Writers(stdout, stderr),
kong.Exit(func(int) {}),
kong.ConfigureHelp(kong.HelpOptions{Compact: true}),
)
if err != nil {
_, _ = fmt.Fprintf(stderr, "error: %v\n", err)
return 1
}
normalizedArgs := normalizeArgs(args)
ctx, err := parser.Parse(normalizedArgs)
if err != nil {
var parseErr *kong.ParseError
if errors.As(err, &parseErr) {
if parseErr.ExitCode() == 0 || isHelpRequest(normalizedArgs) {
return 0
}
_, _ = fmt.Fprintf(stderr, "error: %v\n", err)
if parseErr.Context != nil {
_ = parseErr.Context.PrintUsage(false)
}
return parseErr.ExitCode()
}
_, _ = fmt.Fprintf(stderr, "error: %v\n", err)
return 1
}
app.Quiet = model.Quiet
app.Pretend = model.Pretend || model.DryRun
if err := ctx.Run(app); err != nil {
var exitErr *cliExitError
if errors.As(err, &exitErr) {
_, _ = fmt.Fprintf(stderr, "error: %v\n", exitErr.err)
return exitErr.code
}
_, _ = fmt.Fprintf(stderr, "error: %v\n", err)
return 1
}
return 0
}
type cliModel struct {
Pretend bool `help:"Preview actions without executing commands."`
DryRun bool `name:"dry-run" help:"Alias for --pretend."`
Quiet bool `help:"Suppress status output."`
Clone cloneCmd `cmd:"" help:"Clone a castle."`
List listCmd `cmd:"" help:"List castles."`
ShowPath showPathCmd `cmd:"" name:"show_path" help:"Show the path of a castle."`
Status statusCmd `cmd:"" help:"Show git status for a castle."`
Diff diffCmd `cmd:"" help:"Show git diff for a castle."`
Link linkCmd `cmd:"" help:"Symlink all dotfiles from a castle."`
Unlink unlinkCmd `cmd:"" help:"Unsymlink all dotfiles from a castle."`
Track trackCmd `cmd:"" help:"Track a file in a castle."`
Version versionCmd `cmd:"" help:"Display the current version."`
Pull pullCmd `cmd:"" help:"Pull the specified castle."`
Push pushCmd `cmd:"" help:"Push the specified castle."`
Commit commitCmd `cmd:"" help:"Commit the specified castle's changes."`
Destroy destroyCmd `cmd:"" help:"Destroy a castle."`
Cd cdCmd `cmd:"" help:"Print the path to a castle."`
Open openCmd `cmd:"" help:"Open a castle."`
Exec execCmd `cmd:"" help:"Execute a command in a castle."`
ExecAll execAllCmd `cmd:"" name:"exec_all" help:"Execute a command in every castle."`
Rc rcCmd `cmd:"" help:"Run rc hooks for a castle."`
Generate generateCmd `cmd:"" help:"Generate a castle."`
}
type cloneCmd struct {
URI string `arg:"" name:"URI" help:"Castle URI to clone."`
Destination string `arg:"" optional:"" name:"CASTLE_NAME" help:"Optional local castle name."`
}
func (c *cloneCmd) Run(app *core.App) error {
return app.Clone(c.URI, c.Destination)
}
type listCmd struct{}
func (c *listCmd) Run(app *core.App) error {
return app.List()
}
type showPathCmd struct {
Castle string `arg:"" optional:"" name:"CASTLE" help:"Castle name."`
}
func (c *showPathCmd) Run(app *core.App) error {
return app.ShowPath(defaultCastle(c.Castle))
}
type statusCmd struct {
Castle string `arg:"" optional:"" name:"CASTLE" help:"Castle name."`
}
func (c *statusCmd) Run(app *core.App) error {
return app.Status(defaultCastle(c.Castle))
}
type diffCmd struct {
Castle string `arg:"" optional:"" name:"CASTLE" help:"Castle name."`
}
func (c *diffCmd) Run(app *core.App) error {
return app.Diff(defaultCastle(c.Castle))
}
type linkCmd struct {
Castle string `arg:"" optional:"" name:"CASTLE" help:"Castle name."`
}
func (c *linkCmd) Run(app *core.App) error {
return app.LinkCastle(defaultCastle(c.Castle))
}
type unlinkCmd struct {
Castle string `arg:"" optional:"" name:"CASTLE" help:"Castle name."`
}
func (c *unlinkCmd) Run(app *core.App) error {
return app.Unlink(defaultCastle(c.Castle))
}
type trackCmd struct {
File string `arg:"" name:"FILE" help:"File to track."`
Castle string `arg:"" optional:"" name:"CASTLE" help:"Castle name."`
}
func (c *trackCmd) Run(app *core.App) error {
return app.Track(c.File, defaultCastle(c.Castle))
}
type versionCmd struct{}
func (c *versionCmd) Run(app *core.App) error {
return app.Version(version.String)
}
type pullCmd struct {
All bool `help:"Pull all castles."`
Castle string `arg:"" optional:"" name:"CASTLE" help:"Castle name."`
}
type pushCmd struct {
Castle string `arg:"" optional:"" name:"CASTLE" help:"Castle name."`
}
type commitCmd struct {
Message string `short:"m" required:"" name:"MESSAGE" help:"Commit message."`
Castle string `arg:"" optional:"" name:"CASTLE" help:"Castle name."`
}
type destroyCmd struct {
Castle string `arg:"" optional:"" name:"CASTLE" help:"Castle name."`
}
type cdCmd struct {
Castle string `arg:"" optional:"" name:"CASTLE" help:"Castle name."`
}
type openCmd struct {
Castle string `arg:"" optional:"" name:"CASTLE" help:"Castle name."`
}
type execCmd struct {
Castle string `arg:"" name:"CASTLE" help:"Castle name."`
Command []string `arg:"" name:"COMMAND" help:"Shell command to execute in the castle root."`
}
type execAllCmd struct {
Command []string `arg:"" name:"COMMAND" help:"Shell command to execute in each castle root."`
}
type rcCmd struct {
Force bool `help:"Bypass legacy .homesickrc safety confirmation."`
Castle string `arg:"" optional:"" name:"CASTLE" help:"Castle name."`
}
type generateCmd struct {
Path string `arg:"" name:"PATH" help:"Path to generate a homesick-ready castle repository."`
}
func (c *pullCmd) Run(app *core.App) error {
if c.All {
if strings.TrimSpace(c.Castle) != "" {
return errors.New("pull accepts either --all or CASTLE, not both")
}
return app.PullAll()
}
return app.Pull(defaultCastle(c.Castle))
}
func (c *pushCmd) Run(app *core.App) error { return app.Push(defaultCastle(c.Castle)) }
func (c *commitCmd) Run(app *core.App) error {
return app.Commit(defaultCastle(c.Castle), c.Message)
}
func (c *destroyCmd) Run(app *core.App) error { return app.Destroy(defaultCastle(c.Castle)) }
func (c *cdCmd) Run(app *core.App) error { return app.ShowPath(defaultCastle(c.Castle)) }
func (c *openCmd) Run(app *core.App) error { return app.Open(defaultCastle(c.Castle)) }
func (c *execCmd) Run(app *core.App) error { return app.Exec(c.Castle, c.Command) }
func (c *execAllCmd) Run(app *core.App) error { return app.ExecAll(c.Command) }
func (c *rcCmd) Run(app *core.App) error {
return app.Rc(defaultCastle(c.Castle), c.Force)
}
func (c *generateCmd) Run(app *core.App) error { return app.Generate(c.Path) }
func defaultCastle(castle string) string {
if strings.TrimSpace(castle) == "" {
return "dotfiles"
}
return castle
}
func programName() string {
if len(os.Args) > 0 {
if name := strings.TrimSpace(filepath.Base(os.Args[0])); name != "" {
return name
}
}
return "gosick"
}
func normalizeArgs(args []string) []string {
if len(args) == 0 {
return []string{"--help"}
}
prefix, rest := splitLeadingGlobalFlags(args)
if len(rest) == 0 {
return args
}
switch rest[0] {
case "-h", "--help":
return []string{"--help"}
case "help":
if len(rest) == 1 {
return []string{"--help"}
}
normalized := append([]string{}, prefix...)
normalized = append(normalized, rest[1:]...)
return append(normalized, "--help")
case "-v", "--version":
return []string{"version"}
case "symlink":
normalized := append([]string{}, prefix...)
normalized = append(normalized, "link")
return append(normalized, rest[1:]...)
case "commit":
if len(rest) == 3 && !hasCommitMessageFlag(rest[1:]) {
normalized := append([]string{}, prefix...)
return append(normalized, "commit", "-m", rest[2], rest[1])
}
return args
default:
return args
}
}
func splitLeadingGlobalFlags(args []string) ([]string, []string) {
i := 0
for i < len(args) {
switch args[i] {
case "--pretend", "--dry-run", "--quiet":
i++
default:
return args[:i], args[i:]
}
}
return args, nil
}
func hasCommitMessageFlag(args []string) bool {
for _, arg := range args {
if arg == "-m" || strings.HasPrefix(arg, "--MESSAGE") || strings.HasPrefix(arg, "--message") {
return true
}
}
return false
}
func isHelpRequest(args []string) bool {
for _, arg := range args {
if arg == "-h" || arg == "--help" {
return true
}
}
return false
}
type cliExitError struct {
code int
err error
}
func (e *cliExitError) Error() string {
return e.err.Error()
}

View File

@@ -0,0 +1,272 @@
package cli_test
import (
"bytes"
"os"
"path/filepath"
"testing"
"time"
"git.hrafn.xyz/aether/gosick/internal/homesick/cli"
"git.hrafn.xyz/aether/gosick/internal/homesick/version"
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 CLISuite struct {
suite.Suite
homeDir string
stdout *bytes.Buffer
stderr *bytes.Buffer
}
func TestCLISuite(t *testing.T) {
suite.Run(t, new(CLISuite))
}
func (s *CLISuite) SetupTest() {
s.homeDir = filepath.Join(s.T().TempDir(), "home")
require.NoError(s.T(), os.MkdirAll(s.homeDir, 0o755))
require.NoError(s.T(), os.Setenv("HOME", s.homeDir))
s.stdout = &bytes.Buffer{}
s.stderr = &bytes.Buffer{}
}
func (s *CLISuite) createCastleRepo(castle string) string {
castleRoot := filepath.Join(s.homeDir, ".homesick", "repos", castle)
repo, err := git.PlainInit(castleRoot, false)
require.NoError(s.T(), err)
filePath := filepath.Join(castleRoot, "home", ".vimrc")
require.NoError(s.T(), os.MkdirAll(filepath.Dir(filePath), 0o755))
require.NoError(s.T(), os.WriteFile(filePath, []byte("set number\n"), 0o644))
wt, err := repo.Worktree()
require.NoError(s.T(), err)
_, err = wt.Add("home/.vimrc")
require.NoError(s.T(), err)
_, err = wt.Commit("initial", &git.CommitOptions{Author: &object.Signature{
Name: "Behavior Test",
Email: "behavior@test.local",
When: time.Now(),
}})
require.NoError(s.T(), err)
_, err = repo.CreateRemote(&config.RemoteConfig{Name: "origin", URLs: []string{"git://example.com/test.git"}})
require.NoError(s.T(), err)
return castleRoot
}
func (s *CLISuite) TestRun_VersionAliases() {
for _, args := range [][]string{{"-v"}, {"--version"}, {"version"}} {
s.stdout.Reset()
s.stderr.Reset()
exitCode := cli.Run(args, bytes.NewBuffer(nil), s.stdout, s.stderr)
require.Equal(s.T(), 0, exitCode)
require.Equal(s.T(), version.String+"\n", s.stdout.String())
require.Empty(s.T(), s.stderr.String())
}
}
func (s *CLISuite) TestRun_ShowPath_DefaultCastle() {
exitCode := cli.Run([]string{"show_path"}, bytes.NewBuffer(nil), s.stdout, s.stderr)
require.Equal(s.T(), 0, exitCode)
require.Equal(s.T(), filepath.Join(s.homeDir, ".homesick", "repos", "dotfiles")+"\n", s.stdout.String())
require.Empty(s.T(), s.stderr.String())
}
func (s *CLISuite) TestRun_Cd_DefaultCastle() {
exitCode := cli.Run([]string{"cd"}, bytes.NewBuffer(nil), s.stdout, s.stderr)
require.Equal(s.T(), 0, exitCode)
require.Equal(s.T(), filepath.Join(s.homeDir, ".homesick", "repos", "dotfiles")+"\n", s.stdout.String())
require.Empty(s.T(), s.stderr.String())
}
func (s *CLISuite) TestRun_Cd_ExplicitCastle() {
exitCode := cli.Run([]string{"cd", "work"}, bytes.NewBuffer(nil), s.stdout, s.stderr)
require.Equal(s.T(), 0, exitCode)
require.Equal(s.T(), filepath.Join(s.homeDir, ".homesick", "repos", "work")+"\n", s.stdout.String())
require.Empty(s.T(), s.stderr.String())
}
func (s *CLISuite) TestRun_Exec_RunsCommandInCastleRoot() {
castleRoot := filepath.Join(s.homeDir, ".homesick", "repos", "dotfiles")
require.NoError(s.T(), os.MkdirAll(castleRoot, 0o755))
exitCode := cli.Run([]string{"exec", "dotfiles", "pwd"}, bytes.NewBuffer(nil), s.stdout, s.stderr)
require.Equal(s.T(), 0, exitCode)
require.Contains(s.T(), s.stdout.String(), castleRoot)
require.Empty(s.T(), s.stderr.String())
}
func (s *CLISuite) TestRun_Exec_WithPretend_DoesNotExecute() {
castleRoot := filepath.Join(s.homeDir, ".homesick", "repos", "dotfiles")
require.NoError(s.T(), os.MkdirAll(castleRoot, 0o755))
target := filepath.Join(castleRoot, "should-not-exist")
exitCode := cli.Run([]string{"--pretend", "exec", "dotfiles", "touch should-not-exist"}, bytes.NewBuffer(nil), s.stdout, s.stderr)
require.Equal(s.T(), 0, exitCode)
require.NoFileExists(s.T(), target)
require.Contains(s.T(), s.stdout.String(), "Would execute")
require.Empty(s.T(), s.stderr.String())
}
func (s *CLISuite) TestRun_Exec_WithDryRunAlias_DoesNotExecute() {
castleRoot := filepath.Join(s.homeDir, ".homesick", "repos", "dotfiles")
require.NoError(s.T(), os.MkdirAll(castleRoot, 0o755))
target := filepath.Join(castleRoot, "should-not-exist")
exitCode := cli.Run([]string{"--dry-run", "exec", "dotfiles", "touch should-not-exist"}, bytes.NewBuffer(nil), s.stdout, s.stderr)
require.Equal(s.T(), 0, exitCode)
require.NoFileExists(s.T(), target)
require.Contains(s.T(), s.stdout.String(), "Would execute")
require.Empty(s.T(), s.stderr.String())
}
func (s *CLISuite) TestRun_Exec_WithQuiet_SuppressesStatusOutput() {
castleRoot := filepath.Join(s.homeDir, ".homesick", "repos", "dotfiles")
require.NoError(s.T(), os.MkdirAll(castleRoot, 0o755))
exitCode := cli.Run([]string{"--quiet", "exec", "dotfiles", "true"}, bytes.NewBuffer(nil), s.stdout, s.stderr)
require.Equal(s.T(), 0, exitCode)
require.Empty(s.T(), s.stdout.String())
require.Empty(s.T(), s.stderr.String())
}
func (s *CLISuite) TestRun_PullAll_NoCastlesIsNoop() {
exitCode := cli.Run([]string{"pull", "--all"}, bytes.NewBuffer(nil), s.stdout, s.stderr)
require.Equal(s.T(), 0, exitCode)
require.Empty(s.T(), s.stderr.String())
}
func (s *CLISuite) TestRun_Rc_HomesickrcRequiresForce() {
castleRoot := filepath.Join(s.homeDir, ".homesick", "repos", "dotfiles")
require.NoError(s.T(), os.MkdirAll(castleRoot, 0o755))
require.NoError(s.T(), os.WriteFile(filepath.Join(castleRoot, ".homesickrc"), []byte("# ruby\n"), 0o644))
exitCode := cli.Run([]string{"rc", "dotfiles"}, bytes.NewBuffer(nil), s.stdout, s.stderr)
require.NotEqual(s.T(), 0, exitCode)
require.Contains(s.T(), s.stderr.String(), "--force")
}
func (s *CLISuite) TestRun_Rc_WithForceRuns() {
castleRoot := filepath.Join(s.homeDir, ".homesick", "repos", "dotfiles")
require.NoError(s.T(), os.MkdirAll(castleRoot, 0o755))
require.NoError(s.T(), os.WriteFile(filepath.Join(castleRoot, ".homesickrc"), []byte("# ruby\n"), 0o644))
exitCode := cli.Run([]string{"rc", "--force", "dotfiles"}, bytes.NewBuffer(nil), s.stdout, s.stderr)
require.Equal(s.T(), 0, exitCode)
require.Empty(s.T(), s.stderr.String())
}
func (s *CLISuite) TestRun_CloneSubcommandHelp() {
exitCode := cli.Run([]string{"clone", "--help"}, bytes.NewBuffer(nil), s.stdout, s.stderr)
require.Equal(s.T(), 0, exitCode)
require.Contains(s.T(), s.stdout.String(), "clone")
require.Contains(s.T(), s.stdout.String(), "URI")
require.Empty(s.T(), s.stderr.String())
}
func (s *CLISuite) TestRun_Help_UsesProgramNameAndDescription() {
originalArgs := os.Args
s.T().Cleanup(func() { os.Args = originalArgs })
os.Args = []string{"gosick"}
exitCode := cli.Run([]string{"--help"}, bytes.NewBuffer(nil), s.stdout, s.stderr)
require.Equal(s.T(), 0, exitCode)
require.Contains(s.T(), s.stdout.String(), "Usage: gosick")
require.NotContains(s.T(), s.stdout.String(), "Usage: homesick")
require.Contains(s.T(), s.stdout.String(), "precious dotfiles")
require.Empty(s.T(), s.stderr.String())
}
func (s *CLISuite) TestRun_SymlinkAlias_MatchesLinkCommand() {
castleRoot := filepath.Join(s.homeDir, ".homesick", "repos", "dotfiles")
castleHome := filepath.Join(castleRoot, "home")
require.NoError(s.T(), os.MkdirAll(castleHome, 0o755))
require.NoError(s.T(), os.WriteFile(filepath.Join(castleHome, ".vimrc"), []byte("set number\n"), 0o644))
exitCode := cli.Run([]string{"symlink", "dotfiles"}, bytes.NewBuffer(nil), s.stdout, s.stderr)
require.Equal(s.T(), 0, exitCode)
target := filepath.Join(s.homeDir, ".vimrc")
info, err := os.Lstat(target)
require.NoError(s.T(), err)
require.True(s.T(), info.Mode()&os.ModeSymlink != 0)
require.Empty(s.T(), s.stderr.String())
}
func (s *CLISuite) TestRun_Commit_PositionalMessageCompatibility() {
exitCode := cli.Run([]string{"--pretend", "commit", "dotfiles", "behavior-suite-commit"}, bytes.NewBuffer(nil), s.stdout, s.stderr)
require.Equal(s.T(), 0, exitCode)
require.Contains(s.T(), s.stdout.String(), "git commit -m behavior-suite-commit")
require.Empty(s.T(), s.stderr.String())
}
func (s *CLISuite) TestRun_List_NoArguments() {
s.createCastleRepo("dotfiles")
exitCode := cli.Run([]string{"list"}, bytes.NewBuffer(nil), s.stdout, s.stderr)
require.Equal(s.T(), 0, exitCode)
require.Contains(s.T(), s.stdout.String(), "dotfiles")
require.Empty(s.T(), s.stderr.String())
}
func (s *CLISuite) TestRun_Generate_CreatesNewCastle() {
castlePath := filepath.Join(s.T().TempDir(), "my-castle")
exitCode := cli.Run([]string{"generate", castlePath}, bytes.NewBuffer(nil), s.stdout, s.stderr)
require.Equal(s.T(), 0, exitCode)
require.DirExists(s.T(), filepath.Join(castlePath, ".git"))
require.DirExists(s.T(), filepath.Join(castlePath, "home"))
}
func (s *CLISuite) TestRun_Clone_WithoutArgs() {
exitCode := cli.Run([]string{"clone"}, bytes.NewBuffer(nil), s.stdout, s.stderr)
// Clone requires arguments, should fail
require.NotEqual(s.T(), 0, exitCode)
}
func (s *CLISuite) TestRun_Status_DefaultCastle() {
castleRoot := s.createCastleRepo("dotfiles")
require.NoError(s.T(), os.WriteFile(filepath.Join(castleRoot, "home", ".vimrc"), []byte("changed\n"), 0o644))
exitCode := cli.Run([]string{"status"}, bytes.NewBuffer(nil), s.stdout, s.stderr)
require.Equal(s.T(), 0, exitCode)
require.Contains(s.T(), s.stdout.String(), "modified:")
require.Empty(s.T(), s.stderr.String())
}
func (s *CLISuite) TestRun_Diff_DefaultCastle() {
castleRoot := s.createCastleRepo("dotfiles")
require.NoError(s.T(), os.WriteFile(filepath.Join(castleRoot, "home", ".vimrc"), []byte("changed\n"), 0o644))
exitCode := cli.Run([]string{"diff"}, bytes.NewBuffer(nil), s.stdout, s.stderr)
require.Equal(s.T(), 0, exitCode)
require.Contains(s.T(), s.stdout.String(), "diff --git")
require.Empty(s.T(), s.stderr.String())
}

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,112 @@
package core_test
import (
"io"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"
"git.hrafn.xyz/aether/gosick/internal/homesick/core"
git "github.com/go-git/go-git/v5"
"github.com/go-git/go-git/v5/plumbing/object"
"github.com/stretchr/testify/require"
"github.com/stretchr/testify/suite"
)
type CommitSuite struct {
suite.Suite
tmpDir string
homeDir string
reposDir string
app *core.App
}
func TestCommitSuite(t *testing.T) {
suite.Run(t, new(CommitSuite))
}
func (s *CommitSuite) 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 *CommitSuite) createCastleRepo(castle string) string {
castleRoot := filepath.Join(s.reposDir, castle)
repo, err := git.PlainInit(castleRoot, false)
require.NoError(s.T(), err)
filePath := filepath.Join(castleRoot, "home", ".vimrc")
require.NoError(s.T(), os.MkdirAll(filepath.Dir(filePath), 0o755))
require.NoError(s.T(), os.WriteFile(filePath, []byte("set number\n"), 0o644))
wt, err := repo.Worktree()
require.NoError(s.T(), err)
_, err = wt.Add("home/.vimrc")
require.NoError(s.T(), err)
_, err = wt.Commit("initial", &git.CommitOptions{Author: &object.Signature{
Name: "Commit Test",
Email: "commit@test.local",
When: time.Now(),
}})
require.NoError(s.T(), err)
return castleRoot
}
func gitOutputAt(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 (s *CommitSuite) TestCommit_CreatesCommitWithMessage() {
castleRoot := s.createCastleRepo("dotfiles")
target := filepath.Join(castleRoot, "home", ".vimrc")
require.NoError(s.T(), os.WriteFile(target, []byte("set number\nsyntax on\n"), 0o644))
require.NoError(s.T(), s.app.Commit("dotfiles", "update vimrc"))
subject, err := gitOutputAt(castleRoot, "log", "-1", "--pretty=%s")
require.NoError(s.T(), err)
require.Equal(s.T(), "update vimrc\n", subject)
}
func (s *CommitSuite) TestCommit_MessageEscaping() {
castleRoot := s.createCastleRepo("dotfiles")
target := filepath.Join(castleRoot, "home", ".vimrc")
require.NoError(s.T(), os.WriteFile(target, []byte("set number\nset relativenumber\n"), 0o644))
msg := "fix \"quoted\" message: keep spaces"
require.NoError(s.T(), s.app.Commit("dotfiles", msg))
subject, err := gitOutputAt(castleRoot, "log", "-1", "--pretty=%s")
require.NoError(s.T(), err)
require.Equal(s.T(), msg+"\n", subject)
}
func (s *CommitSuite) TestCommit_RequiresMessage() {
err := s.app.Commit("dotfiles", " ")
require.Error(s.T(), err)
require.Contains(s.T(), strings.ToLower(err.Error()), "message")
}
func (s *CommitSuite) TestCommit_MissingCastleReturnsError() {
err := s.app.Commit("missing", "msg")
require.Error(s.T(), err)
}

View File

@@ -0,0 +1,954 @@
package core
import (
"bufio"
"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
Stdin io.Reader
Stdout io.Writer
Stderr io.Writer
Force bool
Quiet bool
Pretend bool
}
func NewApp(stdin io.Reader, stdout io.Writer, stderr io.Writer) (*App, error) {
if stdin == nil {
return nil, errors.New("stdin reader cannot be nil")
}
if stdout == nil {
return nil, errors.New("stdout writer cannot be nil")
}
if stderr == nil {
return nil, errors.New("stderr writer cannot be nil")
}
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"),
Stdin: stdin,
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, 0o750); 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, 0o750); err != nil {
return fmt.Errorf("ensure repos directory: %w", 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 fmt.Errorf("resolve castle path %q: %w", castleRoot, err)
}
castles = append(castles, rel)
return filepath.SkipDir
})
if err != nil {
return fmt.Errorf("scan repos directory: %w", 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 fmt.Errorf("write castle listing: %w", writeErr)
}
}
return nil
}
func (a *App) Status(castle string) error {
return a.runGit(filepath.Join(a.ReposDir, castle), "status")
}
func (a *App) Diff(castle string) error {
return a.runGit(filepath.Join(a.ReposDir, castle), "diff")
}
func (a *App) Pull(castle string) error {
if strings.TrimSpace(castle) == "" {
castle = "dotfiles"
}
return a.runGit(filepath.Join(a.ReposDir, castle), "pull")
}
func (a *App) PullAll() error {
if _, err := os.Stat(a.ReposDir); err != nil {
if errors.Is(err, os.ErrNotExist) {
return 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 {
if !a.Quiet {
if _, err := fmt.Fprintf(a.Stdout, "%s:\n", castle); err != nil {
return err
}
}
if err := a.runGit(filepath.Join(a.ReposDir, castle), "pull"); err != nil {
return fmt.Errorf("pull --all failed for %q: %w", castle, err)
}
}
return nil
}
func (a *App) Push(castle string) error {
if strings.TrimSpace(castle) == "" {
castle = "dotfiles"
}
return a.runGit(filepath.Join(a.ReposDir, castle), "push")
}
func (a *App) Commit(castle string, message string) error {
if strings.TrimSpace(castle) == "" {
castle = "dotfiles"
}
trimmedMessage := strings.TrimSpace(message)
if trimmedMessage == "" {
return errors.New("commit requires message")
}
castledir := filepath.Join(a.ReposDir, castle)
if err := a.runGit(castledir, "add", "--all"); err != nil {
return err
}
return a.runGit(castledir, "commit", "-m", trimmedMessage)
}
func (a *App) Destroy(castle string) error {
if strings.TrimSpace(castle) == "" {
castle = "dotfiles"
}
castleRoot := filepath.Join(a.ReposDir, castle)
castleInfo, err := os.Lstat(castleRoot)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return fmt.Errorf("castle %q not found", castle)
}
return fmt.Errorf("stat castle %q: %w", castle, err)
}
if !a.Force {
confirmed, confirmErr := a.confirmDestroy(castle)
if confirmErr != nil {
return fmt.Errorf("confirm destroy for %q: %w", castle, confirmErr)
}
if !confirmed {
return nil
}
}
// Only attempt unlinking managed home files for regular castle directories.
if castleInfo.Mode()&os.ModeSymlink == 0 {
castleHome := filepath.Join(castleRoot, "home")
if info, statErr := os.Stat(castleHome); statErr == nil && info.IsDir() {
if unlinkErr := a.UnlinkCastle(castle); unlinkErr != nil {
return fmt.Errorf("unlink castle %q before destroy: %w", castle, unlinkErr)
}
}
}
return os.RemoveAll(castleRoot)
}
func (a *App) confirmDestroy(castle string) (bool, error) {
reader := a.Stdin
if reader == nil {
reader = os.Stdin
}
if _, err := fmt.Fprintf(a.Stdout, "Destroy castle %q? [y/N]: ", castle); err != nil {
return false, fmt.Errorf("write destroy prompt: %w", err)
}
line, err := bufio.NewReader(reader).ReadString('\n')
if err != nil && !errors.Is(err, io.EOF) {
return false, fmt.Errorf("read destroy confirmation: %w", err)
}
return isAffirmativeResponse(line), nil
}
func isAffirmativeResponse(input string) bool {
response := strings.ToLower(strings.TrimSpace(input))
return response == "y" || response == "yes"
}
func (a *App) Open(castle string) error {
if strings.TrimSpace(castle) == "" {
castle = "dotfiles"
}
editor := strings.TrimSpace(os.Getenv("EDITOR"))
if editor == "" {
return errors.New("the $EDITOR environment variable must be set to use this command")
}
castleHome := filepath.Join(a.ReposDir, castle, "home")
if info, err := os.Stat(castleHome); err != nil || !info.IsDir() {
return fmt.Errorf("could not open %s, expected %s to exist and contain dotfiles", castle, castleHome)
}
castleRoot := filepath.Join(a.ReposDir, castle)
// #nosec G702,G204 -- EDITOR is user-controlled local configuration and command is executed directly without a shell.
cmd := exec.Command(editor, ".")
cmd.Dir = castleRoot
cmd.Stdout = a.Stdout
cmd.Stderr = a.Stderr
if err := cmd.Run(); err != nil {
return fmt.Errorf("open failed: %w", err)
}
return nil
}
func (a *App) Exec(castle string, command []string) error {
commandString := strings.TrimSpace(strings.Join(command, " "))
if commandString == "" {
return errors.New("exec requires COMMAND")
}
castleRoot := filepath.Join(a.ReposDir, castle)
if _, err := os.Stat(castleRoot); err != nil {
if errors.Is(err, os.ErrNotExist) {
return fmt.Errorf("castle %q not found", castle)
}
return err
}
a.sayStatus("exec", fmt.Sprintf("%s command %q in castle %q", a.actionVerb(), commandString, castle))
if a.Pretend {
return nil
}
cmd := exec.Command("sh", "-c", commandString) // #nosec G204 — intentional shell command execution feature
cmd.Dir = castleRoot
cmd.Stdout = a.Stdout
cmd.Stderr = a.Stderr
if err := cmd.Run(); err != nil {
return fmt.Errorf("exec failed: %w", err)
}
return nil
}
func (a *App) ExecAll(command []string) error {
commandString := strings.TrimSpace(strings.Join(command, " "))
if commandString == "" {
return errors.New("exec_all requires COMMAND")
}
if _, err := os.Stat(a.ReposDir); err != nil {
if errors.Is(err, os.ErrNotExist) {
return 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 {
if err := a.Exec(castle, []string{commandString}); err != nil {
return fmt.Errorf("exec_all failed for %q: %w", castle, err)
}
}
return nil
}
func (a *App) Generate(castlePath string) error {
trimmed := strings.TrimSpace(castlePath)
if trimmed == "" {
return errors.New("generate requires PATH")
}
absCastle, err := filepath.Abs(trimmed)
if err != nil {
return fmt.Errorf("resolve castle path %q: %w", trimmed, err)
}
if err := os.MkdirAll(absCastle, 0o750); err != nil {
return fmt.Errorf("create castle path %q: %w", absCastle, err)
}
if err := a.runGit(absCastle, "init"); err != nil {
return fmt.Errorf("initialize git repository %q: %w", absCastle, err)
}
githubUser := ""
if out, cfgErr := gitOutput(absCastle, "config", "github.user"); cfgErr == nil {
githubUser = strings.TrimSpace(out)
}
if githubUser != "" {
repoName := filepath.Base(absCastle)
url := fmt.Sprintf("git@github.com:%s/%s.git", githubUser, repoName)
if err := a.runGit(absCastle, "remote", "add", "origin", url); err != nil {
return fmt.Errorf("add origin remote for %q: %w", absCastle, err)
}
}
if err := os.MkdirAll(filepath.Join(absCastle, "home"), 0o750); err != nil {
return fmt.Errorf("create home directory for %q: %w", absCastle, err)
}
return nil
}
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 fmt.Errorf("read subdirs for castle %q: %w", castle, err)
}
if err := a.linkEach(castleHome, castleHome, subdirs); err != nil {
return fmt.Errorf("link castle %q: %w", castle, 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 fmt.Errorf("stat subdir %q for castle %q: %w", base, castle, err)
}
if err := a.linkEach(castleHome, base, subdirs); err != nil {
return fmt.Errorf("link subdir %q for castle %q: %w", subdir, castle, 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 fmt.Errorf("read subdirs for castle %q: %w", castle, err)
}
if err := a.unlinkEach(castleHome, castleHome, subdirs); err != nil {
return fmt.Errorf("unlink castle %q: %w", castle, 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 fmt.Errorf("stat subdir %q for castle %q: %w", base, castle, err)
}
if err := a.unlinkEach(castleHome, base, subdirs); err != nil {
return fmt.Errorf("unlink subdir %q for castle %q: %w", subdir, castle, err)
}
}
return nil
}
func (a *App) Track(filePath string, castle string) error {
return a.TrackPath(filePath, castle)
}
func (a *App) TrackPath(filePath string, castle string) error {
if strings.TrimSpace(castle) == "" {
castle = "dotfiles"
}
trimmedFile := strings.TrimSpace(filePath)
if trimmedFile == "" {
return errors.New("track requires FILE")
}
castleRoot := filepath.Join(a.ReposDir, castle)
castleHome := filepath.Join(castleRoot, "home")
info, err := os.Stat(castleHome)
if err != nil || !info.IsDir() {
return fmt.Errorf("could not track %s, expected %s to exist and contain dotfiles", castle, castleHome)
}
absolutePath, err := filepath.Abs(strings.TrimRight(trimmedFile, string(filepath.Separator)))
if err != nil {
return fmt.Errorf("resolve tracked file %q: %w", trimmedFile, err)
}
if _, err := os.Lstat(absolutePath); err != nil {
return fmt.Errorf("stat tracked file %q: %w", absolutePath, err)
}
relativeDir, err := filepath.Rel(a.HomeDir, filepath.Dir(absolutePath))
if err != nil {
return fmt.Errorf("resolve tracked file directory for %q: %w", absolutePath, err)
}
if relativeDir == ".." || strings.HasPrefix(relativeDir, ".."+string(filepath.Separator)) {
return fmt.Errorf("track requires file under %s", a.HomeDir)
}
castleTargetDir := filepath.Join(castleHome, relativeDir)
if relativeDir == "." {
castleTargetDir = castleHome
}
if err := os.MkdirAll(castleTargetDir, 0o750); err != nil {
return fmt.Errorf("create tracked file directory %q: %w", castleTargetDir, err)
}
trackedPath := filepath.Join(castleTargetDir, filepath.Base(absolutePath))
if _, err := os.Lstat(trackedPath); err == nil {
return fmt.Errorf("%s already exists", trackedPath)
} else if !errors.Is(err, os.ErrNotExist) {
return fmt.Errorf("stat tracked destination %q: %w", trackedPath, err)
}
if err := os.Rename(absolutePath, trackedPath); err != nil {
return fmt.Errorf("move tracked file into castle %q: %w", trackedPath, err)
}
subdirChanged := false
if relativeDir != "." {
subdirPath := filepath.Join(castleRoot, ".homesick_subdir")
subdirChanged, err = appendUniqueSubdir(subdirPath, relativeDir)
if err != nil {
return fmt.Errorf("record tracked subdir %q: %w", relativeDir, err)
}
}
if err := a.linkPath(trackedPath, absolutePath); err != nil {
return fmt.Errorf("relink tracked file %q: %w", absolutePath, err)
}
repo, err := git.PlainOpen(castleRoot)
if err != nil {
return fmt.Errorf("open git repository for castle %q: %w", castle, err)
}
worktree, err := repo.Worktree()
if err != nil {
return fmt.Errorf("open worktree for castle %q: %w", castle, err)
}
trackedRelativePath := filepath.Join("home", relativeDir, filepath.Base(absolutePath))
if relativeDir == "." {
trackedRelativePath = filepath.Join("home", filepath.Base(absolutePath))
}
if _, err := worktree.Add(filepath.ToSlash(filepath.Clean(trackedRelativePath))); err != nil {
return fmt.Errorf("stage tracked file %q: %w", trackedRelativePath, err)
}
if subdirChanged {
if _, err := worktree.Add(".homesick_subdir"); err != nil {
return fmt.Errorf("stage subdir metadata: %w", err)
}
}
return nil
}
func appendUniqueSubdir(path string, subdir string) (bool, error) {
existing, err := readSubdirs(path)
if err != nil {
return false, fmt.Errorf("load subdir metadata %q: %w", path, err)
}
cleanSubdir := filepath.Clean(subdir)
for _, line := range existing {
if filepath.Clean(line) == cleanSubdir {
return false, nil
}
}
file, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o600) // #nosec G304 — internal metadata file
if err != nil {
return false, fmt.Errorf("open subdir metadata %q: %w", path, err)
}
defer file.Close()
if _, err := file.WriteString(cleanSubdir + "\n"); err != nil {
return false, fmt.Errorf("write subdir metadata %q: %w", path, err)
}
return true, nil
}
func (a *App) linkEach(castleHome string, baseDir string, subdirs []string) error {
entries, err := os.ReadDir(baseDir)
if err != nil {
return fmt.Errorf("read castle directory %q: %w", baseDir, 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 fmt.Errorf("check ignored directory %q: %w", source, err)
}
if ignore {
continue
}
relDir, err := filepath.Rel(castleHome, baseDir)
if err != nil {
return fmt.Errorf("resolve castle relative directory %q: %w", baseDir, 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 fmt.Errorf("link %q to %q: %w", source, destination, err)
}
}
return nil
}
func (a *App) unlinkEach(castleHome string, baseDir string, subdirs []string) error {
entries, err := os.ReadDir(baseDir)
if err != nil {
return fmt.Errorf("read castle directory %q: %w", baseDir, 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 fmt.Errorf("check ignored directory %q: %w", source, err)
}
if ignore {
continue
}
relDir, err := filepath.Rel(castleHome, baseDir)
if err != nil {
return fmt.Errorf("resolve castle relative directory %q: %w", baseDir, err)
}
destination := filepath.Join(a.HomeDir, relDir, name)
if relDir == "." {
destination = filepath.Join(a.HomeDir, name)
}
if err := unlinkPath(destination); err != nil {
return fmt.Errorf("unlink %q: %w", destination, 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 fmt.Errorf("resolve link source %q: %w", source, err)
}
if err := os.MkdirAll(filepath.Dir(destination), 0o750); err != nil {
return fmt.Errorf("create destination parent %q: %w", filepath.Dir(destination), 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 fmt.Errorf("remove existing destination %q: %w", destination, rmErr)
}
} else if !errors.Is(err, os.ErrNotExist) {
return fmt.Errorf("stat destination %q: %w", destination, err)
}
if err := os.Symlink(absSource, destination); err != nil {
return fmt.Errorf("create symlink %q -> %q: %w", destination, absSource, err)
}
return nil
}
func readSubdirs(path string) ([]string, error) {
data, err := os.ReadFile(path) // #nosec G304 — internal metadata file
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return []string{}, nil
}
return nil, fmt.Errorf("read subdirs %q: %w", path, 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, fmt.Errorf("resolve candidate path %q: %w", candidate, 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 runGitWithIO(dir string, stdout io.Writer, stderr io.Writer, args ...string) error {
// #nosec G204 -- git is fixed binary; args are internal command parameters for expected git operations.
cmd := exec.Command("git", args...)
cmd.Dir = dir
cmd.Stdout = stdout
cmd.Stderr = stderr
if err := cmd.Run(); err != nil {
return fmt.Errorf("git %s failed: %w", strings.Join(args, " "), err)
}
return nil
}
func (a *App) runGit(dir string, args ...string) error {
if a.Pretend {
a.sayStatus("git", fmt.Sprintf("%s git %s in %s", a.actionVerb(), strings.Join(args, " "), dir))
return nil
}
return runGitWithIO(dir, a.Stdout, a.Stderr, args...)
}
func (a *App) actionVerb() string {
if a.Pretend {
return "Would execute"
}
return "Executing"
}
func (a *App) sayStatus(action string, message string) {
if a.Quiet {
return
}
_, _ = fmt.Fprintf(a.Stdout, "%s: %s\n", action, message)
}
func gitOutput(dir string, args ...string) (string, error) {
// #nosec G204 -- git is fixed binary; args are internal read-only git query parameters.
cmd := exec.Command("git", args...)
cmd.Dir = dir
out, err := cmd.Output()
if err != nil {
return "", err
}
return string(out), nil
}
// Rc runs the rc hooks for the given castle. It looks for executable files
// inside <castle>/.homesick.d and runs them in sorted (lexicographic) order
// with the castle root as the working directory, forwarding stdout and stderr
// to the App writers.
//
// If a .homesickrc file exists in the castle root and no parity.rb wrapper
// already exists in .homesick.d, a Ruby wrapper script named parity.rb is
// written there before execution so that it sorts first.
func (a *App) Rc(castle string, force bool) error {
castleRoot := filepath.Join(a.ReposDir, castle)
if _, err := os.Stat(castleRoot); err != nil {
if errors.Is(err, os.ErrNotExist) {
return fmt.Errorf("castle %q not found", castle)
}
return fmt.Errorf("stat castle %q: %w", castle, err)
}
homesickD := filepath.Join(castleRoot, ".homesick.d")
homesickRc := filepath.Join(castleRoot, ".homesickrc")
if _, err := os.Stat(homesickRc); err == nil && !force {
return errors.New("refusing to run legacy .homesickrc without --force")
}
// If .homesickrc exists, ensure .homesick.d/parity.rb wrapper is created
// (but do not overwrite an existing parity.rb).
if _, err := os.Stat(homesickRc); err == nil {
wrapperPath := filepath.Join(homesickD, "parity.rb")
if _, err := os.Stat(wrapperPath); errors.Is(err, os.ErrNotExist) {
if mkErr := os.MkdirAll(homesickD, 0o750); mkErr != nil {
return fmt.Errorf("create .homesick.d: %w", mkErr)
}
wrapperContent := "#!/usr/bin/env ruby\n" +
"# parity.rb — generated wrapper for legacy .homesickrc\n" +
"# Evaluates .homesickrc in the context of the castle root.\n" +
"rc_file = File.join(__dir__, '..', '.homesickrc')\n" +
"eval(File.read(rc_file), binding, rc_file) if File.exist?(rc_file)\n"
if writeErr := os.WriteFile(wrapperPath, []byte(wrapperContent), 0o600); writeErr != nil {
return fmt.Errorf("write parity.rb: %w", writeErr)
}
// #nosec G302 -- script wrapper must be executable to run properly
if chmodErr := os.Chmod(wrapperPath, 0o700); chmodErr != nil {
return fmt.Errorf("chmod parity.rb: %w", chmodErr)
}
}
}
if _, err := os.Stat(homesickD); err != nil {
if errors.Is(err, os.ErrNotExist) {
return nil
}
return fmt.Errorf("stat rc hooks directory %q: %w", homesickD, err)
}
entries, err := os.ReadDir(homesickD)
if err != nil {
return fmt.Errorf("read rc hooks %q: %w", homesickD, err)
}
// ReadDir returns entries in sorted order already.
for _, entry := range entries {
if entry.IsDir() {
continue
}
info, infoErr := entry.Info()
if infoErr != nil {
return fmt.Errorf("read rc hook metadata %q: %w", entry.Name(), infoErr)
}
if info.Mode()&0o111 == 0 {
// Not executable — skip.
continue
}
scriptPath := filepath.Join(homesickD, entry.Name())
cmd := exec.Command(scriptPath) // #nosec G204 — path validated from app-controlled .homesick.d directory
cmd.Dir = castleRoot
cmd.Stdout = a.Stdout
cmd.Stderr = a.Stderr
if runErr := cmd.Run(); runErr != nil {
return fmt.Errorf("rc script %q failed: %w", entry.Name(), runErr)
}
}
return 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/")
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,90 @@
package core
import (
"bytes"
"path/filepath"
"testing"
)
func TestNewAppRejectsNilReaders(t *testing.T) {
t.Run("nil stdin", func(t *testing.T) {
app, err := NewApp(nil, &bytes.Buffer{}, &bytes.Buffer{})
if err == nil {
t.Fatal("expected error for nil stdin")
}
if app != nil {
t.Fatal("expected nil app for nil stdin")
}
})
t.Run("nil stdout", func(t *testing.T) {
app, err := NewApp(new(bytes.Buffer), nil, &bytes.Buffer{})
if err == nil {
t.Fatal("expected error for nil stdout")
}
if app != nil {
t.Fatal("expected nil app for nil stdout")
}
})
t.Run("nil stderr", func(t *testing.T) {
app, err := NewApp(new(bytes.Buffer), &bytes.Buffer{}, nil)
if err == nil {
t.Fatal("expected error for nil stderr")
}
if app != nil {
t.Fatal("expected nil app for nil stderr")
}
})
}
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)
}
})
}
}
func TestNewAppInitializesApp(t *testing.T) {
stdin := new(bytes.Buffer)
stdout := &bytes.Buffer{}
stderr := &bytes.Buffer{}
app, err := NewApp(stdin, stdout, stderr)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if app == nil {
t.Fatal("expected app instance")
}
if app.Stdin != stdin {
t.Fatal("expected stdin reader to be assigned")
}
if app.Stdout != stdout {
t.Fatal("expected stdout writer to be assigned")
}
if app.Stderr != stderr {
t.Fatal("expected stderr writer to be assigned")
}
if app.HomeDir == "" {
t.Fatal("expected home directory to be set")
}
if app.ReposDir != filepath.Join(app.HomeDir, ".homesick", "repos") {
t.Fatalf("unexpected repos dir: %q", app.ReposDir)
}
}

View File

@@ -0,0 +1,105 @@
package core_test
import (
"io"
"os"
"path/filepath"
"strings"
"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 DestroySuite struct {
suite.Suite
tmpDir string
homeDir string
reposDir string
app *core.App
}
func TestDestroySuite(t *testing.T) {
suite.Run(t, new(DestroySuite))
}
func (s *DestroySuite) 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,
Stdin: strings.NewReader("y\n"),
Stdout: io.Discard,
Stderr: io.Discard,
}
}
func (s *DestroySuite) 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 *DestroySuite) TestDestroy_RemovesCastleDirectory() {
castleRoot := s.createCastleRepo("dotfiles")
require.DirExists(s.T(), castleRoot)
s.app.Stdin = strings.NewReader("y\n")
require.NoError(s.T(), s.app.Destroy("dotfiles"))
require.NoDirExists(s.T(), castleRoot)
}
func (s *DestroySuite) TestDestroy_MissingCastleReturnsError() {
err := s.app.Destroy("missing")
require.Error(s.T(), err)
}
func (s *DestroySuite) TestDestroy_UnlinksDotfilesBeforeRemoval() {
castleRoot := s.createCastleRepo("dotfiles")
tracked := filepath.Join(castleRoot, "home", ".vimrc")
require.NoError(s.T(), os.WriteFile(tracked, []byte("set number\n"), 0o644))
require.NoError(s.T(), s.app.LinkCastle("dotfiles"))
homePath := filepath.Join(s.homeDir, ".vimrc")
info, err := os.Lstat(homePath)
require.NoError(s.T(), err)
require.NotZero(s.T(), info.Mode()&os.ModeSymlink)
s.app.Stdin = strings.NewReader("y\n")
require.NoError(s.T(), s.app.Destroy("dotfiles"))
_, err = os.Lstat(homePath)
require.Error(s.T(), err)
require.True(s.T(), os.IsNotExist(err))
require.NoDirExists(s.T(), castleRoot)
}
func (s *DestroySuite) TestDestroy_RemovesSymlinkedCastleOnly() {
target := filepath.Join(s.tmpDir, "local-castle")
require.NoError(s.T(), os.MkdirAll(target, 0o755))
symlinkCastle := filepath.Join(s.reposDir, "dotfiles")
require.NoError(s.T(), os.Symlink(target, symlinkCastle))
s.app.Stdin = strings.NewReader("y\n")
require.NoError(s.T(), s.app.Destroy("dotfiles"))
require.NoFileExists(s.T(), symlinkCastle)
require.DirExists(s.T(), target)
}
func (s *DestroySuite) TestDestroy_DeclineConfirmationKeepsCastle() {
castleRoot := s.createCastleRepo("dotfiles")
require.DirExists(s.T(), castleRoot)
s.app.Stdin = strings.NewReader("n\n")
require.NoError(s.T(), s.app.Destroy("dotfiles"))
require.DirExists(s.T(), castleRoot)
}

View File

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

View File

@@ -0,0 +1,109 @@
package core_test
import (
"bytes"
"os"
"path/filepath"
"testing"
"git.hrafn.xyz/aether/gosick/internal/homesick/core"
"github.com/stretchr/testify/require"
"github.com/stretchr/testify/suite"
)
type ExecSuite struct {
suite.Suite
tmpDir string
homeDir string
reposDir string
stdout *bytes.Buffer
stderr *bytes.Buffer
app *core.App
}
func TestExecSuite(t *testing.T) {
suite.Run(t, new(ExecSuite))
}
func (s *ExecSuite) SetupTest() {
s.tmpDir = s.T().TempDir()
s.homeDir = filepath.Join(s.tmpDir, "home")
s.reposDir = filepath.Join(s.homeDir, ".homesick", "repos")
require.NoError(s.T(), os.MkdirAll(s.reposDir, 0o755))
s.stdout = &bytes.Buffer{}
s.stderr = &bytes.Buffer{}
s.app = &core.App{
HomeDir: s.homeDir,
ReposDir: s.reposDir,
Stdout: s.stdout,
Stderr: s.stderr,
}
}
func (s *ExecSuite) createCastle(name string) string {
castleRoot := filepath.Join(s.reposDir, name)
require.NoError(s.T(), os.MkdirAll(castleRoot, 0o755))
return castleRoot
}
func (s *ExecSuite) TestExec_UnknownCastleReturnsError() {
err := s.app.Exec("nonexistent", []string{"pwd"})
require.Error(s.T(), err)
require.Contains(s.T(), err.Error(), "not found")
}
func (s *ExecSuite) TestExec_RunsCommandInCastleRoot() {
castleRoot := s.createCastle("dotfiles")
require.NoError(s.T(), s.app.Exec("dotfiles", []string{"pwd"}))
require.Contains(s.T(), s.stdout.String(), castleRoot)
}
func (s *ExecSuite) TestExec_ForwardsStdoutAndStderr() {
s.createCastle("dotfiles")
require.NoError(s.T(), s.app.Exec("dotfiles", []string{"echo out && echo err >&2"}))
require.Contains(s.T(), s.stdout.String(), "out")
require.Contains(s.T(), s.stderr.String(), "err")
}
func (s *ExecSuite) TestExecAll_RunsCommandForEachCastle() {
zeta := s.createCastle("zeta")
alpha := s.createCastle("alpha")
require.NoError(s.T(), os.MkdirAll(filepath.Join(zeta, ".git"), 0o755))
require.NoError(s.T(), os.MkdirAll(filepath.Join(alpha, ".git"), 0o755))
require.NoError(s.T(), s.app.ExecAll([]string{"basename \"$PWD\""}))
require.Contains(s.T(), s.stdout.String(), "alpha")
require.Contains(s.T(), s.stdout.String(), "zeta")
}
func (s *ExecSuite) TestExec_PretendDoesNotExecuteCommand() {
castleRoot := s.createCastle("dotfiles")
target := filepath.Join(castleRoot, "should-not-exist")
s.app.Pretend = true
require.NoError(s.T(), s.app.Exec("dotfiles", []string{"touch should-not-exist"}))
require.NoFileExists(s.T(), target)
require.Contains(s.T(), s.stdout.String(), "Would execute")
}
func (s *ExecSuite) TestExecAll_RequiresCommand() {
err := s.app.ExecAll(nil)
require.Error(s.T(), err)
require.Contains(s.T(), err.Error(), "exec_all requires COMMAND")
}
func (s *ExecSuite) TestExecAll_NoReposDirIsNoop() {
missingRepos := filepath.Join(s.T().TempDir(), "missing", "repos")
app := &core.App{
HomeDir: s.homeDir,
ReposDir: missingRepos,
Stdout: s.stdout,
Stderr: s.stderr,
}
err := app.ExecAll([]string{"echo hi"})
require.NoError(s.T(), err)
}

View File

@@ -0,0 +1,78 @@
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 GenerateSuite struct {
suite.Suite
tmpDir string
app *core.App
}
func TestGenerateSuite(t *testing.T) {
suite.Run(t, new(GenerateSuite))
}
func (s *GenerateSuite) SetupTest() {
s.tmpDir = s.T().TempDir()
s.app = &core.App{
HomeDir: filepath.Join(s.tmpDir, "home"),
ReposDir: filepath.Join(s.tmpDir, "home", ".homesick", "repos"),
Stdout: io.Discard,
Stderr: io.Discard,
}
}
func (s *GenerateSuite) TestGenerate_CreatesGitRepoAndHomeDir() {
castlePath := filepath.Join(s.tmpDir, "my-castle")
require.NoError(s.T(), s.app.Generate(castlePath))
require.DirExists(s.T(), castlePath)
require.DirExists(s.T(), filepath.Join(castlePath, ".git"))
require.DirExists(s.T(), filepath.Join(castlePath, "home"))
}
func (s *GenerateSuite) TestGenerate_AddsOriginWhenGitHubUserConfigured() {
castlePath := filepath.Join(s.tmpDir, "my-castle")
gitConfig := filepath.Join(s.tmpDir, "gitconfig")
require.NoError(s.T(), os.WriteFile(gitConfig, []byte("[github]\n\tuser = octocat\n"), 0o644))
s.T().Setenv("GIT_CONFIG_GLOBAL", gitConfig)
s.T().Setenv("GIT_CONFIG_NOSYSTEM", "1")
require.NoError(s.T(), s.app.Generate(castlePath))
configPath := filepath.Join(castlePath, ".git", "config")
content, err := os.ReadFile(configPath)
require.NoError(s.T(), err)
require.Contains(s.T(), string(content), "git@github.com:octocat/my-castle.git")
}
func (s *GenerateSuite) TestGenerate_DoesNotAddOriginWhenGitHubUserMissing() {
castlePath := filepath.Join(s.tmpDir, "my-castle")
s.T().Setenv("GIT_CONFIG_GLOBAL", filepath.Join(s.tmpDir, "nonexistent-config"))
s.T().Setenv("GIT_CONFIG_NOSYSTEM", "1")
require.NoError(s.T(), s.app.Generate(castlePath))
configPath := filepath.Join(castlePath, ".git", "config")
content, err := os.ReadFile(configPath)
require.NoError(s.T(), err)
require.NotContains(s.T(), string(content), "[remote \"origin\"]")
}
func (s *GenerateSuite) TestGenerate_WrapsCastlePathCreationError() {
blocker := filepath.Join(s.tmpDir, "blocker")
require.NoError(s.T(), os.WriteFile(blocker, []byte("x"), 0o644))
err := s.app.Generate(filepath.Join(blocker, "castle"))
require.Error(s.T(), err)
require.Contains(s.T(), err.Error(), "create castle path")
}

View File

@@ -0,0 +1,279 @@
package core
import (
"bytes"
"errors"
"os"
"path/filepath"
"strings"
"testing"
"github.com/stretchr/testify/require"
)
type errReader struct{}
func (errReader) Read(_ []byte) (int, error) {
return 0, errors.New("boom")
}
type errWriter struct{}
func (errWriter) Write(_ []byte) (int, error) {
return 0, errors.New("boom")
}
func TestRunGitPretendWritesStatus(t *testing.T) {
stdout := &bytes.Buffer{}
app := &App{Stdout: stdout, Stderr: bytes.NewBuffer(nil), Pretend: true}
err := app.runGit("/tmp", "status")
require.NoError(t, err)
require.Contains(t, stdout.String(), "Would execute git status in /tmp")
}
func TestActionVerb(t *testing.T) {
app := &App{Pretend: true}
require.Equal(t, "Would execute", app.actionVerb())
app.Pretend = false
require.Equal(t, "Executing", app.actionVerb())
}
func TestSayStatusHonorsQuiet(t *testing.T) {
stdout := &bytes.Buffer{}
app := &App{Stdout: stdout, Quiet: true}
app.sayStatus("git", "status")
require.Empty(t, stdout.String())
app.Quiet = false
app.sayStatus("git", "status")
require.Contains(t, stdout.String(), "git: status")
}
func TestUnlinkPath(t *testing.T) {
t.Run("missing destination", func(t *testing.T) {
err := unlinkPath(filepath.Join(t.TempDir(), "does-not-exist"))
require.NoError(t, err)
})
t.Run("regular file is preserved", func(t *testing.T) {
dir := t.TempDir()
target := filepath.Join(dir, "regular")
require.NoError(t, os.WriteFile(target, []byte("x"), 0o644))
err := unlinkPath(target)
require.NoError(t, err)
require.FileExists(t, target)
})
t.Run("symlink is removed", func(t *testing.T) {
dir := t.TempDir()
source := filepath.Join(dir, "source")
destination := filepath.Join(dir, "dest")
require.NoError(t, os.WriteFile(source, []byte("x"), 0o644))
require.NoError(t, os.Symlink(source, destination))
err := unlinkPath(destination)
require.NoError(t, err)
_, statErr := os.Lstat(destination)
require.ErrorIs(t, statErr, os.ErrNotExist)
})
}
func TestLinkPath(t *testing.T) {
t.Run("existing symlink to same source is no-op", func(t *testing.T) {
dir := t.TempDir()
source := filepath.Join(dir, "source")
destination := filepath.Join(dir, "dest")
require.NoError(t, os.WriteFile(source, []byte("x"), 0o644))
absSource, err := filepath.Abs(source)
require.NoError(t, err)
require.NoError(t, os.Symlink(absSource, destination))
app := &App{Stdout: bytes.NewBuffer(nil), Stderr: bytes.NewBuffer(nil)}
err = app.linkPath(source, destination)
require.NoError(t, err)
})
t.Run("conflict without force errors", func(t *testing.T) {
dir := t.TempDir()
source := filepath.Join(dir, "source")
destination := filepath.Join(dir, "dest")
require.NoError(t, os.WriteFile(source, []byte("x"), 0o644))
require.NoError(t, os.WriteFile(destination, []byte("existing"), 0o644))
app := &App{Stdout: bytes.NewBuffer(nil), Stderr: bytes.NewBuffer(nil)}
err := app.linkPath(source, destination)
require.Error(t, err)
require.Contains(t, err.Error(), "exists")
})
t.Run("force replaces existing destination", func(t *testing.T) {
dir := t.TempDir()
source := filepath.Join(dir, "source")
destination := filepath.Join(dir, "dest")
require.NoError(t, os.WriteFile(source, []byte("x"), 0o644))
require.NoError(t, os.WriteFile(destination, []byte("existing"), 0o644))
app := &App{Stdout: bytes.NewBuffer(nil), Stderr: bytes.NewBuffer(nil), Force: true}
err := app.linkPath(source, destination)
require.NoError(t, err)
info, statErr := os.Lstat(destination)
require.NoError(t, statErr)
require.True(t, info.Mode()&os.ModeSymlink != 0)
})
t.Run("create destination parent error includes context", func(t *testing.T) {
dir := t.TempDir()
source := filepath.Join(dir, "source")
blocker := filepath.Join(dir, "blocker")
require.NoError(t, os.WriteFile(source, []byte("x"), 0o644))
require.NoError(t, os.WriteFile(blocker, []byte("x"), 0o644))
app := &App{Stdout: bytes.NewBuffer(nil), Stderr: bytes.NewBuffer(nil)}
err := app.linkPath(source, filepath.Join(blocker, "dest"))
require.Error(t, err)
require.Contains(t, err.Error(), "create destination parent")
})
}
func TestReadSubdirsAndMatchesIgnoredDir(t *testing.T) {
dir := t.TempDir()
meta := filepath.Join(dir, ".homesick_subdir")
require.NoError(t, os.WriteFile(meta, []byte(" .config/myapp \n\n"), 0o644))
subdirs, err := readSubdirs(meta)
require.NoError(t, err)
require.Equal(t, []string{filepath.Clean(".config/myapp")}, subdirs)
castleHome := filepath.Join(dir, "castle", "home")
candidate := filepath.Join(castleHome, ".config")
ignored, err := matchesIgnoredDir(castleHome, candidate, subdirs)
require.NoError(t, err)
require.True(t, ignored)
notIgnored, err := matchesIgnoredDir(castleHome, filepath.Join(castleHome, ".vim"), subdirs)
require.NoError(t, err)
require.False(t, notIgnored)
}
func TestReadSubdirsReadErrorIncludesContext(t *testing.T) {
_, err := readSubdirs(t.TempDir())
require.Error(t, err)
require.Contains(t, err.Error(), "read subdirs")
}
func TestPullAndPushDefaultCastlePretend(t *testing.T) {
dir := t.TempDir()
stdout := &bytes.Buffer{}
app := &App{
HomeDir: dir,
ReposDir: filepath.Join(dir, ".homesick", "repos"),
Stdout: stdout,
Stderr: bytes.NewBuffer(nil),
Pretend: true,
}
require.NoError(t, app.Pull(""))
require.NoError(t, app.Push(""))
out := stdout.String()
require.Contains(t, out, "git pull")
require.Contains(t, out, "git push")
require.Contains(t, out, filepath.Join(app.ReposDir, "dotfiles"))
}
func TestGenerateRequiresPath(t *testing.T) {
app := &App{Stdout: bytes.NewBuffer(nil), Stderr: bytes.NewBuffer(nil)}
err := app.Generate(" ")
require.Error(t, err)
require.Contains(t, err.Error(), "generate requires PATH")
}
func TestLinkAndUnlinkDefaultCastle(t *testing.T) {
dir := t.TempDir()
homeDir := filepath.Join(dir, "home")
reposDir := filepath.Join(homeDir, ".homesick", "repos")
castleHome := filepath.Join(reposDir, "dotfiles", "home")
require.NoError(t, os.MkdirAll(castleHome, 0o755))
source := filepath.Join(castleHome, ".vimrc")
require.NoError(t, os.WriteFile(source, []byte("set number\n"), 0o644))
app := &App{HomeDir: homeDir, ReposDir: reposDir, Stdout: bytes.NewBuffer(nil), Stderr: bytes.NewBuffer(nil)}
require.NoError(t, app.Link(""))
destination := filepath.Join(homeDir, ".vimrc")
info, err := os.Lstat(destination)
require.NoError(t, err)
require.True(t, info.Mode()&os.ModeSymlink != 0)
require.NoError(t, app.Unlink(""))
_, err = os.Lstat(destination)
require.ErrorIs(t, err, os.ErrNotExist)
}
func TestLinkAndUnlinkCastleMissingError(t *testing.T) {
dir := t.TempDir()
app := &App{
HomeDir: filepath.Join(dir, "home"),
ReposDir: filepath.Join(dir, "home", ".homesick", "repos"),
Stdout: bytes.NewBuffer(nil),
Stderr: bytes.NewBuffer(nil),
}
err := app.LinkCastle("missing")
require.Error(t, err)
require.Contains(t, err.Error(), "could not symlink")
err = app.UnlinkCastle("missing")
require.Error(t, err)
require.Contains(t, err.Error(), "could not symlink")
}
func TestConfirmDestroyResponses(t *testing.T) {
stdout := &bytes.Buffer{}
app := &App{Stdout: stdout, Stdin: strings.NewReader("yes\n")}
ok, err := app.confirmDestroy("dotfiles")
require.NoError(t, err)
require.True(t, ok)
require.Contains(t, stdout.String(), "Destroy castle \"dotfiles\"?")
stdout.Reset()
app.Stdin = strings.NewReader("n\n")
ok, err = app.confirmDestroy("dotfiles")
require.NoError(t, err)
require.False(t, ok)
}
func TestConfirmDestroyReadError(t *testing.T) {
app := &App{Stdout: bytes.NewBuffer(nil), Stdin: errReader{}}
ok, err := app.confirmDestroy("dotfiles")
require.Error(t, err)
require.False(t, ok)
require.Contains(t, err.Error(), "read destroy confirmation")
}
func TestConfirmDestroyWriteError(t *testing.T) {
app := &App{Stdout: errWriter{}, Stdin: strings.NewReader("yes\n")}
ok, err := app.confirmDestroy("dotfiles")
require.Error(t, err)
require.False(t, ok)
require.Contains(t, err.Error(), "write destroy prompt")
}
func TestExecAllWrapsCastleError(t *testing.T) {
dir := t.TempDir()
homeDir := filepath.Join(dir, "home")
reposDir := filepath.Join(homeDir, ".homesick", "repos")
require.NoError(t, os.MkdirAll(filepath.Join(reposDir, "broken", ".git"), 0o755))
app := &App{HomeDir: homeDir, ReposDir: reposDir, Stdout: bytes.NewBuffer(nil), Stderr: bytes.NewBuffer(nil)}
err := app.ExecAll([]string{"exit 3"})
require.Error(t, err)
require.Contains(t, err.Error(), "exec_all failed for \"broken\"")
}

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

View File

@@ -0,0 +1,86 @@
package core_test
import (
"bytes"
"io"
"os"
"path/filepath"
"testing"
"git.hrafn.xyz/aether/gosick/internal/homesick/core"
git "github.com/go-git/go-git/v5"
"github.com/stretchr/testify/require"
"github.com/stretchr/testify/suite"
)
type OpenSuite struct {
suite.Suite
tmpDir string
homeDir string
reposDir string
stdout *bytes.Buffer
stderr *bytes.Buffer
app *core.App
}
func TestOpenSuite(t *testing.T) {
suite.Run(t, new(OpenSuite))
}
func (s *OpenSuite) SetupTest() {
s.tmpDir = s.T().TempDir()
s.homeDir = filepath.Join(s.tmpDir, "home")
s.reposDir = filepath.Join(s.homeDir, ".homesick", "repos")
require.NoError(s.T(), os.MkdirAll(s.reposDir, 0o755))
s.stdout = &bytes.Buffer{}
s.stderr = &bytes.Buffer{}
s.app = &core.App{
HomeDir: s.homeDir,
ReposDir: s.reposDir,
Stdout: s.stdout,
Stderr: s.stderr,
}
}
func (s *OpenSuite) 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 *OpenSuite) TestOpen_RequiresEditorEnv() {
s.createCastleRepo("dotfiles")
s.T().Setenv("EDITOR", "")
err := s.app.Open("dotfiles")
require.Error(s.T(), err)
require.Contains(s.T(), err.Error(), "$EDITOR")
}
func (s *OpenSuite) TestOpen_MissingCastleReturnsError() {
s.T().Setenv("EDITOR", "vim")
err := s.app.Open("missing")
require.Error(s.T(), err)
require.Contains(s.T(), err.Error(), "could not open")
}
func (s *OpenSuite) TestOpen_RunsEditorInCastleRoot() {
castleRoot := s.createCastleRepo("dotfiles")
capture := filepath.Join(s.tmpDir, "open_capture.txt")
editorScript := filepath.Join(s.tmpDir, "editor.sh")
require.NoError(s.T(), os.WriteFile(editorScript, []byte("#!/bin/sh\npwd > \""+capture+"\"\necho \"$1\" >> \""+capture+"\"\n"), 0o755))
s.T().Setenv("EDITOR", editorScript)
require.NoError(s.T(), s.app.Open("dotfiles"))
content, err := os.ReadFile(capture)
require.NoError(s.T(), err)
require.Equal(s.T(), castleRoot+"\n.\n", string(content))
}
var _ io.Writer

View File

@@ -0,0 +1,156 @@
package core_test
import (
"bytes"
"io"
"os"
"path/filepath"
"testing"
"time"
"git.hrafn.xyz/aether/gosick/internal/homesick/core"
git "github.com/go-git/go-git/v5"
"github.com/go-git/go-git/v5/config"
"github.com/go-git/go-git/v5/plumbing/object"
"github.com/stretchr/testify/require"
"github.com/stretchr/testify/suite"
)
type PullSuite struct {
suite.Suite
tmpDir string
homeDir string
reposDir string
app *core.App
}
func TestPullSuite(t *testing.T) {
suite.Run(t, new(PullSuite))
}
func (s *PullSuite) 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 *PullSuite) createRemoteWithClone(castle string) (string, string) {
remotePath := filepath.Join(s.tmpDir, castle+".git")
_, err := git.PlainInit(remotePath, true)
require.NoError(s.T(), err)
seedPath := filepath.Join(s.tmpDir, castle+"-seed")
seedRepo, err := git.PlainInit(seedPath, false)
require.NoError(s.T(), err)
seedFile := filepath.Join(seedPath, "home", ".vimrc")
require.NoError(s.T(), os.MkdirAll(filepath.Dir(seedFile), 0o755))
require.NoError(s.T(), os.WriteFile(seedFile, []byte("set number\n"), 0o644))
wt, err := seedRepo.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: "Pull Test",
Email: "pull@test.local",
When: time.Now(),
}})
require.NoError(s.T(), err)
_, err = seedRepo.CreateRemote(&config.RemoteConfig{Name: "origin", URLs: []string{remotePath}})
require.NoError(s.T(), err)
require.NoError(s.T(), seedRepo.Push(&git.PushOptions{RemoteName: "origin"}))
clonePath := filepath.Join(s.reposDir, castle)
_, err = git.PlainClone(clonePath, false, &git.CloneOptions{URL: remotePath})
require.NoError(s.T(), err)
return remotePath, clonePath
}
func (s *PullSuite) addRemoteCommit(remotePath string, castle string) {
workPath := filepath.Join(s.tmpDir, castle+"-work")
repo, err := git.PlainClone(workPath, false, &git.CloneOptions{URL: remotePath})
require.NoError(s.T(), err)
filePath := filepath.Join(workPath, "home", ".zshrc")
require.NoError(s.T(), os.MkdirAll(filepath.Dir(filePath), 0o755))
require.NoError(s.T(), os.WriteFile(filePath, []byte("export EDITOR=vim\n"), 0o644))
wt, err := repo.Worktree()
require.NoError(s.T(), err)
_, err = wt.Add("home/.zshrc")
require.NoError(s.T(), err)
_, err = wt.Commit("add zshrc", &git.CommitOptions{Author: &object.Signature{
Name: "Pull Test",
Email: "pull@test.local",
When: time.Now(),
}})
require.NoError(s.T(), err)
require.NoError(s.T(), repo.Push(&git.PushOptions{RemoteName: "origin"}))
}
func (s *PullSuite) TestPull_UpdatesCastleFromOrigin() {
remotePath, clonePath := s.createRemoteWithClone("dotfiles")
s.addRemoteCommit(remotePath, "dotfiles")
require.NoError(s.T(), s.app.Pull("dotfiles"))
require.FileExists(s.T(), filepath.Join(clonePath, "home", ".zshrc"))
}
func (s *PullSuite) TestPull_MissingCastleReturnsError() {
err := s.app.Pull("missing")
require.Error(s.T(), err)
}
func (s *PullSuite) TestPullAll_UpdatesAllCastlesFromOrigin() {
remoteA, cloneA := s.createRemoteWithClone("alpha")
remoteB, cloneB := s.createRemoteWithClone("zeta")
s.addRemoteCommit(remoteA, "alpha")
s.addRemoteCommit(remoteB, "zeta")
require.NoError(s.T(), s.app.PullAll())
require.FileExists(s.T(), filepath.Join(cloneA, "home", ".zshrc"))
require.FileExists(s.T(), filepath.Join(cloneB, "home", ".zshrc"))
}
func (s *PullSuite) TestPullAll_NoCastlesIsNoop() {
require.NoError(s.T(), s.app.PullAll())
}
func (s *PullSuite) TestPullAll_PrintsCastlePrefixes() {
_, _ = s.createRemoteWithClone("alpha")
_, _ = s.createRemoteWithClone("zeta")
stdout := &bytes.Buffer{}
s.app.Stdout = stdout
require.NoError(s.T(), s.app.PullAll())
require.Contains(s.T(), stdout.String(), "alpha:")
require.Contains(s.T(), stdout.String(), "zeta:")
}
func (s *PullSuite) TestPullAll_QuietSuppressesCastlePrefixes() {
require.NoError(s.T(), os.MkdirAll(filepath.Join(s.reposDir, "alpha", ".git"), 0o755))
require.NoError(s.T(), os.MkdirAll(filepath.Join(s.reposDir, "zeta", ".git"), 0o755))
stdout := &bytes.Buffer{}
s.app.Stdout = stdout
s.app.Quiet = true
s.app.Pretend = true
require.NoError(s.T(), s.app.PullAll())
require.NotContains(s.T(), stdout.String(), "alpha:")
require.NotContains(s.T(), stdout.String(), "zeta:")
}

View File

@@ -0,0 +1,116 @@
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 PushSuite struct {
suite.Suite
tmpDir string
homeDir string
reposDir string
app *core.App
}
func TestPushSuite(t *testing.T) {
suite.Run(t, new(PushSuite))
}
func (s *PushSuite) 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 *PushSuite) createRemoteAndClone(castle string) (string, string) {
remotePath := filepath.Join(s.tmpDir, castle+".git")
_, err := git.PlainInit(remotePath, true)
require.NoError(s.T(), err)
seedPath := filepath.Join(s.tmpDir, castle+"-seed")
seedRepo, err := git.PlainInit(seedPath, false)
require.NoError(s.T(), err)
seedFile := filepath.Join(seedPath, "home", ".vimrc")
require.NoError(s.T(), os.MkdirAll(filepath.Dir(seedFile), 0o755))
require.NoError(s.T(), os.WriteFile(seedFile, []byte("set number\n"), 0o644))
wt, err := seedRepo.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: "Push Test",
Email: "push@test.local",
When: time.Now(),
}})
require.NoError(s.T(), err)
_, err = seedRepo.CreateRemote(&config.RemoteConfig{Name: "origin", URLs: []string{remotePath}})
require.NoError(s.T(), err)
require.NoError(s.T(), seedRepo.Push(&git.PushOptions{RemoteName: "origin"}))
clonePath := filepath.Join(s.reposDir, castle)
_, err = git.PlainClone(clonePath, false, &git.CloneOptions{URL: remotePath})
require.NoError(s.T(), err)
return remotePath, clonePath
}
func (s *PushSuite) createLocalCommit(clonePath string) {
repo, err := git.PlainOpen(clonePath)
require.NoError(s.T(), err)
localFile := filepath.Join(clonePath, "home", ".zshrc")
require.NoError(s.T(), os.MkdirAll(filepath.Dir(localFile), 0o755))
require.NoError(s.T(), os.WriteFile(localFile, []byte("export EDITOR=vim\n"), 0o644))
wt, err := repo.Worktree()
require.NoError(s.T(), err)
_, err = wt.Add("home/.zshrc")
require.NoError(s.T(), err)
_, err = wt.Commit("add zshrc", &git.CommitOptions{Author: &object.Signature{
Name: "Push Test",
Email: "push@test.local",
When: time.Now(),
}})
require.NoError(s.T(), err)
}
func (s *PushSuite) TestPush_UpdatesRemoteFromLocalChanges() {
remotePath, clonePath := s.createRemoteAndClone("dotfiles")
s.createLocalCommit(clonePath)
require.NoError(s.T(), s.app.Push("dotfiles"))
verifyPath := filepath.Join(s.tmpDir, "dotfiles-verify")
_, err := git.PlainClone(verifyPath, false, &git.CloneOptions{URL: remotePath})
require.NoError(s.T(), err)
require.FileExists(s.T(), filepath.Join(verifyPath, "home", ".zshrc"))
}
func (s *PushSuite) TestPush_MissingCastleReturnsError() {
err := s.app.Push("missing")
require.Error(s.T(), err)
}

View File

@@ -0,0 +1,242 @@
package core_test
import (
"bytes"
"io"
"os"
"path/filepath"
"testing"
"git.hrafn.xyz/aether/gosick/internal/homesick/core"
"github.com/stretchr/testify/require"
"github.com/stretchr/testify/suite"
)
type RcSuite struct {
suite.Suite
tmpDir string
homeDir string
reposDir string
stdout *bytes.Buffer
stderr *bytes.Buffer
app *core.App
}
func TestRcSuite(t *testing.T) {
suite.Run(t, new(RcSuite))
}
func (s *RcSuite) SetupTest() {
s.tmpDir = s.T().TempDir()
s.homeDir = filepath.Join(s.tmpDir, "home")
s.reposDir = filepath.Join(s.homeDir, ".homesick", "repos")
require.NoError(s.T(), os.MkdirAll(s.reposDir, 0o755))
s.stdout = &bytes.Buffer{}
s.stderr = &bytes.Buffer{}
s.app = &core.App{
HomeDir: s.homeDir,
ReposDir: s.reposDir,
Stdout: s.stdout,
Stderr: s.stderr,
}
}
func (s *RcSuite) createCastle(name string) string {
castleRoot := filepath.Join(s.reposDir, name)
require.NoError(s.T(), os.MkdirAll(castleRoot, 0o755))
return castleRoot
}
var _ io.Writer
// TestRc_UnknownCastleReturnsError ensures Rc returns an error when the
// castle directory does not exist.
func (s *RcSuite) TestRc_UnknownCastleReturnsError() {
err := s.app.Rc("nonexistent", false)
require.Error(s.T(), err)
}
// TestRc_NoScriptsAndNoHomesickrc is a no-op when neither .homesick.d nor
// .homesickrc are present.
func (s *RcSuite) TestRc_NoScriptsAndNoHomesickrcIsNoop() {
s.createCastle("dotfiles")
require.NoError(s.T(), s.app.Rc("dotfiles", false))
}
// TestRc_HomesickrcRequiresForce ensures legacy .homesickrc does not run
// unless force mode is enabled.
func (s *RcSuite) TestRc_HomesickrcRequiresForce() {
castleRoot := s.createCastle("dotfiles")
homesickRc := filepath.Join(castleRoot, ".homesickrc")
require.NoError(s.T(), os.WriteFile(homesickRc, []byte("# ruby setup code\n"), 0o644))
err := s.app.Rc("dotfiles", false)
require.Error(s.T(), err)
require.Contains(s.T(), err.Error(), "--force")
require.NoFileExists(s.T(), filepath.Join(castleRoot, ".homesick.d", "parity.rb"))
}
// TestRc_HomesickrcRunsWithForce ensures legacy .homesickrc handling proceeds
// when force mode is enabled.
func (s *RcSuite) TestRc_HomesickrcRunsWithForce() {
castleRoot := s.createCastle("dotfiles")
homesickRc := filepath.Join(castleRoot, ".homesickrc")
require.NoError(s.T(), os.WriteFile(homesickRc, []byte("# ruby setup code\n"), 0o644))
require.NoError(s.T(), s.app.Rc("dotfiles", true))
require.FileExists(s.T(), filepath.Join(castleRoot, ".homesick.d", "parity.rb"))
}
// TestRc_ExecutesScriptsInSortedOrder verifies that executable scripts inside
// .homesick.d are run in lexicographic (sorted) order.
func (s *RcSuite) TestRc_ExecutesScriptsInSortedOrder() {
castleRoot := s.createCastle("dotfiles")
homesickD := filepath.Join(castleRoot, ".homesick.d")
require.NoError(s.T(), os.MkdirAll(homesickD, 0o755))
orderFile := filepath.Join(s.tmpDir, "order.txt")
scriptA := filepath.Join(homesickD, "10_a.sh")
scriptB := filepath.Join(homesickD, "20_b.sh")
require.NoError(s.T(), os.WriteFile(scriptA, []byte("#!/bin/sh\necho a >> "+orderFile+"\n"), 0o755))
require.NoError(s.T(), os.WriteFile(scriptB, []byte("#!/bin/sh\necho b >> "+orderFile+"\n"), 0o755))
require.NoError(s.T(), s.app.Rc("dotfiles", false))
content, err := os.ReadFile(orderFile)
require.NoError(s.T(), err)
require.Equal(s.T(), "a\nb\n", string(content))
}
// TestRc_SkipsNonExecutableFiles ensures that files without the executable bit
// are not run.
func (s *RcSuite) TestRc_SkipsNonExecutableFiles() {
castleRoot := s.createCastle("dotfiles")
homesickD := filepath.Join(castleRoot, ".homesick.d")
require.NoError(s.T(), os.MkdirAll(homesickD, 0o755))
notExec := filepath.Join(homesickD, "10_script.sh")
// Write a script that would exit 1 if actually run — verify it is skipped.
require.NoError(s.T(), os.WriteFile(notExec, []byte("#!/bin/sh\nexit 1\n"), 0o644))
require.NoError(s.T(), s.app.Rc("dotfiles", false))
}
// TestRc_HomesickrcCreatesRubyWrapper verifies that a .homesickrc file causes
// a Ruby wrapper called parity.rb to be written into .homesick.d before execution.
func (s *RcSuite) TestRc_HomesickrcCreatesRubyWrapper() {
castleRoot := s.createCastle("dotfiles")
homesickRc := filepath.Join(castleRoot, ".homesickrc")
require.NoError(s.T(), os.WriteFile(homesickRc, []byte("# ruby setup code\n"), 0o644))
require.NoError(s.T(), s.app.Rc("dotfiles", true))
wrapperPath := filepath.Join(castleRoot, ".homesick.d", "parity.rb")
require.FileExists(s.T(), wrapperPath)
info, err := os.Stat(wrapperPath)
require.NoError(s.T(), err)
require.NotZero(s.T(), info.Mode()&0o111, "wrapper must be executable")
content, err := os.ReadFile(wrapperPath)
require.NoError(s.T(), err)
require.Contains(s.T(), string(content), ".homesickrc")
}
// TestRc_HomesickrcWrapperNotOverwrittenIfExists verifies that an existing
// parity.rb is not overwritten when Rc is called again.
func (s *RcSuite) TestRc_HomesickrcWrapperNotOverwrittenIfExists() {
castleRoot := s.createCastle("dotfiles")
homesickRc := filepath.Join(castleRoot, ".homesickrc")
require.NoError(s.T(), os.WriteFile(homesickRc, []byte("# ruby setup code\n"), 0o644))
homesickD := filepath.Join(castleRoot, ".homesick.d")
require.NoError(s.T(), os.MkdirAll(homesickD, 0o755))
wrapperPath := filepath.Join(homesickD, "parity.rb")
originalContent := []byte("#!/bin/sh\n# pre-existing wrapper\n")
require.NoError(s.T(), os.WriteFile(wrapperPath, originalContent, 0o755))
require.NoError(s.T(), s.app.Rc("dotfiles", true))
content, err := os.ReadFile(wrapperPath)
require.NoError(s.T(), err)
require.Equal(s.T(), originalContent, content, "existing parity.rb must not be overwritten")
}
// TestRc_HomesickrcWrapperCreatedBeforeExecution ensures parity.rb is present
// in .homesick.d before any scripts in that directory are executed.
func (s *RcSuite) TestRc_HomesickrcWrapperCreatedBeforeExecution() {
castleRoot := s.createCastle("dotfiles")
homesickRc := filepath.Join(castleRoot, ".homesickrc")
require.NoError(s.T(), os.WriteFile(homesickRc, []byte("# ruby setup code\n"), 0o644))
homesickD := filepath.Join(castleRoot, ".homesick.d")
require.NoError(s.T(), os.MkdirAll(homesickD, 0o755))
// A sentinel script that records whether the wrapper already exists.
orderFile := filepath.Join(s.tmpDir, "check.txt")
sentinel := filepath.Join(homesickD, "50_check.sh")
wrapperPath := filepath.Join(homesickD, "parity.rb")
require.NoError(s.T(), os.WriteFile(sentinel, []byte(
"#!/bin/sh\n[ -f "+wrapperPath+" ] && echo present >> "+orderFile+"\n",
), 0o755))
require.NoError(s.T(), s.app.Rc("dotfiles", true))
content, err := os.ReadFile(orderFile)
require.NoError(s.T(), err)
require.Equal(s.T(), "present\n", string(content))
}
// TestRc_FailingScriptReturnsError ensures that a non-zero exit from a script
// propagates as an error.
func (s *RcSuite) TestRc_FailingScriptReturnsError() {
castleRoot := s.createCastle("dotfiles")
homesickD := filepath.Join(castleRoot, ".homesick.d")
require.NoError(s.T(), os.MkdirAll(homesickD, 0o755))
failing := filepath.Join(homesickD, "10_fail.sh")
require.NoError(s.T(), os.WriteFile(failing, []byte("#!/bin/sh\nexit 42\n"), 0o755))
err := s.app.Rc("dotfiles", false)
require.Error(s.T(), err)
}
// TestRc_ScriptOutputForwarded verifies that stdout and stderr from scripts
// are forwarded to the App's writers.
func (s *RcSuite) TestRc_ScriptOutputForwarded() {
castleRoot := s.createCastle("dotfiles")
homesickD := filepath.Join(castleRoot, ".homesick.d")
require.NoError(s.T(), os.MkdirAll(homesickD, 0o755))
script := filepath.Join(homesickD, "10_output.sh")
require.NoError(s.T(), os.WriteFile(script, []byte("#!/bin/sh\necho hello\necho world >&2\n"), 0o755))
require.NoError(s.T(), s.app.Rc("dotfiles", false))
require.Contains(s.T(), s.stdout.String(), "hello")
require.Contains(s.T(), s.stderr.String(), "world")
}
// TestRc_ScriptsRunWithCwdSetToCastleRoot verifies scripts execute with the
// castle root as the working directory.
func (s *RcSuite) TestRc_ScriptsRunWithCwdSetToCastleRoot() {
castleRoot := s.createCastle("dotfiles")
homesickD := filepath.Join(castleRoot, ".homesick.d")
require.NoError(s.T(), os.MkdirAll(homesickD, 0o755))
script := filepath.Join(homesickD, "10_pwd.sh")
require.NoError(s.T(), os.WriteFile(script, []byte("#!/bin/sh\npwd\n"), 0o755))
require.NoError(s.T(), s.app.Rc("dotfiles", false))
require.Contains(s.T(), s.stdout.String(), castleRoot)
}
func (s *RcSuite) TestRc_ReadHooksErrorIncludesContext() {
castleRoot := s.createCastle("dotfiles")
homesickD := filepath.Join(castleRoot, ".homesick.d")
require.NoError(s.T(), os.WriteFile(homesickD, []byte("x"), 0o644))
err := s.app.Rc("dotfiles", false)
require.Error(s.T(), err)
require.Contains(s.T(), err.Error(), "read rc hooks")
}

View File

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

View File

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

View File

@@ -0,0 +1,113 @@
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)
}
func (s *TrackSuite) TestTrack_WrapsSubdirRecordingError() {
castleRoot := s.createCastleRepo("dotfiles")
require.NoError(s.T(), os.MkdirAll(filepath.Join(castleRoot, ".homesick_subdir"), 0o755))
filePath := filepath.Join(s.homeDir, ".config", "myapp", "config.toml")
s.writeFile(filePath, "ok=true\n")
err := s.app.Track(filePath, "dotfiles")
require.Error(s.T(), err)
require.Contains(s.T(), err.Error(), "record tracked subdir")
}

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

View File

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

View File

@@ -0,0 +1,21 @@
package version
import (
"regexp"
"testing"
"github.com/stretchr/testify/assert"
)
func TestStringConstant(t *testing.T) {
// Test that the version constant is not empty
assert.NotEmpty(t, String, "version.String should not be empty")
}
func TestStringMatchesSemVer(t *testing.T) {
// Test that the version string matches semantic versioning pattern (major.minor.patch)
semverPattern := `^\d+\.\d+\.\d+$`
matched, err := regexp.MatchString(semverPattern, String)
assert.NoError(t, err, "regex should be valid")
assert.True(t, matched, "version.String should match semantic versioning pattern (major.minor.patch), got: %s", String)
}

33
justfile Normal file
View File

@@ -0,0 +1,33 @@
set shell := ["bash", "-eu", "-o", "pipefail", "-c"]
default:
@just --list
go-build:
@mkdir -p dist
go build -o dist/gosick ./cmd/homesick
go-build-linux:
@mkdir -p dist
GOOS=linux GOARCH="$(go env GOARCH)" go build -o dist/gosick ./cmd/homesick
go-test:
go test ./...
go-mod-hygiene:
go mod tidy
git diff --exit-code go.mod go.sum
go mod verify
go-security:
gosec ./...
govulncheck ./...
behavior:
./script/run-behavior-suite-docker.sh
behavior-verbose:
./script/run-behavior-suite-docker.sh --verbose
prepare-release version:
@echo "Release preparation is handled by vociferate workflows."

View File

@@ -1,14 +0,0 @@
# -*- encoding : utf-8 -*-
require 'homesick/actions/file_actions'
require 'homesick/actions/git_actions'
require 'homesick/version'
require 'homesick/utils'
require 'homesick/cli'
# Homesick's top-level module
module Homesick
GITHUB_NAME_REPO_PATTERN = %r{\A([A-Za-z0-9_-]+/[A-Za-z0-9_-]+)\Z}
SUBDIR_FILENAME = '.homesick_subdir'
DEFAULT_CASTLE_NAME = 'dotfiles'
end

View File

@@ -1,87 +0,0 @@
# -*- encoding : utf-8 -*-
module Homesick
module Actions
# File-related helper methods for Homesick
module FileActions
def mv(source, destination)
source = Pathname.new(source)
destination = Pathname.new(destination + source.basename)
case
when destination.exist? && (options[:force] || shell.file_collision(destination) { source })
say_status :conflict, "#{destination} exists", :red
FileUtils.mv source, destination unless options[:pretend]
else
FileUtils.mv source, destination unless options[:pretend]
end
end
def rm_rf(dir)
say_status "rm -rf #{dir}", '', :green
FileUtils.rm_r dir, force: true
end
def rm_link(target)
target = Pathname.new(target)
if target.symlink?
say_status :unlink, "#{target.expand_path}", :green
FileUtils.rm_rf target
else
say_status :conflict, "#{target} is not a symlink", :red
end
end
def rm(file)
say_status "rm #{file}", '', :green
FileUtils.rm file, force: true
end
def rm_r(dir)
say_status "rm -r #{dir}", '', :green
FileUtils.rm_r dir
end
def ln_s(source, destination)
source = Pathname.new(source).realpath
destination = Pathname.new(destination)
FileUtils.mkdir_p destination.dirname
action = if destination.symlink? && destination.readlink == source
:identical
elsif destination.symlink?
:symlink_conflict
elsif destination.exist?
:conflict
else
:success
end
handle_symlink_action action, source, destination
end
def handle_symlink_action(action, source, destination)
case action
when :identical
say_status :identical, destination.expand_path, :blue
when :symlink_conflict, :conflict
if action == :conflict
say_status :conflict, "#{destination} exists", :red
else
say_status :conflict,
"#{destination} exists and points to #{destination.readlink}",
:red
end
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]
end
end
end
end
end

View File

@@ -1,113 +0,0 @@
# -*- 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
}
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[[:major, :minor, :patch].zip(info)]
return true if current_version.eql?(MIN_VERSION)
return true if current_version[:major] > MIN_VERSION[:major]
return true if current_version[:major] == MIN_VERSION[:major] && current_version[:minor] > MIN_VERSION[:minor]
return true if current_version[:major] == MIN_VERSION[:major] && current_version[:minor] == MIN_VERSION[:minor] && current_version[:patch] >= MIN_VERSION[:patch]
false
end
# TODO: move this to be more like thor's template, empty_directory, etc
def git_clone(repo, config = {})
config ||= {}
destination = config[:destination] || File.basename(repo, '.git')
destination = Pathname.new(destination) unless destination.is_a?(Pathname)
FileUtils.mkdir_p destination.dirname
if destination.directory?
say_status :exist, destination.expand_path, :blue
else
say_status 'git clone',
"#{repo} to #{destination.expand_path}",
:green
system "git clone -q --config push.default=upstream --recursive #{repo} #{destination}"
end
end
def git_init(path = '.')
path = Pathname.new(path)
inside path do
if path.join('.git').exist?
say_status 'git init', 'already initialized', :blue
else
say_status 'git init', ''
system 'git init >/dev/null'
end
end
end
def git_remote_add(name, url)
existing_remote = `git config remote.#{name}.url`.chomp
existing_remote = nil if existing_remote == ''
if existing_remote
say_status 'git remote', "#{name} already exists", :blue
else
say_status 'git remote', "add #{name} #{url}"
system "git remote add #{name} #{url}"
end
end
def git_submodule_init
say_status 'git submodule', 'init', :green
system 'git submodule --quiet init'
end
def git_submodule_update
say_status 'git submodule', 'update', :green
system 'git submodule --quiet update --init --recursive >/dev/null 2>&1'
end
def git_pull
say_status 'git pull', '', :green
system 'git pull --quiet'
end
def git_push
say_status 'git push', '', :green
system 'git push'
end
def git_commit_all(config = {})
say_status 'git commit all', '', :green
if config[:message]
system %(git commit -a -m "#{config[:message]}")
else
system 'git commit -v -a'
end
end
def git_add(file)
say_status 'git add file', '', :green
system "git add '#{file}'"
end
def git_status
say_status 'git status', '', :green
system 'git status'
end
def git_diff
say_status 'git diff', '', :green
system 'git diff'
end
end
end
end

View File

@@ -1,331 +0,0 @@
# -*- encoding : utf-8 -*-
require 'fileutils'
require 'thor'
module Homesick
# Homesick's command line interface
class CLI < Thor
include Thor::Actions
include Homesick::Actions::FileActions
include Homesick::Actions::GitActions
include Homesick::Version
include Homesick::Utils
add_runtime_options!
map '-v' => :version
map '--version' => :version
# Retain a mapped version of the symlink command for compatibility.
map symlink: :link
def initialize(args = [], options = {}, config = {})
super
# Check if git is installed
unless git_version_correct?
say_status :error, "Git version >= #{Homesick::Actions::GitActions::STRING} must be installed to use Homesick", :red
exit(1)
end
# 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
desc 'clone URI CASTLE_NAME', 'Clone +uri+ as a castle with name CASTLE_NAME for homesick'
def clone(uri, destination=nil)
destination = Pathname.new(destination) unless destination.nil?
inside repos_dir do
if File.exist?(uri)
uri = Pathname.new(uri).expand_path
fail "Castle already cloned to #{uri}" if uri.to_s.start_with?(repos_dir.to_s)
destination = uri.basename if destination.nil?
ln_s uri, destination
elsif uri =~ GITHUB_NAME_REPO_PATTERN
destination = Pathname.new(uri).basename if destination.nil?
git_clone "https://github.com/#{Regexp.last_match[1]}.git",
destination: destination
elsif uri =~ /%r([^%r]*?)(\.git)?\Z/ || uri =~ /[^:]+:([^:]+)(\.git)?\Z/
destination = Pathname.new(Regexp.last_match[1].gsub(/\.git$/, '')).basename if destination.nil?
git_clone uri, destination: destination
else
fail "Unknown URI format: #{uri}"
end
setup_castle(destination)
end
end
desc 'rc CASTLE', 'Run the .homesickrc for the specified castle'
method_option :force,
type: :boolean,
default: false,
desc: 'Evaluate .homesickrc without prompting.'
def rc(name = DEFAULT_CASTLE_NAME)
inside repos_dir do
destination = Pathname.new(name)
homesickrc = destination.join('.homesickrc').expand_path
return unless homesickrc.exist?
proceed = options[:force] || shell.yes?("#{name} has a .homesickrc. Proceed with evaling it? (This could be destructive)")
return say_status 'eval skip', "not evaling #{homesickrc}, #{destination} may need manual configuration", :blue unless proceed
say_status 'eval', homesickrc
inside destination do
eval homesickrc.read, binding, homesickrc.expand_path.to_s
end
end
end
desc 'pull CASTLE', 'Update the specified castle'
method_option :all,
type: :boolean,
default: false,
required: false,
desc: 'Update all cloned castles'
def pull(name = DEFAULT_CASTLE_NAME)
if options[:all]
inside_each_castle do |castle|
say castle.to_s.gsub(repos_dir.to_s + '/', '') + ':'
update_castle castle
end
else
update_castle name
end
end
desc 'commit CASTLE MESSAGE', "Commit the specified castle's changes"
def commit(name = DEFAULT_CASTLE_NAME, message = nil)
commit_castle name, message
end
desc 'push CASTLE', 'Push the specified castle'
def push(name = DEFAULT_CASTLE_NAME)
push_castle name
end
desc 'unlink CASTLE', 'Unsymlinks all dotfiles from the specified castle'
def unlink(name = DEFAULT_CASTLE_NAME)
check_castle_existance(name, 'symlink')
inside castle_dir(name) do
subdirs = subdirs(name)
# unlink files
unsymlink_each(name, castle_dir(name), subdirs)
# unlink files in subdirs
subdirs.each do |subdir|
unsymlink_each(name, subdir, subdirs)
end
end
end
desc 'link CASTLE', 'Symlinks all dotfiles from the specified castle'
method_option :force,
type: :boolean,
default: false,
desc: 'Overwrite existing conflicting symlinks without prompting.'
def link(name = DEFAULT_CASTLE_NAME)
check_castle_existance(name, 'symlink')
inside castle_dir(name) do
subdirs = subdirs(name)
# link files
symlink_each(name, castle_dir(name), subdirs)
# link files in subdirs
subdirs.each do |subdir|
symlink_each(name, subdir, subdirs)
end
end
end
desc 'track FILE CASTLE', 'add a file to a castle'
def track(file, castle = DEFAULT_CASTLE_NAME)
castle = Pathname.new(castle)
file = Pathname.new(file.chomp('/'))
check_castle_existance(castle, 'track')
absolute_path = file.expand_path
relative_dir = absolute_path.relative_path_from(home_dir).dirname
castle_path = Pathname.new(castle_dir(castle)).join(relative_dir)
FileUtils.mkdir_p castle_path
# Are we already tracking this or anything inside it?
target = Pathname.new(castle_path.join(file.basename))
if target.exist?
if absolute_path.directory?
move_dir_contents(target, absolute_path)
absolute_path.rmtree
subdir_remove(castle, relative_dir + file.basename)
elsif more_recent? absolute_path, target
target.delete
mv absolute_path, castle_path
else
say_status(:track,
"#{target} already exists, and is more recent than #{file}. Run 'homesick SYMLINK CASTLE' to create symlinks.",
:blue)
end
else
mv absolute_path, castle_path
end
inside home_dir do
absolute_path = castle_path + file.basename
home_path = home_dir + relative_dir + file.basename
ln_s absolute_path, home_path
end
inside castle_path do
git_add absolute_path
end
# are we tracking something nested? Add the parent dir to the manifest
subdir_add(castle, relative_dir) unless relative_dir.eql?(Pathname.new('.'))
end
desc 'list', 'List cloned castles'
def list
inside_each_castle do |castle|
say_status castle.relative_path_from(repos_dir).to_s,
`git config remote.origin.url`.chomp,
:cyan
end
end
desc 'status CASTLE', 'Shows the git status of a castle'
def status(castle = DEFAULT_CASTLE_NAME)
check_castle_existance(castle, 'status')
inside repos_dir.join(castle) do
git_status
end
end
desc 'diff CASTLE', 'Shows the git diff of uncommitted changes in a castle'
def diff(castle = DEFAULT_CASTLE_NAME)
check_castle_existance(castle, 'diff')
inside repos_dir.join(castle) do
git_diff
end
end
desc 'show_path CASTLE', 'Prints the path of a castle'
def show_path(castle = DEFAULT_CASTLE_NAME)
check_castle_existance(castle, 'show_path')
say repos_dir.join(castle)
end
desc 'generate PATH', 'generate a homesick-ready git repo at PATH'
def generate(castle)
castle = Pathname.new(castle).expand_path
github_user = `git config github.user`.chomp
github_user = nil if github_user == ''
github_repo = castle.basename
empty_directory castle
inside castle do
git_init
if github_user
url = "git@github.com:#{github_user}/#{github_repo}.git"
git_remote_add 'origin', url
end
empty_directory 'home'
end
end
desc 'destroy CASTLE', 'Delete all symlinks and remove the cloned repository'
def destroy(name)
check_castle_existance name, 'destroy'
return unless shell.yes?('This will destroy your castle irreversible! Are you sure?')
unlink(name)
rm_rf repos_dir.join(name)
end
desc 'cd CASTLE', 'Open a new shell in the root of the given castle'
def cd(castle = DEFAULT_CASTLE_NAME)
check_castle_existance castle, 'cd'
castle_dir = repos_dir.join(castle)
say_status "cd #{castle_dir.realpath}",
"Opening a new shell in castle '#{castle}'. To return to the original one exit from the new shell.",
:green
inside castle_dir do
system(ENV['SHELL'])
end
end
desc 'open CASTLE',
'Open your default editor in the root of the given castle'
def open(castle = DEFAULT_CASTLE_NAME)
unless ENV['EDITOR']
say_status :error,
'The $EDITOR environment variable must be set to use this command',
:red
exit(1)
end
check_castle_existance castle, 'open'
castle_dir = repos_dir.join(castle)
say_status "#{castle_dir.realpath}: #{ENV['EDITOR']} .",
"Opening the root directory of castle '#{castle}' in editor '#{ENV['EDITOR']}'.",
:green
inside castle_dir do
system("#{ENV['EDITOR']} .")
end
end
desc 'exec CASTLE COMMAND',
'Execute a single shell command inside the root of a castle'
def exec(castle, *args)
check_castle_existance castle, 'exec'
unless args.count > 0
say_status :error,
'You must pass a shell command to execute',
:red
exit(1)
end
full_command = args.join(' ')
say_status "exec '#{full_command}'",
"#{options[:pretend] ? 'Would execute' : 'Executing command'} '#{full_command}' in castle '#{castle}'",
:green
inside repos_dir.join(castle) do
system(full_command)
end
end
desc 'exec_all COMMAND',
'Execute a single shell command inside the root of every cloned castle'
def exec_all(*args)
unless args.count > 0
say_status :error,
'You must pass a shell command to execute',
:red
exit(1)
end
full_command = args.join(' ')
inside_each_castle do |castle|
say_status "exec '#{full_command}'",
"#{options[:pretend] ? 'Would execute' : 'Executing command'} '#{full_command}' in castle '#{castle}'",
:green
system(full_command)
end
end
desc 'version', 'Display the current version of homesick'
def version
say Homesick::Version::STRING
end
end
end

View File

@@ -1,216 +0,0 @@
# -*- 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'] || '~').realpath
end
def repos_dir
@repos_dir ||= home_dir.join('.homesick', 'repos').expand_path
end
def castle_dir(name)
repos_dir.join(name, 'home')
end
def check_castle_existance(name, action)
return if castle_dir(name).exist?
say_status :error,
"Could not #{action} #{name}, expected #{castle_dir(name)} to exist and contain dotfiles",
:red
exit(1)
end
def all_castles
dirs = Pathname.glob("#{repos_dir}/**/.git", File::FNM_DOTMATCH)
# reject paths that lie inside another castle, like git submodules
dirs.reject do |dir|
dirs.any? do |other|
dir != other && dir.fnmatch(other.parent.join('*').to_s)
end
end
end
def inside_each_castle
all_castles.each do |git_dir|
castle = git_dir.dirname
Dir.chdir castle do # so we can call git config from the right contxt
yield castle
end
end
end
def update_castle(castle)
check_castle_existance(castle, 'pull')
inside repos_dir.join(castle) do
git_pull
git_submodule_init
git_submodule_update
end
end
def commit_castle(castle, message)
check_castle_existance(castle, 'commit')
inside repos_dir.join(castle) do
git_commit_all message: message
end
end
def push_castle(castle)
check_castle_existance(castle, 'push')
inside repos_dir.join(castle) do
git_push
end
end
def subdir_file(castle)
repos_dir.join(castle, SUBDIR_FILENAME)
end
def subdirs(castle)
subdir_filepath = subdir_file(castle)
subdirs = []
if subdir_filepath.exist?
subdir_filepath.readlines.each do |subdir|
subdirs.push(subdir.chomp)
end
end
subdirs
end
def subdir_add(castle, path)
subdir_filepath = subdir_file(castle)
File.open(subdir_filepath, 'a+') do |subdir|
subdir.puts path unless subdir.readlines.reduce(false) do |memo, line|
line.eql?("#{path}\n") || memo
end
end
inside castle_dir(castle) do
git_add subdir_filepath
end
end
def subdir_remove(castle, path)
subdir_filepath = subdir_file(castle)
if subdir_filepath.exist?
lines = IO.readlines(subdir_filepath).delete_if do |line|
line == "#{path}\n"
end
File.open(subdir_filepath, 'w') { |manfile| manfile.puts lines }
end
inside castle_dir(castle) do
git_add subdir_filepath
end
end
def move_dir_contents(target, dir_path)
child_files = dir_path.children
child_files.each do |child|
target_path = target.join(child.basename)
if target_path.exist?
if more_recent?(child, target_path) && target.file?
target_path.delete
mv child, target
end
next
end
mv child, target
end
end
def more_recent?(first, second)
first_p = Pathname.new(first)
second_p = Pathname.new(second)
first_p.mtime > second_p.mtime && !first_p.symlink?
end
def collision_accepted?(destination, source)
fail "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|
rm_link home_path
end
end
def symlink_each(castle, basedir, subdirs)
each_file(castle, basedir, subdirs) do |absolute_path, home_path|
ln_s absolute_path, home_path
end
end
def setup_castle(path)
if path.join('.gitmodules').exist?
inside path do
git_submodule_init
git_submodule_update
end
end
rc(path)
end
end
end

View File

@@ -1,12 +0,0 @@
# -*- 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 = 6
STRING = [MAJOR, MINOR, PATCH].compact.join('.')
end
end

View File

@@ -0,0 +1,57 @@
#!/usr/bin/env bash
set -euo pipefail
: "${HOMESICK_CMD:=/workspace/dist/gosick}"
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,858 +0,0 @@
# -*- encoding : utf-8 -*-
require 'spec_helper'
require 'capture-output'
require 'pathname'
describe Homesick::CLI do
let(:home) { create_construct }
after { home.destroy! }
let(:castles) { home.directory('.homesick/repos') }
let(:homesick) { Homesick::CLI.new }
before { allow(homesick).to receive(:repos_dir).and_return(castles) }
describe 'smoke tests' do
context 'when running bin/homesick' do
before do
bin_path = Pathname.new(__FILE__).parent.parent
@output = `#{bin_path.expand_path}/bin/homesick`
end
it 'should output some text when bin/homesick is called' do
expect(@output.length).to be > 0
end
end
context 'when a git version that doesn\'t meet the minimum required is installed' do
before do
expect_any_instance_of(Homesick::Actions::GitActions).to receive(:`).and_return('git version 1.7.6')
end
it 'should raise an exception' do
output = Capture.stdout { expect { Homesick::CLI.new }.to raise_error SystemExit }
expect(output.chomp).to include(Homesick::Actions::GitActions::STRING)
end
end
context 'when a git version that is the same as the minimum required is installed' do
before do
expect_any_instance_of(Homesick::Actions::GitActions).to receive(:`).at_least(:once).and_return("git version #{Homesick::Actions::GitActions::STRING}")
end
it 'should not raise an exception' do
output = Capture.stdout { expect { Homesick::CLI.new }.not_to raise_error }
expect(output.chomp).not_to include(Homesick::Actions::GitActions::STRING)
end
end
context 'when a git version that is greater than the minimum required is installed' do
before do
expect_any_instance_of(Homesick::Actions::GitActions).to receive(:`).at_least(:once).and_return('git version 3.9.8')
end
it 'should not raise an exception' do
output = Capture.stdout { expect { Homesick::CLI.new }.not_to raise_error }
expect(output.chomp).not_to include(Homesick::Actions::GitActions::STRING)
end
end
end
describe 'clone' do
context 'has a .homesickrc' do
it 'runs the .homesickrc' do
somewhere = create_construct
local_repo = somewhere.directory('some_repo')
local_repo.file('.homesickrc') do |file|
file << "File.open(Dir.pwd + '/testing', 'w') do |f|
f.print 'testing'
end"
end
expect_any_instance_of(Thor::Shell::Basic).to receive(:yes?).with(be_a(String)).and_return(true)
expect(homesick).to receive(:say_status).with('eval', kind_of(Pathname))
homesick.clone local_repo
expect(castles.join('some_repo').join('testing')).to exist
end
end
context 'of a file' do
it 'symlinks existing directories' do
somewhere = create_construct
local_repo = somewhere.directory('wtf')
homesick.clone local_repo
expect(castles.join('wtf').readlink).to eq(local_repo)
end
context 'when it exists in a repo directory' do
before do
existing_castle = given_castle('existing_castle')
@existing_dir = existing_castle.parent
end
it 'raises an error' do
expect(homesick).not_to receive(:git_clone)
expect { homesick.clone @existing_dir.to_s }.to raise_error(/already cloned/i)
end
end
end
it 'clones git repo like file:///path/to.git' do
bare_repo = File.join(create_construct.to_s, 'dotfiles.git')
system "git init --bare #{bare_repo} >/dev/null 2>&1"
# Capture stderr to suppress message about cloning an empty repo.
Capture.stderr do
homesick.clone "file://#{bare_repo}"
end
expect(File.directory?(File.join(home.to_s, '.homesick/repos/dotfiles')))
.to be_truthy
end
it 'clones git repo like git://host/path/to.git' do
expect(homesick).to receive(:git_clone)
.with('git://github.com/technicalpickles/pickled-vim.git', destination: Pathname.new('pickled-vim'))
homesick.clone 'git://github.com/technicalpickles/pickled-vim.git'
end
it 'clones git repo like git@host:path/to.git' do
expect(homesick).to receive(:git_clone)
.with('git@github.com:technicalpickles/pickled-vim.git', destination: Pathname.new('pickled-vim'))
homesick.clone 'git@github.com:technicalpickles/pickled-vim.git'
end
it 'clones git repo like http://host/path/to.git' do
expect(homesick).to receive(:git_clone)
.with('http://github.com/technicalpickles/pickled-vim.git', destination: Pathname.new('pickled-vim'))
homesick.clone 'http://github.com/technicalpickles/pickled-vim.git'
end
it 'clones git repo like http://host/path/to' do
expect(homesick).to receive(:git_clone)
.with('http://github.com/technicalpickles/pickled-vim', destination: Pathname.new('pickled-vim'))
homesick.clone 'http://github.com/technicalpickles/pickled-vim'
end
it 'clones git repo like host-alias:repos.git' do
expect(homesick).to receive(:git_clone).with('gitolite:pickled-vim.git',
destination: Pathname.new('pickled-vim'))
homesick.clone 'gitolite:pickled-vim.git'
end
it 'throws an exception when trying to clone a malformed uri like malformed' do
expect(homesick).not_to receive(:git_clone)
expect { homesick.clone 'malformed' }.to raise_error(RuntimeError)
end
it 'clones a github repo' do
expect(homesick).to receive(:git_clone)
.with('https://github.com/wfarr/dotfiles.git', destination: Pathname.new('dotfiles'))
homesick.clone 'wfarr/dotfiles'
end
it 'accepts a destination', :focus do
expect(homesick).to receive(:git_clone)
.with('https://github.com/wfarr/dotfiles.git',
destination: Pathname.new('other-name'))
homesick.clone 'wfarr/dotfiles', 'other-name'
end
end
describe 'rc' do
let(:castle) { given_castle('glencairn') }
context 'when told to do so' do
before do
expect_any_instance_of(Thor::Shell::Basic).to receive(:yes?).with(be_a(String)).and_return(true)
end
it 'executes the .homesickrc' do
castle.file('.homesickrc') do |file|
file << "File.open(Dir.pwd + '/testing', 'w') do |f|
f.print 'testing'
end"
end
expect(homesick).to receive(:say_status).with('eval', kind_of(Pathname))
homesick.rc castle
expect(castle.join('testing')).to exist
end
end
context 'when options[:force] == true' do
let(:homesick) { Homesick::CLI.new [], force: true }
before do
expect_any_instance_of(Thor::Shell::Basic).to_not receive(:yes?)
end
it 'executes the .homesickrc' do
castle.file('.homesickrc') do |file|
file << "File.open(Dir.pwd + '/testing', 'w') do |f|
f.print 'testing'
end"
end
expect(homesick).to receive(:say_status).with('eval', kind_of(Pathname))
homesick.rc castle
expect(castle.join('testing')).to exist
end
end
context 'when told not to do so' do
before do
expect_any_instance_of(Thor::Shell::Basic).to receive(:yes?).with(be_a(String)).and_return(false)
end
it 'does not execute the .homesickrc' do
castle.file('.homesickrc') do |file|
file << "File.open(Dir.pwd + '/testing', 'w') do |f|
f.print 'testing'
end"
end
expect(homesick).to receive(:say_status).with('eval skip', /not evaling.+/, :blue)
homesick.rc castle
expect(castle.join('testing')).not_to exist
end
end
end
describe 'link_castle' do
let(:castle) { given_castle('glencairn') }
it 'links dotfiles from a castle to the home folder' do
dotfile = castle.file('.some_dotfile')
homesick.link('glencairn')
expect(home.join('.some_dotfile').readlink).to eq(dotfile)
end
it 'links non-dotfiles from a castle to the home folder' do
dotfile = castle.file('bin')
homesick.link('glencairn')
expect(home.join('bin').readlink).to eq(dotfile)
end
context 'when forced' do
let(:homesick) { Homesick::CLI.new [], force: true }
it 'can override symlinks to directories' do
somewhere_else = create_construct
existing_dotdir_link = home.join('.vim')
FileUtils.ln_s somewhere_else, existing_dotdir_link
dotdir = castle.directory('.vim')
homesick.link('glencairn')
expect(existing_dotdir_link.readlink).to eq(dotdir)
end
it 'can override existing directory' do
existing_dotdir = home.directory('.vim')
dotdir = castle.directory('.vim')
homesick.link('glencairn')
expect(existing_dotdir.readlink).to eq(dotdir)
end
end
context "with '.config' in .homesick_subdir" do
let(:castle) { given_castle('glencairn', ['.config']) }
it 'can symlink in sub directory' do
dotdir = castle.directory('.config')
dotfile = dotdir.file('.some_dotfile')
homesick.link('glencairn')
home_dotdir = home.join('.config')
expect(home_dotdir.symlink?).to eq(false)
expect(home_dotdir.join('.some_dotfile').readlink).to eq(dotfile)
end
end
context "with '.config/appA' in .homesick_subdir" do
let(:castle) { given_castle('glencairn', ['.config/appA']) }
it 'can symlink in nested sub directory' do
dotdir = castle.directory('.config').directory('appA')
dotfile = dotdir.file('.some_dotfile')
homesick.link('glencairn')
home_dotdir = home.join('.config').join('appA')
expect(home_dotdir.symlink?).to eq(false)
expect(home_dotdir.join('.some_dotfile').readlink).to eq(dotfile)
end
end
context "with '.config' and '.config/someapp' in .homesick_subdir" do
let(:castle) do
given_castle('glencairn', ['.config', '.config/someapp'])
end
it 'can symlink under both of .config and .config/someapp' do
config_dir = castle.directory('.config')
config_dotfile = config_dir.file('.some_dotfile')
someapp_dir = config_dir.directory('someapp')
someapp_dotfile = someapp_dir.file('.some_appfile')
homesick.link('glencairn')
home_config_dir = home.join('.config')
home_someapp_dir = home_config_dir.join('someapp')
expect(home_config_dir.symlink?).to eq(false)
expect(home_config_dir.join('.some_dotfile').readlink).to eq(config_dotfile)
expect(home_someapp_dir.symlink?).to eq(false)
expect(home_someapp_dir.join('.some_appfile').readlink).to eq(someapp_dotfile)
end
end
context 'when call with no castle name' do
let(:castle) { given_castle('dotfiles') }
it 'using default castle name: "dotfiles"' do
dotfile = castle.file('.some_dotfile')
homesick.link
expect(home.join('.some_dotfile').readlink).to eq(dotfile)
end
end
context 'when call and some files conflict' do
it 'shows differences for conflicting text files' do
contents = {:castle => 'castle has new content', :home => 'home already has content'}
dotfile = castle.file('text')
File.open(dotfile.to_s, 'w') do |f|
f.write contents[:castle]
end
File.open(home.join('text').to_s, 'w') do |f|
f.write contents[:home]
end
message = Capture.stdout { homesick.shell.show_diff(home.join('text'), dotfile) }
expect(message.b).to match(/- ?#{contents[:home]}\n.*\+ ?#{contents[:castle]}$/m)
end
it 'shows message or differences for conflicting binary files' do
# content which contains NULL character, without any parentheses, braces, ...
contents = {:castle => (0..255).step(30).map{|e| e.chr}.join(), :home => (0..255).step(30).reverse_each.map{|e| e.chr}.join()}
dotfile = castle.file('binary')
File.open(dotfile.to_s, 'w') do |f|
f.write contents[:castle]
end
File.open(home.join('binary').to_s, 'w') do |f|
f.write contents[:home]
end
message = Capture.stdout { homesick.shell.show_diff(home.join('binary'), dotfile) }
if homesick.shell.is_a?(Thor::Shell::Color)
expect(message.b).to match(/- ?#{contents[:home]}\n.*\+ ?#{contents[:castle]}$/m)
elsif homesick.shell.is_a?(Thor::Shell::Basic)
expect(message.b).to match(/^Binary files .+ differ$/)
end
end
end
end
describe 'unlink' do
let(:castle) { given_castle('glencairn') }
it 'unlinks dotfiles in the home folder' do
castle.file('.some_dotfile')
homesick.link('glencairn')
homesick.unlink('glencairn')
expect(home.join('.some_dotfile')).not_to exist
end
it 'unlinks non-dotfiles from the home folder' do
castle.file('bin')
homesick.link('glencairn')
homesick.unlink('glencairn')
expect(home.join('bin')).not_to exist
end
context "with '.config' in .homesick_subdir" do
let(:castle) { given_castle('glencairn', ['.config']) }
it 'can unlink sub directories' do
castle.directory('.config').file('.some_dotfile')
homesick.link('glencairn')
homesick.unlink('glencairn')
home_dotdir = home.join('.config')
expect(home_dotdir).to exist
expect(home_dotdir.join('.some_dotfile')).not_to exist
end
end
context "with '.config/appA' in .homesick_subdir" do
let(:castle) { given_castle('glencairn', ['.config/appA']) }
it 'can unsymlink in nested sub directory' do
castle.directory('.config').directory('appA').file('.some_dotfile')
homesick.link('glencairn')
homesick.unlink('glencairn')
home_dotdir = home.join('.config').join('appA')
expect(home_dotdir).to exist
expect(home_dotdir.join('.some_dotfile')).not_to exist
end
end
context "with '.config' and '.config/someapp' in .homesick_subdir" do
let(:castle) do
given_castle('glencairn', ['.config', '.config/someapp'])
end
it 'can unsymlink under both of .config and .config/someapp' do
config_dir = castle.directory('.config')
config_dir.file('.some_dotfile')
config_dir.directory('someapp').file('.some_appfile')
homesick.link('glencairn')
homesick.unlink('glencairn')
home_config_dir = home.join('.config')
home_someapp_dir = home_config_dir.join('someapp')
expect(home_config_dir).to exist
expect(home_config_dir.join('.some_dotfile')).not_to exist
expect(home_someapp_dir).to exist
expect(home_someapp_dir.join('.some_appfile')).not_to exist
end
end
context 'when call with no castle name' do
let(:castle) { given_castle('dotfiles') }
it 'using default castle name: "dotfiles"' do
castle.file('.some_dotfile')
homesick.link
homesick.unlink
expect(home.join('.some_dotfile')).not_to exist
end
end
end
describe 'list' do
it 'says each castle in the castle directory' do
given_castle('zomg')
given_castle('wtf/zomg')
expect(homesick).to receive(:say_status)
.with('zomg', 'git://github.com/technicalpickles/zomg.git', :cyan)
expect(homesick).to receive(:say_status)
.with('wtf/zomg', 'git://github.com/technicalpickles/zomg.git', :cyan)
homesick.list
end
end
describe 'status' do
it 'says "nothing to commit" when there are no changes' do
given_castle('castle_repo')
text = Capture.stdout { homesick.status('castle_repo') }
expect(text).to match(%r{nothing to commit \(create/copy files and use "git add" to track\)$})
end
it 'says "Changes to be committed" when there are changes' do
given_castle('castle_repo')
some_rc_file = home.file '.some_rc_file'
homesick.track(some_rc_file.to_s, 'castle_repo')
text = Capture.stdout { homesick.status('castle_repo') }
expect(text).to match(%r{Changes to be committed:.*new file:\s*home\/.some_rc_file}m)
end
end
describe 'diff' do
it 'outputs an empty message when there are no changes to commit' do
given_castle('castle_repo')
some_rc_file = home.file '.some_rc_file'
homesick.track(some_rc_file.to_s, 'castle_repo')
Capture.stdout do
homesick.commit 'castle_repo', 'Adding a file to the test'
end
text = Capture.stdout { homesick.diff('castle_repo') }
expect(text).to eq('')
end
it 'outputs a diff message when there are changes to commit' do
given_castle('castle_repo')
some_rc_file = home.file '.some_rc_file'
homesick.track(some_rc_file.to_s, 'castle_repo')
Capture.stdout do
homesick.commit 'castle_repo', 'Adding a file to the test'
end
File.open(some_rc_file.to_s, 'w') do |file|
file.puts 'Some test text'
end
text = Capture.stdout { homesick.diff('castle_repo') }
expect(text).to match(/diff --git.+Some test text$/m)
end
end
describe 'show_path' do
it 'says the path of a castle' do
castle = given_castle('castle_repo')
expect(homesick).to receive(:say).with(castle.dirname)
homesick.show_path('castle_repo')
end
end
describe 'pull' do
it 'performs a pull, submodule init and update when the given castle exists' do
given_castle('castle_repo')
allow(homesick).to receive(:system).once.with('git pull --quiet')
allow(homesick).to receive(:system).once.with('git submodule --quiet init')
allow(homesick).to receive(:system).once.with('git submodule --quiet update --init --recursive >/dev/null 2>&1')
homesick.pull 'castle_repo'
end
it 'prints an error message when trying to pull a non-existant castle' do
expect(homesick).to receive('say_status').once
.with(:error,
/Could not pull castle_repo, expected .* to exist and contain dotfiles/,
:red)
expect { homesick.pull 'castle_repo' }.to raise_error(SystemExit)
end
describe '--all' do
it 'pulls each castle when invoked with --all' do
given_castle('castle_repo')
given_castle('glencairn')
allow(homesick).to receive(:system).exactly(2).times.with('git pull --quiet')
allow(homesick).to receive(:system).exactly(2).times
.with('git submodule --quiet init')
allow(homesick).to receive(:system).exactly(2).times
.with('git submodule --quiet update --init --recursive >/dev/null 2>&1')
Capture.stdout do
Capture.stderr { homesick.invoke 'pull', [], all: true }
end
end
end
end
describe 'push' do
it 'performs a git push on the given castle' do
given_castle('castle_repo')
allow(homesick).to receive(:system).once.with('git push')
homesick.push 'castle_repo'
end
it 'prints an error message when trying to push a non-existant castle' do
expect(homesick).to receive('say_status').once
.with(:error, /Could not push castle_repo, expected .* to exist and contain dotfiles/, :red)
expect { homesick.push 'castle_repo' }.to raise_error(SystemExit)
end
end
describe 'track' do
it 'moves the tracked file into the castle' do
castle = given_castle('castle_repo')
some_rc_file = home.file '.some_rc_file'
homesick.track(some_rc_file.to_s, 'castle_repo')
tracked_file = castle.join('.some_rc_file')
expect(tracked_file).to exist
expect(some_rc_file.readlink).to eq(tracked_file)
end
it 'handles files with parens' do
castle = given_castle('castle_repo')
some_rc_file = home.file 'Default (Linux).sublime-keymap'
homesick.track(some_rc_file.to_s, 'castle_repo')
tracked_file = castle.join('Default (Linux).sublime-keymap')
expect(tracked_file).to exist
expect(some_rc_file.readlink).to eq(tracked_file)
end
it 'tracks a file in nested folder structure' do
castle = given_castle('castle_repo')
some_nested_file = home.file('some/nested/file.txt')
homesick.track(some_nested_file.to_s, 'castle_repo')
tracked_file = castle.join('some/nested/file.txt')
expect(tracked_file).to exist
expect(some_nested_file.readlink).to eq(tracked_file)
end
it 'tracks a nested directory' do
castle = given_castle('castle_repo')
some_nested_dir = home.directory('some/nested/directory/')
homesick.track(some_nested_dir.to_s, 'castle_repo')
tracked_file = castle.join('some/nested/directory/')
expect(tracked_file).to exist
expect(some_nested_dir.realpath).to eq(tracked_file.realpath)
end
context 'when call with no castle name' do
it 'using default castle name: "dotfiles"' do
castle = given_castle('dotfiles')
some_rc_file = home.file '.some_rc_file'
homesick.track(some_rc_file.to_s)
tracked_file = castle.join('.some_rc_file')
expect(tracked_file).to exist
expect(some_rc_file.readlink).to eq(tracked_file)
end
end
describe 'commit' do
it 'has a commit message when the commit succeeds' do
given_castle('castle_repo')
some_rc_file = home.file '.a_random_rc_file'
homesick.track(some_rc_file.to_s, 'castle_repo')
text = Capture.stdout do
homesick.commit('castle_repo', 'Test message')
end
expect(text).to match(/^\[master \(root-commit\) \w+\] Test message/)
end
end
# Note that this is a test for the subdir_file related feature of track,
# not for the subdir_file method itself.
describe 'subdir_file' do
it 'adds the nested files parent to the subdir_file' do
castle = given_castle('castle_repo')
some_nested_file = home.file('some/nested/file.txt')
homesick.track(some_nested_file.to_s, 'castle_repo')
subdir_file = castle.parent.join(Homesick::SUBDIR_FILENAME)
File.open(subdir_file, 'r') do |f|
expect(f.readline).to eq("some/nested\n")
end
end
it 'does NOT add anything if the files parent is already listed' do
castle = given_castle('castle_repo')
some_nested_file = home.file('some/nested/file.txt')
other_nested_file = home.file('some/nested/other.txt')
homesick.track(some_nested_file.to_s, 'castle_repo')
homesick.track(other_nested_file.to_s, 'castle_repo')
subdir_file = castle.parent.join(Homesick::SUBDIR_FILENAME)
File.open(subdir_file, 'r') do |f|
expect(f.readlines.size).to eq(1)
end
end
it 'removes the parent of a tracked file from the subdir_file if the parent itself is tracked' do
castle = given_castle('castle_repo')
some_nested_file = home.file('some/nested/file.txt')
nested_parent = home.directory('some/nested/')
homesick.track(some_nested_file.to_s, 'castle_repo')
homesick.track(nested_parent.to_s, 'castle_repo')
subdir_file = castle.parent.join(Homesick::SUBDIR_FILENAME)
File.open(subdir_file, 'r') do |f|
f.each_line { |line| expect(line).not_to eq("some/nested\n") }
end
end
end
end
describe 'destroy' do
it 'removes the symlink files' do
expect_any_instance_of(Thor::Shell::Basic).to receive(:yes?).and_return('y')
given_castle('stronghold')
some_rc_file = home.file '.some_rc_file'
homesick.track(some_rc_file.to_s, 'stronghold')
homesick.destroy('stronghold')
expect(some_rc_file).not_to be_exist
end
it 'deletes the cloned repository' do
expect_any_instance_of(Thor::Shell::Basic).to receive(:yes?).and_return('y')
castle = given_castle('stronghold')
some_rc_file = home.file '.some_rc_file'
homesick.track(some_rc_file.to_s, 'stronghold')
homesick.destroy('stronghold')
expect(castle).not_to be_exist
end
end
describe 'cd' do
it "cd's to the root directory of the given castle" do
given_castle('castle_repo')
expect(homesick).to receive('inside').once.with(kind_of(Pathname)).and_yield
expect(homesick).to receive('system').once.with(ENV['SHELL'])
Capture.stdout { homesick.cd 'castle_repo' }
end
it 'returns an error message when the given castle does not exist' do
expect(homesick).to receive('say_status').once
.with(:error, /Could not cd castle_repo, expected .* to exist and contain dotfiles/, :red)
expect { homesick.cd 'castle_repo' }.to raise_error(SystemExit)
end
end
describe 'open' do
it 'opens the system default editor in the root of the given castle' do
# Make sure calls to ENV use default values for most things...
allow(ENV).to receive(:[]).and_call_original
# Set a default value for 'EDITOR' just in case none is set
allow(ENV).to receive(:[]).with('EDITOR').and_return('vim')
given_castle 'castle_repo'
expect(homesick).to receive('inside').once.with(kind_of(Pathname)).and_yield
expect(homesick).to receive('system').once.with('vim .')
Capture.stdout { homesick.open 'castle_repo' }
end
it 'returns an error message when the $EDITOR environment variable is not set' do
# Set the default editor to make sure it fails.
allow(ENV).to receive(:[]).with('EDITOR').and_return(nil)
expect(homesick).to receive('say_status').once
.with(:error, 'The $EDITOR environment variable must be set to use this command', :red)
expect { homesick.open 'castle_repo' }.to raise_error(SystemExit)
end
it 'returns an error message when the given castle does not exist' do
# Set a default just in case none is set
allow(ENV).to receive(:[]).with('EDITOR').and_return('vim')
allow(homesick).to receive('say_status').once
.with(:error, /Could not open castle_repo, expected .* to exist and contain dotfiles/, :red)
expect { homesick.open 'castle_repo' }.to raise_error(SystemExit)
end
end
describe 'version' do
it 'prints the current version of homesick' do
text = Capture.stdout { homesick.version }
expect(text.chomp).to match(/#{Regexp.escape(Homesick::Version::STRING)}/)
end
end
describe 'exec' do
before do
given_castle 'castle_repo'
end
it 'executes a single command with no arguments inside a given castle' do
allow(homesick).to receive('inside').once.with(kind_of(Pathname)).and_yield
allow(homesick).to receive('say_status').once
.with(be_a(String), be_a(String), :green)
allow(homesick).to receive('system').once.with('ls')
Capture.stdout { homesick.exec 'castle_repo', 'ls' }
end
it 'executes a single command with arguments inside a given castle' do
allow(homesick).to receive('inside').once.with(kind_of(Pathname)).and_yield
allow(homesick).to receive('say_status').once
.with(be_a(String), be_a(String), :green)
allow(homesick).to receive('system').once.with('ls -la')
Capture.stdout { homesick.exec 'castle_repo', 'ls', '-la' }
end
it 'raises an error when the method is called without a command' do
allow(homesick).to receive('say_status').once
.with(:error, be_a(String), :red)
allow(homesick).to receive('exit').once.with(1)
Capture.stdout { homesick.exec 'castle_repo' }
end
context 'pretend' do
it 'does not execute a command when the pretend option is passed' do
allow(homesick).to receive('say_status').once
.with(be_a(String), match(/.*Would execute.*/), :green)
expect(homesick).to receive('system').never
Capture.stdout { homesick.invoke 'exec', %w(castle_repo ls -la), pretend: true }
end
end
context 'quiet' do
it 'does not print status information when quiet is passed' do
expect(homesick).to receive('say_status').never
allow(homesick).to receive('system').once
.with('ls -la')
Capture.stdout { homesick.invoke 'exec', %w(castle_repo ls -la), quiet: true }
end
end
end
describe 'exec_all' do
before do
given_castle 'castle_repo'
given_castle 'another_castle_repo'
end
it 'executes a command without arguments inside the root of each cloned castle' do
allow(homesick).to receive('inside_each_castle').exactly(:twice).and_yield('castle_repo').and_yield('another_castle_repo')
allow(homesick).to receive('say_status').at_least(:once)
.with(be_a(String), be_a(String), :green)
allow(homesick).to receive('system').at_least(:once).with('ls')
Capture.stdout { homesick.exec_all 'ls' }
end
it 'executes a command with arguments inside the root of each cloned castle' do
allow(homesick).to receive('inside_each_castle').exactly(:twice).and_yield('castle_repo').and_yield('another_castle_repo')
allow(homesick).to receive('say_status').at_least(:once)
.with(be_a(String), be_a(String), :green)
allow(homesick).to receive('system').at_least(:once).with('ls -la')
Capture.stdout { homesick.exec_all 'ls', '-la' }
end
it 'raises an error when the method is called without a command' do
allow(homesick).to receive('say_status').once
.with(:error, be_a(String), :red)
allow(homesick).to receive('exit').once.with(1)
Capture.stdout { homesick.exec_all }
end
context 'pretend' do
it 'does not execute a command when the pretend option is passed' do
allow(homesick).to receive('say_status').at_least(:once)
.with(be_a(String), match(/.*Would execute.*/), :green)
expect(homesick).to receive('system').never
Capture.stdout { homesick.invoke 'exec_all', %w(ls -la), pretend: true }
end
end
context 'quiet' do
it 'does not print status information when quiet is passed' do
expect(homesick).to receive('say_status').never
allow(homesick).to receive('system').at_least(:once)
.with('ls -la')
Capture.stdout { homesick.invoke 'exec_all', %w(ls -la), quiet: true }
end
end
end
end

View File

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

View File

@@ -1,42 +0,0 @@
require 'coveralls'
Coveralls.wear!
$LOAD_PATH.unshift(File.dirname(__FILE__))
$LOAD_PATH.unshift(File.join(File.dirname(__FILE__), '..', 'lib'))
require 'homesick'
require 'rspec'
require 'test_construct'
require 'tempfile'
RSpec.configure do |config|
config.include TestConstruct::Helpers
config.expect_with(:rspec) { |c| c.syntax = :expect }
config.before { ENV['HOME'] = home.to_s }
config.before { silence! }
def silence!
allow(homesick).to receive(:say_status)
end
def given_castle(path, subdirs = [])
name = Pathname.new(path).basename
castles.directory(path) do |castle|
Dir.chdir(castle) do
system 'git init >/dev/null 2>&1'
system 'git config user.email "test@test.com"'
system 'git config user.name "Test Name"'
system "git remote add origin git://github.com/technicalpickles/#{name}.git >/dev/null 2>&1"
if subdirs
subdir_file = castle.join(Homesick::SUBDIR_FILENAME)
subdirs.each do |subdir|
File.open(subdir_file, 'a') { |file| file.write "\n#{subdir}\n" }
end
end
return castle.directory('home')
end
end
end
end

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

@@ -0,0 +1,328 @@
#!/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"
}
run_homesick_with_stdin() {
local stdin_data="$1"
shift
local out_file
local output
out_file="$(mktemp)"
if ! printf '%b' "$stdin_data" | 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"
}
run_homesick_with_env() {
local env_prefix="$1"
shift
local out_file
local output
out_file="$(mktemp)"
if ! bash -lc "$env_prefix $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/18] help"
run_homesick "help"
[[ "$RUN_OUTPUT" == *"Usage:"* || "$RUN_OUTPUT" == *"Commands:"* ]] || fail "expected help output to include command usage information"
run_homesick "help clone"
[[ "$RUN_OUTPUT" == *"clone"* ]] || fail "expected command help output for clone"
pass
echo "[2/18] clone"
run_homesick "clone file://$remote_root/base.git parity-castle"
run_homesick "clone file://$remote_root/base.git parity-castle-2"
assert_path_exists "$HOME/.homesick/repos/parity-castle/.git"
assert_path_exists "$HOME/.homesick/repos/parity-castle/home/.vimrc"
assert_path_exists "$HOME/.homesick/repos/parity-castle-2/.git"
run_git -C "$HOME/.homesick/repos/parity-castle" config user.email "behavior@test.local"
run_git -C "$HOME/.homesick/repos/parity-castle" config user.name "Behavior Test"
run_git -C "$HOME/.homesick/repos/parity-castle-2" config user.email "behavior@test.local"
run_git -C "$HOME/.homesick/repos/parity-castle-2" config user.name "Behavior Test"
pass
echo "[3/18] 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 "[4/18] 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 "[5/18] symlink alias"
run_homesick "symlink parity-castle"
assert_symlink_target "$HOME/.vimrc" "$HOME/.homesick/repos/parity-castle/home/.vimrc"
pass
echo "[6/18] 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 "[7/18] 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 equal parity-castle root path"
pass
echo "[8/18] 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 "[9/18] pull --all"
local pull_all_output
run_homesick "pull --all"
pull_all_output="$RUN_OUTPUT"
[[ "$pull_all_output" == *"parity-castle:"* ]] || fail "expected pull --all output to include parity-castle"
[[ "$pull_all_output" == *"parity-castle-2:"* ]] || fail "expected pull --all output to include parity-castle-2"
pass
echo "[10/18] single-castle pull"
pushd "$work_root/base" >/dev/null
echo "single-castle-pull" > home/.pull-single
run_git add .
run_git commit -m "single-castle pull fixture"
run_git push
popd >/dev/null
run_homesick "pull parity-castle"
assert_path_exists "$HOME/.homesick/repos/parity-castle/home/.pull-single"
pass
echo "[11/18] exec"
local exec_marker="$HOME/.homesick/repos/parity-castle/.exec-marker"
run_homesick "exec parity-castle touch .exec-marker"
assert_path_exists "$exec_marker"
pass
echo "[12/18] exec_all"
local exec_all_marker_a="$HOME/.homesick/repos/parity-castle/.exec-all-marker"
local exec_all_marker_b="$HOME/.homesick/repos/parity-castle-2/.exec-all-marker"
run_homesick "exec_all touch .exec-all-marker"
assert_path_exists "$exec_all_marker_a"
assert_path_exists "$exec_all_marker_b"
pass
echo "[13/18] generate"
local generated_castle="$HOME/generated-castle"
run_homesick "generate $generated_castle"
assert_path_exists "$generated_castle/.git"
assert_path_exists "$generated_castle/home"
pass
echo "[14/18] commit and push"
echo "commit-change" >> "$HOME/.zshrc"
run_homesick "commit parity-castle behavior-suite-commit"
run_homesick "push parity-castle"
local remote_head
remote_head="$(git --git-dir "$remote_root/base.git" log --oneline -1)"
[[ "$remote_head" == *"behavior-suite-commit"* ]] || fail "expected pushed commit in remote history"
pass
echo "[15/18] open"
run_homesick_with_env "EDITOR=true" "open parity-castle"
pass
echo "[16/18] cd"
run_homesick_with_env "SHELL=/bin/true" "cd parity-castle"
pass
echo "[17/18] rc --force"
local rc_marker="$HOME/rc-force-was-here"
cat > "$HOME/.homesick/repos/parity-castle/.homesickrc" <<EOF
File.write('$rc_marker', 'ok\n')
EOF
run_homesick "rc --force parity-castle"
assert_path_exists "$rc_marker"
pass
echo "[18/18] destroy confirmation + version"
run_homesick_with_stdin "n\n" "destroy parity-castle"
assert_path_exists "$HOME/.homesick/repos/parity-castle"
run_homesick_with_stdin "y\n" "destroy parity-castle"
assert_path_missing "$HOME/.homesick/repos/parity-castle"
assert_path_missing "$HOME/.vimrc"
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