568 Commits

Author SHA1 Message Date
Micheal Wilkinson
bbbacb0eb6 docs: update changelog for workflow hardening
Some checks failed
Push Validation / check-open-pr (push) Successful in 2s
Push Validation / validate (push) Has been skipped
Pull Request Validation / validate (pull_request) Failing after 2m9s
2026-03-21 22:54:07 +00:00
Micheal Wilkinson
28820748f7 ci: harden workflow dedup and badge gating 2026-03-21 22:54:07 +00:00
Micheal Wilkinson
1f93a3d532 docs: update changelog for push dedup guard
Some checks failed
Push Validation / check-open-pr (push) Failing after 2s
Push Validation / validate (push) Has been skipped
Pull Request Validation / validate (pull_request) Failing after 1m44s
2026-03-21 22:36:23 +00:00
Micheal Wilkinson
3104feb738 ci(push-validation): skip branch pushes with open PR 2026-03-21 22:36:23 +00:00
Micheal Wilkinson
e1a58b6607 docs: update changelog for concurrency deduplication
Some checks failed
Push Validation / validate (push) Has been cancelled
Pull Request Validation / validate (pull_request) Failing after 2m8s
2026-03-21 22:32:35 +00:00
Micheal Wilkinson
411c99532d ci: deduplicate runs via shared branch-name concurrency group 2026-03-21 21:21:33 +00:00
Micheal Wilkinson
607f43eaa0 docs: update changelog for push-validation branch trigger
Some checks failed
Push Validation / validate (push) Has been cancelled
Pull Request Validation / validate (pull_request) Failing after 1m54s
2026-03-21 21:18:56 +00:00
Micheal Wilkinson
0691c54965 ci(push-validation): trigger on all branches 2026-03-21 21:17:56 +00:00
Micheal Wilkinson
74640ddaa8 docs: update changelog for duplicate-run prevention
Some checks failed
Pull Request Validation / validate (pull_request) Failing after 2m0s
2026-03-21 21:15:19 +00:00
Micheal Wilkinson
354f3599b4 ci(push-validation): trigger only on main pushes 2026-03-21 21:15:19 +00:00
Micheal Wilkinson
ae86431d50 docs: update changelog for PR decoration gate fallback
Some checks failed
Pull Request Validation / validate (pull_request) Failing after 2m29s
Push Validation / validate (push) Successful in 3m7s
2026-03-21 21:12:10 +00:00
Micheal Wilkinson
9c7f6fbdf4 ci(pr-validation): fallback changelog gate and fix badge condition 2026-03-21 21:12:09 +00:00
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
Jeremy Cook
7080321081 Regenerate gemspec for version 1.1.6 2017-12-20 16:03:17 -05:00
Jeremy Cook
9d9cf66de6 Prepare for release of new version 2017-12-20 16:01:20 -05:00
Jeremy Cook
9e9a940825 Merge pull request #170 from diraol/fix-fileutils
Require fileutils correctly
2017-12-18 10:48:14 -05:00
Diego Rabatone Oliveira
257e974c38 Require fileutils correctly
Fix #165
2017-12-18 13:40:44 -02:00
Jeremy Cook
615e31428c Merge pull request #167 from mail6543210/master
Fix "diff" action on binary files
2017-10-13 17:59:36 -04:00
mail6543210
8c2a1d0f84 Add testcase 2017-09-23 00:41:33 +08:00
mail6543210
62c934774b Real fix for #148 2017-09-19 20:30:15 +08:00
mail6543210
d3d6974b7b Rename content to source
It is a instance of Pathname, not binary content
2017-09-19 20:29:06 +08:00
mail6543210
474d69da0b Revert "Use source content instead of source path (fixes: #148)"
This reverts commit ed397bdaf8.
2017-09-19 20:26:01 +08:00
Jeremy Cook
db6a513d1d Merge pull request #166 from MainShayne233/master
Enhancement/Add 'to' to error message.
2017-09-02 19:59:40 -04:00
Shayne Tremblay
ae343c4cab Add 'to' to error message. 2017-09-02 13:06:46 -07:00
Jeremy Cook
a2b365fb6f Merge pull request #164 from fuzzbomb/update-README-symlink-command
Rename symlink command to link in README.
2017-08-28 12:55:50 -04:00
Andrew Macpherson
4cb2006f41 Rename symlink command to link in README.
symlink command was previously renamed to link, but references in the README
were not updated.
2017-06-12 17:37:45 +01:00
Jeremy Cook
66347d307f Merge pull request #161 from philoserf/patch-1
Update ChangeLog.markdown
2017-03-23 17:33:31 -04:00
Mark Ayers
8f92a1b4f0 Update ChangeLog.markdown
Add the space that markdown requires between the header marker `#` and the header text.
2017-03-23 13:44:33 -07:00
Jeremy Cook
7a2df591c0 Regenerate gemspec for version 1.1.5 2017-03-23 08:37:44 -04:00
Jeremy Cook
4923265dea Updated homesick version and changelog due to problem with version
number in release.
2017-03-23 08:34:47 -04:00
Jeremy Cook
79421580e9 Merge branch 'master' of github.com:technicalpickles/homesick 2017-03-22 17:24:28 -04:00
Jeremy Cook
cabde9e5f1 Updated change log for new version. 2017-03-22 17:23:49 -04:00
Jeremy Cook
0d60ae9d1a Regenerate gemspec for version 1.1.4 2017-03-22 17:14:09 -04:00
Jeremy Cook
d5317b8e17 Bumped version in preparation for release. 2017-03-22 17:10:23 -04:00
Jeremy Cook
3b8a5b4be4 Merge pull request #159 from mruwek/refactor-conflict-actions
Wrap symlink and regular conflicts into one case
2017-03-20 16:07:19 -04:00
Jacek Sowiński
6590a1eeff Wrap symlink and regular conflicts into one case
This way we're not duplicating collision-related code.
2017-03-20 20:26:14 +01:00
Jeremy Cook
693ae5f05e Merge pull request #157 from mruwek/verbose-symlink-conflicts
Don't overwrite silently on symlink conflicts
2017-03-19 22:02:41 -04:00
Jeremy Cook
da3002f199 Merge pull request #154 from singular0/symlink_realpath
Use real paths of symlinks when linking castle into home
2017-03-19 22:02:01 -04:00
Jeremy Cook
feaaab2fa4 Merge pull request #158 from JCook21/master
Minor updates
2017-03-19 22:00:34 -04:00
Jeremy Cook
59f75711a4 Changed strings to use symbols. 2017-03-19 15:57:44 -04:00
Jeremy Cook
f24030b51f Updates to Gemfile to clean it up. 2017-03-19 15:57:22 -04:00
Jeremy Cook
71bb120a12 Update to dependency. 2017-03-19 14:44:44 -04:00
Jacek Sowiński
85f46e01b1 Don't overwrite silently on symlink conflicts
Symlink conflicts are now handled in similar fashion as normal
file-conflicts.
2017-03-17 19:31:23 +01:00
Jeremy Cook
c5b24b9b38 Merge pull request #155 from danielbayerlein/ruby-2.4.0
Add support for Ruby 2.4.0
2016-12-26 07:08:38 -05:00
Daniel Bayerlein
68460af45e Add support for Ruby 2.4.0 2016-12-26 10:31:34 +01:00
Denis Yantarev
5614b6b8b3 Use real paths of symlinks when linking castle into home 2016-12-25 18:34:33 +03:00
Jeremy Cook
570b063632 Merge pull request #152 from singular0/master
Thanks for taking care of this.
2016-12-24 12:13:55 -05:00
Jeremy Cook
1d398587d0 Remove config for removed ruby versions.
Deleted config for unused ruby versions.
2016-12-24 12:11:01 -05:00
Jeremy Cook
085853faaa Merge branch 'master' into master 2016-12-24 12:05:54 -05:00
Jeremy Cook
21b4e344a9 Merge pull request #153 from danielbayerlein/ruby-version
Looks good to me, thanks!
2016-12-24 12:03:51 -05:00
Daniel Bayerlein
a6194dfe8b Update RSpec 2016-12-23 11:59:44 +01:00
Daniel Bayerlein
5692194fa2 Add support for Ruby 2.2.6 and 2.3.3 2016-12-23 11:37:47 +01:00
Daniel Bayerlein
11745098c2 Support Ruby 2.1.0, 2.2.0, 2.3.0 2016-12-23 11:23:11 +01:00
Denis Yantarev
b1bb0c996c Add Ruby 2.2 & 2.3 to Travis config and fix GEM dependencies 2016-12-05 03:34:57 +03:00
Denis Yantarev
a62039da50 Ignore rbenv configuration files 2016-12-05 03:34:14 +03:00
Denis Yantarev
4bfd1c60c2 Fix default option value type warning 2016-12-03 15:56:22 +03:00
Jeremy Cook
f0e11abb5b Merge pull request #149 from mail6543210/master
Use source content instead of source path (fixes: #148). Thanks for fixing this!
2016-01-21 20:05:33 -05:00
mail6543210
ed397bdaf8 Use source content instead of source path (fixes: #148) 2016-01-21 18:36:52 +08:00
Jeremy Cook
2f5e20d963 Fixed formatting in Changelog file. 2015-10-31 09:54:41 -04:00
Jeremy Cook
cc83a4e1fa Preparing for 1.1.3 release. 2015-10-31 09:48:10 -04:00
Jeremy Cook
dcc5cb0bc1 Merge pull request #146 from JCook21/issue134
Fix for issue134
2015-10-25 15:21:26 -04:00
Jeremy Cook
978416d1e4 Fixing diff problems by providing source block and checking for
directories in diff
2015-10-20 21:47:43 -04:00
Jeremy Cook
1c12c73e4b Merge branch 'rweng-named_castles' 2015-10-14 22:31:20 -04:00
Jeremy Cook
1016002638 Merge branch 'named_castles' of https://github.com/rweng/homesick into rweng-named_castles 2015-10-14 22:30:46 -04:00
Jeremy Cook
6431a864ad Merge pull request #145 from JCook21/master
Maintenance fixes
2015-10-13 22:43:07 -04:00
Jeremy Cook
42f661cfbf Update to use new travis container infrastructure (see
http://docs.travis-ci.com/user/migrating-from-legacy/?utm_source=legacy-notice&utm_medium=banner&utm_campaign=legacy-upgrade)
2015-10-12 19:30:29 -04:00
Jeremy Cook
7632591681 Coding standards fixes based off of Rubocop and minor edits to make
logic flow easier to understand.
2015-10-12 19:30:29 -04:00
Jeremy Cook
a9a5b81dc5 Adding system notifications to development gems. 2015-10-12 19:30:29 -04:00
Jeremy Cook
721c10cffd Merge pull request #143 from rweng/fix-open
Fix homesick open
2015-10-12 11:06:10 -04:00
Robin Wenglewski
332aad8ad0 change 'homesick open' to run '$EDITOR .' instead of '$EDITOR' in castle_dir #142 2015-10-12 16:39:52 +02:00
Robin Wenglewski
171b4c1fb8 add option to pass in destination to homesick clone 2015-10-11 18:21:22 +02:00
Jeremy Cook
60d4458bbc Merge pull request #124 from shioyama/clone_destination
Pass destination when cloning url.
2015-03-22 14:12:41 -04:00
Chris Salzberg
9ad171ab78 Pass destination when cloning url. 2015-03-05 22:26:41 +09:00
Jeremy Cook
5918746059 Merge pull request #137 from gerasiov/master
Thanks!
2015-02-23 23:00:22 -05:00
Alexander GQ Gerasiov
4641843ffd Add "requite 'pathname'" to lib/homesick/utils.rb
Since Pathname is used in lib/homesick/utils.rb, it should require this module
itself.

Signed-off-by: Alexander GQ Gerasiov <gq@cs.msu.su>
2015-02-22 23:09:18 +03:00
Jeremy Cook
1a181b907c Merge pull request #136 from williamboman/patch-1
Thanks for fixing this!
2015-02-20 09:19:15 -05:00
William Boman
fb7595d254 Escape message correctly on git_commit_all. 2015-02-19 16:51:53 +01:00
Jeremy Cook
c8f0999035 Preparing for new release. 2015-01-02 00:06:20 -05:00
Jeremy Cook
46faec7857 Bug fix to make sure git check works properly. 2015-01-01 21:21:27 -05:00
Jeremy Cook
e35d3fe6ba Merge pull request #128 from wireframe/force-rc 2014-12-13 08:12:04 -05:00
Jeremy Cook
ba620e0f7f Merge pull request #127 from JCook21/CheckGit
In lieu of other comments I'm going to go ahead and merge this.
2014-12-02 20:53:42 -05:00
Ryan Sonnek
5700f55dc3 Add --force option to homesick rc command
Support automatically eval-ing .homesickrc file without prompting for user input.
This is particularly useful for headless scripts that do not support
user input.
2014-12-01 13:38:43 -06:00
Jeremy Cook
2c92010093 Fix to make tests pass in Ruby 1.9.3 2014-11-25 21:18:13 -05:00
Jeremy Cook
03490531d8 Changed name of git check method to be more descriptive. 2014-11-24 08:49:17 -05:00
Jeremy Cook
7bd9759e81 Added tests for a minimumGit version of 1.8.0. 2014-11-23 22:22:44 -05:00
Jeremy Cook
a808f56caf Tightened up git checking to check for a minimum installed version of
Git.
2014-11-23 14:32:47 -05:00
Jeremy Cook
b7e2b45e69 Added simple implementation to check if git is installed before
executing commands.
2014-11-20 21:25:47 -05:00
Jeremy Cook
63c45d7c3a Removed unneeded config line. 2014-11-09 11:44:09 -05:00
Jeremy Cook
096067ac62 Merge pull request #125 from JCook21/master
Updated to use Rspec 3.1
2014-10-01 21:37:44 -04:00
Jeremy Cook
8d6bf4c0c5 Updated to use Rspec 3.1 2014-09-30 19:39:16 -04:00
Jeremy Cook
882b862780 Merge pull request #123 from JCook21/SymlinkedHomes
I'm merging this as it seems to be harmless and hasn't elicited any responses.
2014-09-27 17:37:21 -04:00
Jeremy Cook
e06a5d6300 Merge pull request #122 from JCook21/SmokeTest
Added basic smoke test
2014-09-19 14:38:44 -04:00
Jeremy Cook
7451e8c739 Bug fix to cover cases where homes are symlinked. 2014-09-19 14:36:32 -04:00
Jeremy Cook
f034f773c5 Added a smoke test to ensure that calling bin/homesick outputs some text 2014-09-19 14:19:09 -04:00
Jeremy Cook
681fd98dc3 Merge pull request #121 from PeterDaveHello/patch-1
Looks good to me, thanks.
2014-09-19 12:10:34 -04:00
Jeremy Cook
e57b139e32 Merge pull request #117 from JCook21/Shell
Merging as there doesn't seem to be any comments or issues with this.
2014-09-19 12:08:54 -04:00
Peter Dave Hello
b64bfe2bb6 Use svg instead of png to get better image quality 2014-09-08 15:42:54 +08:00
Jeremy Cook
ee04b5788a Removed the homesick shell module and folded its code in elsewhere. 2014-06-14 15:03:57 -04:00
Jeremy Cook
2e8d431ab5 Regenerate gemspec for version 1.1.1 2014-05-21 20:56:20 -04:00
Jeremy Cook
3465c37c0e Updated version to 1.1.1 2014-05-21 20:56:07 -04:00
Jeremy Cook
bf6894e313 Regenerate gemspec for version 1.1.0 2014-05-21 20:55:35 -04:00
Jeremy Cook
77e3f7f479 Merge pull request #116 from JCook21/master
Minimum Ruby Version
2014-05-21 20:54:15 -04:00
Jeremy Cook
753f5027b0 Added minimum ruby version to the gemspec. 2014-05-08 22:07:37 -04:00
Jeremy Cook
23c012a527 Regenerate gemspec for version 1.1.0 2014-04-28 20:48:43 -04:00
Jeremy Cook
895543641b Added details of 1.1.0 release. 2014-04-28 20:45:52 -04:00
Jeremy Cook
72bfc5a2fd Regenerate gemspec for version 1.1.0 2014-04-28 19:20:20 -04:00
Jeremy Cook
f5054cf41d Bumped version number. 2014-04-28 19:20:02 -04:00
Jeremy Cook
b60703d496 Bumped version number in preparation for a release. 2014-04-26 18:06:54 -04:00
Jeremy Cook
9a8788fb80 Removed uneeded config lines since the pretend and quiet options are set
in global config.
2014-04-26 18:04:34 -04:00
Jeremy Cook
1a44edcde1 Merge pull request #111 from JCook21/Pretend
Edits to keep code DRY
2014-04-26 17:45:08 -04:00
Nicolas McCurdy
383f2a9f32 Merge pull request #113 from JCook21/BugFix
Bug fix
2014-04-23 01:17:08 -04:00
Jeremy Cook
f55828f1d4 Fixing a bug that breaks the handling of collisions. 2014-04-22 20:54:05 -04:00
Jeremy Cook
d4f9633a0c Added ability for methods to be overrode, through the pretend and quiet
options, skipping their default behaviour if so.
2014-04-22 19:43:40 -04:00
Jeremy Cook
b5138bcdd1 Merge pull request #110 from nicolasmccurdy/refactor
Refactor
2014-04-22 12:46:49 -04:00
Nicolas McCurdy
d9ee74bf14 Move GitActions and FileActions into a new Actions module 2014-04-16 20:54:26 -04:00
Nicolas McCurdy
2148697864 Separate Actions into two new modules: FileActions and GitActions 2014-04-16 18:05:02 -04:00
Nicolas McCurdy
1c3403064e Don't refer to Homesick as a class 2014-04-16 17:57:00 -04:00
Nicolas McCurdy
03d87807e0 Use require instead of autoload (fix #108) 2014-04-16 17:48:36 -04:00
Nicolas McCurdy
f6c4e5e42e Require Thor in Homesick::Shell 2014-04-16 17:29:17 -04:00
Nicolas McCurdy
705a416d74 Extract the CLI into a new Homesick::CLI class, while making Homesick a module 2014-04-16 17:20:05 -04:00
Nicolas McCurdy
41f3f9d374 Merge pull request #107 from JCook21/Utils
Small refactoring to keep main homesick class smaller.
2014-04-16 13:38:56 -04:00
Jeremy Cook
74cfd29272 Small refactoring to keep main homesick class smaller. 2014-04-15 22:20:12 -04:00
Jeremy Cook
9a3268b7c3 Merge pull request #100 from nicolasmccurdy/use-coveralls
Use coveralls
2014-04-15 21:15:46 -04:00
Nicolas McCurdy
70c1666606 Add a Gitter badge to the readme 2014-04-13 17:20:28 -04:00
Jeremy Cook
53ac09a5e9 Merge pull request #101 from nicolasmccurdy/use-libraries
Use stdlib methods to replace most non-git shell calls. Looks good.
2014-04-13 17:17:30 -04:00
Jeremy Cook
c790e34b39 Added documentation for the exec and exec_all commands. 2014-04-13 14:58:54 -04:00
Nicolas McCurdy
8428ad1c9c Merge pull request #105 from JCook21/ExecCommands
Further work on exec command
2014-04-13 14:29:59 -04:00
Jeremy Cook
efea18327b Added options for exec command and a new exec_all command. 2014-04-13 10:59:40 -04:00
Nicolas McCurdy
84fb1d1462 Replace a system call in the spec helper 2014-04-06 02:01:15 -04:00
Nicolas McCurdy
5dc7b5068d Replace any system calls in lib that don't use git with calls to FileUtils 2014-04-06 02:01:15 -04:00
Nicolas McCurdy
ab603240e4 Merge pull request #97 from JCook21/exec
Added Exec command
2014-04-06 00:48:20 -04:00
Nicolas McCurdy
2e5c2ec018 Add coverage status to the readme 2014-04-05 23:44:27 -04:00
Nicolas McCurdy
349e75584f Switch from simplecov to coveralls for code coverage (in the cloud!!!) 2014-04-05 23:27:29 -04:00
Jeremy Cook
bea3a0b680 Added exec command to homesick. 2014-04-05 09:21:16 -04:00
Jeremy Cook
8b6bf92e9a Applied fixes that allow tests to pass again. 2014-04-05 09:18:56 -04:00
Nicolas McCurdy
133c3613e9 Merge pull request #96 from nicolasmccurdy/fix-simplecov
Fix simplecov
2014-04-05 00:47:31 -04:00
Nicolas McCurdy
ff2e5ee064 Merge pull request #95 from nicolasmccurdy/use-expect-syntax
Use expect syntax
2014-04-04 23:23:42 -04:00
Nicolas McCurdy
22aed48d4e Set up simplecov (it was in the Gemfile before, but it wasn't actually used) 2014-04-04 15:11:31 -04:00
Nicolas McCurdy
4c7e45a1d5 Use simplecov instead of rcov for all Rubies, since we have dropped 1.8 anyway 2014-04-04 15:08:36 -04:00
Nicolas McCurdy
ca41ae7f85 Only allow RSpec's new "expect" syntax 2014-04-04 02:10:19 -04:00
Nicolas McCurdy
fa61e7b10e Avoid using "should" in example descriptions 2014-04-04 01:53:23 -04:00
Nicolas McCurdy
8397dcacc5 Convert specs to RSpec 2.14.8 syntax with Transpec
This conversion is done by Transpec 1.10.4 with the following command:
    transpec

* 39 conversions
    from: obj.should
      to: expect(obj).to

* 22 conversions
    from: == expected
      to: eq(expected)

* 18 conversions
    from: obj.should_receive(:message)
      to: expect(obj).to receive(:message)

* 13 conversions
    from: obj.stub(:message)
      to: allow(obj).to receive(:message)

* 11 conversions
    from: obj.should_not
      to: expect(obj).not_to

* 5 conversions
    from: =~ /pattern/
      to: match(/pattern/)

* 2 conversions
    from: obj.should_not_receive(:message)
      to: expect(obj).not_to receive(:message)
2014-04-04 01:37:44 -04:00
Jeremy Cook
3f2d343161 Merge pull request #93 from JCook21/master
Made tests less reliant on file paths
2014-03-16 10:44:57 -04:00
Jeremy Cook
d91628f811 Made tests more generic since file paths may be different on various
systems.
2014-03-08 17:26:07 -05:00
Jeremy Cook
94bff3aa9d Small change to make symlink text a symbol in the map command. 2014-01-25 23:16:15 -05:00
Christian Bundy
7253bdd634 Merge pull request #82 from thenickperson/rubocop-fixes
Reduce Rubocop errors
2014-01-23 12:19:57 -08:00
Jeremy Cook
8c6a17404f Merge pull request #83 from thenickperson/remove-duplicate-methods
Remove duplicate methods rm and rm_link
2014-01-22 04:14:27 -08:00
Nicolas McCurdy
98edb54ca4 Merge remote-tracking branch 'upstream/master' into rubocop-fixes
Conflicts:
	lib/homesick.rb
2014-01-21 22:37:48 -05:00
Christian Bundy
9b780ffac6 Merge pull request #87 from JCook21/Consistent
Rename "symlink" command to "link" (with backward compatibility)
2014-01-21 16:17:12 -08:00
Jeremy Cook
59f6239ea0 Renamed symlink command to link. 2014-01-21 17:21:07 -05:00
Nicolas McCurdy
c2cb6081e1 Fix some more code style issues with block params being in the wrong place 2014-01-20 23:26:29 -05:00
Nicolas McCurdy
95943deb82 Revert changes that use "\" and disable the line length cop 2014-01-20 21:03:21 -05:00
Nicolas McCurdy
08a71f657f Fix a minor code style issue where "do" wasn't on the same line as its params 2014-01-20 16:03:18 -05:00
Nicolas McCurdy
2b48544e32 Merge pull request #81 from thenickperson/ruby-2.1
Test with Ruby 2.1.0 on Travis and add it to the supported Rubies
2014-01-20 12:56:20 -08:00
Nicolas McCurdy
f1191d4b3c Remove duplicate methods rm and rm_link 2014-01-16 02:44:04 -05:00
Nicolas McCurdy
8173429131 Ignore the remaining cyclomatic complexity issues 2014-01-16 00:31:00 -05:00
Nicolas McCurdy
82be04ad8a Fix rubocop issues for some recently merged code 2014-01-16 00:22:13 -05:00
Nicolas McCurdy
bb735763c6 Separate the action handling of ln_s into a new method to lower complexity 2014-01-15 23:48:56 -05:00
Nicolas McCurdy
0bbb82f9ba Wrap all lines of Ruby code to 79 characters (maximum) 2014-01-15 23:44:39 -05:00
Nicolas McCurdy
e42eff4e10 Refactor the ln_s method to decrease its cyclomatic complexity 2014-01-15 23:34:12 -05:00
Nicolas McCurdy
7e659f11fe Fix some broken spec expectations and move/rename a method 2014-01-15 23:29:40 -05:00
Nicolas McCurdy
c667cefd4c Refactor the clone method to decrease its cyclomatic complexity 2014-01-15 23:29:40 -05:00
Nicolas McCurdy
571c5799e9 Swap some if/else statements so the positive case is always first 2014-01-15 23:23:30 -05:00
Nicolas McCurdy
604a3b2a20 Add todo comments to the rubocop config 2014-01-15 23:23:30 -05:00
Nicolas McCurdy
7d36460851 Rename the todo config to .rubocop.yml, now that more rubocop issues are fixed 2014-01-15 23:23:30 -05:00
Nicolas McCurdy
8f634b9d07 Add brief documentation comments for Homesick and Homesick::Actions 2014-01-15 23:23:30 -05:00
Nicolas McCurdy
12244abb56 Fix a few more syntax-related rubocop issues manually 2014-01-15 23:23:29 -05:00
Nicolas McCurdy
fc2bbb1d6e Fix several rubocop issues with "rubocop -a" 2014-01-15 23:23:29 -05:00
Nicolas McCurdy
f03e7670cf Fix an issue reported by rubocop that wasn't ignored in the todo config
Issue text: Favor modifier if/unless usage when you have a single-line body.
2014-01-15 23:22:03 -05:00
Nicolas McCurdy
e202b7eae7 Add a rubocop todo-style config created by "--auto-gen-config" and inherit it 2014-01-15 23:22:03 -05:00
Nicolas McCurdy
3fcbd21104 Test with Ruby 2.1.0 on Travis and add it to the supported Rubies 2014-01-15 21:55:28 -05:00
Jeremy Cook
db0f604faf Regenerate gemspec for version 1.0.0 2014-01-15 20:24:36 -05:00
Jeremy Cook
e5a6e43333 Preparing for 1.0.0 release. 2014-01-15 20:23:25 -05:00
Jeremy Cook
faa5f0b9ed Merge pull request #79 from JCook21/Tests
Added tests for untested methods
2014-01-15 16:43:35 -08:00
Jeremy Cook
7c13727978 Merge branch 'master' into Tests 2014-01-15 19:41:06 -05:00
Jeremy Cook
2dba8b6496 Merge branch 'master' of github.com:technicalpickles/homesick 2014-01-15 19:38:29 -05:00
Jeremy Cook
2dadd4e064 Removed libnotify which seems to be causing build errors. 2014-01-15 19:38:02 -05:00
Jeremy Cook
a60ca62eba Merge pull request #71 from thenickperson/master
Merging this one in following discussions on #80.
2014-01-15 16:35:17 -08:00
Nicolas McCurdy
674ffb6bb2 Merge remote-tracking branch 'upstream/master' 2014-01-15 16:57:41 -05:00
Jeremy Cook
5fa0fc037c Updated README for .homesickrc. 2014-01-10 09:36:09 -05:00
Nicolas McCurdy
8a537b8204 Add gem version, dependency status, and code quality badges to the readme 2014-01-10 09:36:09 -05:00
Jeremy Cook
6e25f13e06 Added libnotify Gem on *nix systems in case people want to use it for
Guard notifications.
2014-01-10 09:36:09 -05:00
Jeremy Cook
df8f6b1cb0 Added command to display current version of homesick. 2014-01-10 09:36:09 -05:00
Jeremy Cook
6050a9a7ac Updated README for .homesickrc. 2014-01-09 22:38:47 -05:00
Jeremy Cook
0abd9436ad Merge branch 'master' of github.com:technicalpickles/homesick 2014-01-09 22:34:36 -05:00
Jeremy Cook
af159f5b97 Added libnotify Gem on *nix systems in case people want to use it for
Guard notifications.
2014-01-09 17:45:05 -05:00
Josh Nichols
a657c5622e Merge pull request #77 from thenickperson/readme-badges
Add gem version, dependency status, and code quality badges to the readme
2014-01-09 08:11:56 -08:00
Nicolas McCurdy
6f216cd916 Add gem version, dependency status, and code quality badges to the readme 2014-01-09 02:59:26 -05:00
Nicolas McCurdy
8bf1864335 Switch from the test-construct gem (deprecated) to test_construct 2014-01-09 02:49:52 -05:00
Nicolas McCurdy
8931739e97 Travis: Don't test on Ruby 1.8 (it's deprecated, and it breaks the build) 2014-01-09 02:49:52 -05:00
Jeremy Cook
ab46cf7b2f Merge branch 'master' of github.com:technicalpickles/homesick into Tests 2014-01-06 21:47:17 -05:00
Jeremy Cook
d115714a9f Merge branch 'master' of github.com:technicalpickles/homesick 2014-01-06 21:45:04 -05:00
Jeremy Cook
e787abd3f3 Merge pull request #78 from JCook21/Docs
Added docs for the rc command.
2014-01-06 18:43:06 -08:00
Jeremy Cook
1c0fe66944 Added docs for the rc command. 2014-01-06 21:42:24 -05:00
Jeremy Cook
148d18565f Added tests for untested methods. 2014-01-06 21:24:24 -05:00
Jeremy Cook
264d586863 Updated tests to remove shell output from test results. 2014-01-06 21:24:24 -05:00
Jeremy Cook
76bee65475 Updated gems to use test_construct, removing warning message. 2014-01-06 21:24:24 -05:00
Jeremy Cook
8dac49548c Added command to display current version of homesick. 2014-01-06 19:12:13 -05:00
Jeremy Cook
5c5d204d15 Removed notification settings from project so that user can set them in
~/.guard.rb instead.
2014-01-05 10:08:29 -05:00
Jeremy Cook
e1f85973c1 Added documentation for the new commands to the readme file. 2014-01-02 10:03:24 -05:00
Jeremy Cook
3554806741 Updated gemspec file for homesick. 2013-12-31 21:50:17 -05:00
Jeremy Cook
e4cc308d43 Merge pull request #69 from JCook21/master
Added cd and open commands and tests for commit and status
2013-12-31 18:23:23 -08:00
Jeremy Cook
78271a9ed4 Added commands to cd into a castle and to open a shell and to open the
default editor in the root of a given castle.
2013-12-31 21:20:13 -05:00
Jeremy Cook
8f67188c19 Added new tests for status and commit 2013-12-31 21:18:14 -05:00
Jeremy Cook
c432b27c92 Added guard to project to run tests automatically. 2013-12-30 20:08:17 -05:00
Josh Nichols
30a3bbb198 Merge pull request #67 from technicalpickles/reword-third-person-to-second-person
Reword README's language from third person to second person
2013-12-17 14:21:17 -08:00
Josh Nichols
e7f9358f96 Update jeweler's gemspec language too 2013-12-17 17:18:51 -05:00
Josh Nichols
750c7773ae Reword 'A person's home is their case' to 'Your home directory is your casetle' 2013-12-17 17:16:54 -05:00
Trae Robrock
900277f426 Merge pull request #64 from zacharyalexstern/master
Update README.markdown to remove gendered terms
2013-12-01 16:38:57 -08:00
Zachary Alex Stern
208adeef6c Update README.markdown
Replaced gendered terms/pronouns.

This is a great project, and it can be even greater if it doesn't perpetuate the assumption-of-maleness that seems to pervade all tech spaces.

I do realize that "a man's home is his castle" is a saying, and not just arbitrary, but I counter that with the age old argument: But Still.
2013-11-30 14:23:47 -08:00
Thilko Richter
086828b12f Merge pull request #24 from thilko/master
Destroy your castles
2013-11-25 11:26:29 -08:00
Christian Bundy
c73d556e6f Merge branch 'thilko' into destroy-castles
Conflicts:
	lib/homesick/actions.rb
	spec/homesick_spec.rb
2013-11-24 19:01:30 -08:00
thilko
357e2f60f2 delete repo dir 2013-11-23 22:05:07 +01:00
thilko
243ba70b33 using rm_rf to avoid confirmations on delete 2013-11-23 21:54:40 +01:00
thilko
640da07089 call unlink before removing the castle repo 2013-11-23 21:06:41 +01:00
thilko
69c38774fe ask user before start destroying 2013-11-23 21:01:53 +01:00
Thilko Richter
44527850f6 destroy action implemented 2013-11-23 19:42:39 +01:00
Trae Robrock
8d96b2c31f Regenerate gemspec for version 0.9.7 2013-11-23 19:38:53 +01:00
Trae Robrock
0019e8c61c Bump version 2013-11-23 19:38:53 +01:00
bcd
545f5fc3e9 Git clone now uses config push.default upstream 2013-11-23 19:38:53 +01:00
Chris Ball
5108de20c3 Remove symlink shorthand note in README
Seems that using `homesick symlink <username>/<reponame>` after a clone does not locate the directory properly. `homesick symlink <reponame>` works in both cases regardless if the shorthand or full git url was used to clone.
2013-11-23 19:38:53 +01:00
Trae Robrock
9656be1dde Regenerate gemspec for version 0.9.6 2013-11-23 19:38:53 +01:00
Trae Robrock
09890e8048 Bump version 2013-11-23 19:38:53 +01:00
Trae Robrock
b672b4c526 Regenerate gemspec for version 0.9.5 2013-11-23 19:38:53 +01:00
mingkai
6ca49327c3 Change github-repo pattern to allow numbers for usernames/repos 2013-11-23 19:38:53 +01:00
bcd87
3d47cc44af Rake now passes on Ruby 1.8.7 on my machine 2013-11-23 19:38:52 +01:00
bcd87
e8b471ac97 Changed the spec, bundle exec rake works 2013-11-23 19:38:52 +01:00
bcd87
0e6915b860 Using the homesick clone github shortcut now clones to ~/.homesick/repos/repo/, in stead of ~/.homesick/repos/username/repo/ 2013-11-23 19:38:52 +01:00
Trae Robrock
0d48e517f8 Regenerate gemspec for version 0.9.5 2013-11-23 19:38:52 +01:00
Trae Robrock
f2469ecaaf Bump version the right way 2013-11-23 19:38:52 +01:00
Trae Robrock
c8451c7d3f Regenerate gemspec for version 0.9.4 2013-11-23 19:38:52 +01:00
Trae Robrock
d3025a34ca Bump version 2013-11-23 19:38:52 +01:00
Trae Robrock
17426583e0 Add test for parens in filenames, and fixed 2013-11-23 19:38:52 +01:00
muratayusuke
04c4a4c059 Regenerate gemspec for version 0.9.4 2013-11-23 19:38:52 +01:00
muratayusuke
6ae0aaa6f9 bump up version 2013-11-23 19:38:51 +01:00
muratayusuke
d22361f2ac Regenerate gemspec for version 0.9.3 2013-11-23 19:38:51 +01:00
muratayusuke
e21e608cca bump up version 2013-11-23 19:38:51 +01:00
muratayusuke
238658cf69 remove unused variables 2013-11-23 19:38:51 +01:00
muratayusuke
2a361d86e0 fix deprecated method: stub! -> stub 2013-11-23 19:38:51 +01:00
Trae Robrock
294fb3d4ce Add tests for the rc command 2013-11-23 19:38:51 +01:00
Trae Robrock
342da7e250 Adding test for clone running homesickrc 2013-11-23 19:38:51 +01:00
John Bellone
9c52108035 Update specs to for change to https vs. git protocol. 2013-11-23 19:38:51 +01:00
Trae Robrock
5b954b93e3 Fix #19 homesickrc pathname needs a to_s to eval
Also, moved the file evaluation into a new function so the script can be
ran manually which should make testing these scripts easier.
2013-11-23 19:38:50 +01:00
Trae Robrock
b596e063f5 Add unlink functionality 2013-11-23 19:38:50 +01:00
Trae Robrock
965b35b78c Ignore vendor dir 2013-11-23 19:37:25 +01:00
muratayusuke
9551b3acb4 Regenerate gemspec for version 0.9.3 2013-11-23 19:37:25 +01:00
muratayusuke
27ac1c7782 bump up version 2013-11-23 19:37:25 +01:00
muratayusuke
f9d0b69bce add recursive option to 'homesick clone' 2013-11-23 19:37:25 +01:00
muratayusuke
a91ce82d77 Regenerate gemspec for version 0.9.2 2013-11-23 19:37:25 +01:00
muratayusuke
d8b5d8163b remove Gemfile.lock from repository 2013-11-23 19:37:25 +01:00
muratayusuke
6fca06d341 bump up version 2013-11-23 19:37:24 +01:00
Austin Lin
44080829e3 Update readme with correct file path for .homesick_subdir per technicalpickles/homesick@360e8185f7 2013-11-23 19:37:24 +01:00
David Simon
651e028d5b Whoops, fixed typo 2013-11-23 19:37:24 +01:00
David Simon
6f3186df2f Using DEFAULT_CASTLE_NAME in show_path, diff, status 2013-11-23 19:37:24 +01:00
David Simon
92c61f928e Added three commands: show_path, status, diff 2013-11-23 19:37:24 +01:00
akahige
d3cb45f879 default castle name to constant 2013-11-23 19:37:24 +01:00
John Bellone
314e2932fb Update homesick.rb to make https default for GitHub clones.
If we use HTTPS it is a lot easier for corporate worlds to manage proxies since its usually already done for us. Also HTTPS cloning is just as fast as the git protocol as of more recent versions.
2013-11-23 19:37:24 +01:00
akahige
8ee5165ccf set default castle name: 'dotfiles' for some commands 2013-11-23 19:37:24 +01:00
muratayusuke
8f2a9e6703 remove duplicate spec 2013-11-23 19:37:24 +01:00
muratayusuke
f8a6fb9ce2 follow Ruby Style Guide for some points 2013-11-23 19:37:23 +01:00
muratayusuke
bf248cd645 don't fail test even if rubocop detects some offence 2013-11-23 19:36:38 +01:00
muratayusuke
1563814cb0 use single-quate if don't need double-quate 2013-11-23 19:36:37 +01:00
muratayusuke
91770998a7 don't install rubocop under ruby 1.9.2 2013-11-23 19:36:37 +01:00
muratayusuke
d7aca1025f don't run rubocode on ruby 1.8.7 2013-11-23 19:36:37 +01:00
muratayusuke
b3298d18c8 fix coding style 2013-11-23 19:36:37 +01:00
muratayusuke
359147e7e8 use rubocop 2013-11-23 19:36:37 +01:00
muratayusuke
937f1912d7 Regenerate gemspec for version 0.9.1 2013-11-23 19:36:37 +01:00
muratayusuke
18c0e32309 bump up version 2013-11-23 19:36:37 +01:00
Fletcher Nichol
1518cb1155 Remove .git suffix on destination directory if URL ends with it.
For example, the following:

    homesick clone git://github.com/technicalpickles/pickled-vim.git

should produce a castle directory of:

    $HOME/.homesick/repos/pickled-vim
2013-11-23 19:36:37 +01:00
muratayusuke
c5b7dd2918 fix #35 2013-11-23 19:36:36 +01:00
muratayusuke
b668b7eda2 Regenerate gemspec for version 0.9.0 2013-11-23 19:36:36 +01:00
muratayusuke
a595ead2c6 bump up version and update changelog 2013-11-23 19:36:36 +01:00
muratayusuke
cc1ee544c3 move castle/home/.homesick_subdir to castle/.homesick_subdir 2013-11-23 19:36:36 +01:00
Yusuke Murata
a0862936e8 fix typo 2013-11-23 19:36:36 +01:00
Yusuke Murata
f91f5743b6 fix style of README 2013-11-23 19:36:36 +01:00
Yusuke Murata
eb74b90b42 fix style of README 2013-11-23 19:36:36 +01:00
muratayusuke
c3c108bd50 add .homesick_subdir explanation to README 2013-11-23 19:36:36 +01:00
muratayusuke
06975f79f5 deal with edge case: the parent and descendant are both listed in the manifest 2013-11-23 19:36:36 +01:00
muratayusuke
830106a168 refactor symlink 2013-11-23 19:36:35 +01:00
muratayusuke
aa2dfcc42f refactor given_castle 2013-11-23 19:36:35 +01:00
muratayusuke
ed71fd6227 add ruby-2.0.0 to travis 2013-11-23 19:36:35 +01:00
muratayusuke
fd60528567 fix spec for ruby-1.8.7 2013-11-23 19:36:35 +01:00
muratayusuke
01934d5b00 replace .manifest to .homesick_subdir 2013-11-23 19:36:35 +01:00
muratayusuke
bba0e3ed7d support nested dir in .homesick_subdir 2013-11-23 19:36:35 +01:00
Eric West
7db0b13d30 refactor, cleanup and tweak 2013-11-23 19:36:35 +01:00
Eric West
b1c6c8f835 Handling edge cases
Covers only edge cases related to tracking, not yet
handling linking or updating. Getting a bit hairy,
must be refactored.
2013-11-23 19:36:35 +01:00
muratayusuke
7620f40cb2 symlink subdirs with .homesick_subdir 2013-11-23 19:36:34 +01:00
Eric West
cf9058be04 Track makes entries in .manifest
When a user tracks a file or directory that is in
a nested folder, Homesick creates a .manifest in the
user's castle (if there isn't one already) and adds
an entry listing the file or directory's parent
directory (if it isn't already listed).
2013-11-23 19:36:34 +01:00
Eric West
234532ebef Specs for track 2013-11-23 19:36:34 +01:00
Eric West
ccddbb1316 Track now properly traverses folder structure 2013-11-23 19:36:34 +01:00
muratayusuke
4ef315d4e2 Regenerate gemspec for version 0.8.1 2013-11-23 19:36:34 +01:00
muratayusuke
9b7fe331f6 update changelog 2013-11-23 19:36:34 +01:00
muratayusuke
eeff0b40fb update rake version
"rake spec" showed following warning
/home/muratayusuke/.rvm/gems/ruby-1.9.3-p392/gems/rake-0.8.7/lib/rake/alt_system.rb:32: Use RbConfig instead of obsolete and deprecated Config.
(in /mnt/projects/homesick)
/home/muratayusuke/.rvm/rubies/ruby-1.9.3-p392/bin/ruby -S rspec spec/homesick_spec.rb
so update rake version to fix it.
2013-11-23 19:36:34 +01:00
Eric West
0c933c0085 Fixes glob to work with 2.0.0
homesick list fails on ruby 2.0.0-rc2, I think because they fixed this
bug: https://bugs.ruby-lang.org/issues/6977, changing the way recursive
globs work. Test case:

```ruby
require "homesick"
require "pathname"

repos = Homesick.new.send :repos_dir

Dir.glob("#{repos}/**/*/.git") # => []

Dir.glob("#{repos}/**/*/.git", File::FNM_DOTMATCH) # => ["/home/eric/.homesick/repos/dotfiles/.git"]

```
This change, however, then broke 1.9.3, but removing the extra "/*"
works on both 1.9 and 2.0.
2013-11-23 19:36:34 +01:00
muratayusuke
a3d94fcca6 add build status to README 2013-11-23 19:36:33 +01:00
muratayusuke
9fe1d190da prepare for release 0.8.0 2013-11-23 19:36:33 +01:00
Jacob Buys
73981c2e75 Build with Travis CI 2013-11-23 19:36:33 +01:00
Josh Nichols
107dec388e Fix git_clone to work with github URLs like https://github.com/technicalpickles/dotpickles 2013-11-23 19:36:33 +01:00
muratayusuke
1df44aea40 enable recursive submodule 2013-11-23 19:36:33 +01:00
Jason Buckner
3bc623be7c perform a git add when doing a homesick track 2013-11-23 19:36:33 +01:00
Jason Buckner
123e6cf82d splits up castle commit from castle push for more fine-grained control 2013-11-23 19:34:08 +01:00
Jason Buckner
5e9d134021 update documentation for naming consistency 2013-11-23 19:34:08 +01:00
Jason Buckner
0a022fddcc fix homesick pull documentation 2013-11-23 19:34:08 +01:00
Jason Buckner
f18a4dc16f add homesick push to readme, a placeholder test, and removed the all option from git push 2013-11-23 19:34:07 +01:00
Jason Buckner
9ac754fd40 start adding git push functionality 2013-11-23 19:34:07 +01:00
Jorge
2667053fde Making git repos uri non greedy so it works with uris with / 2013-11-23 19:34:07 +01:00
Trae Robrock
8874994feb Regenerate gemspec for version 0.9.7 2013-11-02 16:03:57 -07:00
Trae Robrock
5a8b92f556 Bump version 2013-11-02 16:03:51 -07:00
Trae Robrock
a65c2e6a1f Merge pull request #57 from boyvanduuren/issue_54
Git clone now uses config push.default upstream
2013-11-02 16:02:20 -07:00
bcd
2d0304feb1 Git clone now uses config push.default upstream 2013-11-02 21:15:51 +01:00
Trae Robrock
34fec63234 Merge pull request #56 from cball/update-readme
Remove symlink shorthand note in README
2013-10-30 15:20:33 -07:00
Chris Ball
1d5f27f567 Remove symlink shorthand note in README
Seems that using `homesick symlink <username>/<reponame>` after a clone does not locate the directory properly. `homesick symlink <reponame>` works in both cases regardless if the shorthand or full git url was used to clone.
2013-10-30 14:01:40 -04:00
Trae Robrock
093db8bdac Regenerate gemspec for version 0.9.6 2013-10-18 07:18:15 -07:00
Trae Robrock
04602efd6e Bump version 2013-10-18 07:18:07 -07:00
Trae Robrock
3265de0c1d Regenerate gemspec for version 0.9.5 2013-10-18 07:17:39 -07:00
Trae Robrock
e4a428e0c5 Merge pull request #55 from mingkai0812/master
Change github-repo pattern to allow numbers for usernames/repos
2013-10-18 07:15:05 -07:00
mingkai
6cc28450a4 Change github-repo pattern to allow numbers for usernames/repos 2013-10-18 14:35:56 +02:00
Yusuke Murata
a0c7fbacb7 Merge pull request #53 from boyvanduuren/issue52
Change the destination for the github clone shortcut
2013-10-07 09:39:40 -07:00
bcd87
b750094934 Rake now passes on Ruby 1.8.7 on my machine 2013-09-24 08:15:56 +02:00
bcd87
d953a964cd Changed the spec, bundle exec rake works 2013-09-23 20:58:16 +02:00
bcd87
aa95ffac82 Using the homesick clone github shortcut now clones to ~/.homesick/repos/repo/, in stead of ~/.homesick/repos/username/repo/ 2013-09-23 20:30:10 +02:00
Trae Robrock
9c345828b0 Regenerate gemspec for version 0.9.5 2013-09-18 09:48:33 -07:00
Trae Robrock
59c137c653 Bump version the right way 2013-09-18 09:48:26 -07:00
Trae Robrock
3d405542af Regenerate gemspec for version 0.9.4 2013-09-18 09:47:46 -07:00
Trae Robrock
88ff4b85ce Bump version 2013-09-18 09:47:36 -07:00
Trae Robrock
461ac5f226 Merge pull request #51 from trobrock/fix-quoting
Files with parentheses in filename fail to symlink/track
2013-09-18 09:46:40 -07:00
Trae Robrock
a7d2d0a3f3 Add test for parens in filenames, and fixed 2013-09-18 09:25:09 -07:00
muratayusuke
191ce11d8e Regenerate gemspec for version 0.9.4 2013-07-31 23:41:49 +09:00
muratayusuke
0d28a3ef9b bump up version 2013-07-31 23:41:36 +09:00
muratayusuke
ac34249afe Regenerate gemspec for version 0.9.3 2013-07-31 23:40:22 +09:00
muratayusuke
d1f87be435 bump up version 2013-07-31 23:38:37 +09:00
muratayusuke
cbb6117d69 remove unused variables 2013-07-31 23:21:46 +09:00
muratayusuke
334a1db262 fix deprecated method: stub! -> stub 2013-07-31 23:14:51 +09:00
Yusuke Murata
e3bee69b27 Merge pull request #48 from trobrock/fix-homesickrc
Fix #19 homesickrc pathname needs a to_s to eval
2013-07-30 09:54:48 -07:00
Yusuke Murata
0ff5325e3e Merge pull request #43 from johnbellone/master
Default GitHub to use HTTPS protocol.
2013-07-21 10:47:31 -07:00
Yusuke Murata
40efb2f58a Merge pull request #47 from trobrock/unlink
Adding unlink function
2013-07-21 10:37:16 -07:00
Trae Robrock
4b20c7224e Add tests for the rc command 2013-07-17 08:04:34 -07:00
Trae Robrock
8e06beced6 Ignore vendor 2013-07-17 07:26:00 -07:00
Trae Robrock
b043f2a5ed Adding test for clone running homesickrc 2013-07-17 07:23:35 -07:00
John Bellone
3d59bc7a97 Update specs to for change to https vs. git protocol. 2013-07-17 07:28:39 -04:00
Trae Robrock
75dcad8ea4 Fix #19 homesickrc pathname needs a to_s to eval
Also, moved the file evaluation into a new function so the script can be
ran manually which should make testing these scripts easier.
2013-07-16 21:13:09 -07:00
Trae Robrock
4b38eb848f Add unlink functionality 2013-07-16 20:50:36 -07:00
Trae Robrock
bfce04e63c Ignore vendor dir 2013-07-16 20:49:40 -07:00
muratayusuke
846c5c202b Regenerate gemspec for version 0.9.3 2013-07-07 03:36:51 +09:00
muratayusuke
600811ff01 bump up version 2013-07-07 03:26:34 +09:00
muratayusuke
26ce289e9b add recursive option to 'homesick clone' 2013-07-01 16:35:19 +00:00
muratayusuke
10a9c0f482 Regenerate gemspec for version 0.9.2 2013-06-27 17:19:32 +00:00
muratayusuke
498ffa27f9 remove Gemfile.lock from repository 2013-06-27 17:11:56 +00:00
muratayusuke
ce8b46f300 bump up version 2013-06-27 17:08:33 +00:00
Yusuke Murata
995eff975f Merge pull request #45 from austinylin/master
Fix the path for .homesick_subdir in README.md
2013-06-27 09:47:49 -07:00
Yusuke Murata
2ab35e91e2 Merge pull request #44 from DavidMikeSimon/master
Added commands: show_path, status, diff
2013-06-27 09:47:18 -07:00
Austin Lin
4867ac2c7c Update readme with correct file path for .homesick_subdir per technicalpickles/homesick@360e8185f7 2013-06-27 12:06:54 -04:00
David Simon
a68149a87b Whoops, fixed typo 2013-06-26 14:34:43 -04:00
David Simon
8be3cdb6a0 Using DEFAULT_CASTLE_NAME in show_path, diff, status 2013-06-26 14:29:45 -04:00
David Simon
99760c27af Added three commands: show_path, status, diff 2013-06-25 16:11:08 -04:00
Yusuke Murata
4aa76ce444 Merge pull request #42 from akahigeg/default-castle-name
set default castle name: 'dotfiles' for some commands
2013-06-23 09:56:55 -07:00
akahige
82ae128429 default castle name to constant 2013-06-24 00:44:17 +09:00
John Bellone
92dc611bb1 Update homesick.rb to make https default for GitHub clones.
If we use HTTPS it is a lot easier for corporate worlds to manage proxies since its usually already done for us. Also HTTPS cloning is just as fast as the git protocol as of more recent versions.
2013-06-21 09:45:26 -03:00
akahige
dbf333c971 set default castle name: 'dotfiles' for some commands 2013-06-21 14:02:50 +09:00
Yusuke Murata
ca5dc3a4cc Merge pull request #41 from muratayusuke/feature/rubocop
Feature/rubocop
2013-06-20 16:44:12 -07:00
muratayusuke
a267a9c0b8 remove duplicate spec 2013-06-19 17:42:16 +00:00
muratayusuke
21cbb2c697 follow Ruby Style Guide for some points 2013-06-19 17:21:24 +00:00
muratayusuke
00f49be42c don't fail test even if rubocop detects some offence 2013-06-19 16:41:12 +00:00
muratayusuke
37b55bf934 use single-quate if don't need double-quate 2013-06-19 16:23:00 +00:00
muratayusuke
f2aca02b82 don't install rubocop under ruby 1.9.2 2013-06-19 15:58:45 +00:00
muratayusuke
dd101259f0 don't run rubocode on ruby 1.8.7 2013-06-19 15:41:53 +00:00
muratayusuke
b1f2742422 fix coding style 2013-06-19 15:30:18 +00:00
muratayusuke
114b44d4b6 use rubocop 2013-06-19 15:01:36 +00:00
muratayusuke
e07f3f0658 Regenerate gemspec for version 0.9.1 2013-06-17 12:36:14 +00:00
muratayusuke
b21aef09be bump up version 2013-06-17 12:35:39 +00:00
Yusuke Murata
d964e65a7e Merge pull request #40 from fnichol/fix-clone-destination
Remove .git suffix on destination directory if URL ends with it.
2013-06-17 04:47:20 -07:00
Fletcher Nichol
024856e538 Remove .git suffix on destination directory if URL ends with it.
For example, the following:

    homesick clone git://github.com/technicalpickles/pickled-vim.git

should produce a castle directory of:

    $HOME/.homesick/repos/pickled-vim
2013-06-16 10:47:53 -06:00
muratayusuke
e530df7239 fix #35 2013-06-09 23:10:21 +00:00
muratayusuke
e817c816c9 Regenerate gemspec for version 0.9.0 2013-06-06 12:59:00 +00:00
muratayusuke
14f0f8c121 bump up version and update changelog 2013-06-06 12:58:36 +00:00
Yusuke Murata
4c97948e04 Merge pull request #39 from technicalpickles/feature/merge_directory
Merge directories
2013-06-06 05:47:43 -07:00
muratayusuke
360e8185f7 move castle/home/.homesick_subdir to castle/.homesick_subdir 2013-06-06 12:39:41 +00:00
Yusuke Murata
da0958d455 fix typo 2013-06-06 21:19:21 +09:00
Yusuke Murata
70f5d24e0a fix style of README 2013-06-05 03:18:40 +09:00
Yusuke Murata
6b281ef001 fix style of README 2013-06-05 03:15:44 +09:00
muratayusuke
3ddd3207b3 add .homesick_subdir explanation to README 2013-06-04 18:14:20 +00:00
muratayusuke
8e58a3f5e2 deal with edge case: the parent and descendant are both listed in the manifest 2013-06-04 17:36:47 +00:00
muratayusuke
a95c4b2446 refactor symlink 2013-06-03 18:17:38 +00:00
muratayusuke
97fe1686f5 refactor given_castle 2013-05-30 17:38:06 +00:00
muratayusuke
76fcf5d0b7 add ruby-2.0.0 to travis 2013-05-30 14:33:45 +00:00
muratayusuke
bf1fc58e10 fix spec for ruby-1.8.7 2013-05-30 14:33:08 +00:00
muratayusuke
3559d825ca replace .manifest to .homesick_subdir 2013-05-27 18:08:09 +00:00
muratayusuke
2d54086d89 Merge remote-tracking branch 'edubkendo/nested_dirs' into feature/merge_directory 2013-05-27 16:13:31 +00:00
muratayusuke
c31c67a3eb support nested dir in .homesick_subdir 2013-05-26 17:12:28 +00:00
Eric West
e924cbefda refactor, cleanup and tweak 2013-05-24 21:57:12 -05:00
Eric West
6867ef78dc Handling edge cases
Covers only edge cases related to tracking, not yet
handling linking or updating. Getting a bit hairy,
must be refactored.
2013-05-24 16:24:42 -05:00
muratayusuke
a76d09d3f6 symlink subdirs with .homesick_subdir 2013-05-24 17:40:35 +00:00
Eric West
b93eea0e24 Track makes entries in .manifest
When a user tracks a file or directory that is in
a nested folder, Homesick creates a .manifest in the
user's castle (if there isn't one already) and adds
an entry listing the file or directory's parent
directory (if it isn't already listed).
2013-05-24 07:52:54 -05:00
Eric West
7332aa4acd Specs for track 2013-05-20 21:06:31 -05:00
Eric West
49e4d2844b Track now properly traverses folder structure 2013-05-20 19:11:48 -05:00
Thilko Richter
54b2b9b339 correct typo 2012-09-06 21:23:45 +02:00
Thilko Richter
fede78c337 cleanup correctly 2012-09-06 21:12:31 +02:00
Thilko Richter
8a41dca46d destroy action implemented 2012-09-06 07:38:54 +02:00
55 changed files with 5404 additions and 916 deletions

View File

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

View File

@@ -0,0 +1,221 @@
name: Pull Request Validation
on:
pull_request:
types:
- opened
- synchronize
- reopened
concurrency:
group: ci-${{ github.head_ref }}
cancel-in-progress: true
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: Check coverage artefacts
id: coverage-files
if: ${{ always() && steps.coverage.outcome == 'success' }}
run: |
set -euo pipefail
if [[ -f coverage.out ]]; then
echo "exists=true" >> "$GITHUB_OUTPUT"
else
echo "exists=false" >> "$GITHUB_OUTPUT"
echo "coverage.out was not produced; skipping coverage badge upload." >> "$GITHUB_STEP_SUMMARY"
fi
- name: Upload coverage badge
id: badge
if: ${{ always() && steps.coverage.outcome == 'success' && steps.coverage-files.outputs.exists == 'true' }}
uses: https://git.hrafn.xyz/aether/vociferate/coverage-badge@v1.1.0
with:
coverage-profile: coverage.out
artefact-bucket-name: ${{ vars.ARTEFACT_BUCKET_NAME }}
artefact-bucket-endpoint: ${{ vars.ARTEFACT_BUCKET_ENDPONT }}
- name: Validate changelog gate
if: ${{ always() }}
run: |
set -euo pipefail
if ! awk '
/^## \[Unreleased\]/ { in_unreleased=1; next }
/^## \[/ && in_unreleased { exit 0 }
in_unreleased && /^- / { found=1 }
END { exit found ? 0 : 1 }
' CHANGELOG.md; then
echo "Missing changelog entry under [Unreleased]." >&2
exit 1
fi
- name: Decorate PR
if: ${{ always() }}
uses: https://git.hrafn.xyz/aether/vociferate/decorate-pr@v1.1.0
continue-on-error: true
with:
coverage-percentage: ${{ steps.badge.outputs.total }}
badge-url: ${{ steps.badge.outputs.badge-url }}
enable-changelog-gate: 'false'
- 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,224 @@
name: Push Validation
on:
push:
branches:
- "**"
tags-ignore:
- "*"
concurrency:
group: ci-${{ github.ref_name }}
cancel-in-progress: true
jobs:
check-open-pr:
runs-on: ubuntu-latest
container: docker.io/catthehacker/ubuntu:act-latest
outputs:
should_run: ${{ steps.detect.outputs.should_run }}
steps:
- name: Detect open PR for branch
id: detect
env:
REPOSITORY: ${{ github.repository }}
OWNER: ${{ github.repository_owner }}
BRANCH: ${{ github.ref_name }}
SERVER_URL: ${{ github.server_url }}
TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -eu
api_url="${SERVER_URL}/api/v1/repos/${REPOSITORY}/pulls?state=open&head=${OWNER}:${BRANCH}"
if [ -n "${TOKEN:-}" ]; then
response="$(curl -fsSL -H "Authorization: token ${TOKEN}" -H "accept: application/json" "$api_url" || echo '[]')"
else
response="$(curl -fsSL -H "accept: application/json" "$api_url" || echo '[]')"
fi
open_prs="$(printf '%s' "$response" | grep -o '"number":[0-9]\+' | wc -l | tr -d ' ')"
if [ "$open_prs" -gt 0 ]; then
echo "should_run=false" >> "$GITHUB_OUTPUT"
echo "Open PR detected for ${OWNER}:${BRANCH}; skipping push validation." >> "$GITHUB_STEP_SUMMARY"
else
echo "should_run=true" >> "$GITHUB_OUTPUT"
echo "No open PR detected for ${OWNER}:${BRANCH}; running push validation." >> "$GITHUB_STEP_SUMMARY"
fi
validate:
needs: check-open-pr
if: ${{ needs.check-open-pr.outputs.should_run == 'true' }}
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

26
.gitignore vendored
View File

@@ -1,17 +1,3 @@
# rcov generated
coverage
# rdoc generated
rdoc
# yard generated
doc
.yardoc
# jeweler generated
pkg
.bundle
# Have editor/IDE/OS specific files you need to ignore? Consider using a global gitignore: # Have editor/IDE/OS specific files you need to ignore? Consider using a global gitignore:
# #
@@ -39,3 +25,15 @@ pkg
# #
# For vim: # For vim:
*.swp *.swp
#
# For IDEA:
.idea/
*.iml
# Go scaffolding artifacts
dist/
*.test
*.out
.github/*

1
.rspec
View File

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

View File

@@ -1,4 +0,0 @@
language: ruby
rvm:
- 1.9.3
- 1.8.7

328
CHANGELOG.md Normal file
View File

@@ -0,0 +1,328 @@
# 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.
- PR validation now keys coverage badge upload off the coverage step outcome and performs changelog gate validation in a native workflow step; decorate-pr changelog gating is disabled to bypass the broken internal extractor action.
- Push validation now triggers on all branches, not only `main`.
- Push and PR validation workflows now share a `concurrency` group keyed on the branch name (`github.ref_name` / `github.head_ref`) with `cancel-in-progress: true`; when a push to a PR branch fires both workflows, the second run cancels the first so only one validation executes per commit.
- Push validation now performs an open-PR branch check via the Gitea API and skips the heavy validation job when the branch already has an open PR, preventing duplicate full pipeline runs.
- Push validation open-PR detection is now POSIX-shell compatible (no bash-only `pipefail`/array/`[[ ... ]]` usage), fixing failures on runners that execute `run` scripts with `/bin/sh`.
- PR validation now checks that `coverage.out` exists before invoking `coverage-badge`; when missing, badge upload is skipped with a summary note instead of failing the workflow.
- PR decoration is now `continue-on-error` to avoid hard-failing validation when the external `decorate-pr` action's internal extractor step is unavailable.
- 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,65 +0,0 @@
# 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

15
Gemfile
View File

@@ -1,15 +0,0 @@
source 'https://rubygems.org'
# 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 "rake", ">= 0.8.7"
gem "rspec", "~> 2.10"
gem "jeweler", ">= 1.6.2"
gem "rcov", :platforms => :mri_18
gem "simplecov", :platforms => :mri_19
gem "test-construct"
end

View File

@@ -1,42 +0,0 @@
GEM
remote: https://rubygems.org/
specs:
diff-lcs (1.2.4)
git (1.2.5)
jeweler (1.8.4)
bundler (~> 1.0)
git (>= 1.2.5)
rake
rdoc
json (1.8.0)
multi_json (1.7.3)
rake (10.0.4)
rcov (1.0.0)
rdoc (4.0.1)
json (~> 1.4)
rspec (2.13.0)
rspec-core (~> 2.13.0)
rspec-expectations (~> 2.13.0)
rspec-mocks (~> 2.13.0)
rspec-core (2.13.1)
rspec-expectations (2.13.0)
diff-lcs (>= 1.1.3, < 2.0)
rspec-mocks (2.13.1)
simplecov (0.7.1)
multi_json (~> 1.0)
simplecov-html (~> 0.7.1)
simplecov-html (0.7.1)
test-construct (1.2.0)
thor (0.18.1)
PLATFORMS
ruby
DEPENDENCIES
jeweler (>= 1.6.2)
rake (>= 0.8.7)
rcov
rspec (~> 2.10)
simplecov
test-construct
thor (>= 0.14.0)

View File

@@ -1,68 +0,0 @@
# homesick
[![Build Status](https://travis-ci.org/technicalpickles/homesick.png?branch=master)](https://travis-ci.org/technicalpickles/homesick)
A man's home (directory) is his castle, so don't leave home with out it.
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 symlink pickled-vim
If you use the shorthand syntax for GitHub repositories in your clone, please note you will instead need to run:
homesick symlink technicalpickles/pickled-vim
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
Not sure what else homesick has up its sleeve? There's always the built in help:
homesick help
## 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,56 +0,0 @@
require 'rubygems'
require 'bundler'
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{A man's home is his castle. Never leave your dotfiles behind.}
gem.description = %Q{
A man's home (directory) is his castle, so don't leave home with out it.
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 = "0.8.1"
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 :default => :spec
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.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,76 +0,0 @@
# Generated by jeweler
# DO NOT EDIT THIS FILE DIRECTLY
# Instead, edit Jeweler::Tasks in Rakefile, and run 'rake gemspec'
# -*- encoding: utf-8 -*-
Gem::Specification.new do |s|
s.name = "homesick"
s.version = "0.8.1"
s.required_rubygems_version = Gem::Requirement.new(">= 0") if s.respond_to? :required_rubygems_version=
s.authors = ["Joshua Nichols", "Yusuke Murata"]
s.date = "2013-05-19"
s.description = "\n A man's home (directory) is his castle, so don't leave home with out it.\n\n Homesick is sorta like rip, but for dotfiles. It uses git to clone a repository containing dotfiles, and saves them in ~/.homesick. It then allows you to symlink all the dotfiles into place with a single command. \n\n "
s.email = ["josh@technicalpickles.com", "info@muratayusuke.com"]
s.executables = ["homesick"]
s.extra_rdoc_files = [
"ChangeLog.markdown",
"LICENSE",
"README.markdown"
]
s.files = [
".document",
".rspec",
".travis.yml",
"ChangeLog.markdown",
"Gemfile",
"Gemfile.lock",
"LICENSE",
"README.markdown",
"Rakefile",
"bin/homesick",
"homesick.gemspec",
"lib/homesick.rb",
"lib/homesick/actions.rb",
"lib/homesick/shell.rb",
"spec/homesick_spec.rb",
"spec/spec.opts",
"spec/spec_helper.rb"
]
s.homepage = "http://github.com/technicalpickles/homesick"
s.licenses = ["MIT"]
s.require_paths = ["lib"]
s.rubygems_version = "1.8.25"
s.summary = "A man's home is his castle. Never leave your dotfiles behind."
if s.respond_to? :specification_version then
s.specification_version = 3
if Gem::Version.new(Gem::VERSION) >= Gem::Version.new('1.2.0') then
s.add_runtime_dependency(%q<thor>, [">= 0.14.0"])
s.add_development_dependency(%q<rake>, [">= 0.8.7"])
s.add_development_dependency(%q<rspec>, ["~> 2.10"])
s.add_development_dependency(%q<jeweler>, [">= 1.6.2"])
s.add_development_dependency(%q<rcov>, [">= 0"])
s.add_development_dependency(%q<simplecov>, [">= 0"])
s.add_development_dependency(%q<test-construct>, [">= 0"])
else
s.add_dependency(%q<thor>, [">= 0.14.0"])
s.add_dependency(%q<rake>, [">= 0.8.7"])
s.add_dependency(%q<rspec>, ["~> 2.10"])
s.add_dependency(%q<jeweler>, [">= 1.6.2"])
s.add_dependency(%q<rcov>, [">= 0"])
s.add_dependency(%q<simplecov>, [">= 0"])
s.add_dependency(%q<test-construct>, [">= 0"])
end
else
s.add_dependency(%q<thor>, [">= 0.14.0"])
s.add_dependency(%q<rake>, [">= 0.8.7"])
s.add_dependency(%q<rspec>, ["~> 2.10"])
s.add_dependency(%q<jeweler>, [">= 1.6.2"])
s.add_dependency(%q<rcov>, [">= 0"])
s.add_dependency(%q<simplecov>, [">= 0"])
s.add_dependency(%q<test-construct>, [">= 0"])
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,222 +0,0 @@
require 'thor'
class Homesick < Thor
autoload :Shell, 'homesick/shell'
autoload :Actions, 'homesick/actions'
include Thor::Actions
include Homesick::Actions
add_runtime_options!
GITHUB_NAME_REPO_PATTERN = /\A([A-Za-z_-]+\/[A-Za-z_-]+)\Z/
def initialize(args=[], options={}, config={})
super
self.shell = Homesick::Shell.new
end
desc "clone URI", "Clone +uri+ as a castle for homesick"
def clone(uri)
inside repos_dir do
destination = nil
if File.exist?(uri)
uri = Pathname.new(uri).expand_path
if uri.to_s.start_with?(repos_dir.to_s)
raise "Castle already cloned to #{uri}"
end
destination = uri.basename
ln_s uri, destination
elsif uri =~ GITHUB_NAME_REPO_PATTERN
destination = Pathname.new($1)
git_clone "git://github.com/#{$1}.git", :destination => destination
elsif uri =~ /\/([^\/]*?)(\.git)?\Z/
destination = Pathname.new($1)
git_clone uri
elsif uri =~ /[^:]+:([^:]+)(\.git)?\Z/
destination = Pathname.new($1)
git_clone uri
else
raise "Unknown URI format: #{uri}"
end
if destination.join('.gitmodules').exist?
inside destination do
git_submodule_init
git_submodule_update
end
end
homesickrc = destination.join('.homesickrc').expand_path
if homesickrc.exist?
proceed = shell.yes?("#{uri} has a .homesickrc. Proceed with evaling it? (This could be destructive)")
if proceed
shell.say_status "eval", homesickrc
inside destination do
eval homesickrc.read, binding, homesickrc.expand_path
end
else
shell.say_status "eval skip", "not evaling #{homesickrc}, #{destination} may need manual configuration", :blue
end
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="")
if options[:all]
inside_each_castle do |castle|
shell.say castle.to_s.gsub(repos_dir.to_s + '/', '') + ':'
update_castle castle
end
else
update_castle name
end
end
desc "commit CASTLE", "Commit the specified castle's changes"
def commit(name)
commit_castle name
end
desc "push CASTLE", "Push the specified castle"
def push(name)
push_castle name
end
desc "symlink CASTLE", "Symlinks all dotfiles from the specified castle"
method_option :force, :default => false, :desc => "Overwrite existing conflicting symlinks without prompting."
def symlink(name)
check_castle_existance(name, "symlink")
inside castle_dir(name) do
files = Pathname.glob('{.*,*}').reject{|a| [".",".."].include?(a.to_s)}
files.each do |path|
absolute_path = path.expand_path
inside home_dir do
adjusted_path = (home_dir + path).basename
ln_s absolute_path, adjusted_path
end
end
end
end
desc "track FILE CASTLE", "add a file to a castle"
def track(file, castle)
castle = Pathname.new(castle)
file = Pathname.new(file)
check_castle_existance(castle, 'track')
absolute_path = file.expand_path
castle_path = castle_dir(castle)
mv absolute_path, castle_path
inside home_dir do
absolute_path = castle_dir(castle) + file.basename
home_path = home_dir + file
ln_s absolute_path, home_path
end
inside castle_path do
git_add absolute_path
end
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 "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
protected
def home_dir
@home_dir ||= Pathname.new(ENV['HOME'] || '~').expand_path
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)
unless castle_dir(name).exist?
say_status :error, "Could not #{action} #{name}, expected #{castle_dir(name)} exist and contain dotfiles", :red
exit(1)
end
end
def all_castles
dirs = Pathname.glob("#{repos_dir}/**/.git", File::FNM_DOTMATCH)
# reject paths that lie inside another castle, like git submodules
return dirs.reject do |dir|
dirs.any? {|other| dir != other && dir.fnmatch(other.parent.join('*').to_s) }
end
end
def inside_each_castle(&block)
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)
check_castle_existance(castle, "commit")
inside repos_dir.join(castle) do
git_commit_all
end
end
def push_castle(castle)
check_castle_existance(castle, "push")
inside repos_dir.join(castle) do
git_push
end
end
end

View File

@@ -1,119 +0,0 @@
class Homesick
module Actions
# TODO move this to be more like thor's template, empty_directory, etc
def git_clone(repo, config = {})
config ||= {}
destination = config[:destination] || begin
repo =~ /([^\/]+)(?:\.git)?$/
$1
end
destination = Pathname.new(destination) unless destination.kind_of?(Pathname)
FileUtils.mkdir_p destination.dirname
if ! destination.directory?
say_status 'git clone', "#{repo} to #{destination.expand_path}", :green unless options[:quiet]
system "git clone -q #{repo} #{destination}" unless options[:pretend]
else
say_status :exist, destination.expand_path, :blue unless options[:quiet]
end
end
def git_init(path = ".")
path = Pathname.new(path)
inside path do
unless path.join('.git').exist?
say_status 'git init', '' unless options[:quiet]
system "git init >/dev/null" unless options[:pretend]
else
say_status 'git init', 'already initialized', :blue unless options[:quiet]
end
end
end
def git_remote_add(name, url)
existing_remote = `git config remote.#{name}.url`.chomp
existing_remote = nil if existing_remote == ''
unless existing_remote
say_status 'git remote', "add #{name} #{url}" unless options[:quiet]
system "git remote add #{name} #{url}" unless options[:pretend]
else
say_status 'git remote', "#{name} already exists", :blue unless options[:quiet]
end
end
def git_submodule_init(config = {})
say_status 'git submodule', 'init', :green unless options[:quiet]
system "git submodule --quiet init" unless options[:pretend]
end
def git_submodule_update(config = {})
say_status 'git submodule', 'update', :green unless options[:quiet]
system "git submodule --quiet update --init --recursive >/dev/null 2>&1" unless options[:pretend]
end
def git_pull(config = {})
say_status 'git pull', '', :green unless options[:quiet]
system "git pull --quiet" unless options[:pretend]
end
def git_push(config = {})
say_status 'git push', '', :green unless options[:quiet]
system "git push" unless options[:pretend]
end
def git_commit_all(config = {})
say_status 'git commit all', '', :green unless options[:quiet]
system "git commit -v -a" unless options[:pretend]
end
def git_add(file, config = {})
say_status 'git add file', '', :green unless options[:quiet]
system "git add #{file}" unless options[:pretend]
end
def mv(source, destination, config = {})
source = Pathname.new(source)
destination = Pathname.new(destination + source.basename)
if destination.exist?
say_status :conflict, "#{destination} exists", :red unless options[:quiet]
if options[:force] || shell.file_collision(destination) { source }
system "mv #{source} #{destination}" unless options[:pretend]
end
else
# this needs some sort of message here.
system "mv #{source} #{destination}" unless options[:pretend]
end
end
def ln_s(source, destination, config = {})
source = Pathname.new(source)
destination = Pathname.new(destination)
if destination.symlink?
if destination.readlink == source
say_status :identical, destination.expand_path, :blue unless options[:quiet]
else
say_status :conflict, "#{destination} exists and points to #{destination.readlink}", :red unless options[:quiet]
if options[:force] || shell.file_collision(destination) { source }
system "ln -nsf #{source} #{destination}" unless options[:pretend]
end
end
elsif destination.exist?
say_status :conflict, "#{destination} exists", :red unless options[:quiet]
if options[:force] || shell.file_collision(destination) { source }
system "ln -sf #{source} #{destination}" unless options[:pretend]
end
else
say_status :symlink, "#{source.expand_path} to #{destination.expand_path}", :green unless options[:quiet]
system "ln -s #{source} #{destination}" unless options[:pretend]
end
end
end
end

View File

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

View File

@@ -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,173 +0,0 @@
require 'spec_helper'
describe "homesick" do
let(:home) { create_construct }
after { home.destroy! }
let(:castles) { home.directory(".homesick/repos") }
let(:homesick) { Homesick.new }
before { homesick.stub!(:repos_dir).and_return(castles) }
describe "clone" do
context "of a file" do
it "should symlink existing directories" do
somewhere = create_construct
local_repo = somewhere.directory('wtf')
homesick.clone local_repo
castles.join("wtf").readlink.should == 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 "should not symlink" do
homesick.should_not_receive(:git_clone)
homesick.clone @existing_dir.to_s rescue nil
end
it "should raise an error" do
expect { homesick.clone @existing_dir.to_s }.to raise_error(/already cloned/i)
end
end
end
it "should clone git repo like git://host/path/to.git" do
homesick.should_receive(:git_clone).with('git://github.com/technicalpickles/pickled-vim.git')
homesick.clone "git://github.com/technicalpickles/pickled-vim.git"
end
it "should clone git repo like git@host:path/to.git" do
homesick.should_receive(:git_clone).with('git@github.com:technicalpickles/pickled-vim.git')
homesick.clone 'git@github.com:technicalpickles/pickled-vim.git'
end
it "should clone git repo like http://host/path/to.git" do
homesick.should_receive(:git_clone).with('http://github.com/technicalpickles/pickled-vim.git')
homesick.clone 'http://github.com/technicalpickles/pickled-vim.git'
end
it "should clone git repo like http://host/path/to" do
homesick.should_receive(:git_clone).with('http://github.com/technicalpickles/pickled-vim')
homesick.clone 'http://github.com/technicalpickles/pickled-vim'
end
it "should clone git repo like host-alias:repos.git" do
homesick.should_receive(:git_clone).with('gitolite:pickled-vim.git')
homesick.clone 'gitolite:pickled-vim.git'
end
it "should not try to clone a malformed uri like malformed" do
homesick.should_not_receive(:git_clone)
homesick.clone 'malformed' rescue nil
end
it "should throw an exception when trying to clone a malformed uri like malformed" do
expect { homesick.clone 'malformed' }.to raise_error
end
it "should clone a github repo" do
homesick.should_receive(:git_clone).with('git://github.com/wfarr/dotfiles.git', :destination => Pathname.new('wfarr/dotfiles'))
homesick.clone "wfarr/dotfiles"
end
end
describe "symlink" do
let(:castle) { given_castle("glencairn") }
it "links dotfiles from a castle to the home folder" do
dotfile = castle.file(".some_dotfile")
homesick.symlink("glencairn")
home.join(".some_dotfile").readlink.should == dotfile
end
it "links non-dotfiles from a castle to the home folder" do
dotfile = castle.file("bin")
homesick.symlink("glencairn")
home.join("bin").readlink.should == dotfile
end
context "when forced" do
let(:homesick) { Homesick.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.symlink("glencairn")
existing_dotdir_link.readlink.should == dotdir
end
end
end
describe "list" do
it "should say each castle in the castle directory" do
given_castle('zomg')
given_castle('zomg', 'wtf/zomg')
homesick.should_receive(:say_status).with("zomg", "git://github.com/technicalpickles/zomg.git", :cyan)
homesick.should_receive(:say_status).with("wtf/zomg", "git://github.com/technicalpickles/zomg.git", :cyan)
homesick.list
end
end
describe "pull" do
xit "needs testing"
describe "--all" do
xit "needs testing"
end
end
describe "commit" do
xit "needs testing"
end
describe "push" do
xit "needs testing"
end
describe "track" do
it "should move 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")
tracked_file.should exist
some_rc_file.readlink.should == tracked_file
end
end
end

View File

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

View File

@@ -1,28 +0,0 @@
$LOAD_PATH.unshift(File.dirname(__FILE__))
$LOAD_PATH.unshift(File.join(File.dirname(__FILE__), '..', 'lib'))
require 'homesick'
require 'rspec'
require 'rspec/autorun'
require 'construct'
RSpec.configure do |config|
config.include Construct::Helpers
config.before { ENV['HOME'] = home.to_s }
config.before { silence! }
def silence!
homesick.stub(:say_status)
end
def given_castle(name, path=name)
castles.directory(path) do |castle|
Dir.chdir(castle) do
system "git init >/dev/null 2>&1"
system "git remote add origin git://github.com/technicalpickles/#{name}.git >/dev/null 2>&1"
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