Compare commits
135 Commits
484db0781b
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ac41276c50 | ||
|
|
b97da893fb | ||
|
|
b24ca1214c | ||
|
|
76460cddee | ||
|
|
d63a8bb615 | ||
| ced23e0156 | |||
|
|
710fe049f5 | ||
|
|
2294bb940b | ||
|
|
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 |
@@ -7,11 +7,18 @@ on:
|
||||
- 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:
|
||||
RUNNER_TOOL_CACHE: /cache/tools
|
||||
ARTEFACT_BUCKET_NAME: ${{ vars.ARTEFACT_BUCKET_NAME }}
|
||||
ARTEFACT_BUCKET_ENDPONT: ${{ vars.ARTEFACT_BUCKET_ENDPONT }}
|
||||
ARTEFACT_BUCKET_REGION: ${{ vars.ARTEFACT_BUCKET_REGION }}
|
||||
@@ -19,160 +26,226 @@ jobs:
|
||||
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-file: go.mod
|
||||
go-version: '1.26.1'
|
||||
check-latest: true
|
||||
cache: true
|
||||
cache-dependency-path: go.sum
|
||||
|
||||
- name: Get go-hashfiles
|
||||
uses: https://gitea.com/actions/go-hashfiles@v0.0.1
|
||||
id: hash-go
|
||||
- name: Cache Go modules and build cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
patterns: |-
|
||||
go.mod
|
||||
go.sum
|
||||
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: Cache go
|
||||
id: cache-go
|
||||
uses: https://github.com/actions/cache@v3
|
||||
with:
|
||||
path: |-
|
||||
/root/go/pkg/mod
|
||||
/root/.cache/go-build
|
||||
key: go_cache-${{ steps.hash-go.outputs.hash }}
|
||||
restore-keys: |-
|
||||
go_cache-${{ steps.hash-go.outputs.hash }}
|
||||
|
||||
- name: Ensure tooling is available
|
||||
- name: Verify module hygiene
|
||||
run: |
|
||||
set -euo pipefail
|
||||
go mod tidy
|
||||
git diff --exit-code go.mod go.sum
|
||||
go mod verify
|
||||
|
||||
if ! command -v aws >/dev/null 2>&1; then
|
||||
pipx install awscli
|
||||
fi
|
||||
|
||||
if ! command -v jq >/dev/null 2>&1; then
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y jq
|
||||
fi
|
||||
- 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 ./...
|
||||
go tool cover -html=coverage.out -o coverage.html
|
||||
go test -covermode=atomic -coverprofile=coverage.out ./... | tee go-test-coverage.log
|
||||
|
||||
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
|
||||
|
||||
- name: Generate coverage badge
|
||||
env:
|
||||
COVERAGE_TOTAL: ${{ steps.coverage.outputs.total }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
{
|
||||
echo '| Package | Coverage | Status |'
|
||||
echo '| --- | ---: | --- |'
|
||||
} > coverage-packages.md
|
||||
|
||||
color="$(awk -v total="$COVERAGE_TOTAL" 'BEGIN {
|
||||
if (total >= 80) print "brightgreen";
|
||||
else if (total >= 70) print "green";
|
||||
else if (total >= 60) print "yellowgreen";
|
||||
else if (total >= 50) print "yellow";
|
||||
else print "red";
|
||||
}')"
|
||||
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
|
||||
|
||||
cat > coverage-badge.svg <<EOF
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="126" height="20" role="img" aria-label="coverage: ${COVERAGE_TOTAL}%">
|
||||
<linearGradient id="smooth" x2="0" y2="100%">
|
||||
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
|
||||
<stop offset="1" stop-opacity=".1"/>
|
||||
</linearGradient>
|
||||
<clipPath id="round">
|
||||
<rect width="126" height="20" rx="3" fill="#fff"/>
|
||||
</clipPath>
|
||||
<g clip-path="url(#round)">
|
||||
<rect width="63" height="20" fill="#555"/>
|
||||
<rect x="63" width="63" height="20" fill="${color}"/>
|
||||
<rect width="126" height="20" fill="url(#smooth)"/>
|
||||
</g>
|
||||
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" font-size="11">
|
||||
<text x="32.5" y="15" fill="#010101" fill-opacity=".3">coverage</text>
|
||||
<text x="32.5" y="14">coverage</text>
|
||||
<text x="93.5" y="15" fill="#010101" fill-opacity=".3">${COVERAGE_TOTAL}%</text>
|
||||
<text x="93.5" y="14">${COVERAGE_TOTAL}%</text>
|
||||
</g>
|
||||
</svg>
|
||||
EOF
|
||||
|
||||
- name: Upload PR coverage artefacts
|
||||
id: upload
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
aws configure set default.s3.addressing_style path
|
||||
|
||||
repo_name="${GITHUB_REPOSITORY##*/}"
|
||||
prefix="${repo_name}/pull-requests/${{ github.event.pull_request.number }}"
|
||||
report_url="${ARTEFACT_BUCKET_ENDPONT%/}/${ARTEFACT_BUCKET_NAME}/${prefix}/coverage.html"
|
||||
badge_url="${ARTEFACT_BUCKET_ENDPONT%/}/${ARTEFACT_BUCKET_NAME}/${prefix}/coverage-badge.svg"
|
||||
|
||||
aws --endpoint-url "${ARTEFACT_BUCKET_ENDPONT}" s3 cp coverage.html "s3://${ARTEFACT_BUCKET_NAME}/${prefix}/coverage.html" --content-type text/html
|
||||
aws --endpoint-url "${ARTEFACT_BUCKET_ENDPONT}" s3 cp coverage-badge.svg "s3://${ARTEFACT_BUCKET_NAME}/${prefix}/coverage-badge.svg" --content-type image/svg+xml
|
||||
aws --endpoint-url "${ARTEFACT_BUCKET_ENDPONT}" s3 cp coverage-summary.json "s3://${ARTEFACT_BUCKET_NAME}/${prefix}/coverage-summary.json" --content-type application/json
|
||||
|
||||
printf 'report_url=%s\n' "$report_url" >> "$GITHUB_OUTPUT"
|
||||
printf 'badge_url=%s\n' "$badge_url" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Comment coverage report on pull request
|
||||
env:
|
||||
COVERAGE_BADGE_URL: ${{ steps.upload.outputs.badge_url }}
|
||||
COVERAGE_REPORT_URL: ${{ steps.upload.outputs.report_url }}
|
||||
COVERAGE_TOTAL: ${{ steps.coverage.outputs.total }}
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
marker='<!-- gosick-coverage-report -->'
|
||||
api_base="${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}"
|
||||
|
||||
payload="$(jq -n \
|
||||
--arg marker "$marker" \
|
||||
--arg total "$COVERAGE_TOTAL" \
|
||||
--arg report "$COVERAGE_REPORT_URL" \
|
||||
--arg badge "$COVERAGE_BADGE_URL" \
|
||||
'{body: ($marker + "\n## Coverage Report\n\nCoverage total: **" + $total + "%**\n\n[HTML report](" + $report + ")\n\n")}')"
|
||||
|
||||
comments="$(curl -sS -H "Authorization: token ${GITHUB_TOKEN}" "${api_base}/issues/${{ github.event.pull_request.number }}/comments")"
|
||||
comment_id="$(printf '%s' "$comments" | jq -r '.[] | select(.body | contains("<!-- gosick-coverage-report -->")) | .id' | tail -n 1)"
|
||||
|
||||
if [[ -n "$comment_id" ]]; then
|
||||
curl -sS -X PATCH \
|
||||
-H "Authorization: token ${GITHUB_TOKEN}" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d "$payload" \
|
||||
"${api_base}/issues/comments/${comment_id}" >/dev/null
|
||||
else
|
||||
curl -sS -X POST \
|
||||
-H "Authorization: token ${GITHUB_TOKEN}" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d "$payload" \
|
||||
"${api_base}/issues/${{ github.event.pull_request.number }}/comments" >/dev/null
|
||||
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: Add coverage summary
|
||||
- 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' }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
total="$(go tool cover -func=coverage.out | awk '/^total:/ {sub(/%/, "", $3); print $3}')"
|
||||
if [[ -z "$total" ]]; then
|
||||
total="n/a"
|
||||
fi
|
||||
echo "total=${total}" >> "$GITHUB_OUTPUT"
|
||||
echo "report-url=n/a" >> "$GITHUB_OUTPUT"
|
||||
echo "badge-url=n/a" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- 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() && github.server_url == 'https://github.com' && steps.badge.outcome == 'success' }}
|
||||
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: Skip external PR decoration on non-GitHub runners
|
||||
if: ${{ always() && github.server_url != 'https://github.com' }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
echo "Skipping decorate-pr action on ${GITHUB_SERVER_URL}; external composite action is not stable on this runner." >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Add coverage summary
|
||||
if: ${{ always() }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
total="${{ steps.badge.outputs.total }}"
|
||||
report_url="${{ steps.badge.outputs.report-url }}"
|
||||
badge_url="${{ steps.badge.outputs.badge-url }}"
|
||||
|
||||
if [[ -z "$total" ]]; then
|
||||
total="n/a"
|
||||
fi
|
||||
if [[ -z "$report_url" ]]; then
|
||||
report_url="n/a"
|
||||
fi
|
||||
if [[ -z "$badge_url" ]]; then
|
||||
badge_url="n/a"
|
||||
fi
|
||||
|
||||
{
|
||||
echo '## Coverage'
|
||||
echo
|
||||
echo '- Total: `${{ steps.coverage.outputs.total }}%`'
|
||||
echo '- Report: ${{ steps.upload.outputs.report_url }}'
|
||||
echo '- Badge: ${{ steps.upload.outputs.badge_url }}'
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "- Total: ${total}%"
|
||||
echo "- Report: ${report_url}"
|
||||
echo "- Badge: ${badge_url}"
|
||||
echo
|
||||
echo '### Package Coverage'
|
||||
if [[ -f coverage-packages.md ]]; then
|
||||
cat coverage-packages.md
|
||||
else
|
||||
echo '_Package coverage details unavailable for this run._'
|
||||
fi
|
||||
} >> "$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
|
||||
|
||||
38
.gitea/workflows/prepare-release.yml
Normal file
38
.gitea/workflows/prepare-release.yml
Normal file
@@ -0,0 +1,38 @@
|
||||
name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
prepare:
|
||||
if: "${{ !startsWith(github.event.head_commit.message, 'chore(release): 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: Prepare release
|
||||
run: bash ./script/prepare-release.sh
|
||||
|
||||
- name: Summary
|
||||
if: ${{ always() }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if git rev-parse -q --verify "refs/tags/$(sed -n 's/^const String = "\([^"]*\)"$/v\1/p' internal/homesick/version/version.go)" >/dev/null; then
|
||||
echo "Prepared and pushed release tag $(sed -n 's/^const String = "\([^"]*\)"$/v\1/p' internal/homesick/version/version.go)." >> "$GITHUB_STEP_SUMMARY"
|
||||
else
|
||||
echo "No release prepared in this run." >> "$GITHUB_STEP_SUMMARY"
|
||||
fi
|
||||
@@ -7,11 +7,18 @@ on:
|
||||
tags-ignore:
|
||||
- "*"
|
||||
|
||||
concurrency:
|
||||
group: ci-${{ github.ref_name }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
validate:
|
||||
runs-on: ubuntu-latest
|
||||
container: docker.io/catthehacker/ubuntu:act-latest
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
env:
|
||||
RUNNER_TOOL_CACHE: /cache/tools
|
||||
ARTEFACT_BUCKET_NAME: ${{ vars.ARTEFACT_BUCKET_NAME }}
|
||||
ARTEFACT_BUCKET_ENDPONT: ${{ vars.ARTEFACT_BUCKET_ENDPONT }}
|
||||
ARTEFACT_BUCKET_REGION: ${{ vars.ARTEFACT_BUCKET_REGION }}
|
||||
@@ -19,6 +26,8 @@ jobs:
|
||||
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
|
||||
@@ -26,113 +35,162 @@ jobs:
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
go-version: '1.26.1'
|
||||
check-latest: true
|
||||
cache: true
|
||||
cache-dependency-path: go.sum
|
||||
|
||||
- name: Get go-hashfiles
|
||||
uses: https://gitea.com/actions/go-hashfiles@v0.0.1
|
||||
id: hash-go
|
||||
- name: Cache Go modules and build cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
patterns: |-
|
||||
go.mod
|
||||
go.sum
|
||||
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: Cache go
|
||||
id: cache-go
|
||||
uses: https://github.com/actions/cache@v3
|
||||
with:
|
||||
path: |-
|
||||
/root/go/pkg/mod
|
||||
/root/.cache/go-build
|
||||
key: go_cache-${{ steps.hash-go.outputs.hash }}
|
||||
restore-keys: |-
|
||||
go_cache-${{ steps.hash-go.outputs.hash }}
|
||||
|
||||
- name: Ensure tooling is available
|
||||
- name: Verify module hygiene
|
||||
run: |
|
||||
set -euo pipefail
|
||||
go mod tidy
|
||||
git diff --exit-code go.mod go.sum
|
||||
go mod verify
|
||||
|
||||
if ! command -v aws >/dev/null 2>&1; then
|
||||
pipx install awscli
|
||||
- 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
|
||||
id: coverage-tests
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
go test -covermode=atomic -coverprofile=coverage.out ./...
|
||||
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"
|
||||
|
||||
- name: Generate coverage badge
|
||||
env:
|
||||
COVERAGE_TOTAL: ${{ steps.coverage.outputs.total }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
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
|
||||
|
||||
color="$(awk -v total="$COVERAGE_TOTAL" 'BEGIN {
|
||||
if (total >= 80) print "brightgreen";
|
||||
else if (total >= 70) print "green";
|
||||
else if (total >= 60) print "yellowgreen";
|
||||
else if (total >= 50) print "yellow";
|
||||
else print "red";
|
||||
}')"
|
||||
{
|
||||
echo '| Package | Coverage | Status |'
|
||||
echo '| --- | ---: | --- |'
|
||||
} > coverage-packages.md
|
||||
|
||||
cat > coverage-badge.svg <<EOF
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="126" height="20" role="img" aria-label="coverage: ${COVERAGE_TOTAL}%">
|
||||
<linearGradient id="smooth" x2="0" y2="100%">
|
||||
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
|
||||
<stop offset="1" stop-opacity=".1"/>
|
||||
</linearGradient>
|
||||
<clipPath id="round">
|
||||
<rect width="126" height="20" rx="3" fill="#fff"/>
|
||||
</clipPath>
|
||||
<g clip-path="url(#round)">
|
||||
<rect width="63" height="20" fill="#555"/>
|
||||
<rect x="63" width="63" height="20" fill="${color}"/>
|
||||
<rect width="126" height="20" fill="url(#smooth)"/>
|
||||
</g>
|
||||
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" font-size="11">
|
||||
<text x="32.5" y="15" fill="#010101" fill-opacity=".3">coverage</text>
|
||||
<text x="32.5" y="14">coverage</text>
|
||||
<text x="93.5" y="15" fill="#010101" fill-opacity=".3">${COVERAGE_TOTAL}%</text>
|
||||
<text x="93.5" y="14">${COVERAGE_TOTAL}%</text>
|
||||
</g>
|
||||
</svg>
|
||||
EOF
|
||||
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
|
||||
|
||||
- name: Upload branch coverage artefacts
|
||||
id: upload
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
aws configure set default.s3.addressing_style path
|
||||
|
||||
repo_name="${GITHUB_REPOSITORY##*/}"
|
||||
prefix="${repo_name}/branch/${GITHUB_REF_NAME}"
|
||||
report_url="${ARTEFACT_BUCKET_ENDPONT%/}/${ARTEFACT_BUCKET_NAME}/${prefix}/coverage.html"
|
||||
badge_url="${ARTEFACT_BUCKET_ENDPONT%/}/${ARTEFACT_BUCKET_NAME}/${prefix}/coverage-badge.svg"
|
||||
|
||||
aws --endpoint-url "${ARTEFACT_BUCKET_ENDPONT}" s3 cp coverage.html "s3://${ARTEFACT_BUCKET_NAME}/${prefix}/coverage.html" --content-type text/html
|
||||
aws --endpoint-url "${ARTEFACT_BUCKET_ENDPONT}" s3 cp coverage-badge.svg "s3://${ARTEFACT_BUCKET_NAME}/${prefix}/coverage-badge.svg" --content-type image/svg+xml
|
||||
aws --endpoint-url "${ARTEFACT_BUCKET_ENDPONT}" s3 cp coverage-summary.json "s3://${ARTEFACT_BUCKET_NAME}/${prefix}/coverage-summary.json" --content-type application/json
|
||||
|
||||
printf 'report_url=%s\n' "$report_url" >> "$GITHUB_OUTPUT"
|
||||
printf 'badge_url=%s\n' "$badge_url" >> "$GITHUB_OUTPUT"
|
||||
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: Add coverage summary
|
||||
if: ${{ always() && steps.coverage-tests.outcome == 'success' }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
total="${{ steps.coverage-tests.outputs.total }}"
|
||||
if [[ -z "$total" ]]; then
|
||||
total="n/a"
|
||||
fi
|
||||
|
||||
{
|
||||
echo '## Coverage'
|
||||
echo
|
||||
echo '- Total: `${{ steps.coverage.outputs.total }}%`'
|
||||
echo '- Report: ${{ steps.upload.outputs.report_url }}'
|
||||
echo '- Badge: ${{ steps.upload.outputs.badge_url }}'
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "- Total: ${total}%"
|
||||
echo
|
||||
echo '### Package Coverage'
|
||||
if [[ -f coverage-packages.md ]]; then
|
||||
cat coverage-packages.md
|
||||
else
|
||||
echo '_Package coverage details unavailable for this run._'
|
||||
fi
|
||||
} >> "$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
|
||||
|
||||
@@ -8,6 +8,10 @@ on:
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
container: docker.io/catthehacker/ubuntu:act-latest
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
env:
|
||||
RUNNER_TOOL_CACHE: /cache/tools
|
||||
strategy:
|
||||
@@ -26,36 +30,37 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
go-version: '1.26.1'
|
||||
check-latest: true
|
||||
cache: true
|
||||
cache-dependency-path: go.sum
|
||||
|
||||
- name: Get go-hashfiles
|
||||
uses: https://gitea.com/actions/go-hashfiles@v0.0.1
|
||||
id: hash-go
|
||||
- name: Install UPX
|
||||
uses: crazy-max/ghaction-upx@v3
|
||||
with:
|
||||
patterns: |-
|
||||
go.mod
|
||||
go.sum
|
||||
|
||||
- name: Cache go
|
||||
id: cache-go
|
||||
uses: https://github.com/actions/cache@v3
|
||||
with:
|
||||
path: |-
|
||||
/root/go/pkg/mod
|
||||
/root/.cache/go-build
|
||||
key: go_cache-${{ steps.hash-go.outputs.hash }}
|
||||
restore-keys: |-
|
||||
go_cache-${{ steps.hash-go.outputs.hash }}
|
||||
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 dist/gosick_${{ matrix.goos }}_${{ matrix.goarch }} ./cmd/homesick
|
||||
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: |
|
||||
@@ -71,58 +76,26 @@ jobs:
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
needs: build
|
||||
env:
|
||||
RELEASE_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
steps:
|
||||
- name: Download build artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
path: dist
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Ensure jq is installed
|
||||
run: |
|
||||
if ! command -v jq >/dev/null 2>&1; then
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y jq
|
||||
fi
|
||||
|
||||
- name: Create release if needed and upload assets
|
||||
- name: Provide lowercase changelog compatibility
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
if [[ -z "${RELEASE_TOKEN:-}" ]]; then
|
||||
echo "RELEASE_TOKEN is empty. Expected secrets.GITHUB_TOKEN to be available." >&2
|
||||
exit 1
|
||||
if [[ -f CHANGELOG.md && ! -e changelog.md ]]; then
|
||||
ln -s CHANGELOG.md changelog.md
|
||||
fi
|
||||
|
||||
tag="${GITHUB_REF_NAME}"
|
||||
api_base="${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}"
|
||||
- name: Install jq
|
||||
run: |
|
||||
set -euo pipefail
|
||||
apt-get update
|
||||
apt-get install -y jq
|
||||
|
||||
release_json="$(curl -sS -H "Authorization: token ${RELEASE_TOKEN}" "${api_base}/releases/tags/${tag}" || true)"
|
||||
release_id="$(printf '%s' "${release_json}" | jq -r '.id // empty')"
|
||||
|
||||
if [[ -z "${release_id}" ]]; then
|
||||
create_payload="$(jq -n --arg tag "${tag}" --arg name "${tag}" '{tag_name:$tag, name:$name, draft:false, prerelease:false}')"
|
||||
release_json="$(curl -sS -X POST \
|
||||
-H "Authorization: token ${RELEASE_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "${create_payload}" \
|
||||
"${api_base}/releases")"
|
||||
release_id="$(printf '%s' "${release_json}" | jq -r '.id // empty')"
|
||||
fi
|
||||
|
||||
if [[ -z "${release_id}" ]]; then
|
||||
echo "Unable to determine or create release id for tag ${tag}" >&2
|
||||
printf '%s\n' "${release_json}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
find dist -type f -name '*.tar.gz' -print0 | while IFS= read -r -d '' file; do
|
||||
asset_name="$(basename "${file}")"
|
||||
curl -sS -X POST \
|
||||
-H "Authorization: token ${RELEASE_TOKEN}" \
|
||||
-H "Content-Type: application/octet-stream" \
|
||||
--data-binary @"${file}" \
|
||||
"${api_base}/releases/${release_id}/assets?name=${asset_name}"
|
||||
echo "Uploaded ${asset_name}"
|
||||
done
|
||||
- name: Create or update release
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: bash ./script/publish-release.sh
|
||||
|
||||
332
CHANGELOG.md
Normal file
332
CHANGELOG.md
Normal file
@@ -0,0 +1,332 @@
|
||||
# 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` to reduce redundant in-flight runs per branch.
|
||||
- Push validation now runs as a single runner-compatible job; the separate open-PR precheck job was removed due workflow-engine incompatibility.
|
||||
- PR validation now checks that `coverage.out` exists before computing coverage metadata; when missing, coverage output steps are 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.
|
||||
- PR validation now skips external PR decoration on non-GitHub runners and writes a summary note instead, avoiding runner-specific action resolution failures.
|
||||
- Coverage summary generation is now resilient when badge outputs or `coverage-packages.md` are unavailable, preventing summary-step hard failures after earlier skips.
|
||||
- Push and PR validation no longer depend on external `vociferate/coverage-badge` action fetches, avoiding pipeline failures during external TLS/certificate outages.
|
||||
- Release automation no longer depends on external `vociferate` action fetches; local repository scripts now prepare tags from `CHANGELOG.md` and publish Gitea releases directly via the API, avoiding TLS/certificate outages on the external action host.
|
||||
- `prepare-release.yml` now quotes the job-level `if:` expression guarding release-preparation commits, fixing YAML parsing errors caused by the colon in the release commit message prefix.
|
||||
- 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.
|
||||
46
README.md
46
README.md
@@ -1,8 +1,7 @@
|
||||
# homesick
|
||||
# gosick
|
||||
|
||||
[](https://git.hrafn.xyz/aether/gosick/actions/workflows/push-validation.yml)
|
||||
[](https://git.hrafn.xyz/aether/gosick/actions/workflows/pr-validation.yml)
|
||||
[](https://git.hrafn.xyz/aether/gosick/actions/workflows/tag-build-artifacts.yml)
|
||||
[](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.
|
||||
|
||||
@@ -34,20 +33,37 @@ Implemented commands:
|
||||
- `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`
|
||||
|
||||
Not yet implemented:
|
||||
Global options:
|
||||
|
||||
- `pull`
|
||||
- `push`
|
||||
- `commit`
|
||||
- `destroy`
|
||||
- `cd`
|
||||
- `open`
|
||||
- `exec`
|
||||
- `exec_all`
|
||||
- `rc`
|
||||
- `generate`
|
||||
- `--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
|
||||
|
||||
|
||||
11
bin/homesick
11
bin/homesick
@@ -1,11 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
repo_root="$(cd "$script_dir/.." && pwd)"
|
||||
|
||||
if [[ -x "$repo_root/dist/gosick" ]]; then
|
||||
exec "$repo_root/dist/gosick" "$@"
|
||||
fi
|
||||
|
||||
exec go run "$repo_root/cmd/homesick" "$@"
|
||||
269
changelog.md
269
changelog.md
@@ -1,269 +0,0 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- Native Go implementations for `clone`, `link`, `unlink`, and `track`.
|
||||
- Containerized behavior test suite for command parity validation.
|
||||
- Dedicated test suites for `list`, `show_path`, `status`, `diff`, and `version`.
|
||||
- Just workflow support for building and running the Linux behavior binary.
|
||||
- Coverage reports and badges published to shared object storage for branches and pull requests.
|
||||
- Pull requests now receive coverage report links in CI comments.
|
||||
|
||||
### Changed
|
||||
|
||||
- CLI argument parsing migrated to Kong.
|
||||
- Git operations for clone and track migrated to `go-git`.
|
||||
- Build and behavior workflows now produce and run the `gosick` binary name.
|
||||
- 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.
|
||||
|
||||
### Fixed
|
||||
|
||||
- `status` and `diff` now consistently write through configured app output writers.
|
||||
|
||||
### Removed
|
||||
|
||||
- Legacy Ruby implementation and Ruby toolchain.
|
||||
|
||||
## [1.1.6] - 2017-12-20
|
||||
|
||||
### Fixed
|
||||
|
||||
- Ensure `FileUtils` is imported correctly to avoid a potential error.
|
||||
- Fix an issue where comparing a diff did not use the content of the new file.
|
||||
|
||||
### Changed
|
||||
|
||||
- Small documentation fixes.
|
||||
|
||||
## [1.1.5] - 2017-03-23
|
||||
|
||||
### Fixed
|
||||
|
||||
- Problem with version number being incorrect.
|
||||
|
||||
## [1.1.4] - 2017-03-22
|
||||
|
||||
### Fixed
|
||||
|
||||
- Ensure symlink conflicts are explicitly communicated to users and symlinks are not silently overwritten.
|
||||
- Fix a problem in diff when asking a user to resolve a conflict.
|
||||
|
||||
### Changed
|
||||
|
||||
- Use real paths of symlinks when linking a castle into home.
|
||||
- Code refactoring and fixes.
|
||||
|
||||
## [1.1.3] - 2015-10-31
|
||||
|
||||
### Added
|
||||
|
||||
- Allow a destination to be passed when cloning a castle.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Make sure `homesick edit` opens the default editor in the root of the given castle.
|
||||
- Bug when diffing edited files.
|
||||
- Crashing bug when attempting to diff directories.
|
||||
- Ensure that messages are escaped correctly on `git commit all`.
|
||||
|
||||
## [1.1.2] - 2015-01-02
|
||||
|
||||
### Added
|
||||
|
||||
- `--force` option to the rc command to bypass confirmation checks when running a `.homesickrc` file.
|
||||
- Check to ensure that at least Git 1.8.0 is installed.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Stop Homesick failing silently when Git is not installed.
|
||||
|
||||
### Changed
|
||||
|
||||
- Code refactoring and fixes.
|
||||
|
||||
## [1.1.0] - 2014-04-28
|
||||
|
||||
### Added
|
||||
|
||||
- `exec` and `exec_all` commands to run commands inside one or all cloned castles.
|
||||
|
||||
### Changed
|
||||
|
||||
- Code refactoring.
|
||||
|
||||
## [1.0.0] - 2014-01-15
|
||||
|
||||
### Added
|
||||
|
||||
- `version` command.
|
||||
|
||||
### Removed
|
||||
|
||||
- Support for Ruby 1.8.7.
|
||||
|
||||
## [0.9.8] - 2014-01-02
|
||||
|
||||
### Added
|
||||
|
||||
- `homesick cd` command.
|
||||
- `homesick open` command.
|
||||
|
||||
## [0.9.4] - 2013-07-31
|
||||
|
||||
### Added
|
||||
|
||||
- `homesick unlink` command.
|
||||
- `homesick rc` command.
|
||||
|
||||
### Changed
|
||||
|
||||
- Use HTTPS protocol instead of git protocol.
|
||||
|
||||
## [0.9.3] - 2013-07-07
|
||||
|
||||
### Added
|
||||
|
||||
- Recursive option to `homesick clone`.
|
||||
|
||||
## [0.9.2] - 2013-06-27
|
||||
|
||||
### Added
|
||||
|
||||
- `homesick show_path` command.
|
||||
- `homesick status` command.
|
||||
- `homesick diff` command.
|
||||
|
||||
### Changed
|
||||
|
||||
- Set `dotfiles` as default castle name.
|
||||
|
||||
## [0.9.1] - 2013-06-17
|
||||
|
||||
### Fixed
|
||||
|
||||
- Small bugs: #35, #40.
|
||||
|
||||
## [0.9.0] - 2013-06-06
|
||||
|
||||
### Added
|
||||
|
||||
- `.homesick_subdir` (#39).
|
||||
|
||||
## [0.8.1] - 2013-05-19
|
||||
|
||||
### Fixed
|
||||
|
||||
- `homesick list` bug on Ruby 2.0 (#37).
|
||||
|
||||
## [0.8.0] - 2013-04-06
|
||||
|
||||
### Added
|
||||
|
||||
- `commit` and `push` command.
|
||||
- Commit changes in a castle and push to remote.
|
||||
- Enable recursive submodule update.
|
||||
- Git add when using track.
|
||||
|
||||
## [0.7.0] - 2012-05-28
|
||||
|
||||
### Added
|
||||
|
||||
- New option for pull command: `--all`.
|
||||
- Pull each castle instead of just one.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Double-cloning (#14).
|
||||
|
||||
## [0.6.1] - 2010-11-13
|
||||
|
||||
### Added
|
||||
|
||||
- License.
|
||||
|
||||
## [0.6.0] - 2010-10-27
|
||||
|
||||
### Added
|
||||
|
||||
- `.homesickrc` support.
|
||||
- Castles can now have a `.homesickrc` inside them.
|
||||
- On clone, this is eval'd inside the destination directory.
|
||||
- `track` command.
|
||||
- Allows easily moving an existing file into a castle and symlinking it back.
|
||||
|
||||
## [0.5.0] - 2010-05-18
|
||||
|
||||
### Added
|
||||
|
||||
- `homesick pull <CASTLE>` for updating castles (thanks Jorge Dias).
|
||||
- A very basic `homesick generate <CASTLE>`.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Listing of castles cloned using `homesick clone <github-user>/<github-repo>` (issue 3).
|
||||
|
||||
## [0.4.1] - 2010-04-02
|
||||
|
||||
### Fixed
|
||||
|
||||
- Improve error message when a castle's home dir does not exist.
|
||||
|
||||
## [0.4.0] - 2010-04-01
|
||||
|
||||
### Added
|
||||
|
||||
- `homesick clone` can take a path to a directory on the filesystem, which is symlinked into place.
|
||||
- `homesick clone` tries to run `git submodule init` and `git submodule update` if git submodules are defined for a cloned repo.
|
||||
|
||||
### Changed
|
||||
|
||||
- Use `HOME` environment variable for where to store files, instead of assuming `~`.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Missing dependency on thor and others.
|
||||
|
||||
## [0.3.0] - 2010-04-01
|
||||
|
||||
### Changed
|
||||
|
||||
- Rename `link` to `symlink`.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Conflict resolution when symlink destination exists and is a normal file.
|
||||
|
||||
## [0.2.0] - 2010-03-19
|
||||
|
||||
### Added
|
||||
|
||||
- Better support for recognizing git URLs (thanks jacobat).
|
||||
- If it looks like a GitHub user/repo, use that.
|
||||
- Otherwise hand off to git clone.
|
||||
- Listing now displays in color and shows git remote.
|
||||
- Support pretend, force, and quiet modes.
|
||||
|
||||
## [0.1.1] - 2010-03-17
|
||||
|
||||
### Fixed
|
||||
|
||||
- Trying to link against castles that do not exist.
|
||||
- Linking now excludes `.` and `..` from the list of files to link (thanks Martinos).
|
||||
|
||||
## [0.1.0] - 2010-03-10
|
||||
|
||||
### Added
|
||||
|
||||
- Initial release.
|
||||
@@ -1,12 +1,18 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"io"
|
||||
"os"
|
||||
|
||||
"git.hrafn.xyz/aether/gosick/internal/homesick/cli"
|
||||
)
|
||||
|
||||
func main() {
|
||||
exitCode := cli.Run(os.Args[1:], os.Stdout, os.Stderr)
|
||||
_ = 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)
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,8 @@ FROM alpine:3.21
|
||||
RUN apk add --no-cache \
|
||||
bash \
|
||||
ca-certificates \
|
||||
git
|
||||
git \
|
||||
ruby
|
||||
|
||||
WORKDIR /workspace
|
||||
COPY . /workspace
|
||||
|
||||
35
go.mod
35
go.mod
@@ -4,32 +4,35 @@ go 1.26
|
||||
|
||||
toolchain go1.26.1
|
||||
|
||||
require github.com/stretchr/testify v1.10.0
|
||||
|
||||
require github.com/go-git/go-git/v5 v5.14.0
|
||||
require github.com/stretchr/testify v1.11.1
|
||||
|
||||
require (
|
||||
dario.cat/mergo v1.0.0 // indirect
|
||||
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.1.5 // indirect
|
||||
github.com/alecthomas/kong v1.12.1 // indirect
|
||||
github.com/cloudflare/circl v1.6.0 // indirect
|
||||
github.com/cyphar/filepath-securejoin v0.4.1 // 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.6.2 // 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.2.0 // indirect
|
||||
github.com/pjbgf/sha1cd v0.3.2 // 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.3.2-0.20230802210424-5b0b94c5c0d3 // indirect
|
||||
github.com/skeema/knownhosts v1.3.1 // 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.35.0 // indirect
|
||||
golang.org/x/net v0.35.0 // indirect
|
||||
golang.org/x/sys v0.30.0 // 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
|
||||
)
|
||||
|
||||
72
go.sum
72
go.sum
@@ -1,20 +1,24 @@
|
||||
dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk=
|
||||
dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk=
|
||||
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.1.5 h1:eoAQfK2dwL+tFSFpr7TbOaPNUbPiJj4fLYwwGE1FQO4=
|
||||
github.com/ProtonMail/go-crypto v1.1.5/go.mod h1:rA3QumHc/FZ8pAHreoekgiAbzpNsfQAosU5td4SnOrE=
|
||||
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.0 h1:cr5JKic4HI+LkINy2lg3W2jF8sHCVTBncJr5gIIq7qk=
|
||||
github.com/cloudflare/circl v1.6.0/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs=
|
||||
github.com/cyphar/filepath-securejoin v0.4.1 h1:JyxxyPEaktOD+GAnqIqTf9A8tHyAG22rowi7HkoSU1s=
|
||||
github.com/cyphar/filepath-securejoin v0.4.1/go.mod h1:Sdj7gXlvMcPZsbhwhQ33GguGLDGQL7h7bg04C/+u9jI=
|
||||
github.com/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=
|
||||
@@ -26,20 +30,24 @@ github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c=
|
||||
github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU=
|
||||
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI=
|
||||
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic=
|
||||
github.com/go-git/go-billy/v5 v5.6.2 h1:6Q86EsPXMa7c3YZ3aLAQsMA0VlWmy43r6FHqa/UNbRM=
|
||||
github.com/go-git/go-billy/v5 v5.6.2/go.mod h1:rcFC2rAsp/erv7CMz9GczHcuD0D32fWzH+MJAU+jaUU=
|
||||
github.com/go-git/go-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.14.0 h1:/MD3lCrGjCen5WfEAzKg00MJJffKhC8gzS80ycmCi60=
|
||||
github.com/go-git/go-git/v5 v5.14.0/go.mod h1:Z5Xhoia5PcWA3NF8vRLURn9E5FRhSl7dGj9ItW3Wk5k=
|
||||
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.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4=
|
||||
github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM=
|
||||
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=
|
||||
@@ -49,48 +57,48 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k=
|
||||
github.com/onsi/gomega v1.34.1/go.mod h1:kU1QgUvBDLXBJq618Xvm2LUX6rSAfRaFRTcdOeDLwwY=
|
||||
github.com/pjbgf/sha1cd v0.3.2 h1:a9wb0bp1oC2TGwStyn0Umc/IGKQnEgF0vVaZ8QF8eo4=
|
||||
github.com/pjbgf/sha1cd v0.3.2/go.mod h1:zQWigSxVmsHEZow5qaLtPYxpcKMMQpa09ixqBxuCS6A=
|
||||
github.com/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.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8=
|
||||
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
|
||||
github.com/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.1 h1:X2osQ+RAjK76shCbvhHHHVl3ZlgDm8apHEHFqRjnBY8=
|
||||
github.com/skeema/knownhosts v1.3.1/go.mod h1:r7KTdC8l4uxWRyK2TpQZ/1o5HaSzh06ePQNxPwTcfiY=
|
||||
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.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
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.35.0 h1:b15kiHdrGCHrP6LvwaQ3c03kgNhhiMgvlhxHQhmg2Xs=
|
||||
golang.org/x/crypto v0.35.0/go.mod h1:dy7dXNW32cAb/6/PRuTNsix8T+vJAqvuIy5Bli/x0YQ=
|
||||
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.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8=
|
||||
golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk=
|
||||
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.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
|
||||
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
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.29.0 h1:L6pJp37ocefwRRtYPKSWOWzOtWSxVajvz2ldH/xi3iU=
|
||||
golang.org/x/term v0.29.0/go.mod h1:6bl4lRlvVuDgSf3179VpIxBF0o10JUpXWOnI7nErv7s=
|
||||
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.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
|
||||
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
|
||||
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=
|
||||
|
||||
@@ -13,15 +13,17 @@ import (
|
||||
"github.com/alecthomas/kong"
|
||||
)
|
||||
|
||||
func Run(args []string, stdout io.Writer, stderr io.Writer) int {
|
||||
app, err := core.New(stdout, stderr)
|
||||
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(
|
||||
&cliModel{},
|
||||
model,
|
||||
kong.Name(programName()),
|
||||
kong.Description("Your home is your castle. Don't leave your precious dotfiles behind."),
|
||||
kong.Writers(stdout, stderr),
|
||||
@@ -51,6 +53,9 @@ func Run(args []string, stdout io.Writer, stderr io.Writer) int {
|
||||
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) {
|
||||
@@ -64,6 +69,10 @@ func Run(args []string, stdout io.Writer, stderr io.Writer) int {
|
||||
}
|
||||
|
||||
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."`
|
||||
@@ -155,36 +164,72 @@ func (c *versionCmd) Run(app *core.App) error {
|
||||
return app.Version(version.String)
|
||||
}
|
||||
|
||||
type pullCmd struct{}
|
||||
type pullCmd struct {
|
||||
All bool `help:"Pull all castles."`
|
||||
Castle string `arg:"" optional:"" name:"CASTLE" help:"Castle name."`
|
||||
}
|
||||
|
||||
type pushCmd struct{}
|
||||
type pushCmd struct {
|
||||
Castle string `arg:"" optional:"" name:"CASTLE" help:"Castle name."`
|
||||
}
|
||||
|
||||
type commitCmd struct{}
|
||||
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{}
|
||||
type destroyCmd struct {
|
||||
Castle string `arg:"" optional:"" name:"CASTLE" help:"Castle name."`
|
||||
}
|
||||
|
||||
type cdCmd struct{}
|
||||
type cdCmd struct {
|
||||
Castle string `arg:"" optional:"" name:"CASTLE" help:"Castle name."`
|
||||
}
|
||||
|
||||
type openCmd struct{}
|
||||
type openCmd struct {
|
||||
Castle string `arg:"" optional:"" name:"CASTLE" help:"Castle name."`
|
||||
}
|
||||
|
||||
type execCmd struct{}
|
||||
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{}
|
||||
type execAllCmd struct {
|
||||
Command []string `arg:"" name:"COMMAND" help:"Shell command to execute in each castle root."`
|
||||
}
|
||||
|
||||
type rcCmd struct{}
|
||||
type rcCmd struct {
|
||||
Force bool `help:"Bypass legacy .homesickrc safety confirmation."`
|
||||
Castle string `arg:"" optional:"" name:"CASTLE" help:"Castle name."`
|
||||
}
|
||||
|
||||
type generateCmd struct{}
|
||||
type generateCmd struct {
|
||||
Path string `arg:"" name:"PATH" help:"Path to generate a homesick-ready castle repository."`
|
||||
}
|
||||
|
||||
func (c *pullCmd) Run() error { return notImplemented("pull") }
|
||||
func (c *pushCmd) Run() error { return notImplemented("push") }
|
||||
func (c *commitCmd) Run() error { return notImplemented("commit") }
|
||||
func (c *destroyCmd) Run() error { return notImplemented("destroy") }
|
||||
func (c *cdCmd) Run() error { return notImplemented("cd") }
|
||||
func (c *openCmd) Run() error { return notImplemented("open") }
|
||||
func (c *execCmd) Run() error { return notImplemented("exec") }
|
||||
func (c *execAllCmd) Run() error { return notImplemented("exec_all") }
|
||||
func (c *rcCmd) Run() error { return notImplemented("rc") }
|
||||
func (c *generateCmd) Run() error { return notImplemented("generate") }
|
||||
func (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) == "" {
|
||||
@@ -208,21 +253,61 @@ func normalizeArgs(args []string) []string {
|
||||
return []string{"--help"}
|
||||
}
|
||||
|
||||
switch args[0] {
|
||||
prefix, rest := splitLeadingGlobalFlags(args)
|
||||
if len(rest) == 0 {
|
||||
return args
|
||||
}
|
||||
|
||||
switch rest[0] {
|
||||
case "-h", "--help":
|
||||
return []string{"--help"}
|
||||
case "help":
|
||||
if len(args) == 1 {
|
||||
if len(rest) == 1 {
|
||||
return []string{"--help"}
|
||||
}
|
||||
return append(args[1:], "--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" {
|
||||
@@ -240,11 +325,3 @@ type cliExitError struct {
|
||||
func (e *cliExitError) Error() string {
|
||||
return e.err.Error()
|
||||
}
|
||||
|
||||
func notImplemented(command string) error {
|
||||
return &cliExitError{code: 2, err: fmt.Errorf("%s is not implemented in Go yet", command)}
|
||||
}
|
||||
|
||||
func init() {
|
||||
_ = os.Setenv("GIT_TERMINAL_PROMPT", "0")
|
||||
}
|
||||
|
||||
@@ -5,9 +5,13 @@ import (
|
||||
"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"
|
||||
)
|
||||
@@ -32,12 +36,38 @@ func (s *CLISuite) SetupTest() {
|
||||
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, s.stdout, s.stderr)
|
||||
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())
|
||||
@@ -45,15 +75,108 @@ func (s *CLISuite) TestRun_VersionAliases() {
|
||||
}
|
||||
|
||||
func (s *CLISuite) TestRun_ShowPath_DefaultCastle() {
|
||||
exitCode := cli.Run([]string{"show_path"}, s.stdout, s.stderr)
|
||||
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"}, s.stdout, s.stderr)
|
||||
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")
|
||||
@@ -66,7 +189,7 @@ func (s *CLISuite) TestRun_Help_UsesProgramNameAndDescription() {
|
||||
s.T().Cleanup(func() { os.Args = originalArgs })
|
||||
os.Args = []string{"gosick"}
|
||||
|
||||
exitCode := cli.Run([]string{"--help"}, s.stdout, s.stderr)
|
||||
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")
|
||||
@@ -74,3 +197,76 @@ func (s *CLISuite) TestRun_Help_UsesProgramNameAndDescription() {
|
||||
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())
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -17,13 +18,25 @@ import (
|
||||
type App struct {
|
||||
HomeDir string
|
||||
ReposDir string
|
||||
Stdin io.Reader
|
||||
Stdout io.Writer
|
||||
Stderr io.Writer
|
||||
Verbose bool
|
||||
Force bool
|
||||
Quiet bool
|
||||
Pretend bool
|
||||
}
|
||||
|
||||
func New(stdout io.Writer, stderr io.Writer) (*App, error) {
|
||||
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)
|
||||
@@ -32,6 +45,7 @@ func New(stdout io.Writer, stderr io.Writer) (*App, error) {
|
||||
return &App{
|
||||
HomeDir: home,
|
||||
ReposDir: filepath.Join(home, ".homesick", "repos"),
|
||||
Stdin: stdin,
|
||||
Stdout: stdout,
|
||||
Stderr: stderr,
|
||||
}, nil
|
||||
@@ -59,7 +73,7 @@ func (a *App) Clone(uri string, destination string) error {
|
||||
return fmt.Errorf("unable to derive destination from uri %q", uri)
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(a.ReposDir, 0o755); err != nil {
|
||||
if err := os.MkdirAll(a.ReposDir, 0o750); err != nil {
|
||||
return fmt.Errorf("create repos directory: %w", err)
|
||||
}
|
||||
|
||||
@@ -84,7 +98,67 @@ func (a *App) Clone(uri string, destination string) error {
|
||||
}
|
||||
|
||||
func (a *App) List() error {
|
||||
if err := os.MkdirAll(a.ReposDir, 0o755); err != nil {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -111,26 +185,243 @@ func (a *App) List() error {
|
||||
|
||||
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 = ""
|
||||
if !a.Quiet {
|
||||
if _, err := fmt.Fprintf(a.Stdout, "%s:\n", castle); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
_, writeErr := fmt.Fprintf(a.Stdout, "%s %s\n", castle, strings.TrimSpace(remote))
|
||||
if writeErr != nil {
|
||||
return writeErr
|
||||
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) Status(castle string) error {
|
||||
return runGitWithIO(filepath.Join(a.ReposDir, castle), a.Stdout, a.Stderr, "status")
|
||||
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) Diff(castle string) error {
|
||||
return runGitWithIO(filepath.Join(a.ReposDir, castle), a.Stdout, a.Stderr, "diff")
|
||||
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 {
|
||||
@@ -149,11 +440,11 @@ func (a *App) LinkCastle(castle string) error {
|
||||
|
||||
subdirs, err := readSubdirs(filepath.Join(a.ReposDir, castle, ".homesick_subdir"))
|
||||
if err != nil {
|
||||
return err
|
||||
return fmt.Errorf("read subdirs for castle %q: %w", castle, err)
|
||||
}
|
||||
|
||||
if err := a.linkEach(castleHome, castleHome, subdirs); err != nil {
|
||||
return err
|
||||
return fmt.Errorf("link castle %q: %w", castle, err)
|
||||
}
|
||||
|
||||
for _, subdir := range subdirs {
|
||||
@@ -162,11 +453,11 @@ func (a *App) LinkCastle(castle string) error {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
continue
|
||||
}
|
||||
return err
|
||||
return fmt.Errorf("stat subdir %q for castle %q: %w", base, castle, err)
|
||||
}
|
||||
|
||||
if err := a.linkEach(castleHome, base, subdirs); err != nil {
|
||||
return err
|
||||
return fmt.Errorf("link subdir %q for castle %q: %w", subdir, castle, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -189,11 +480,11 @@ func (a *App) UnlinkCastle(castle string) error {
|
||||
|
||||
subdirs, err := readSubdirs(filepath.Join(a.ReposDir, castle, ".homesick_subdir"))
|
||||
if err != nil {
|
||||
return err
|
||||
return fmt.Errorf("read subdirs for castle %q: %w", castle, err)
|
||||
}
|
||||
|
||||
if err := a.unlinkEach(castleHome, castleHome, subdirs); err != nil {
|
||||
return err
|
||||
return fmt.Errorf("unlink castle %q: %w", castle, err)
|
||||
}
|
||||
|
||||
for _, subdir := range subdirs {
|
||||
@@ -202,11 +493,11 @@ func (a *App) UnlinkCastle(castle string) error {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
continue
|
||||
}
|
||||
return err
|
||||
return fmt.Errorf("stat subdir %q for castle %q: %w", base, castle, err)
|
||||
}
|
||||
|
||||
if err := a.unlinkEach(castleHome, base, subdirs); err != nil {
|
||||
return err
|
||||
return fmt.Errorf("unlink subdir %q for castle %q: %w", subdir, castle, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -236,15 +527,15 @@ func (a *App) TrackPath(filePath string, castle string) error {
|
||||
|
||||
absolutePath, err := filepath.Abs(strings.TrimRight(trimmedFile, string(filepath.Separator)))
|
||||
if err != nil {
|
||||
return err
|
||||
return fmt.Errorf("resolve tracked file %q: %w", trimmedFile, err)
|
||||
}
|
||||
if _, err := os.Lstat(absolutePath); err != nil {
|
||||
return err
|
||||
return fmt.Errorf("stat tracked file %q: %w", absolutePath, err)
|
||||
}
|
||||
|
||||
relativeDir, err := filepath.Rel(a.HomeDir, filepath.Dir(absolutePath))
|
||||
if err != nil {
|
||||
return err
|
||||
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)
|
||||
@@ -254,19 +545,19 @@ func (a *App) TrackPath(filePath string, castle string) error {
|
||||
if relativeDir == "." {
|
||||
castleTargetDir = castleHome
|
||||
}
|
||||
if err := os.MkdirAll(castleTargetDir, 0o755); err != nil {
|
||||
return err
|
||||
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 err
|
||||
return fmt.Errorf("stat tracked destination %q: %w", trackedPath, err)
|
||||
}
|
||||
|
||||
if err := os.Rename(absolutePath, trackedPath); err != nil {
|
||||
return err
|
||||
return fmt.Errorf("move tracked file into castle %q: %w", trackedPath, err)
|
||||
}
|
||||
|
||||
subdirChanged := false
|
||||
@@ -274,21 +565,21 @@ func (a *App) TrackPath(filePath string, castle string) error {
|
||||
subdirPath := filepath.Join(castleRoot, ".homesick_subdir")
|
||||
subdirChanged, err = appendUniqueSubdir(subdirPath, relativeDir)
|
||||
if err != nil {
|
||||
return err
|
||||
return fmt.Errorf("record tracked subdir %q: %w", relativeDir, err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := a.linkPath(trackedPath, absolutePath); err != nil {
|
||||
return err
|
||||
return fmt.Errorf("relink tracked file %q: %w", absolutePath, err)
|
||||
}
|
||||
|
||||
repo, err := git.PlainOpen(castleRoot)
|
||||
if err != nil {
|
||||
return err
|
||||
return fmt.Errorf("open git repository for castle %q: %w", castle, err)
|
||||
}
|
||||
worktree, err := repo.Worktree()
|
||||
if err != nil {
|
||||
return err
|
||||
return fmt.Errorf("open worktree for castle %q: %w", castle, err)
|
||||
}
|
||||
|
||||
trackedRelativePath := filepath.Join("home", relativeDir, filepath.Base(absolutePath))
|
||||
@@ -296,12 +587,12 @@ func (a *App) TrackPath(filePath string, castle string) error {
|
||||
trackedRelativePath = filepath.Join("home", filepath.Base(absolutePath))
|
||||
}
|
||||
if _, err := worktree.Add(filepath.ToSlash(filepath.Clean(trackedRelativePath))); err != nil {
|
||||
return err
|
||||
return fmt.Errorf("stage tracked file %q: %w", trackedRelativePath, err)
|
||||
}
|
||||
|
||||
if subdirChanged {
|
||||
if _, err := worktree.Add(".homesick_subdir"); err != nil {
|
||||
return err
|
||||
return fmt.Errorf("stage subdir metadata: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -311,7 +602,7 @@ func (a *App) TrackPath(filePath string, castle string) error {
|
||||
func appendUniqueSubdir(path string, subdir string) (bool, error) {
|
||||
existing, err := readSubdirs(path)
|
||||
if err != nil {
|
||||
return false, err
|
||||
return false, fmt.Errorf("load subdir metadata %q: %w", path, err)
|
||||
}
|
||||
|
||||
cleanSubdir := filepath.Clean(subdir)
|
||||
@@ -321,14 +612,14 @@ func appendUniqueSubdir(path string, subdir string) (bool, error) {
|
||||
}
|
||||
}
|
||||
|
||||
file, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644)
|
||||
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, err
|
||||
return false, fmt.Errorf("open subdir metadata %q: %w", path, err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
if _, err := file.WriteString(cleanSubdir + "\n"); err != nil {
|
||||
return false, err
|
||||
return false, fmt.Errorf("write subdir metadata %q: %w", path, err)
|
||||
}
|
||||
|
||||
return true, nil
|
||||
@@ -337,7 +628,7 @@ func appendUniqueSubdir(path string, subdir string) (bool, error) {
|
||||
func (a *App) linkEach(castleHome string, baseDir string, subdirs []string) error {
|
||||
entries, err := os.ReadDir(baseDir)
|
||||
if err != nil {
|
||||
return err
|
||||
return fmt.Errorf("read castle directory %q: %w", baseDir, err)
|
||||
}
|
||||
|
||||
for _, entry := range entries {
|
||||
@@ -349,7 +640,7 @@ func (a *App) linkEach(castleHome string, baseDir string, subdirs []string) erro
|
||||
source := filepath.Join(baseDir, name)
|
||||
ignore, err := matchesIgnoredDir(castleHome, source, subdirs)
|
||||
if err != nil {
|
||||
return err
|
||||
return fmt.Errorf("check ignored directory %q: %w", source, err)
|
||||
}
|
||||
if ignore {
|
||||
continue
|
||||
@@ -357,7 +648,7 @@ func (a *App) linkEach(castleHome string, baseDir string, subdirs []string) erro
|
||||
|
||||
relDir, err := filepath.Rel(castleHome, baseDir)
|
||||
if err != nil {
|
||||
return err
|
||||
return fmt.Errorf("resolve castle relative directory %q: %w", baseDir, err)
|
||||
}
|
||||
|
||||
destination := filepath.Join(a.HomeDir, relDir, name)
|
||||
@@ -366,7 +657,7 @@ func (a *App) linkEach(castleHome string, baseDir string, subdirs []string) erro
|
||||
}
|
||||
|
||||
if err := a.linkPath(source, destination); err != nil {
|
||||
return err
|
||||
return fmt.Errorf("link %q to %q: %w", source, destination, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -376,7 +667,7 @@ func (a *App) linkEach(castleHome string, baseDir string, subdirs []string) erro
|
||||
func (a *App) unlinkEach(castleHome string, baseDir string, subdirs []string) error {
|
||||
entries, err := os.ReadDir(baseDir)
|
||||
if err != nil {
|
||||
return err
|
||||
return fmt.Errorf("read castle directory %q: %w", baseDir, err)
|
||||
}
|
||||
|
||||
for _, entry := range entries {
|
||||
@@ -388,7 +679,7 @@ func (a *App) unlinkEach(castleHome string, baseDir string, subdirs []string) er
|
||||
source := filepath.Join(baseDir, name)
|
||||
ignore, err := matchesIgnoredDir(castleHome, source, subdirs)
|
||||
if err != nil {
|
||||
return err
|
||||
return fmt.Errorf("check ignored directory %q: %w", source, err)
|
||||
}
|
||||
if ignore {
|
||||
continue
|
||||
@@ -396,7 +687,7 @@ func (a *App) unlinkEach(castleHome string, baseDir string, subdirs []string) er
|
||||
|
||||
relDir, err := filepath.Rel(castleHome, baseDir)
|
||||
if err != nil {
|
||||
return err
|
||||
return fmt.Errorf("resolve castle relative directory %q: %w", baseDir, err)
|
||||
}
|
||||
|
||||
destination := filepath.Join(a.HomeDir, relDir, name)
|
||||
@@ -405,7 +696,7 @@ func (a *App) unlinkEach(castleHome string, baseDir string, subdirs []string) er
|
||||
}
|
||||
|
||||
if err := unlinkPath(destination); err != nil {
|
||||
return err
|
||||
return fmt.Errorf("unlink %q: %w", destination, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -431,11 +722,11 @@ func unlinkPath(destination string) error {
|
||||
func (a *App) linkPath(source string, destination string) error {
|
||||
absSource, err := filepath.Abs(source)
|
||||
if err != nil {
|
||||
return err
|
||||
return fmt.Errorf("resolve link source %q: %w", source, err)
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(destination), 0o755); err != nil {
|
||||
return 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)
|
||||
@@ -452,26 +743,26 @@ func (a *App) linkPath(source string, destination string) error {
|
||||
}
|
||||
|
||||
if rmErr := os.RemoveAll(destination); rmErr != nil {
|
||||
return rmErr
|
||||
return fmt.Errorf("remove existing destination %q: %w", destination, rmErr)
|
||||
}
|
||||
} else if !errors.Is(err, os.ErrNotExist) {
|
||||
return err
|
||||
return fmt.Errorf("stat destination %q: %w", destination, err)
|
||||
}
|
||||
|
||||
if err := os.Symlink(absSource, destination); err != nil {
|
||||
return err
|
||||
return fmt.Errorf("create symlink %q -> %q: %w", destination, absSource, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func readSubdirs(path string) ([]string, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
data, err := os.ReadFile(path) // #nosec G304 — internal metadata file
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return []string{}, nil
|
||||
}
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("read subdirs %q: %w", path, err)
|
||||
}
|
||||
|
||||
lines := strings.Split(string(data), "\n")
|
||||
@@ -490,7 +781,7 @@ func readSubdirs(path string) ([]string, error) {
|
||||
func matchesIgnoredDir(castleHome string, candidate string, subdirs []string) (bool, error) {
|
||||
absCandidate, err := filepath.Abs(candidate)
|
||||
if err != nil {
|
||||
return false, err
|
||||
return false, fmt.Errorf("resolve candidate path %q: %w", candidate, err)
|
||||
}
|
||||
|
||||
ignoreSet := map[string]struct{}{}
|
||||
@@ -511,6 +802,7 @@ func matchesIgnoredDir(castleHome string, candidate string, subdirs []string) (b
|
||||
}
|
||||
|
||||
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
|
||||
@@ -521,7 +813,30 @@ func runGitWithIO(dir string, stdout io.Writer, stderr io.Writer, args ...string
|
||||
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()
|
||||
@@ -531,6 +846,90 @@ func gitOutput(dir string, args ...string) (string, error) {
|
||||
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/")
|
||||
|
||||
@@ -1,6 +1,42 @@
|
||||
package core
|
||||
|
||||
import "testing"
|
||||
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 {
|
||||
@@ -22,3 +58,33 @@ func TestDeriveDestination(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
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\"")
|
||||
}
|
||||
@@ -70,3 +70,13 @@ func (s *ListSuite) TestList_OutputsSortedCastlesWithRemoteURLs() {
|
||||
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")
|
||||
}
|
||||
@@ -20,7 +20,7 @@ type TrackSuite struct {
|
||||
app *core.App
|
||||
}
|
||||
|
||||
//NB: this has nothing to do with jogging
|
||||
// NB: this has nothing to do with jogging
|
||||
func TestTrackSuite(t *testing.T) {
|
||||
suite.Run(t, new(TrackSuite))
|
||||
}
|
||||
@@ -99,3 +99,15 @@ func (s *TrackSuite) TestTrack_DefaultCastleName() {
|
||||
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")
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
12
justfile
12
justfile
@@ -14,8 +14,20 @@ go-build-linux:
|
||||
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."
|
||||
|
||||
132
script/prepare-release.sh
Normal file
132
script/prepare-release.sh
Normal file
@@ -0,0 +1,132 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
repo_root="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
cd "$repo_root"
|
||||
|
||||
version_file="internal/homesick/version/version.go"
|
||||
primary_changelog="CHANGELOG.md"
|
||||
compat_changelog="changelog.md"
|
||||
|
||||
current_version="$(sed -n 's/^const String = "\([^"]*\)"$/\1/p' "$version_file")"
|
||||
if [[ -z "$current_version" ]]; then
|
||||
echo "Failed to read current version from ${version_file}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
bump="$(awk '
|
||||
BEGIN { in_unreleased = 0; section = ""; has_entries = 0; bump = "" }
|
||||
/^## \[Unreleased\]/ { in_unreleased = 1; next }
|
||||
/^## \[/ && in_unreleased { exit }
|
||||
/^### / && in_unreleased { section = substr($0, 5); next }
|
||||
in_unreleased && /^- / {
|
||||
has_entries = 1
|
||||
if (section == "Breaking" || section == "Removed") {
|
||||
bump = "major"
|
||||
} else if (section == "Added") {
|
||||
if (bump != "major") {
|
||||
bump = "minor"
|
||||
}
|
||||
} else if (bump == "") {
|
||||
bump = "patch"
|
||||
}
|
||||
}
|
||||
END {
|
||||
if (!has_entries) {
|
||||
print "none"
|
||||
exit
|
||||
}
|
||||
if (bump == "") {
|
||||
bump = "patch"
|
||||
}
|
||||
print bump
|
||||
}
|
||||
' "$primary_changelog")"
|
||||
|
||||
if [[ "$bump" == "none" ]]; then
|
||||
echo "No unreleased changelog entries found; skipping release preparation."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
IFS=. read -r major minor patch <<< "$current_version"
|
||||
case "$bump" in
|
||||
major)
|
||||
major=$((major + 1))
|
||||
minor=0
|
||||
patch=0
|
||||
;;
|
||||
minor)
|
||||
minor=$((minor + 1))
|
||||
patch=0
|
||||
;;
|
||||
patch)
|
||||
patch=$((patch + 1))
|
||||
;;
|
||||
*)
|
||||
echo "Unsupported bump type: ${bump}" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
next_version="${major}.${minor}.${patch}"
|
||||
tag="v${next_version}"
|
||||
today="$(date -u +%F)"
|
||||
|
||||
if git rev-parse -q --verify "refs/tags/${tag}" >/dev/null; then
|
||||
echo "Tag ${tag} already exists; skipping release preparation."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
unreleased_line="$(grep -n '^## \[Unreleased\]' "$primary_changelog" | cut -d: -f1)"
|
||||
if [[ -z "$unreleased_line" ]]; then
|
||||
echo "Missing [Unreleased] section in ${primary_changelog}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
next_heading_line="$(awk -v start="$unreleased_line" 'NR > start && /^## \[/ { print NR; exit }' "$primary_changelog")"
|
||||
total_lines="$(wc -l < "$primary_changelog" | tr -d ' ')"
|
||||
if [[ -z "$next_heading_line" ]]; then
|
||||
next_heading_line=$((total_lines + 1))
|
||||
fi
|
||||
|
||||
tmp_changelog="$(mktemp)"
|
||||
{
|
||||
sed -n "1,$((unreleased_line - 1))p" "$primary_changelog"
|
||||
echo "## [Unreleased]"
|
||||
echo
|
||||
echo "### Breaking"
|
||||
echo
|
||||
echo "### Added"
|
||||
echo
|
||||
echo "### Changed"
|
||||
echo
|
||||
echo "### Fixed"
|
||||
echo
|
||||
echo "### Removed"
|
||||
echo
|
||||
echo "## [${next_version}] - ${today}"
|
||||
echo
|
||||
sed -n "$((unreleased_line + 2)),$((next_heading_line - 1))p" "$primary_changelog"
|
||||
if (( next_heading_line <= total_lines )); then
|
||||
sed -n "${next_heading_line},${total_lines}p" "$primary_changelog"
|
||||
fi
|
||||
} > "$tmp_changelog"
|
||||
|
||||
tmp_version="$(mktemp)"
|
||||
sed "s/^const String = \".*\"$/const String = \"${next_version}\"/" "$version_file" > "$tmp_version"
|
||||
|
||||
mv "$tmp_version" "$version_file"
|
||||
mv "$tmp_changelog" "$primary_changelog"
|
||||
cp "$primary_changelog" "$compat_changelog"
|
||||
|
||||
git config user.name "gitea-actions[bot]"
|
||||
git config user.email "gitea-actions[bot]@users.noreply.local"
|
||||
|
||||
git add "$version_file" "$primary_changelog" "$compat_changelog"
|
||||
git commit -m "chore(release): prepare ${tag}"
|
||||
git tag "$tag"
|
||||
git push origin HEAD:main
|
||||
git push origin "$tag"
|
||||
|
||||
echo "Prepared ${tag} from ${current_version} with a ${bump} bump."
|
||||
63
script/publish-release.sh
Normal file
63
script/publish-release.sh
Normal file
@@ -0,0 +1,63 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
repo_root="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
cd "$repo_root"
|
||||
|
||||
if [[ -z "${GITHUB_REF_NAME:-}" ]]; then
|
||||
echo "GITHUB_REF_NAME is required" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ -z "${GITHUB_REPOSITORY:-}" || -z "${GITHUB_SERVER_URL:-}" || -z "${GITHUB_TOKEN:-}" ]]; then
|
||||
echo "GITHUB_REPOSITORY, GITHUB_SERVER_URL, and GITHUB_TOKEN are required" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
tag="${GITHUB_REF_NAME}"
|
||||
version="${tag#v}"
|
||||
notes_file="$(mktemp)"
|
||||
|
||||
awk -v version="$version" '
|
||||
$0 ~ ("^## \\\[" version "\\\] - ") { in_section = 1 }
|
||||
/^## \[/ && in_section && $0 !~ ("^## \\\[" version "\\\] - ") { exit }
|
||||
in_section { print }
|
||||
' CHANGELOG.md > "$notes_file"
|
||||
|
||||
if [[ ! -s "$notes_file" ]]; then
|
||||
printf '## [%s]\n\n- Release %s\n' "$version" "$tag" > "$notes_file"
|
||||
fi
|
||||
|
||||
payload_file="$(mktemp)"
|
||||
jq -n \
|
||||
--arg tag "$tag" \
|
||||
--arg name "$tag" \
|
||||
--arg target "main" \
|
||||
--rawfile body "$notes_file" \
|
||||
'{tag_name: $tag, target_commitish: $target, name: $name, body: $body, draft: false, prerelease: false}' > "$payload_file"
|
||||
|
||||
api_base="${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}"
|
||||
status="$(curl -sS -o /tmp/release_lookup.json -w '%{http_code}' \
|
||||
-H "Authorization: token ${GITHUB_TOKEN}" \
|
||||
-H 'accept: application/json' \
|
||||
"${api_base}/releases/tags/${tag}" || true)"
|
||||
|
||||
if [[ "$status" == "200" ]]; then
|
||||
release_id="$(jq -r '.id' /tmp/release_lookup.json)"
|
||||
curl -fsSL -X PATCH \
|
||||
-H "Authorization: token ${GITHUB_TOKEN}" \
|
||||
-H 'accept: application/json' \
|
||||
-H 'content-type: application/json' \
|
||||
--data @"$payload_file" \
|
||||
"${api_base}/releases/${release_id}" >/dev/null
|
||||
echo "Updated release ${tag}."
|
||||
else
|
||||
curl -fsSL -X POST \
|
||||
-H "Authorization: token ${GITHUB_TOKEN}" \
|
||||
-H 'accept: application/json' \
|
||||
-H 'content-type: application/json' \
|
||||
--data @"$payload_file" \
|
||||
"${api_base}/releases" >/dev/null
|
||||
echo "Created release ${tag}."
|
||||
fi
|
||||
@@ -1,7 +1,7 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
: "${HOMESICK_CMD:=/workspace/dist/gosick}"
|
||||
: "${HOMESICK_CMD:=ruby /workspace/bin/homesick}"
|
||||
: "${BEHAVIOR_VERBOSE:=0}"
|
||||
|
||||
RUN_OUTPUT=""
|
||||
@@ -80,6 +80,52 @@ run_homesick() {
|
||||
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"
|
||||
@@ -125,13 +171,26 @@ run_suite() {
|
||||
|
||||
setup_remote_castle "$remote_root" "$work_root"
|
||||
|
||||
echo "[1/7] clone"
|
||||
run_homesick "clone file://$remote_root/base.git parity-castle"
|
||||
assert_path_exists "$HOME/.homesick/repos/parity-castle/.git"
|
||||
assert_path_exists "$HOME/.homesick/repos/parity-castle/home/.vimrc"
|
||||
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/7] link"
|
||||
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"
|
||||
@@ -139,7 +198,7 @@ run_suite() {
|
||||
assert_symlink_target "$HOME/.config/myapp" "$HOME/.homesick/repos/parity-castle/home/.config/myapp"
|
||||
pass
|
||||
|
||||
echo "[3/7] unlink"
|
||||
echo "[4/18] unlink"
|
||||
run_homesick "unlink parity-castle"
|
||||
assert_path_missing "$HOME/.vimrc"
|
||||
assert_path_missing "$HOME/.zshrc"
|
||||
@@ -147,7 +206,12 @@ run_suite() {
|
||||
assert_path_missing "$HOME/.config/myapp"
|
||||
pass
|
||||
|
||||
echo "[4/7] relink + track"
|
||||
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"
|
||||
@@ -156,7 +220,7 @@ run_suite() {
|
||||
grep -q "^.local/bin$" "$HOME/.homesick/repos/parity-castle/.homesick_subdir" || fail "expected .homesick_subdir to contain .local/bin"
|
||||
pass
|
||||
|
||||
echo "[5/7] list and show_path"
|
||||
echo "[7/18] list and show_path"
|
||||
local list_output
|
||||
run_homesick "list"
|
||||
list_output="$RUN_OUTPUT"
|
||||
@@ -164,10 +228,10 @@ run_suite() {
|
||||
local show_path_output
|
||||
run_homesick "show_path parity-castle"
|
||||
show_path_output="$RUN_OUTPUT"
|
||||
[[ "$show_path_output" == *"$HOME/.homesick/repos/parity-castle"* ]] || fail "expected show_path output to include parity-castle path"
|
||||
[[ "$show_path_output" == "$HOME/.homesick/repos/parity-castle" ]] || fail "expected show_path output to equal parity-castle root path"
|
||||
pass
|
||||
|
||||
echo "[6/7] status and diff"
|
||||
echo "[8/18] status and diff"
|
||||
echo "change" >> "$HOME/.vimrc"
|
||||
local status_output
|
||||
run_homesick "status parity-castle"
|
||||
@@ -179,7 +243,78 @@ run_suite() {
|
||||
[[ "$diff_output" == *"diff --git"* ]] || fail "expected diff output to include git diff"
|
||||
pass
|
||||
|
||||
echo "[7/7] version"
|
||||
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"
|
||||
|
||||
Reference in New Issue
Block a user