Compare commits
341 Commits
v1.0.0
...
bbbacb0eb6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bbbacb0eb6 | ||
|
|
28820748f7 | ||
|
|
1f93a3d532 | ||
|
|
3104feb738 | ||
|
|
e1a58b6607 | ||
|
|
411c99532d | ||
|
|
607f43eaa0 | ||
|
|
0691c54965 | ||
|
|
74640ddaa8 | ||
|
|
354f3599b4 | ||
|
|
ae86431d50 | ||
|
|
9c7f6fbdf4 | ||
|
|
cf183d9bb0 | ||
|
|
65d0a95968 | ||
|
|
7fbbb442a0 | ||
|
|
a316723cfc | ||
|
|
7405044fb5 | ||
|
|
4fc9401741 | ||
|
|
c793925828 | ||
|
|
bc0a6747b8 | ||
|
|
d642870a66 | ||
|
|
038b109e7b | ||
|
|
519c6703d2 | ||
|
|
8a3fde8e07 | ||
|
|
3fa377efe2 | ||
|
|
02eebb02fe | ||
|
|
dd1d802605 | ||
|
|
a65f62ea9d | ||
|
|
014b330931 | ||
|
|
5b37057b61 | ||
|
|
4b54a45a76 | ||
|
|
eb63da9354 | ||
|
|
494eea998d | ||
|
|
15f05a1999 | ||
|
|
a01a2171ff | ||
|
|
f134361b6e | ||
|
|
ecda12fc49 | ||
|
|
be14cfdc29 | ||
|
|
302acbe9bb | ||
|
|
3cc90ff54e | ||
|
|
c36b738240 | ||
|
|
2cf5851231 | ||
|
|
4cfda23187 | ||
|
|
fb4b3f7ed1 | ||
|
|
a92ab1a29c | ||
|
|
0d3c9b5214 | ||
|
|
106e45d16b | ||
|
|
332de3a3f6 | ||
|
|
19c9e5485b | ||
|
|
fc9a30fed1 | ||
|
|
b235c6ca45 | ||
|
|
5ecbad8f27 | ||
|
|
ef554dde2d | ||
|
|
55867df599 | ||
|
|
cd92a961bd | ||
|
|
7bc7ee4746 | ||
|
|
8a6a21811a | ||
|
|
001983b76e | ||
|
|
ad5196420e | ||
|
|
692e205a63 | ||
|
|
ca3215f2c4 | ||
|
|
0112d9a0a6 | ||
|
|
e68575f15a | ||
|
|
ce1d253814 | ||
|
|
8f51cf368a | ||
|
|
d73049baa4 | ||
|
|
abfd6b817b | ||
|
|
bbe41a6d72 | ||
|
|
310979d799 | ||
|
|
f7af294d30 | ||
|
|
1abf298c47 | ||
|
|
28ba4aab70 | ||
|
|
665a488c3b | ||
|
|
3dc7924de5 | ||
|
|
bbc64eb756 | ||
|
|
ad8ec1bd6c | ||
|
|
7f8a5d24e3 | ||
|
|
5307e4d35f | ||
|
|
b070267bde | ||
|
|
cd2258e267 | ||
|
|
c887a573e0 | ||
|
|
9e6f98948e | ||
|
|
edd1c4357a | ||
|
|
2fc3f3d006 | ||
|
|
58f70860ee | ||
|
|
79d4577083 | ||
|
|
59caa62ac6 | ||
|
|
043b859a42 | ||
|
|
c36cae2e33 | ||
|
|
5fe37a7f12 | ||
|
|
7f46ab43ac | ||
|
|
82dde43f24 | ||
|
|
88b07ea934 | ||
|
|
4901f7b664 | ||
|
|
f186286a7e | ||
|
|
d8eaf4d058 | ||
|
|
eeeb9f7d8e | ||
|
|
f0dc55159b | ||
|
|
8a451cbaee | ||
|
|
e60000680b | ||
|
|
4a2f0ff0b8 | ||
|
|
4fb028cd81 | ||
|
|
4a422bd241 | ||
|
|
6719fb170b | ||
|
|
f3b1a7707a | ||
|
|
a381746cef | ||
|
|
75f636f9ba | ||
|
|
1e5de20a41 | ||
|
|
07d73660eb | ||
|
|
029175cb55 | ||
|
|
38f649e99b | ||
|
|
af491aa267 | ||
|
|
0dd38e5267 | ||
|
|
93918f3a39 | ||
|
|
3b8dadbd29 | ||
|
|
f9c853a4e9 | ||
|
|
799c8d167d | ||
|
|
feb8ca3434 | ||
|
|
dbb6c82562 | ||
|
|
c3f809a586 | ||
|
|
8fc831dfdf | ||
|
|
7e32cd83c5 | ||
|
|
3d71433630 | ||
|
|
c6c382afce | ||
|
|
665401f2bd | ||
|
|
d084abd636 | ||
|
|
a6034ce470 | ||
|
|
484db0781b | ||
|
|
4a8ef7e1f6 | ||
|
|
b3f66e9e2e | ||
|
|
9d6dacb0f8 | ||
|
|
195b936de6 | ||
|
|
f6b5186f31 | ||
|
|
ea16ba8430 | ||
|
|
96ce572792 | ||
|
|
d638f201fe | ||
|
|
e09bdd78c2 | ||
|
|
0034a6f4e2 | ||
|
|
aa66695665 | ||
|
|
a7e4c501e4 | ||
|
|
0dfacc31d4 | ||
|
|
1d26594010 | ||
|
|
c10ff251d5 | ||
|
|
8d34674415 | ||
|
|
8174c6a983 | ||
|
|
1d4c088edc | ||
|
|
040bf31b56 | ||
|
|
4355e7fd9d | ||
|
|
b7c353553a | ||
|
|
2f45d28acb | ||
|
|
904c1be192 | ||
|
|
f443e96f9e | ||
|
|
0076588e1f | ||
|
|
919f033c8b | ||
|
|
dbc77a1b34 | ||
|
|
d02d118b28 | ||
|
|
a952c4f6bf | ||
|
|
e733dff818 | ||
|
|
41584dec6a | ||
|
|
005209703e | ||
|
|
ee4388b0f4 | ||
|
|
a44a514007 | ||
|
|
9431cb78af | ||
|
|
46c52769a6 | ||
|
|
fdb57cd846 | ||
|
|
ff387280d5 | ||
|
|
f09c62d922 | ||
|
|
dd7d52a25d | ||
|
|
f1630ece79 | ||
|
|
11ee8cdc0d | ||
|
|
ceb08cbe22 | ||
|
|
057e1cfc59 | ||
|
|
89f3000d8b | ||
|
|
36e3cb6bbf | ||
|
|
9ebae75e7d | ||
|
|
35e1909790 | ||
|
|
3b633ed326 | ||
|
|
fdf2da84dd | ||
|
|
e561566b46 | ||
|
|
dcef34c17d | ||
|
|
72d11c4a47 | ||
|
|
c2457bae9f | ||
|
|
001bd32bb3 | ||
|
|
7080321081 | ||
|
|
9d9cf66de6 | ||
|
|
9e9a940825 | ||
|
|
257e974c38 | ||
|
|
615e31428c | ||
|
|
8c2a1d0f84 | ||
|
|
62c934774b | ||
|
|
d3d6974b7b | ||
|
|
474d69da0b | ||
|
|
db6a513d1d | ||
|
|
ae343c4cab | ||
|
|
a2b365fb6f | ||
|
|
4cb2006f41 | ||
|
|
66347d307f | ||
|
|
8f92a1b4f0 | ||
|
|
7a2df591c0 | ||
|
|
4923265dea | ||
|
|
79421580e9 | ||
|
|
cabde9e5f1 | ||
|
|
0d60ae9d1a | ||
|
|
d5317b8e17 | ||
|
|
3b8a5b4be4 | ||
|
|
6590a1eeff | ||
|
|
693ae5f05e | ||
|
|
da3002f199 | ||
|
|
feaaab2fa4 | ||
|
|
59f75711a4 | ||
|
|
f24030b51f | ||
|
|
71bb120a12 | ||
|
|
85f46e01b1 | ||
|
|
c5b24b9b38 | ||
|
|
68460af45e | ||
|
|
5614b6b8b3 | ||
|
|
570b063632 | ||
|
|
1d398587d0 | ||
|
|
085853faaa | ||
|
|
21b4e344a9 | ||
|
|
a6194dfe8b | ||
|
|
5692194fa2 | ||
|
|
11745098c2 | ||
|
|
b1bb0c996c | ||
|
|
a62039da50 | ||
|
|
4bfd1c60c2 | ||
|
|
f0e11abb5b | ||
|
|
ed397bdaf8 | ||
|
|
2f5e20d963 | ||
|
|
cc83a4e1fa | ||
|
|
dcc5cb0bc1 | ||
|
|
978416d1e4 | ||
|
|
1c12c73e4b | ||
|
|
1016002638 | ||
|
|
6431a864ad | ||
|
|
42f661cfbf | ||
|
|
7632591681 | ||
|
|
a9a5b81dc5 | ||
|
|
721c10cffd | ||
|
|
332aad8ad0 | ||
|
|
171b4c1fb8 | ||
|
|
60d4458bbc | ||
|
|
9ad171ab78 | ||
|
|
5918746059 | ||
|
|
4641843ffd | ||
|
|
1a181b907c | ||
|
|
fb7595d254 | ||
|
|
c8f0999035 | ||
|
|
46faec7857 | ||
|
|
e35d3fe6ba | ||
|
|
ba620e0f7f | ||
|
|
5700f55dc3 | ||
|
|
2c92010093 | ||
|
|
03490531d8 | ||
|
|
7bd9759e81 | ||
|
|
a808f56caf | ||
|
|
b7e2b45e69 | ||
|
|
63c45d7c3a | ||
|
|
096067ac62 | ||
|
|
8d6bf4c0c5 | ||
|
|
882b862780 | ||
|
|
e06a5d6300 | ||
|
|
7451e8c739 | ||
|
|
f034f773c5 | ||
|
|
681fd98dc3 | ||
|
|
e57b139e32 | ||
|
|
b64bfe2bb6 | ||
|
|
ee04b5788a | ||
|
|
2e8d431ab5 | ||
|
|
3465c37c0e | ||
|
|
bf6894e313 | ||
|
|
77e3f7f479 | ||
|
|
753f5027b0 | ||
|
|
23c012a527 | ||
|
|
895543641b | ||
|
|
72bfc5a2fd | ||
|
|
f5054cf41d | ||
|
|
b60703d496 | ||
|
|
9a8788fb80 | ||
|
|
1a44edcde1 | ||
|
|
383f2a9f32 | ||
|
|
f55828f1d4 | ||
|
|
d4f9633a0c | ||
|
|
b5138bcdd1 | ||
|
|
d9ee74bf14 | ||
|
|
2148697864 | ||
|
|
1c3403064e | ||
|
|
03d87807e0 | ||
|
|
f6c4e5e42e | ||
|
|
705a416d74 | ||
|
|
41f3f9d374 | ||
|
|
74cfd29272 | ||
|
|
9a3268b7c3 | ||
|
|
70c1666606 | ||
|
|
53ac09a5e9 | ||
|
|
c790e34b39 | ||
|
|
8428ad1c9c | ||
|
|
efea18327b | ||
|
|
84fb1d1462 | ||
|
|
5dc7b5068d | ||
|
|
ab603240e4 | ||
|
|
2e5c2ec018 | ||
|
|
349e75584f | ||
|
|
bea3a0b680 | ||
|
|
8b6bf92e9a | ||
|
|
133c3613e9 | ||
|
|
ff2e5ee064 | ||
|
|
22aed48d4e | ||
|
|
4c7e45a1d5 | ||
|
|
ca41ae7f85 | ||
|
|
fa61e7b10e | ||
|
|
8397dcacc5 | ||
|
|
3f2d343161 | ||
|
|
d91628f811 | ||
|
|
94bff3aa9d | ||
|
|
7253bdd634 | ||
|
|
8c6a17404f | ||
|
|
98edb54ca4 | ||
|
|
9b780ffac6 | ||
|
|
59f6239ea0 | ||
|
|
c2cb6081e1 | ||
|
|
95943deb82 | ||
|
|
08a71f657f | ||
|
|
2b48544e32 | ||
|
|
f1191d4b3c | ||
|
|
8173429131 | ||
|
|
82be04ad8a | ||
|
|
bb735763c6 | ||
|
|
0bbb82f9ba | ||
|
|
e42eff4e10 | ||
|
|
7e659f11fe | ||
|
|
c667cefd4c | ||
|
|
571c5799e9 | ||
|
|
604a3b2a20 | ||
|
|
7d36460851 | ||
|
|
8f634b9d07 | ||
|
|
12244abb56 | ||
|
|
fc2bbb1d6e | ||
|
|
f03e7670cf | ||
|
|
e202b7eae7 | ||
|
|
3fcbd21104 |
221
.gitea/workflows/pr-validation.yml
Normal file
221
.gitea/workflows/pr-validation.yml
Normal 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
|
||||
46
.gitea/workflows/prepare-release.yml
Normal file
46
.gitea/workflows/prepare-release.yml
Normal 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
|
||||
224
.gitea/workflows/push-validation.yml
Normal file
224
.gitea/workflows/push-validation.yml
Normal 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
|
||||
93
.gitea/workflows/tag-build-artifacts.yml
Normal file
93
.gitea/workflows/tag-build-artifacts.yml
Normal 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
|
||||
23
.gitignore
vendored
23
.gitignore
vendored
@@ -1,17 +1,3 @@
|
||||
# rcov generated
|
||||
coverage
|
||||
|
||||
# rdoc generated
|
||||
rdoc
|
||||
|
||||
# yard generated
|
||||
doc
|
||||
.yardoc
|
||||
|
||||
# jeweler generated
|
||||
pkg
|
||||
|
||||
.bundle
|
||||
|
||||
# Have editor/IDE/OS specific files you need to ignore? Consider using a global gitignore:
|
||||
#
|
||||
@@ -44,5 +30,10 @@ pkg
|
||||
.idea/
|
||||
*.iml
|
||||
|
||||
Gemfile.lock
|
||||
vendor/
|
||||
|
||||
# Go scaffolding artifacts
|
||||
dist/
|
||||
*.test
|
||||
*.out
|
||||
|
||||
.github/*
|
||||
@@ -1,4 +0,0 @@
|
||||
language: ruby
|
||||
rvm:
|
||||
- 2.0.0
|
||||
- 1.9.3
|
||||
328
CHANGELOG.md
Normal file
328
CHANGELOG.md
Normal 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.
|
||||
@@ -1,96 +0,0 @@
|
||||
#1.0.0
|
||||
* Removed support for Ruby 1.8.7
|
||||
* Added a version command
|
||||
|
||||
# 0.9.8
|
||||
* Introduce new commands
|
||||
* `homesick cd`
|
||||
* `homesick open`
|
||||
|
||||
# 0.9.4
|
||||
* Use https protocol instead of git protocol
|
||||
* Introduce new commands
|
||||
* `homesick unlink`
|
||||
* `homesick rc`
|
||||
|
||||
# 0.9.3
|
||||
* Add recursive option to `homesick clone`
|
||||
|
||||
# 0.9.2
|
||||
* Set "dotfiles" as default castle name
|
||||
* Introduce new commands
|
||||
* `homesick show_path`
|
||||
* `homesick status`
|
||||
* `homesick diff`
|
||||
|
||||
# 0.9.1
|
||||
* Fixed small bugs: #35, #40
|
||||
|
||||
# 0.9.0
|
||||
* Introduce .homesick_subdir #39
|
||||
|
||||
# 0.8.1
|
||||
*Fixed `homesick list` bug on ruby 2.0 #37
|
||||
|
||||
# 0.8.0
|
||||
* Introduce commit & push command
|
||||
* commit changes in castle and push to remote
|
||||
* Enable recursive submodule update
|
||||
* Git add when track
|
||||
|
||||
# 0.7.0
|
||||
* Fixed double-cloning #14
|
||||
* New option for pull command: --all
|
||||
* pulls each castle, instead of just one
|
||||
|
||||
# 0.6.1
|
||||
|
||||
* Add a license
|
||||
|
||||
# 0.6.0
|
||||
|
||||
* Introduce .homesickrc
|
||||
* Castles can now have a .homesickrc inside them
|
||||
* On clone, this is eval'd inside the destination directory
|
||||
* Introduce track command
|
||||
* Allows easily moving an existing file into a castle, and symlinking it back
|
||||
|
||||
# 0.5.0
|
||||
|
||||
* Fixed listing of castles cloned using `homesick clone <github-user>/<github-repo>` (issue 3)
|
||||
* Added `homesick pull <CASTLE>` for updating castles (thanks Jorge Dias!)
|
||||
* Added a very basic `homesick generate <CASTLE>`
|
||||
|
||||
# 0.4.1
|
||||
|
||||
* Improved error message when a castle's home dir doesn't exist
|
||||
|
||||
# 0.4.0
|
||||
|
||||
* `homesick clone` can now take a path to a directory on the filesystem, which will be symlinked into place
|
||||
* `homesick clone` now tries to `git submodule init` and `git submodule update` if git submodules are defined for a cloned repo
|
||||
* Fixed missing dependency on thor and others
|
||||
* Use HOME environment variable for where to store files, instead of assuming ~
|
||||
|
||||
# 0.3.0
|
||||
|
||||
* Renamed 'link' to 'symlink'
|
||||
* Fixed conflict resolution when symlink destination exists and is a normal file
|
||||
|
||||
# 0.2.0
|
||||
|
||||
* Better support for recognizing git urls (thanks jacobat!)
|
||||
* if it looks like a github user/repo, do that
|
||||
* otherwise hand off to git clone
|
||||
* Listing now displays in color, and show git remote
|
||||
* Support pretend, force, and quiet modes
|
||||
|
||||
# 0.1.1
|
||||
|
||||
* Fixed trying to link against castles that don't exist
|
||||
* Fixed linking, which tries to exclude . and .. from the list of files to
|
||||
link (thanks Martinos!)
|
||||
|
||||
# 0.1.0
|
||||
|
||||
* Initial release
|
||||
26
Gemfile
26
Gemfile
@@ -1,26 +0,0 @@
|
||||
require 'rbconfig'
|
||||
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 "guard"
|
||||
gem "guard-rspec"
|
||||
gem "rb-readline", "~> 0.5.0"
|
||||
gem "jeweler", ">= 1.6.2"
|
||||
gem "rcov", :platforms => :mri_18
|
||||
gem "simplecov", :platforms => :mri_19
|
||||
gem "test_construct"
|
||||
gem "capture-output", "~> 1.0.0"
|
||||
if RbConfig::CONFIG['host_os'] =~ /linux|freebsd|openbsd|sunos|solaris/
|
||||
gem 'libnotify'
|
||||
end
|
||||
if RUBY_VERSION >= '1.9.2'
|
||||
gem "rubocop"
|
||||
end
|
||||
end
|
||||
@@ -1,6 +0,0 @@
|
||||
guard :rspec, :cmd => 'bundle exec rspec' do
|
||||
watch(%r{^spec/.+_spec\.rb$})
|
||||
watch(%r{^lib/(.+)\.rb$}) { |m| "spec/#{m[1]}_spec.rb" }
|
||||
watch(%r{^lib/homesick/.*\.rb}) { "spec" }
|
||||
watch('spec/spec_helper.rb') { "spec" }
|
||||
end
|
||||
174
README.markdown
174
README.markdown
@@ -1,174 +0,0 @@
|
||||
# homesick
|
||||
|
||||
[](http://badge.fury.io/rb/homesick)
|
||||
[](https://travis-ci.org/technicalpickles/homesick)
|
||||
[](https://gemnasium.com/technicalpickles/homesick)
|
||||
[](https://codeclimate.com/github/technicalpickles/homesick)
|
||||
|
||||
Your home directory is your castle. Don't leave your dotfiles behind.
|
||||
|
||||
Homesick is sorta like [rip](http://github.com/defunkt/rip), but for dotfiles. It uses git to clone a repository containing dotfiles, and saves them in `~/.homesick`. It then allows you to symlink all the dotfiles into place with a single command.
|
||||
|
||||
We call a repository that is compatible with homesick to be a 'castle'. To act as a castle, a repository must be organized like so:
|
||||
|
||||
* Contains a 'home' directory
|
||||
* 'home' contains any number of files and directories that begin with '.'
|
||||
|
||||
To get started, install homesick first:
|
||||
|
||||
gem install homesick
|
||||
|
||||
Next, you use the homesick command to clone a castle:
|
||||
|
||||
homesick clone git://github.com/technicalpickles/pickled-vim.git
|
||||
|
||||
Alternatively, if it's on github, there's a slightly shorter way:
|
||||
|
||||
homesick clone technicalpickles/pickled-vim
|
||||
|
||||
With the castle cloned, you can now link its contents into your home dir:
|
||||
|
||||
homesick symlink pickled-vim
|
||||
|
||||
You can remove symlinks anytime when you don't need them anymore
|
||||
|
||||
homesick unlink pickled-vim
|
||||
|
||||
If you need to add further configuration steps you can add these in a file called '.homesickrc' in the root of a castle. Once you've cloned a castle with a .homesickrc run the configuration with:
|
||||
|
||||
homesick rc CASTLE
|
||||
|
||||
The contents of the .homesickrc file must be valid Ruby code as the file will be executed with Ruby's eval construct. The .homesickrc is also passed the current homesick object during its execution and this is available within the .homesickrc file as the 'self' variable.
|
||||
|
||||
If you're not sure what castles you have around, you can easily list them:
|
||||
|
||||
homesick list
|
||||
|
||||
To pull your castle (or all castles):
|
||||
|
||||
homesick pull --all|CASTLE
|
||||
|
||||
To commit your castle's changes:
|
||||
|
||||
homesick commit CASTLE
|
||||
|
||||
To push your castle:
|
||||
|
||||
homesick push CASTLE
|
||||
|
||||
To open a terminal in the root of a castle:
|
||||
|
||||
homesick cd CASTLE
|
||||
|
||||
To open your default editor in the root of a castle (the $EDITOR environment variable must be set):
|
||||
|
||||
homesick open CASTLE
|
||||
|
||||
Not sure what else homesick has up its sleeve? There's always the built in help:
|
||||
|
||||
homesick help
|
||||
|
||||
If you ever want to see what version of homesick you have type:
|
||||
|
||||
homesick version|-v|--version
|
||||
|
||||
## .homesick_subdir
|
||||
|
||||
`homesick symlink` basically makes symlink to only first depth in `castle/home`. If you want to link nested files/directories, please use .homesick_subdir.
|
||||
|
||||
For example, when you have castle like this:
|
||||
|
||||
castle/home
|
||||
`-- .config
|
||||
`-- fooapp
|
||||
|-- config1
|
||||
|-- config2
|
||||
`-- config3
|
||||
|
||||
and have home like this:
|
||||
|
||||
$ tree -a
|
||||
~
|
||||
|-- .config
|
||||
| `-- barapp
|
||||
| |-- config1
|
||||
| |-- config2
|
||||
| `-- config3
|
||||
`-- .emacs.d
|
||||
|-- elisp
|
||||
`-- inits
|
||||
|
||||
You may want to symlink only to `castle/home/.config/fooapp` instead of `castle/home/.config` because you already have `~/.config/barapp`. In this case, you can use .homesick_subdir. Please write "directories you want to look up sub directories (instead of just first depth)" in this file.
|
||||
|
||||
castle/.homesick_subdir
|
||||
|
||||
.config
|
||||
|
||||
and run `homesick symlink CASTLE`. The result is:
|
||||
|
||||
~
|
||||
|-- .config
|
||||
| |-- barapp
|
||||
| | |-- config1
|
||||
| | |-- config2
|
||||
| | `-- config3
|
||||
| `-- fooapp -> castle/home/.config/fooapp
|
||||
`-- .emacs.d
|
||||
|-- elisp
|
||||
`-- inits
|
||||
|
||||
Or `homesick track NESTED_FILE CASTLE` adds a line automatically. For example:
|
||||
|
||||
homesick track .emacs.d/elisp castle
|
||||
|
||||
castle/.homesick_subdir
|
||||
|
||||
.config
|
||||
.emacs.d
|
||||
|
||||
home directory
|
||||
|
||||
~
|
||||
|-- .config
|
||||
| |-- barapp
|
||||
| | |-- config1
|
||||
| | |-- config2
|
||||
| | `-- config3
|
||||
| `-- fooapp -> castle/home/.config/fooapp
|
||||
`-- .emacs.d
|
||||
|-- elisp -> castle/home/.emacs.d/elisp
|
||||
`-- inits
|
||||
|
||||
and castle
|
||||
|
||||
castle/home
|
||||
|-- .config
|
||||
| `-- fooapp
|
||||
| |-- config1
|
||||
| |-- config2
|
||||
| `-- config3
|
||||
`-- .emacs.d
|
||||
`-- elisp
|
||||
|
||||
## Supported Ruby Versions
|
||||
|
||||
Homesick is tested on the following Ruby versions:
|
||||
|
||||
* 1.9.3
|
||||
* 2.0.0
|
||||
|
||||
## Note on Patches/Pull Requests
|
||||
|
||||
* Fork the project.
|
||||
* Make your feature addition or bug fix.
|
||||
* Add tests for it. This is important so I don't break it in a future version unintentionally.
|
||||
* Commit, do not mess with rakefile, version, or history. (if you want to have your own version, that is fine but bump version in a commit by itself I can ignore when I pull)
|
||||
* Send me a pull request. Bonus points for topic branches.
|
||||
|
||||
## Need homesick without the ruby dependency?
|
||||
|
||||
Check out [homeshick](https://github.com/andsens/homeshick).
|
||||
|
||||
## Copyright
|
||||
|
||||
Copyright (c) 2010 Joshua Nichols. See LICENSE for details.
|
||||
94
README.md
Normal file
94
README.md
Normal file
@@ -0,0 +1,94 @@
|
||||
# homesick
|
||||
|
||||
[](https://git.hrafn.xyz/aether/gosick/actions/runs/latest?workflow=push-validation.yml&branch=main&event=push)
|
||||
[](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`.
|
||||
68
Rakefile
68
Rakefile
@@ -1,68 +0,0 @@
|
||||
require 'rubygems'
|
||||
require 'bundler'
|
||||
require_relative 'lib/homesick/version'
|
||||
begin
|
||||
Bundler.setup(:default, :development)
|
||||
rescue Bundler::BundlerError => e
|
||||
$stderr.puts e.message
|
||||
$stderr.puts "Run `bundle install` to install missing gems"
|
||||
exit e.status_code
|
||||
end
|
||||
require 'rake'
|
||||
|
||||
require 'jeweler'
|
||||
Jeweler::Tasks.new do |gem|
|
||||
gem.name = "homesick"
|
||||
gem.summary = %Q{Your home directory is your castle. Don't leave your dotfiles behind.}
|
||||
gem.description = %Q{
|
||||
Your home directory is your castle. Don't leave your dotfiles behind.
|
||||
|
||||
|
||||
Homesick is sorta like rip, but for dotfiles. It uses git to clone a repository containing dotfiles, and saves them in ~/.homesick. It then allows you to symlink all the dotfiles into place with a single command.
|
||||
|
||||
}
|
||||
gem.email = ["josh@technicalpickles.com", "info@muratayusuke.com"]
|
||||
gem.homepage = "http://github.com/technicalpickles/homesick"
|
||||
gem.authors = ["Joshua Nichols", "Yusuke Murata"]
|
||||
gem.version = Homesick::Version::STRING
|
||||
gem.license = "MIT"
|
||||
# Have dependencies? Add them to Gemfile
|
||||
|
||||
# gem is a Gem::Specification... see http://www.rubygems.org/read/chapter/20 for additional settings
|
||||
end
|
||||
Jeweler::GemcutterTasks.new
|
||||
|
||||
|
||||
require 'rspec/core/rake_task'
|
||||
RSpec::Core::RakeTask.new(:spec) do |spec|
|
||||
spec.pattern = FileList['spec/**/*_spec.rb']
|
||||
end
|
||||
|
||||
RSpec::Core::RakeTask.new(:rcov) do |spec|
|
||||
spec.pattern = 'spec/**/*_spec.rb'
|
||||
spec.rcov = true
|
||||
end
|
||||
|
||||
task :rubocop do
|
||||
if RUBY_VERSION >= '1.9.2'
|
||||
system('rubocop')
|
||||
end
|
||||
end
|
||||
|
||||
task :test do
|
||||
Rake::Task['spec'].execute
|
||||
Rake::Task['rubocop'].execute
|
||||
end
|
||||
|
||||
task :default => :test
|
||||
|
||||
require 'rdoc/task'
|
||||
Rake::RDocTask.new do |rdoc|
|
||||
version = File.exist?('VERSION') ? File.read('VERSION') : ""
|
||||
|
||||
rdoc.rdoc_dir = 'rdoc'
|
||||
rdoc.title = "homesick #{version}"
|
||||
rdoc.rdoc_files.include('README*')
|
||||
rdoc.rdoc_files.include('lib/**/*.rb')
|
||||
end
|
||||
|
||||
@@ -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
18
cmd/homesick/main.go
Normal 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
53
cmd/homesick/main_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
22
docker/behavior/Dockerfile
Normal file
22
docker/behavior/Dockerfile
Normal 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
38
go.mod
Normal 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
112
go.sum
Normal 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=
|
||||
@@ -1,96 +0,0 @@
|
||||
# Generated by jeweler
|
||||
# DO NOT EDIT THIS FILE DIRECTLY
|
||||
# Instead, edit Jeweler::Tasks in Rakefile, and run 'rake gemspec'
|
||||
# -*- encoding: utf-8 -*-
|
||||
# stub: homesick 1.0.0 ruby lib
|
||||
|
||||
Gem::Specification.new do |s|
|
||||
s.name = "homesick"
|
||||
s.version = "1.0.0"
|
||||
|
||||
s.required_rubygems_version = Gem::Requirement.new(">= 0") if s.respond_to? :required_rubygems_version=
|
||||
s.authors = ["Joshua Nichols", "Yusuke Murata"]
|
||||
s.date = "2014-01-16"
|
||||
s.description = "\n Your home directory is your castle. Don't leave your dotfiles behind.\n \n\n Homesick is sorta like rip, but for dotfiles. It uses git to clone a repository containing dotfiles, and saves them in ~/.homesick. It then allows you to symlink all the dotfiles into place with a single command. \n\n "
|
||||
s.email = ["josh@technicalpickles.com", "info@muratayusuke.com"]
|
||||
s.executables = ["homesick"]
|
||||
s.extra_rdoc_files = [
|
||||
"ChangeLog.markdown",
|
||||
"LICENSE",
|
||||
"README.markdown"
|
||||
]
|
||||
s.files = [
|
||||
".document",
|
||||
".rspec",
|
||||
".travis.yml",
|
||||
"ChangeLog.markdown",
|
||||
"Gemfile",
|
||||
"Guardfile",
|
||||
"LICENSE",
|
||||
"README.markdown",
|
||||
"Rakefile",
|
||||
"bin/homesick",
|
||||
"homesick.gemspec",
|
||||
"lib/homesick.rb",
|
||||
"lib/homesick/actions.rb",
|
||||
"lib/homesick/shell.rb",
|
||||
"lib/homesick/version.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 = "2.1.11"
|
||||
s.summary = "Your home directory is your castle. Don't leave your dotfiles behind."
|
||||
|
||||
if s.respond_to? :specification_version then
|
||||
s.specification_version = 4
|
||||
|
||||
if Gem::Version.new(Gem::VERSION) >= Gem::Version.new('1.2.0') then
|
||||
s.add_runtime_dependency(%q<thor>, [">= 0.14.0"])
|
||||
s.add_development_dependency(%q<rake>, [">= 0.8.7"])
|
||||
s.add_development_dependency(%q<rspec>, ["~> 2.10"])
|
||||
s.add_development_dependency(%q<guard>, [">= 0"])
|
||||
s.add_development_dependency(%q<guard-rspec>, [">= 0"])
|
||||
s.add_development_dependency(%q<rb-readline>, ["~> 0.5.0"])
|
||||
s.add_development_dependency(%q<jeweler>, [">= 1.6.2"])
|
||||
s.add_development_dependency(%q<rcov>, [">= 0"])
|
||||
s.add_development_dependency(%q<simplecov>, [">= 0"])
|
||||
s.add_development_dependency(%q<test_construct>, [">= 0"])
|
||||
s.add_development_dependency(%q<capture-output>, ["~> 1.0.0"])
|
||||
s.add_development_dependency(%q<libnotify>, [">= 0"])
|
||||
s.add_development_dependency(%q<rubocop>, [">= 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<guard>, [">= 0"])
|
||||
s.add_dependency(%q<guard-rspec>, [">= 0"])
|
||||
s.add_dependency(%q<rb-readline>, ["~> 0.5.0"])
|
||||
s.add_dependency(%q<jeweler>, [">= 1.6.2"])
|
||||
s.add_dependency(%q<rcov>, [">= 0"])
|
||||
s.add_dependency(%q<simplecov>, [">= 0"])
|
||||
s.add_dependency(%q<test_construct>, [">= 0"])
|
||||
s.add_dependency(%q<capture-output>, ["~> 1.0.0"])
|
||||
s.add_dependency(%q<libnotify>, [">= 0"])
|
||||
s.add_dependency(%q<rubocop>, [">= 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<guard>, [">= 0"])
|
||||
s.add_dependency(%q<guard-rspec>, [">= 0"])
|
||||
s.add_dependency(%q<rb-readline>, ["~> 0.5.0"])
|
||||
s.add_dependency(%q<jeweler>, [">= 1.6.2"])
|
||||
s.add_dependency(%q<rcov>, [">= 0"])
|
||||
s.add_dependency(%q<simplecov>, [">= 0"])
|
||||
s.add_dependency(%q<test_construct>, [">= 0"])
|
||||
s.add_dependency(%q<capture-output>, ["~> 1.0.0"])
|
||||
s.add_dependency(%q<libnotify>, [">= 0"])
|
||||
s.add_dependency(%q<rubocop>, [">= 0"])
|
||||
end
|
||||
end
|
||||
|
||||
327
internal/homesick/cli/cli.go
Normal file
327
internal/homesick/cli/cli.go
Normal 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()
|
||||
}
|
||||
272
internal/homesick/cli/cli_test.go
Normal file
272
internal/homesick/cli/cli_test.go
Normal 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())
|
||||
}
|
||||
103
internal/homesick/core/clone_test.go
Normal file
103
internal/homesick/core/clone_test.go
Normal 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)
|
||||
}
|
||||
112
internal/homesick/core/commit_test.go
Normal file
112
internal/homesick/core/commit_test.go
Normal 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)
|
||||
}
|
||||
954
internal/homesick/core/core.go
Normal file
954
internal/homesick/core/core.go
Normal 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
|
||||
}
|
||||
90
internal/homesick/core/core_test.go
Normal file
90
internal/homesick/core/core_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
105
internal/homesick/core/destroy_test.go
Normal file
105
internal/homesick/core/destroy_test.go
Normal 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)
|
||||
}
|
||||
76
internal/homesick/core/diff_test.go
Normal file
76
internal/homesick/core/diff_test.go
Normal 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")
|
||||
}
|
||||
109
internal/homesick/core/exec_test.go
Normal file
109
internal/homesick/core/exec_test.go
Normal 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)
|
||||
}
|
||||
78
internal/homesick/core/generate_test.go
Normal file
78
internal/homesick/core/generate_test.go
Normal 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")
|
||||
}
|
||||
279
internal/homesick/core/helpers_test.go
Normal file
279
internal/homesick/core/helpers_test.go
Normal 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\"")
|
||||
}
|
||||
118
internal/homesick/core/link_test.go
Normal file
118
internal/homesick/core/link_test.go
Normal 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)
|
||||
}
|
||||
82
internal/homesick/core/list_test.go
Normal file
82
internal/homesick/core/list_test.go
Normal 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")
|
||||
}
|
||||
86
internal/homesick/core/open_test.go
Normal file
86
internal/homesick/core/open_test.go
Normal 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
|
||||
156
internal/homesick/core/pull_test.go
Normal file
156
internal/homesick/core/pull_test.go
Normal 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:")
|
||||
}
|
||||
116
internal/homesick/core/push_test.go
Normal file
116
internal/homesick/core/push_test.go
Normal 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)
|
||||
}
|
||||
242
internal/homesick/core/rc_test.go
Normal file
242
internal/homesick/core/rc_test.go
Normal 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")
|
||||
}
|
||||
51
internal/homesick/core/show_path_test.go
Normal file
51
internal/homesick/core/show_path_test.go
Normal 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(),
|
||||
)
|
||||
}
|
||||
79
internal/homesick/core/status_test.go
Normal file
79
internal/homesick/core/status_test.go
Normal 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
|
||||
113
internal/homesick/core/track_test.go
Normal file
113
internal/homesick/core/track_test.go
Normal 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")
|
||||
}
|
||||
106
internal/homesick/core/unlink_test.go
Normal file
106
internal/homesick/core/unlink_test.go
Normal 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"))
|
||||
}
|
||||
46
internal/homesick/core/version_test.go
Normal file
46
internal/homesick/core/version_test.go
Normal 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())
|
||||
}
|
||||
3
internal/homesick/version/version.go
Normal file
3
internal/homesick/version/version.go
Normal file
@@ -0,0 +1,3 @@
|
||||
package version
|
||||
|
||||
const String = "1.1.6"
|
||||
21
internal/homesick/version/version_test.go
Normal file
21
internal/homesick/version/version_test.go
Normal 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
33
justfile
Normal 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."
|
||||
450
lib/homesick.rb
450
lib/homesick.rb
@@ -1,450 +0,0 @@
|
||||
# -*- encoding : utf-8 -*-
|
||||
require 'thor'
|
||||
|
||||
class Homesick < Thor
|
||||
autoload :Shell, 'homesick/shell'
|
||||
autoload :Actions, 'homesick/actions'
|
||||
autoload :Version, 'homesick/version'
|
||||
|
||||
include Thor::Actions
|
||||
include Homesick::Actions
|
||||
include Homesick::Version
|
||||
|
||||
add_runtime_options!
|
||||
|
||||
GITHUB_NAME_REPO_PATTERN = /\A([A-Za-z0-9_-]+\/[A-Za-z0-9_-]+)\Z/
|
||||
SUBDIR_FILENAME = '.homesick_subdir'
|
||||
|
||||
DEFAULT_CASTLE_NAME = 'dotfiles'
|
||||
|
||||
map '-v' => :version
|
||||
map '--version' => :version
|
||||
|
||||
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(uri).basename
|
||||
git_clone "https://github.com/#{$1}.git", :destination => destination
|
||||
elsif uri =~ /%r([^%r]*?)(\.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
|
||||
|
||||
rc(destination)
|
||||
end
|
||||
end
|
||||
|
||||
desc 'rc CASTLE', 'Run the .homesickrc for the specified castle'
|
||||
def rc(name = DEFAULT_CASTLE_NAME)
|
||||
inside repos_dir do
|
||||
destination = Pathname.new(name)
|
||||
homesickrc = destination.join('.homesickrc').expand_path
|
||||
if homesickrc.exist?
|
||||
proceed = shell.yes?("#{name} has a .homesickrc. Proceed with evaling it? (This could be destructive)")
|
||||
if proceed
|
||||
shell.say_status 'eval', homesickrc
|
||||
inside destination do
|
||||
eval homesickrc.read, binding, homesickrc.expand_path.to_s
|
||||
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 = DEFAULT_CASTLE_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 MESSAGE', "Commit the specified castle's changes"
|
||||
def commit(name = DEFAULT_CASTLE_NAME, message = nil)
|
||||
commit_castle name, message
|
||||
|
||||
end
|
||||
|
||||
desc 'push CASTLE', 'Push the specified castle'
|
||||
def push(name = DEFAULT_CASTLE_NAME)
|
||||
push_castle name
|
||||
end
|
||||
|
||||
desc 'unlink CASTLE', 'Unsymlinks all dotfiles from the specified castle'
|
||||
def unlink(name = DEFAULT_CASTLE_NAME)
|
||||
check_castle_existance(name, 'symlink')
|
||||
|
||||
inside castle_dir(name) do
|
||||
subdirs = subdirs(name)
|
||||
|
||||
# unlink files
|
||||
unsymlink_each(name, castle_dir(name), subdirs)
|
||||
|
||||
# unlink files in subdirs
|
||||
subdirs.each do |subdir|
|
||||
unsymlink_each(name, subdir, subdirs)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
desc 'symlink CASTLE', 'Symlinks all dotfiles from the specified castle'
|
||||
method_option :force, :default => false, :desc => 'Overwrite existing conflicting symlinks without prompting.'
|
||||
def symlink(name = DEFAULT_CASTLE_NAME)
|
||||
check_castle_existance(name, 'symlink')
|
||||
|
||||
inside castle_dir(name) do
|
||||
subdirs = subdirs(name)
|
||||
|
||||
# link files
|
||||
symlink_each(name, castle_dir(name), subdirs)
|
||||
|
||||
# link files in subdirs
|
||||
subdirs.each do |subdir|
|
||||
symlink_each(name, subdir, subdirs)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
desc 'track FILE CASTLE', 'add a file to a castle'
|
||||
def track(file, castle = DEFAULT_CASTLE_NAME)
|
||||
castle = Pathname.new(castle)
|
||||
file = Pathname.new(file.chomp('/'))
|
||||
check_castle_existance(castle, 'track')
|
||||
|
||||
absolute_path = file.expand_path
|
||||
relative_dir = absolute_path.relative_path_from(home_dir).dirname
|
||||
castle_path = Pathname.new(castle_dir(castle)).join(relative_dir)
|
||||
FileUtils.mkdir_p castle_path
|
||||
|
||||
# Are we already tracking this or anything inside it?
|
||||
target = Pathname.new(castle_path.join(file.basename))
|
||||
if target.exist?
|
||||
if absolute_path.directory?
|
||||
move_dir_contents(target, absolute_path)
|
||||
absolute_path.rmtree
|
||||
subdir_remove(castle, relative_dir + file.basename)
|
||||
|
||||
elsif more_recent? absolute_path, target
|
||||
target.delete
|
||||
mv absolute_path, castle_path
|
||||
else
|
||||
shell.say_status(:track, "#{target} already exists, and is more recent than #{file}. Run 'homesick SYMLINK CASTLE' to create symlinks.", :blue) unless options[:quiet]
|
||||
end
|
||||
else
|
||||
mv absolute_path, castle_path
|
||||
end
|
||||
|
||||
inside home_dir do
|
||||
absolute_path = castle_path + file.basename
|
||||
home_path = home_dir + relative_dir + file.basename
|
||||
ln_s absolute_path, home_path
|
||||
end
|
||||
|
||||
inside castle_path do
|
||||
git_add absolute_path
|
||||
end
|
||||
|
||||
# are we tracking something nested? Add the parent dir to the manifest
|
||||
subdir_add(castle, relative_dir) unless relative_dir.eql?(Pathname.new('.'))
|
||||
end
|
||||
|
||||
desc 'list', 'List cloned castles'
|
||||
def list
|
||||
inside_each_castle do |castle|
|
||||
say_status castle.relative_path_from(repos_dir).to_s, `git config remote.origin.url`.chomp, :cyan
|
||||
end
|
||||
end
|
||||
|
||||
desc 'status CASTLE', 'Shows the git status of a castle'
|
||||
def status(castle = DEFAULT_CASTLE_NAME)
|
||||
check_castle_existance(castle, 'status')
|
||||
inside repos_dir.join(castle) do
|
||||
git_status
|
||||
end
|
||||
end
|
||||
|
||||
desc 'diff CASTLE', 'Shows the git diff of uncommitted changes in a castle'
|
||||
def diff(castle = DEFAULT_CASTLE_NAME)
|
||||
check_castle_existance(castle, 'diff')
|
||||
inside repos_dir.join(castle) do
|
||||
git_diff
|
||||
end
|
||||
end
|
||||
|
||||
desc 'show_path CASTLE', 'Prints the path of a castle'
|
||||
def show_path(castle = DEFAULT_CASTLE_NAME)
|
||||
check_castle_existance(castle, 'show_path')
|
||||
say repos_dir.join(castle)
|
||||
end
|
||||
|
||||
desc 'generate PATH', 'generate a homesick-ready git repo at PATH'
|
||||
def generate(castle)
|
||||
castle = Pathname.new(castle).expand_path
|
||||
|
||||
github_user = `git config github.user`.chomp
|
||||
github_user = nil if github_user == ''
|
||||
github_repo = castle.basename
|
||||
|
||||
empty_directory castle
|
||||
inside castle do
|
||||
git_init
|
||||
if github_user
|
||||
url = "git@github.com:#{github_user}/#{github_repo}.git"
|
||||
git_remote_add 'origin', url
|
||||
end
|
||||
|
||||
empty_directory 'home'
|
||||
end
|
||||
end
|
||||
|
||||
desc "destroy CASTLE", "Delete all symlinks and remove the cloned repository"
|
||||
def destroy(name)
|
||||
check_castle_existance name, "destroy"
|
||||
|
||||
if shell.yes?("This will destroy your castle irreversible! Are you sure?")
|
||||
unlink(name)
|
||||
rm_rf repos_dir.join(name)
|
||||
end
|
||||
|
||||
end
|
||||
|
||||
desc "cd CASTLE", "Open a new shell in the root of the given castle"
|
||||
def cd(castle = DEFAULT_CASTLE_NAME)
|
||||
check_castle_existance castle, "cd"
|
||||
castle_dir = repos_dir.join(castle)
|
||||
say_status "cd #{castle_dir.realpath}", "Opening a new shell in castle '#{castle}'. To return to the original one exit from the new shell.", :green
|
||||
inside castle_dir do
|
||||
system(ENV['SHELL'])
|
||||
end
|
||||
end
|
||||
|
||||
desc "open CASTLE", "Open your default editor in the root of the given castle"
|
||||
def open(castle = DEFAULT_CASTLE_NAME)
|
||||
if ! ENV['EDITOR']
|
||||
say_status :error,"The $EDITOR environment variable must be set to use this command", :red
|
||||
|
||||
exit(1)
|
||||
end
|
||||
check_castle_existance castle, "open"
|
||||
castle_dir = repos_dir.join(castle)
|
||||
say_status "#{ENV['EDITOR']} #{castle_dir.realpath}", "Opening the root directory of castle '#{castle}' in editor '#{ENV['EDITOR']}'.", :green
|
||||
inside castle_dir do
|
||||
system(ENV['EDITOR'])
|
||||
end
|
||||
end
|
||||
|
||||
desc 'version', 'Display the current version of homesick'
|
||||
def version
|
||||
say Homesick::Version::STRING
|
||||
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? do |other|
|
||||
dir != other && dir.fnmatch(other.parent.join('*').to_s)
|
||||
end
|
||||
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, message)
|
||||
check_castle_existance(castle, 'commit')
|
||||
inside repos_dir.join(castle) do
|
||||
git_commit_all :message => message
|
||||
end
|
||||
end
|
||||
|
||||
def push_castle(castle)
|
||||
check_castle_existance(castle, 'push')
|
||||
inside repos_dir.join(castle) do
|
||||
git_push
|
||||
end
|
||||
end
|
||||
|
||||
def subdir_file(castle)
|
||||
repos_dir.join(castle, SUBDIR_FILENAME)
|
||||
end
|
||||
|
||||
def subdirs(castle)
|
||||
subdir_filepath = subdir_file(castle)
|
||||
subdirs = []
|
||||
if subdir_filepath.exist?
|
||||
subdir_filepath.readlines.each do |subdir|
|
||||
subdirs.push(subdir.chomp)
|
||||
end
|
||||
end
|
||||
subdirs
|
||||
end
|
||||
|
||||
def subdir_add(castle, path)
|
||||
subdir_filepath = subdir_file(castle)
|
||||
File.open(subdir_filepath, 'a+') do |subdir|
|
||||
subdir.puts path unless subdir.readlines.reduce(false) do |memo, line|
|
||||
line.eql?("#{path.to_s}\n") || memo
|
||||
end
|
||||
end
|
||||
|
||||
inside castle_dir(castle) do
|
||||
git_add subdir_filepath
|
||||
end
|
||||
end
|
||||
|
||||
def subdir_remove(castle, path)
|
||||
subdir_filepath = subdir_file(castle)
|
||||
if subdir_filepath.exist?
|
||||
lines = IO.readlines(subdir_filepath).delete_if { |line| line == "#{path}\n" }
|
||||
File.open(subdir_filepath, 'w') { |manfile| manfile.puts lines }
|
||||
end
|
||||
|
||||
inside castle_dir(castle) do
|
||||
git_add subdir_filepath
|
||||
end
|
||||
end
|
||||
|
||||
def move_dir_contents(target, dir_path)
|
||||
child_files = dir_path.children
|
||||
child_files.each do |child|
|
||||
|
||||
target_path = target.join(child.basename)
|
||||
if target_path.exist?
|
||||
if more_recent?(child, target_path) && target.file?
|
||||
target_path.delete
|
||||
mv child, target
|
||||
end
|
||||
next
|
||||
end
|
||||
|
||||
mv child, target
|
||||
end
|
||||
end
|
||||
|
||||
def more_recent?(first, second)
|
||||
first_p = Pathname.new(first)
|
||||
second_p = Pathname.new(second)
|
||||
first_p.mtime > second_p.mtime && !first_p.symlink?
|
||||
end
|
||||
|
||||
def each_file(castle, basedir, subdirs)
|
||||
absolute_basedir = Pathname.new(basedir).expand_path
|
||||
inside basedir do
|
||||
files = Pathname.glob('{.*,*}').reject{ |a| ['.', '..'].include?(a.to_s) }
|
||||
files.each do |path|
|
||||
absolute_path = path.expand_path
|
||||
castle_home = castle_dir(castle)
|
||||
|
||||
# make ignore dirs
|
||||
ignore_dirs = []
|
||||
subdirs.each do |subdir|
|
||||
# ignore all parent of each line in subdir file
|
||||
Pathname.new(subdir).ascend do |p|
|
||||
ignore_dirs.push(p)
|
||||
end
|
||||
end
|
||||
|
||||
# ignore dirs written in subdir file
|
||||
matched = false
|
||||
ignore_dirs.uniq.each do |ignore_dir|
|
||||
if absolute_path == castle_home.join(ignore_dir)
|
||||
matched = true
|
||||
break
|
||||
end
|
||||
end
|
||||
next if matched
|
||||
|
||||
relative_dir = absolute_basedir.relative_path_from(castle_home)
|
||||
home_path = home_dir.join(relative_dir).join(path)
|
||||
|
||||
yield(absolute_path, home_path)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def unsymlink_each(castle, basedir, subdirs)
|
||||
each_file(castle, basedir, subdirs) do |absolute_path, home_path|
|
||||
rm_link home_path
|
||||
end
|
||||
end
|
||||
|
||||
def symlink_each(castle, basedir, subdirs)
|
||||
each_file(castle, basedir, subdirs) do |absolute_path, home_path|
|
||||
ln_s absolute_path, home_path
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,175 +0,0 @@
|
||||
# -*- encoding : utf-8 -*-
|
||||
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] || File.basename(repo, '.git')
|
||||
|
||||
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 --config push.default=upstream --recursive #{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
|
||||
if !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 == ''
|
||||
|
||||
if !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]
|
||||
if config[:message]
|
||||
system "git commit -a -m '#{config[:message]}'" unless options[:pretend]
|
||||
else
|
||||
system 'git commit -v -a' unless options[:pretend]
|
||||
end
|
||||
end
|
||||
|
||||
def git_add(file, config = {})
|
||||
say_status 'git add file', '', :green unless options[:quiet]
|
||||
system "git add '#{file}'" unless options[:pretend]
|
||||
end
|
||||
|
||||
def git_status(config = {})
|
||||
say_status 'git status', '', :green unless options[:quiet]
|
||||
system "git status" unless options[:pretend]
|
||||
end
|
||||
|
||||
def git_diff(config = {})
|
||||
say_status 'git diff', '', :green unless options[:quiet]
|
||||
system "git diff" 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 rm_link(target)
|
||||
target = Pathname.new(target)
|
||||
|
||||
if target.symlink?
|
||||
say_status :unlink, "#{target.expand_path}", :green unless options[:quiet]
|
||||
FileUtils.rm_rf target
|
||||
else
|
||||
say_status :conflict, "#{target} is not a symlink", :red unless options[:quiet]
|
||||
end
|
||||
end
|
||||
|
||||
def rm(file)
|
||||
say_status "rm #{file}", '', :green unless options[:quiet]
|
||||
system "rm #{file}" if File.exists?(file)
|
||||
end
|
||||
|
||||
def rm_rf(dir)
|
||||
say_status "rm -rf #{dir}", '', :green unless options[:quiet]
|
||||
system "rm -rf #{dir}"
|
||||
end
|
||||
|
||||
def rm_link(target)
|
||||
target = Pathname.new(target)
|
||||
|
||||
if target.symlink?
|
||||
say_status :unlink, "#{target.expand_path}", :green unless options[:quiet]
|
||||
FileUtils.rm_rf target
|
||||
else
|
||||
say_status :conflict, "#{target} is not a symlink", :red unless options[:quiet]
|
||||
end
|
||||
end
|
||||
|
||||
def rm(file)
|
||||
say_status "rm #{file}", '', :green unless options[:quiet]
|
||||
system "rm #{file}"
|
||||
end
|
||||
|
||||
def rm_r(dir)
|
||||
say_status "rm -r #{dir}", '', :green unless options[:quiet]
|
||||
system "rm -r #{dir}"
|
||||
end
|
||||
|
||||
def ln_s(source, destination, config = {})
|
||||
source = Pathname.new(source)
|
||||
destination = Pathname.new(destination)
|
||||
FileUtils.mkdir_p destination.dirname
|
||||
|
||||
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 "rm -rf '#{destination}'" unless options[:pretend]
|
||||
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
|
||||
@@ -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
|
||||
@@ -1,10 +0,0 @@
|
||||
# -*- encoding : utf-8 -*-
|
||||
class Homesick
|
||||
module Version
|
||||
MAJOR = 1
|
||||
MINOR = 0
|
||||
PATCH = 0
|
||||
|
||||
STRING = "#{MAJOR}.#{MINOR}.#{PATCH}"
|
||||
end
|
||||
end
|
||||
57
script/run-behavior-suite-docker.sh
Executable file
57
script/run-behavior-suite-docker.sh
Executable 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
|
||||
@@ -1,621 +0,0 @@
|
||||
# -*- encoding : utf-8 -*-
|
||||
require 'spec_helper'
|
||||
require 'capture-output'
|
||||
|
||||
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 'has a .homesickrc' do
|
||||
it 'should run the .homesickrc' do
|
||||
somewhere = create_construct
|
||||
local_repo = somewhere.directory('some_repo')
|
||||
local_repo.file('.homesickrc') do |file|
|
||||
file << "File.open(Dir.pwd + '/testing', 'w') { |f| f.print 'testing' }"
|
||||
end
|
||||
|
||||
expect($stdout).to receive(:print)
|
||||
expect($stdin).to receive(:gets).and_return('y')
|
||||
expect_any_instance_of(Thor::Shell::Basic).to receive(:say_status).with('eval', kind_of(Pathname))
|
||||
homesick.clone local_repo
|
||||
|
||||
castles.join('some_repo').join('testing').should exist
|
||||
end
|
||||
end
|
||||
|
||||
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 raise an error' do
|
||||
homesick.should_not_receive(:git_clone)
|
||||
expect { homesick.clone @existing_dir.to_s }.to raise_error(/already cloned/i)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
it 'should clone git repo like file:///path/to.git' do
|
||||
bare_repo = File.join(create_construct.to_s, 'dotfiles.git')
|
||||
system "git init --bare #{bare_repo} >/dev/null 2>&1"
|
||||
# Capture stderr to suppress message about cloning an empty repo.
|
||||
Capture.stderr do
|
||||
homesick.clone "file://#{bare_repo}"
|
||||
end
|
||||
File.directory?(File.join(home.to_s, '.homesick/repos/dotfiles')).should be_true
|
||||
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 throw an exception when trying to clone a malformed uri like malformed' do
|
||||
homesick.should_not_receive(:git_clone)
|
||||
expect { homesick.clone 'malformed' }.to raise_error
|
||||
end
|
||||
|
||||
it 'should clone a github repo' do
|
||||
homesick.should_receive(:git_clone).with('https://github.com/wfarr/dotfiles.git', :destination => Pathname.new('dotfiles'))
|
||||
|
||||
homesick.clone 'wfarr/dotfiles'
|
||||
end
|
||||
end
|
||||
|
||||
describe 'rc' do
|
||||
let(:castle) { given_castle('glencairn') }
|
||||
|
||||
context 'when told to do so' do
|
||||
before do
|
||||
expect($stdout).to receive(:print)
|
||||
expect($stdin).to receive(:gets).and_return('y')
|
||||
end
|
||||
|
||||
it 'executes the .homesickrc' do
|
||||
castle.file('.homesickrc') do |file|
|
||||
file << "File.open(Dir.pwd + '/testing', 'w') { |f| f.print 'testing' }"
|
||||
end
|
||||
|
||||
expect_any_instance_of(Thor::Shell::Basic).to receive(:say_status).with('eval', kind_of(Pathname))
|
||||
homesick.rc castle
|
||||
|
||||
castle.join('testing').should exist
|
||||
end
|
||||
end
|
||||
|
||||
context 'when told not to do so' do
|
||||
before do
|
||||
expect($stdout).to receive(:print)
|
||||
expect($stdin).to receive(:gets).and_return('n')
|
||||
end
|
||||
|
||||
it 'does not execute the .homesickrc' do
|
||||
castle.file('.homesickrc') do |file|
|
||||
file << "File.open(Dir.pwd + '/testing', 'w') { |f| f.print 'testing' }"
|
||||
end
|
||||
|
||||
expect_any_instance_of(Thor::Shell::Basic).to receive(:say_status).with('eval skip', /not evaling.+/, :blue)
|
||||
homesick.rc castle
|
||||
|
||||
castle.join('testing').should_not exist
|
||||
end
|
||||
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
|
||||
|
||||
it 'can override existing directory' do
|
||||
existing_dotdir = home.directory('.vim')
|
||||
|
||||
dotdir = castle.directory('.vim')
|
||||
|
||||
homesick.symlink('glencairn')
|
||||
|
||||
existing_dotdir.readlink.should == dotdir
|
||||
end
|
||||
end
|
||||
|
||||
context "with '.config' in .homesick_subdir" do
|
||||
let(:castle) { given_castle('glencairn', ['.config']) }
|
||||
it 'can symlink in sub directory' do
|
||||
dotdir = castle.directory('.config')
|
||||
dotfile = dotdir.file('.some_dotfile')
|
||||
|
||||
homesick.symlink('glencairn')
|
||||
|
||||
home_dotdir = home.join('.config')
|
||||
home_dotdir.symlink?.should be == false
|
||||
home_dotdir.join('.some_dotfile').readlink.should == dotfile
|
||||
end
|
||||
end
|
||||
|
||||
context "with '.config/appA' in .homesick_subdir" do
|
||||
let(:castle) { given_castle('glencairn', ['.config/appA']) }
|
||||
it 'can symlink in nested sub directory' do
|
||||
dotdir = castle.directory('.config').directory('appA')
|
||||
dotfile = dotdir.file('.some_dotfile')
|
||||
|
||||
homesick.symlink('glencairn')
|
||||
|
||||
home_dotdir = home.join('.config').join('appA')
|
||||
home_dotdir.symlink?.should be == false
|
||||
home_dotdir.join('.some_dotfile').readlink.should == dotfile
|
||||
end
|
||||
end
|
||||
|
||||
context "with '.config' and '.config/someapp' in .homesick_subdir" do
|
||||
let(:castle) { given_castle('glencairn', ['.config', '.config/someapp']) }
|
||||
it 'can symlink under both of .config and .config/someapp' do
|
||||
config_dir = castle.directory('.config')
|
||||
config_dotfile = config_dir.file('.some_dotfile')
|
||||
someapp_dir = config_dir.directory('someapp')
|
||||
someapp_dotfile = someapp_dir.file('.some_appfile')
|
||||
|
||||
homesick.symlink('glencairn')
|
||||
|
||||
home_config_dir = home.join('.config')
|
||||
home_someapp_dir = home_config_dir.join('someapp')
|
||||
home_config_dir.symlink?.should be == false
|
||||
home_config_dir.join('.some_dotfile').readlink.should be == config_dotfile
|
||||
home_someapp_dir.symlink?.should be == false
|
||||
home_someapp_dir.join('.some_appfile').readlink.should == someapp_dotfile
|
||||
end
|
||||
end
|
||||
|
||||
context "when call with no castle name" do
|
||||
let(:castle) { given_castle('dotfiles') }
|
||||
it 'using default castle name: "dotfiles"' do
|
||||
dotfile = castle.file('.some_dotfile')
|
||||
|
||||
homesick.symlink
|
||||
|
||||
home.join('.some_dotfile').readlink.should == dotfile
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'unlink' do
|
||||
let(:castle) { given_castle('glencairn') }
|
||||
|
||||
it 'unlinks dotfiles in the home folder' do
|
||||
castle.file('.some_dotfile')
|
||||
|
||||
homesick.symlink('glencairn')
|
||||
homesick.unlink('glencairn')
|
||||
|
||||
home.join('.some_dotfile').should_not exist
|
||||
end
|
||||
|
||||
it 'unlinks non-dotfiles from the home folder' do
|
||||
castle.file('bin')
|
||||
|
||||
homesick.symlink('glencairn')
|
||||
homesick.unlink('glencairn')
|
||||
|
||||
home.join('bin').should_not exist
|
||||
end
|
||||
|
||||
context "with '.config' in .homesick_subdir" do
|
||||
let(:castle) { given_castle('glencairn', ['.config']) }
|
||||
|
||||
it 'can unlink sub directories' do
|
||||
castle.directory('.config').file('.some_dotfile')
|
||||
|
||||
homesick.symlink('glencairn')
|
||||
homesick.unlink('glencairn')
|
||||
|
||||
home_dotdir = home.join('.config')
|
||||
home_dotdir.should exist
|
||||
home_dotdir.join('.some_dotfile').should_not exist
|
||||
end
|
||||
end
|
||||
|
||||
context "with '.config/appA' in .homesick_subdir" do
|
||||
let(:castle) { given_castle('glencairn', ['.config/appA']) }
|
||||
|
||||
it 'can unsymlink in nested sub directory' do
|
||||
castle.directory('.config').directory('appA').file('.some_dotfile')
|
||||
|
||||
homesick.symlink('glencairn')
|
||||
homesick.unlink('glencairn')
|
||||
|
||||
home_dotdir = home.join('.config').join('appA')
|
||||
home_dotdir.should exist
|
||||
home_dotdir.join('.some_dotfile').should_not exist
|
||||
end
|
||||
end
|
||||
|
||||
context "with '.config' and '.config/someapp' in .homesick_subdir" do
|
||||
let(:castle) { given_castle('glencairn', ['.config', '.config/someapp']) }
|
||||
|
||||
it 'can unsymlink under both of .config and .config/someapp' do
|
||||
config_dir = castle.directory('.config')
|
||||
config_dir.file('.some_dotfile')
|
||||
config_dir.directory('someapp').file('.some_appfile')
|
||||
|
||||
homesick.symlink('glencairn')
|
||||
homesick.unlink('glencairn')
|
||||
|
||||
home_config_dir = home.join('.config')
|
||||
home_someapp_dir = home_config_dir.join('someapp')
|
||||
home_config_dir.should exist
|
||||
home_config_dir.join('.some_dotfile').should_not exist
|
||||
home_someapp_dir.should exist
|
||||
home_someapp_dir.join('.some_appfile').should_not exist
|
||||
end
|
||||
end
|
||||
|
||||
context "when call with no castle name" do
|
||||
let(:castle) { given_castle('dotfiles') }
|
||||
|
||||
it 'using default castle name: "dotfiles"' do
|
||||
castle.file('.some_dotfile')
|
||||
|
||||
homesick.symlink
|
||||
homesick.unlink
|
||||
|
||||
home.join('.some_dotfile').should_not exist
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'list' do
|
||||
it 'should say each castle in the castle directory' do
|
||||
given_castle('zomg')
|
||||
given_castle('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 'status' do
|
||||
it 'should say "nothing to commit" when there are no changes' do
|
||||
given_castle('castle_repo')
|
||||
text = Capture.stdout { homesick.status('castle_repo') }
|
||||
text.should =~ /nothing to commit \(create\/copy files and use "git add" to track\)$/
|
||||
end
|
||||
|
||||
it 'should say "Changes to be committed" when there are changes' do
|
||||
given_castle('castle_repo')
|
||||
some_rc_file = home.file '.some_rc_file'
|
||||
homesick.track(some_rc_file.to_s, 'castle_repo')
|
||||
text = Capture.stdout { homesick.status('castle_repo') }
|
||||
text.should =~ /Changes to be committed:.*new file:\s*home\/.some_rc_file/m
|
||||
end
|
||||
end
|
||||
|
||||
describe 'diff' do
|
||||
it 'should output an empty message when there are no changes to commit' do
|
||||
given_castle('castle_repo')
|
||||
some_rc_file = home.file '.some_rc_file'
|
||||
homesick.track(some_rc_file.to_s, 'castle_repo')
|
||||
Capture.stdout { homesick.commit 'castle_repo', 'Adding a file to the test' }
|
||||
text = Capture.stdout { homesick.diff('castle_repo') }
|
||||
text.should eq('')
|
||||
end
|
||||
|
||||
it 'should output a diff message when there are changes to commit' do
|
||||
given_castle('castle_repo')
|
||||
some_rc_file = home.file '.some_rc_file'
|
||||
homesick.track(some_rc_file.to_s, 'castle_repo')
|
||||
Capture.stdout { homesick.commit 'castle_repo', 'Adding a file to the test' }
|
||||
File.open(some_rc_file.to_s, 'w') do |file|
|
||||
file.puts "Some test text"
|
||||
end
|
||||
text = Capture.stdout { homesick.diff('castle_repo') }
|
||||
text.should =~ /diff --git.+Some test text$/m
|
||||
end
|
||||
end
|
||||
|
||||
describe 'show_path' do
|
||||
it 'should say the path of a castle' do
|
||||
castle = given_castle('castle_repo')
|
||||
|
||||
homesick.should_receive(:say).with(castle.dirname)
|
||||
|
||||
homesick.show_path('castle_repo')
|
||||
end
|
||||
end
|
||||
|
||||
describe 'pull' do
|
||||
it 'should perform a pull, submodule init and update when the given castle exists' do
|
||||
given_castle('castle_repo')
|
||||
homesick.stub(:system).once.with('git pull --quiet')
|
||||
homesick.stub(:system).once.with('git submodule --quiet init')
|
||||
homesick.stub(:system).once.with('git submodule --quiet update --init --recursive >/dev/null 2>&1')
|
||||
homesick.pull 'castle_repo'
|
||||
end
|
||||
|
||||
it 'should print an error message when trying to pull a non-existant castle' do
|
||||
homesick.should_receive("say_status").once.with(:error, /Could not pull castle_repo, expected \/tmp\/construct_container.* exist and contain dotfiles/, :red)
|
||||
expect { homesick.pull "castle_repo" }.to raise_error(SystemExit)
|
||||
end
|
||||
|
||||
describe '--all' do
|
||||
it 'should pull each castle when invoked with --all' do
|
||||
given_castle('castle_repo')
|
||||
given_castle('glencairn')
|
||||
homesick.stub(:system).exactly(2).times.with('git pull --quiet')
|
||||
homesick.stub(:system).exactly(2).times.with('git submodule --quiet init')
|
||||
homesick.stub(:system).exactly(2).times.with('git submodule --quiet update --init --recursive >/dev/null 2>&1')
|
||||
Capture.stdout { Capture.stderr { homesick.invoke 'pull', [], all: true } }
|
||||
end
|
||||
end
|
||||
|
||||
end
|
||||
|
||||
describe 'push' do
|
||||
it 'should perform a git push on the given castle' do
|
||||
given_castle('castle_repo')
|
||||
homesick.stub(:system).once.with('git push')
|
||||
homesick.push 'castle_repo'
|
||||
end
|
||||
|
||||
it 'should print an error message when trying to push a non-existant castle' do
|
||||
homesick.should_receive("say_status").once.with(:error, /Could not push castle_repo, expected \/tmp\/construct_container.* exist and contain dotfiles/, :red)
|
||||
expect { homesick.push "castle_repo" }.to raise_error(SystemExit)
|
||||
end
|
||||
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
|
||||
|
||||
it 'should handle files with parens' do
|
||||
castle = given_castle('castle_repo')
|
||||
|
||||
some_rc_file = home.file 'Default (Linux).sublime-keymap'
|
||||
|
||||
homesick.track(some_rc_file.to_s, 'castle_repo')
|
||||
|
||||
tracked_file = castle.join('Default (Linux).sublime-keymap')
|
||||
tracked_file.should exist
|
||||
|
||||
some_rc_file.readlink.should == tracked_file
|
||||
end
|
||||
|
||||
it 'should track a file in nested folder structure' do
|
||||
castle = given_castle('castle_repo')
|
||||
|
||||
some_nested_file = home.file('some/nested/file.txt')
|
||||
homesick.track(some_nested_file.to_s, 'castle_repo')
|
||||
|
||||
tracked_file = castle.join('some/nested/file.txt')
|
||||
tracked_file.should exist
|
||||
some_nested_file.readlink.should == tracked_file
|
||||
end
|
||||
|
||||
it 'should track a nested directory' do
|
||||
castle = given_castle('castle_repo')
|
||||
|
||||
some_nested_dir = home.directory('some/nested/directory/')
|
||||
homesick.track(some_nested_dir.to_s, 'castle_repo')
|
||||
|
||||
tracked_file = castle.join('some/nested/directory/')
|
||||
tracked_file.should exist
|
||||
some_nested_dir.realpath.should == tracked_file.realpath
|
||||
end
|
||||
|
||||
context "when call with no castle name" do
|
||||
it 'using default castle name: "dotfiles"' do
|
||||
castle = given_castle('dotfiles')
|
||||
|
||||
some_rc_file = home.file '.some_rc_file'
|
||||
|
||||
homesick.track(some_rc_file.to_s)
|
||||
|
||||
tracked_file = castle.join('.some_rc_file')
|
||||
tracked_file.should exist
|
||||
|
||||
some_rc_file.readlink.should == tracked_file
|
||||
end
|
||||
end
|
||||
|
||||
describe 'commit' do
|
||||
it 'should have a commit message when the commit succeeds' do
|
||||
given_castle('castle_repo')
|
||||
some_rc_file = home.file '.a_random_rc_file'
|
||||
homesick.track(some_rc_file.to_s, 'castle_repo')
|
||||
text = Capture.stdout { homesick.commit('castle_repo', 'Test message') }
|
||||
text.should =~ /^\[master \(root-commit\) \w+\] Test message/
|
||||
end
|
||||
end
|
||||
|
||||
describe 'subdir_file' do
|
||||
|
||||
it 'should add the nested files parent to the subdir_file' do
|
||||
castle = given_castle('castle_repo')
|
||||
|
||||
some_nested_file = home.file('some/nested/file.txt')
|
||||
homesick.track(some_nested_file.to_s, 'castle_repo')
|
||||
|
||||
subdir_file = castle.parent.join(Homesick::SUBDIR_FILENAME)
|
||||
File.open(subdir_file, 'r') do |f|
|
||||
f.readline.should == "some/nested\n"
|
||||
end
|
||||
end
|
||||
|
||||
it 'should NOT add anything if the files parent is already listed' do
|
||||
castle = given_castle('castle_repo')
|
||||
|
||||
some_nested_file = home.file('some/nested/file.txt')
|
||||
other_nested_file = home.file('some/nested/other.txt')
|
||||
homesick.track(some_nested_file.to_s, 'castle_repo')
|
||||
homesick.track(other_nested_file.to_s, 'castle_repo')
|
||||
|
||||
subdir_file = castle.parent.join(Homesick::SUBDIR_FILENAME)
|
||||
File.open(subdir_file, 'r') do |f|
|
||||
f.readlines.size.should == 1
|
||||
end
|
||||
end
|
||||
|
||||
it 'should remove the parent of a tracked file from the subdir_file if the parent itself is tracked' do
|
||||
castle = given_castle('castle_repo')
|
||||
|
||||
some_nested_file = home.file('some/nested/file.txt')
|
||||
nested_parent = home.directory('some/nested/')
|
||||
homesick.track(some_nested_file.to_s, 'castle_repo')
|
||||
homesick.track(nested_parent.to_s, 'castle_repo')
|
||||
|
||||
subdir_file = castle.parent.join(Homesick::SUBDIR_FILENAME)
|
||||
File.open(subdir_file, 'r') do |f|
|
||||
f.each_line { |line| line.should_not == "some/nested\n" }
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe "destroy" do
|
||||
it "removes the symlink files" do
|
||||
expect_any_instance_of(Thor::Shell::Basic).to receive(:yes?).and_return('y')
|
||||
given_castle("stronghold")
|
||||
some_rc_file = home.file '.some_rc_file'
|
||||
homesick.track(some_rc_file.to_s, "stronghold")
|
||||
homesick.destroy('stronghold')
|
||||
|
||||
some_rc_file.should_not be_exist
|
||||
end
|
||||
|
||||
it "deletes the cloned repository" do
|
||||
expect_any_instance_of(Thor::Shell::Basic).to receive(:yes?).and_return('y')
|
||||
castle = given_castle("stronghold")
|
||||
some_rc_file = home.file '.some_rc_file'
|
||||
homesick.track(some_rc_file.to_s, "stronghold")
|
||||
homesick.destroy('stronghold')
|
||||
|
||||
castle.should_not be_exist
|
||||
end
|
||||
end
|
||||
|
||||
describe "cd" do
|
||||
it "cd's to the root directory of the given castle" do
|
||||
given_castle('castle_repo')
|
||||
homesick.should_receive("inside").once.with(kind_of(Pathname)).and_yield
|
||||
homesick.should_receive("system").once.with(ENV["SHELL"])
|
||||
Capture.stdout { homesick.cd 'castle_repo' }
|
||||
end
|
||||
|
||||
it "returns an error message when the given castle does not exist" do
|
||||
homesick.should_receive("say_status").once.with(:error, /Could not cd castle_repo, expected \/tmp\/construct_container.* exist and contain dotfiles/, :red)
|
||||
expect { homesick.cd "castle_repo" }.to raise_error(SystemExit)
|
||||
end
|
||||
end
|
||||
|
||||
describe "open" do
|
||||
it "opens the system default editor in the root of the given castle" do
|
||||
ENV.stub(:[]).and_call_original # Make sure calls to ENV use default values for most things...
|
||||
ENV.stub(:[]).with('EDITOR').and_return('vim') # Set a default value for 'EDITOR' just in case none is set
|
||||
given_castle 'castle_repo'
|
||||
homesick.should_receive("inside").once.with(kind_of(Pathname)).and_yield
|
||||
homesick.should_receive("system").once.with('vim')
|
||||
Capture.stdout { homesick.open 'castle_repo' }
|
||||
end
|
||||
|
||||
it "returns an error message when the $EDITOR environment variable is not set" do
|
||||
ENV.stub(:[]).with('EDITOR').and_return(nil) # Set the default editor to make sure it fails.
|
||||
homesick.should_receive("say_status").once.with(:error, "The $EDITOR environment variable must be set to use this command", :red)
|
||||
expect { homesick.open "castle_repo" }.to raise_error(SystemExit)
|
||||
end
|
||||
|
||||
it "returns an error message when the given castle does not exist" do
|
||||
ENV.stub(:[]).with('EDITOR').and_return('vim') # Set a default just in case none is set
|
||||
homesick.should_receive("say_status").once.with(:error, /Could not open castle_repo, expected \/tmp\/construct_container.* exist and contain dotfiles/, :red)
|
||||
expect { homesick.open "castle_repo" }.to raise_error(SystemExit)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'version' do
|
||||
it 'should print the current version of homesick' do
|
||||
text = Capture.stdout { homesick.version }
|
||||
text.chomp.should =~ /\d+\.\d+\.\d+/
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1 +0,0 @@
|
||||
--color
|
||||
@@ -1,38 +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 'test_construct'
|
||||
require 'tempfile'
|
||||
|
||||
RSpec.configure do |config|
|
||||
config.include TestConstruct::Helpers
|
||||
|
||||
config.before { ENV['HOME'] = home.to_s }
|
||||
|
||||
config.before { silence! }
|
||||
|
||||
def silence!
|
||||
homesick.stub(:say_status)
|
||||
end
|
||||
|
||||
def given_castle(path, subdirs = [])
|
||||
name = Pathname.new(path).basename
|
||||
castles.directory(path) do |castle|
|
||||
Dir.chdir(castle) do
|
||||
system 'git init >/dev/null 2>&1'
|
||||
system 'git config user.email "test@test.com"'
|
||||
system 'git config user.name "Test Name"'
|
||||
system "git remote add origin git://github.com/technicalpickles/#{name}.git >/dev/null 2>&1"
|
||||
if subdirs
|
||||
subdir_file = castle.join(Homesick::SUBDIR_FILENAME)
|
||||
subdirs.each do |subdir|
|
||||
system "echo #{subdir} >> #{subdir_file}"
|
||||
end
|
||||
end
|
||||
return castle.directory('home')
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
328
test/behavior/behavior_suite.sh
Executable file
328
test/behavior/behavior_suite.sh
Executable 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
|
||||
Reference in New Issue
Block a user