40 Commits

Author SHA1 Message Date
Micheal Wilkinson
ac41276c50 ci: Correct pipeline
Some checks failed
Release / prepare (push) Failing after 7s
Push Validation / validate (push) Failing after 13m8s
2026-03-21 23:41:13 +00:00
Micheal Wilkinson
b97da893fb docs: update changelog for local release automation
All checks were successful
Push Validation / validate (push) Successful in 2m29s
2026-03-21 23:21:40 +00:00
Micheal Wilkinson
b24ca1214c ci(release): replace vociferate with local release scripts 2026-03-21 23:21:40 +00:00
Micheal Wilkinson
76460cddee docs: update changelog for runner compatibility
Some checks failed
Release / prepare (push) Failing after 5s
Release / publish (push) Has been skipped
Push Validation / validate (push) Successful in 3m23s
2026-03-21 23:15:32 +00:00
Micheal Wilkinson
d63a8bb615 ci: remove fragile external badge actions 2026-03-21 23:15:32 +00:00
ced23e0156 Update README.md
Some checks failed
Release / prepare (push) Failing after 4s
Release / publish (push) Has been skipped
Push Validation / check-open-pr (push) Successful in 3s
Push Validation / validate (push) Failing after 20s
2026-03-21 23:08:31 +00:00
Micheal Wilkinson
710fe049f5 docs: update changelog for pr validation fallbacks
Some checks failed
Pull Request Validation / validate (pull_request) Successful in 4m34s
Release / prepare (push) Failing after 4s
Push Validation / check-open-pr (push) Successful in 3s
Release / publish (push) Has been skipped
Push Validation / validate (push) Failing after 16s
2026-03-21 23:02:46 +00:00
Micheal Wilkinson
2294bb940b ci(pr-validation): harden decoration and summary fallback 2026-03-21 23:02:46 +00:00
Micheal Wilkinson
bbbacb0eb6 docs: update changelog for workflow hardening
Some checks failed
Push Validation / check-open-pr (push) Successful in 2s
Push Validation / validate (push) Has been skipped
Pull Request Validation / validate (pull_request) Failing after 2m9s
2026-03-21 22:54:07 +00:00
Micheal Wilkinson
28820748f7 ci: harden workflow dedup and badge gating 2026-03-21 22:54:07 +00:00
Micheal Wilkinson
1f93a3d532 docs: update changelog for push dedup guard
Some checks failed
Push Validation / check-open-pr (push) Failing after 2s
Push Validation / validate (push) Has been skipped
Pull Request Validation / validate (pull_request) Failing after 1m44s
2026-03-21 22:36:23 +00:00
Micheal Wilkinson
3104feb738 ci(push-validation): skip branch pushes with open PR 2026-03-21 22:36:23 +00:00
Micheal Wilkinson
e1a58b6607 docs: update changelog for concurrency deduplication
Some checks failed
Push Validation / validate (push) Has been cancelled
Pull Request Validation / validate (pull_request) Failing after 2m8s
2026-03-21 22:32:35 +00:00
Micheal Wilkinson
411c99532d ci: deduplicate runs via shared branch-name concurrency group 2026-03-21 21:21:33 +00:00
Micheal Wilkinson
607f43eaa0 docs: update changelog for push-validation branch trigger
Some checks failed
Push Validation / validate (push) Has been cancelled
Pull Request Validation / validate (pull_request) Failing after 1m54s
2026-03-21 21:18:56 +00:00
Micheal Wilkinson
0691c54965 ci(push-validation): trigger on all branches 2026-03-21 21:17:56 +00:00
Micheal Wilkinson
74640ddaa8 docs: update changelog for duplicate-run prevention
Some checks failed
Pull Request Validation / validate (pull_request) Failing after 2m0s
2026-03-21 21:15:19 +00:00
Micheal Wilkinson
354f3599b4 ci(push-validation): trigger only on main pushes 2026-03-21 21:15:19 +00:00
Micheal Wilkinson
ae86431d50 docs: update changelog for PR decoration gate fallback
Some checks failed
Pull Request Validation / validate (pull_request) Failing after 2m29s
Push Validation / validate (push) Successful in 3m7s
2026-03-21 21:12:10 +00:00
Micheal Wilkinson
9c7f6fbdf4 ci(pr-validation): fallback changelog gate and fix badge condition 2026-03-21 21:12:09 +00:00
Micheal Wilkinson
cf183d9bb0 docs: update changelog for badge upload guard
Some checks failed
Pull Request Validation / validate (pull_request) Failing after 2m32s
Push Validation / validate (push) Successful in 3m3s
2026-03-21 21:07:43 +00:00
Micheal Wilkinson
65d0a95968 ci(pr-validation): guard badge upload on coverage file 2026-03-21 21:07:43 +00:00
Micheal Wilkinson
7fbbb442a0 ci(pr-validation): always run badge upload and PR decoration
Some checks failed
Push Validation / validate (push) Successful in 4m41s
Pull Request Validation / validate (pull_request) Failing after 3m30s
2026-03-21 20:59:39 +00:00
Micheal Wilkinson
a316723cfc docs: update changelog for gosec scanner fix
Some checks failed
Push Validation / validate (push) Has been cancelled
Pull Request Validation / validate (pull_request) Failing after 4m5s
2026-03-21 20:58:17 +00:00
Micheal Wilkinson
7405044fb5 chore(go): annotate intentional command execution for gosec 2026-03-21 20:58:17 +00:00
Micheal Wilkinson
4fc9401741 docs: update changelog for core error wrapping
Some checks failed
Push Validation / validate (push) Failing after 1m31s
Pull Request Validation / validate (pull_request) Failing after 1m53s
2026-03-21 20:52:13 +00:00
Micheal Wilkinson
c793925828 chore(go): wrap core filesystem errors with context 2026-03-21 20:52:13 +00:00
Micheal Wilkinson
bc0a6747b8 docs: update changelog for parity fixes 2026-03-21 20:45:05 +00:00
Micheal Wilkinson
d642870a66 chore(go): inject stdin and pass rc force explicitly 2026-03-21 20:45:05 +00:00
Micheal Wilkinson
038b109e7b ci: align govulncheck action inputs with workflow standard 2026-03-21 20:45:05 +00:00
Micheal Wilkinson
519c6703d2 docs: update changelog for vociferate v1.1.0 bump 2026-03-21 20:18:25 +00:00
Micheal Wilkinson
8a3fde8e07 ci: bump vociferate prepare and publish to v1.1.0 2026-03-21 20:18:25 +00:00
Micheal Wilkinson
3fa377efe2 docs: update changelog for CI security hardening and badge URL fix 2026-03-21 20:16:24 +00:00
Micheal Wilkinson
02eebb02fe docs: fix badge link target to use actions/runs/latest per workflow standards 2026-03-21 20:15:30 +00:00
Micheal Wilkinson
dd1d802605 ci: replace gosec action with direct invocation, pin govulncheck to v1.0.4
Per security scanning requirements in project instructions:
- Replace securego/gosec@v2.22.3 action with go install + gosec run step
  in both push-validation and pr-validation to avoid compatibility issues
  with Go 1.26.1
- Pin golang/govulncheck-action from @v1 to @v1.0.4 in both workflows;
  major-version tags do not resolve reliably in Gitea API
- Move GOTOOLCHAIN=auto from per-step env to job-level env in both workflows
- Bump coverage-badge in push-validation from v1.0.1 to v1.1.0
2026-03-21 20:15:08 +00:00
Micheal Wilkinson
a65f62ea9d docs: update changelog for coverage test improvements and vociferate PR gate migration 2026-03-21 20:13:58 +00:00
Micheal Wilkinson
014b330931 ci(pr-validation): replace manual badge/gate logic with vociferate actions
- Remove manual changelog validation shell script
- Remove AWS CLI install and jq tooling steps
- Remove hand-rolled SVG badge generation, S3 upload, and PR comment steps
- Replace with coverage-badge@v1.1.0 for coverage artefact upload
- Replace with decorate-pr@v1.1.0 for PR comment and changelog gate
  (enable-changelog-gate: true, changelog-gate-mode: strict)
- Retain per-package coverage gate awk logic (Aether threshold enforcement)
2026-03-21 20:13:40 +00:00
Micheal Wilkinson
5b37057b61 test(coverage): add targeted tests to raise per-package coverage gates
- internal/homesick/version: new version_test.go covers String constant
  and semver format validation
- internal/homesick/cli: add list, generate, clone, status, diff, and
  git-repo helper tests; coverage raised from 62.5% to 71.2%
- internal/homesick/core: new helpers_test.go covers runGit pretend,
  actionVerb, sayStatus, unlinkPath, linkPath, readSubdirs,
  matchesIgnoredDir, confirmDestroy, ExecAll edge cases, and
  Link/Unlink default castle wrappers; core_test.go and pull_test.go
  extended with New constructor and PullAll quiet-mode tests;
  exec_test.go extended with ExecAll no-repos-dir and error-wrap tests;
  coverage raised from 75.6% to 80.2%
2026-03-21 20:13:31 +00:00
Micheal Wilkinson
4b54a45a76 docs: note scanner toolchain compatibility fix
All checks were successful
Push Validation / validate (push) Successful in 3m29s
2026-03-21 13:54:11 +00:00
Micheal Wilkinson
eb63da9354 chore(ci): allow scanner actions to auto-select Go toolchain 2026-03-21 13:54:11 +00:00
22 changed files with 973 additions and 270 deletions

View File

@@ -7,6 +7,10 @@ on:
- synchronize - synchronize
- reopened - reopened
concurrency:
group: ci-${{ github.head_ref }}
cancel-in-progress: true
jobs: jobs:
validate: validate:
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -22,10 +26,13 @@ jobs:
AWS_SECRET_ACCESS_KEY: ${{ secrets.ARTEFACT_BUCKET_WRITE_ACCESS_SECRET }} AWS_SECRET_ACCESS_KEY: ${{ secrets.ARTEFACT_BUCKET_WRITE_ACCESS_SECRET }}
AWS_DEFAULT_REGION: ${{ vars.ARTEFACT_BUCKET_REGION }} AWS_DEFAULT_REGION: ${{ vars.ARTEFACT_BUCKET_REGION }}
AWS_EC2_METADATA_DISABLED: true AWS_EC2_METADATA_DISABLED: true
GOTOOLCHAIN: auto
SUMMARY_FILE: ${{ runner.temp }}/summary.md SUMMARY_FILE: ${{ runner.temp }}/summary.md
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup Go - name: Setup Go
uses: actions/setup-go@v5 uses: actions/setup-go@v5
@@ -53,20 +60,6 @@ jobs:
git diff --exit-code go.mod go.sum git diff --exit-code go.mod go.sum
go mod verify go mod verify
- name: Install AWS CLI v2
uses: ankurk91/install-aws-cli-action@v1
- name: Ensure tooling is available
run: |
set -euo pipefail
aws --version
if ! command -v jq >/dev/null 2>&1; then
apt-get update
apt-get install -y jq
fi
- name: Prepare test runtime - name: Prepare test runtime
run: | run: |
set -euo pipefail set -euo pipefail
@@ -81,11 +74,6 @@ jobs:
set -euo pipefail set -euo pipefail
go test -covermode=atomic -coverprofile=coverage.out ./... | tee go-test-coverage.log go test -covermode=atomic -coverprofile=coverage.out ./... | tee go-test-coverage.log
go tool cover -html=coverage.out -o coverage.html
total="$(go tool cover -func=coverage.out | awk '/^total:/ {sub(/%/, "", $3); print $3}')"
printf '{\n "total": "%s"\n}\n' "$total" > coverage-summary.json
printf 'total=%s\n' "$total" >> "$GITHUB_OUTPUT"
set +e set +e
awk ' awk '
@@ -153,116 +141,103 @@ jobs:
fi fi
- name: Run Gosec Security Scanner - name: Run Gosec Security Scanner
uses: securego/gosec@v2.22.3 run: |
with: set -euo pipefail
args: './...' go install github.com/securego/gosec/v2/cmd/gosec@latest
gosec ./...
- name: Run Go Vulnerability Check - name: Run Go Vulnerability Check
uses: golang/govulncheck-action@v1 uses: golang/govulncheck-action@v1.0.4
with:
go-package: ./...
cache: true
cache-dependency-path: go.sum
- name: Generate coverage badge - name: Check coverage artefacts
env: id: coverage-files
COVERAGE_TOTAL: ${{ steps.coverage.outputs.total }} if: ${{ always() && steps.coverage.outcome == 'success' }}
run: | run: |
set -euo pipefail set -euo pipefail
if [[ -f coverage.out ]]; then
color="$(awk -v total="$COVERAGE_TOTAL" 'BEGIN { echo "exists=true" >> "$GITHUB_OUTPUT"
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";
}')"
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![Coverage badge](" + $badge + ")")}')"
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 else
curl -sS -X POST \ echo "exists=false" >> "$GITHUB_OUTPUT"
-H "Authorization: token ${GITHUB_TOKEN}" \ echo "coverage.out was not produced; skipping coverage badge upload." >> "$GITHUB_STEP_SUMMARY"
-H 'Content-Type: application/json' \
-d "$payload" \
"${api_base}/issues/${{ github.event.pull_request.number }}/comments" >/dev/null
fi fi
- name: Add coverage summary - name: Upload coverage badge
id: badge
if: ${{ always() && steps.coverage.outcome == 'success' && steps.coverage-files.outputs.exists == 'true' }}
run: | 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 '## Coverage'
echo echo
echo '- Total: `${{ steps.coverage.outputs.total }}%`' echo "- Total: ${total}%"
echo '- Report: ${{ steps.upload.outputs.report_url }}' echo "- Report: ${report_url}"
echo '- Badge: ${{ steps.upload.outputs.badge_url }}' echo "- Badge: ${badge_url}"
echo echo
echo '### Package Coverage' echo '### Package Coverage'
cat coverage-packages.md if [[ -f coverage-packages.md ]]; then
cat coverage-packages.md
else
echo '_Package coverage details unavailable for this run._'
fi
} >> "$SUMMARY_FILE" } >> "$SUMMARY_FILE"
- name: Run behavior suite - name: Run behavior suite

View File

@@ -9,6 +9,7 @@ permissions:
jobs: jobs:
prepare: prepare:
if: "${{ !startsWith(github.event.head_commit.message, 'chore(release): prepare ') }}"
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout - name: Checkout
@@ -23,24 +24,15 @@ jobs:
ln -s CHANGELOG.md changelog.md ln -s CHANGELOG.md changelog.md
fi fi
- name: Vociferate prepare - name: Prepare release
uses: https://git.hrafn.xyz/aether/vociferate/prepare@v1.0.1 run: bash ./script/prepare-release.sh
publish: - name: Summary
needs: prepare if: ${{ always() }}
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Provide lowercase changelog compatibility
run: | run: |
set -euo pipefail set -euo pipefail
if [[ -f CHANGELOG.md && ! -e changelog.md ]]; then if git rev-parse -q --verify "refs/tags/$(sed -n 's/^const String = "\([^"]*\)"$/v\1/p' internal/homesick/version/version.go)" >/dev/null; then
ln -s CHANGELOG.md changelog.md echo "Prepared and pushed release tag $(sed -n 's/^const String = "\([^"]*\)"$/v\1/p' internal/homesick/version/version.go)." >> "$GITHUB_STEP_SUMMARY"
fi else
echo "No release prepared in this run." >> "$GITHUB_STEP_SUMMARY"
- name: Vociferate publish fi
uses: https://git.hrafn.xyz/aether/vociferate/publish@v1.0.1

View File

@@ -7,6 +7,10 @@ on:
tags-ignore: tags-ignore:
- "*" - "*"
concurrency:
group: ci-${{ github.ref_name }}
cancel-in-progress: true
jobs: jobs:
validate: validate:
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -22,6 +26,7 @@ jobs:
AWS_SECRET_ACCESS_KEY: ${{ secrets.ARTEFACT_BUCKET_WRITE_ACCESS_SECRET }} AWS_SECRET_ACCESS_KEY: ${{ secrets.ARTEFACT_BUCKET_WRITE_ACCESS_SECRET }}
AWS_DEFAULT_REGION: ${{ vars.ARTEFACT_BUCKET_REGION }} AWS_DEFAULT_REGION: ${{ vars.ARTEFACT_BUCKET_REGION }}
AWS_EC2_METADATA_DISABLED: true AWS_EC2_METADATA_DISABLED: true
GOTOOLCHAIN: auto
SUMMARY_FILE: ${{ runner.temp }}/summary.md SUMMARY_FILE: ${{ runner.temp }}/summary.md
steps: steps:
- name: Checkout - name: Checkout
@@ -64,12 +69,17 @@ jobs:
fi fi
- name: Run Gosec Security Scanner - name: Run Gosec Security Scanner
uses: securego/gosec@v2.22.3 run: |
with: set -euo pipefail
args: './...' go install github.com/securego/gosec/v2/cmd/gosec@latest
gosec ./...
- name: Run Go Vulnerability Check - name: Run Go Vulnerability Check
uses: golang/govulncheck-action@v1 uses: golang/govulncheck-action@v1.0.4
with:
go-package: ./...
cache: true
cache-dependency-path: go.sum
- name: Install AWS CLI v2 - name: Install AWS CLI v2
uses: ankurk91/install-aws-cli-action@v1 uses: ankurk91/install-aws-cli-action@v1
@@ -152,19 +162,27 @@ jobs:
exit 1 exit 1
fi fi
- name: Publish coverage artefacts - name: Add coverage summary
id: coverage-badge if: ${{ always() && steps.coverage-tests.outcome == 'success' }}
uses: https://git.hrafn.xyz/aether/vociferate/coverage-badge@v1.0.1 run: |
with: set -euo pipefail
coverage-profile: coverage.out total="${{ steps.coverage-tests.outputs.total }}"
coverage-html: coverage.html if [[ -z "$total" ]]; then
coverage-badge: coverage-badge.svg total="n/a"
coverage-summary: coverage-summary.json fi
artefact-bucket-name: ${{ vars.ARTEFACT_BUCKET_NAME }}
artefact-bucket-endpoint: ${{ vars.ARTEFACT_BUCKET_ENDPONT }} {
branch-name: ${{ github.ref_name }} echo '## Coverage'
repository-name: ${{ github.repository }} echo
summary-file: ${{ env.SUMMARY_FILE }} 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 - name: Run behavior suite on main pushes
if: ${{ github.ref == 'refs/heads/main' }} if: ${{ github.ref == 'refs/heads/main' }}

View File

@@ -89,5 +89,13 @@ jobs:
ln -s CHANGELOG.md changelog.md ln -s CHANGELOG.md changelog.md
fi fi
- name: Vociferate publish - name: Install jq
uses: https://git.hrafn.xyz/aether/vociferate/publish@v1.0.1 run: |
set -euo pipefail
apt-get update
apt-get install -y jq
- name: Create or update release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: bash ./script/publish-release.sh

View File

@@ -13,10 +13,38 @@ A `### Breaking` section is used in addition to Keep a Changelog's standard sect
### Added ### 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 ### 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 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 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. - 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. - 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. - 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.

View File

@@ -1,6 +1,6 @@
# homesick # gosick
[![Main Validation](https://git.hrafn.xyz/aether/gosick/actions/workflows/push-validation.yml/badge.svg?branch=main&event=push)](https://git.hrafn.xyz/aether/gosick/actions/workflows/push-validation.yml) [![Main Validation](https://git.hrafn.xyz/aether/gosick/actions/workflows/push-validation.yml/badge.svg?branch=main&event=push)](https://git.hrafn.xyz/aether/gosick/actions/runs/latest?workflow=push-validation.yml&branch=main&event=push)
[![Coverage](https://s3.hrafn.xyz/aether-workflow-report-artefacts/gosick/branch/main/coverage-badge.svg)](https://s3.hrafn.xyz/aether-workflow-report-artefacts/gosick/branch/main/coverage.html) [![Coverage](https://s3.hrafn.xyz/aether-workflow-report-artefacts/gosick/branch/main/coverage-badge.svg)](https://s3.hrafn.xyz/aether-workflow-report-artefacts/gosick/branch/main/coverage.html)
Your home directory is your castle. Don't leave your dotfiles behind. Your home directory is your castle. Don't leave your dotfiles behind.

View File

@@ -8,10 +8,11 @@ import (
) )
func main() { func main() {
exitCode := 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) os.Exit(exitCode)
} }
func run(args []string, stdout io.Writer, stderr io.Writer) int { func run(args []string, stdin io.Reader, stdout io.Writer, stderr io.Writer) int {
return cli.Run(args, stdout, stderr) return cli.Run(args, stdin, stdout, stderr)
} }

View File

@@ -13,7 +13,7 @@ func TestRunVersionCommand(t *testing.T) {
stdout := &bytes.Buffer{} stdout := &bytes.Buffer{}
stderr := &bytes.Buffer{} stderr := &bytes.Buffer{}
exitCode := run([]string{"version"}, stdout, stderr) exitCode := run([]string{"version"}, bytes.NewBuffer(nil), stdout, stderr)
if exitCode != 0 { if exitCode != 0 {
t.Fatalf("run(version) exit code = %d, want 0", exitCode) t.Fatalf("run(version) exit code = %d, want 0", exitCode)
} }

View File

@@ -13,10 +13,10 @@ import (
"github.com/alecthomas/kong" "github.com/alecthomas/kong"
) )
func Run(args []string, stdout io.Writer, stderr io.Writer) int { func Run(args []string, stdin io.Reader, stdout io.Writer, stderr io.Writer) int {
model := &cliModel{} model := &cliModel{}
app, err := core.New(stdout, stderr) app, err := core.NewApp(stdin, stdout, stderr)
if err != nil { if err != nil {
_, _ = fmt.Fprintf(stderr, "error: %v\n", err) _, _ = fmt.Fprintf(stderr, "error: %v\n", err)
return 1 return 1
@@ -227,11 +227,7 @@ func (c *openCmd) Run(app *core.App) error { return app.Open(defaultCastle(c.
func (c *execCmd) Run(app *core.App) error { return app.Exec(c.Castle, c.Command) } 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 *execAllCmd) Run(app *core.App) error { return app.ExecAll(c.Command) }
func (c *rcCmd) Run(app *core.App) error { func (c *rcCmd) Run(app *core.App) error {
originalForce := app.Force return app.Rc(defaultCastle(c.Castle), c.Force)
app.Force = c.Force
err := app.Rc(defaultCastle(c.Castle))
app.Force = originalForce
return err
} }
func (c *generateCmd) Run(app *core.App) error { return app.Generate(c.Path) } func (c *generateCmd) Run(app *core.App) error { return app.Generate(c.Path) }
@@ -329,11 +325,3 @@ type cliExitError struct {
func (e *cliExitError) Error() string { func (e *cliExitError) Error() string {
return e.err.Error() 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")
}

View File

@@ -5,9 +5,13 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"testing" "testing"
"time"
"git.hrafn.xyz/aether/gosick/internal/homesick/cli" "git.hrafn.xyz/aether/gosick/internal/homesick/cli"
"git.hrafn.xyz/aether/gosick/internal/homesick/version" "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/require"
"github.com/stretchr/testify/suite" "github.com/stretchr/testify/suite"
) )
@@ -32,12 +36,38 @@ func (s *CLISuite) SetupTest() {
s.stderr = &bytes.Buffer{} s.stderr = &bytes.Buffer{}
} }
func (s *CLISuite) createCastleRepo(castle string) string {
castleRoot := filepath.Join(s.homeDir, ".homesick", "repos", castle)
repo, err := git.PlainInit(castleRoot, false)
require.NoError(s.T(), err)
filePath := filepath.Join(castleRoot, "home", ".vimrc")
require.NoError(s.T(), os.MkdirAll(filepath.Dir(filePath), 0o755))
require.NoError(s.T(), os.WriteFile(filePath, []byte("set number\n"), 0o644))
wt, err := repo.Worktree()
require.NoError(s.T(), err)
_, err = wt.Add("home/.vimrc")
require.NoError(s.T(), err)
_, err = wt.Commit("initial", &git.CommitOptions{Author: &object.Signature{
Name: "Behavior Test",
Email: "behavior@test.local",
When: time.Now(),
}})
require.NoError(s.T(), err)
_, err = repo.CreateRemote(&config.RemoteConfig{Name: "origin", URLs: []string{"git://example.com/test.git"}})
require.NoError(s.T(), err)
return castleRoot
}
func (s *CLISuite) TestRun_VersionAliases() { func (s *CLISuite) TestRun_VersionAliases() {
for _, args := range [][]string{{"-v"}, {"--version"}, {"version"}} { for _, args := range [][]string{{"-v"}, {"--version"}, {"version"}} {
s.stdout.Reset() s.stdout.Reset()
s.stderr.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(), 0, exitCode)
require.Equal(s.T(), version.String+"\n", s.stdout.String()) require.Equal(s.T(), version.String+"\n", s.stdout.String())
require.Empty(s.T(), s.stderr.String()) require.Empty(s.T(), s.stderr.String())
@@ -45,7 +75,7 @@ func (s *CLISuite) TestRun_VersionAliases() {
} }
func (s *CLISuite) TestRun_ShowPath_DefaultCastle() { 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(), 0, exitCode)
require.Equal(s.T(), filepath.Join(s.homeDir, ".homesick", "repos", "dotfiles")+"\n", s.stdout.String()) require.Equal(s.T(), filepath.Join(s.homeDir, ".homesick", "repos", "dotfiles")+"\n", s.stdout.String())
@@ -53,7 +83,7 @@ func (s *CLISuite) TestRun_ShowPath_DefaultCastle() {
} }
func (s *CLISuite) TestRun_Cd_DefaultCastle() { func (s *CLISuite) TestRun_Cd_DefaultCastle() {
exitCode := cli.Run([]string{"cd"}, s.stdout, s.stderr) exitCode := cli.Run([]string{"cd"}, bytes.NewBuffer(nil), s.stdout, s.stderr)
require.Equal(s.T(), 0, exitCode) require.Equal(s.T(), 0, exitCode)
require.Equal(s.T(), filepath.Join(s.homeDir, ".homesick", "repos", "dotfiles")+"\n", s.stdout.String()) require.Equal(s.T(), filepath.Join(s.homeDir, ".homesick", "repos", "dotfiles")+"\n", s.stdout.String())
@@ -61,7 +91,7 @@ func (s *CLISuite) TestRun_Cd_DefaultCastle() {
} }
func (s *CLISuite) TestRun_Cd_ExplicitCastle() { func (s *CLISuite) TestRun_Cd_ExplicitCastle() {
exitCode := cli.Run([]string{"cd", "work"}, s.stdout, s.stderr) exitCode := cli.Run([]string{"cd", "work"}, bytes.NewBuffer(nil), s.stdout, s.stderr)
require.Equal(s.T(), 0, exitCode) require.Equal(s.T(), 0, exitCode)
require.Equal(s.T(), filepath.Join(s.homeDir, ".homesick", "repos", "work")+"\n", s.stdout.String()) require.Equal(s.T(), filepath.Join(s.homeDir, ".homesick", "repos", "work")+"\n", s.stdout.String())
@@ -72,7 +102,7 @@ func (s *CLISuite) TestRun_Exec_RunsCommandInCastleRoot() {
castleRoot := filepath.Join(s.homeDir, ".homesick", "repos", "dotfiles") castleRoot := filepath.Join(s.homeDir, ".homesick", "repos", "dotfiles")
require.NoError(s.T(), os.MkdirAll(castleRoot, 0o755)) require.NoError(s.T(), os.MkdirAll(castleRoot, 0o755))
exitCode := cli.Run([]string{"exec", "dotfiles", "pwd"}, s.stdout, s.stderr) exitCode := cli.Run([]string{"exec", "dotfiles", "pwd"}, bytes.NewBuffer(nil), s.stdout, s.stderr)
require.Equal(s.T(), 0, exitCode) require.Equal(s.T(), 0, exitCode)
require.Contains(s.T(), s.stdout.String(), castleRoot) require.Contains(s.T(), s.stdout.String(), castleRoot)
@@ -84,7 +114,7 @@ func (s *CLISuite) TestRun_Exec_WithPretend_DoesNotExecute() {
require.NoError(s.T(), os.MkdirAll(castleRoot, 0o755)) require.NoError(s.T(), os.MkdirAll(castleRoot, 0o755))
target := filepath.Join(castleRoot, "should-not-exist") target := filepath.Join(castleRoot, "should-not-exist")
exitCode := cli.Run([]string{"--pretend", "exec", "dotfiles", "touch should-not-exist"}, s.stdout, s.stderr) 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.Equal(s.T(), 0, exitCode)
require.NoFileExists(s.T(), target) require.NoFileExists(s.T(), target)
@@ -97,7 +127,7 @@ func (s *CLISuite) TestRun_Exec_WithDryRunAlias_DoesNotExecute() {
require.NoError(s.T(), os.MkdirAll(castleRoot, 0o755)) require.NoError(s.T(), os.MkdirAll(castleRoot, 0o755))
target := filepath.Join(castleRoot, "should-not-exist") target := filepath.Join(castleRoot, "should-not-exist")
exitCode := cli.Run([]string{"--dry-run", "exec", "dotfiles", "touch should-not-exist"}, s.stdout, s.stderr) 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.Equal(s.T(), 0, exitCode)
require.NoFileExists(s.T(), target) require.NoFileExists(s.T(), target)
@@ -109,7 +139,7 @@ func (s *CLISuite) TestRun_Exec_WithQuiet_SuppressesStatusOutput() {
castleRoot := filepath.Join(s.homeDir, ".homesick", "repos", "dotfiles") castleRoot := filepath.Join(s.homeDir, ".homesick", "repos", "dotfiles")
require.NoError(s.T(), os.MkdirAll(castleRoot, 0o755)) require.NoError(s.T(), os.MkdirAll(castleRoot, 0o755))
exitCode := cli.Run([]string{"--quiet", "exec", "dotfiles", "true"}, s.stdout, s.stderr) exitCode := cli.Run([]string{"--quiet", "exec", "dotfiles", "true"}, bytes.NewBuffer(nil), s.stdout, s.stderr)
require.Equal(s.T(), 0, exitCode) require.Equal(s.T(), 0, exitCode)
require.Empty(s.T(), s.stdout.String()) require.Empty(s.T(), s.stdout.String())
@@ -117,7 +147,7 @@ func (s *CLISuite) TestRun_Exec_WithQuiet_SuppressesStatusOutput() {
} }
func (s *CLISuite) TestRun_PullAll_NoCastlesIsNoop() { func (s *CLISuite) TestRun_PullAll_NoCastlesIsNoop() {
exitCode := cli.Run([]string{"pull", "--all"}, s.stdout, s.stderr) exitCode := cli.Run([]string{"pull", "--all"}, bytes.NewBuffer(nil), s.stdout, s.stderr)
require.Equal(s.T(), 0, exitCode) require.Equal(s.T(), 0, exitCode)
require.Empty(s.T(), s.stderr.String()) require.Empty(s.T(), s.stderr.String())
@@ -128,7 +158,7 @@ func (s *CLISuite) TestRun_Rc_HomesickrcRequiresForce() {
require.NoError(s.T(), os.MkdirAll(castleRoot, 0o755)) require.NoError(s.T(), os.MkdirAll(castleRoot, 0o755))
require.NoError(s.T(), os.WriteFile(filepath.Join(castleRoot, ".homesickrc"), []byte("# ruby\n"), 0o644)) require.NoError(s.T(), os.WriteFile(filepath.Join(castleRoot, ".homesickrc"), []byte("# ruby\n"), 0o644))
exitCode := cli.Run([]string{"rc", "dotfiles"}, s.stdout, s.stderr) exitCode := cli.Run([]string{"rc", "dotfiles"}, bytes.NewBuffer(nil), s.stdout, s.stderr)
require.NotEqual(s.T(), 0, exitCode) require.NotEqual(s.T(), 0, exitCode)
require.Contains(s.T(), s.stderr.String(), "--force") require.Contains(s.T(), s.stderr.String(), "--force")
@@ -139,14 +169,14 @@ func (s *CLISuite) TestRun_Rc_WithForceRuns() {
require.NoError(s.T(), os.MkdirAll(castleRoot, 0o755)) require.NoError(s.T(), os.MkdirAll(castleRoot, 0o755))
require.NoError(s.T(), os.WriteFile(filepath.Join(castleRoot, ".homesickrc"), []byte("# ruby\n"), 0o644)) require.NoError(s.T(), os.WriteFile(filepath.Join(castleRoot, ".homesickrc"), []byte("# ruby\n"), 0o644))
exitCode := cli.Run([]string{"rc", "--force", "dotfiles"}, s.stdout, s.stderr) exitCode := cli.Run([]string{"rc", "--force", "dotfiles"}, bytes.NewBuffer(nil), s.stdout, s.stderr)
require.Equal(s.T(), 0, exitCode) require.Equal(s.T(), 0, exitCode)
require.Empty(s.T(), s.stderr.String()) require.Empty(s.T(), s.stderr.String())
} }
func (s *CLISuite) TestRun_CloneSubcommandHelp() { 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.Equal(s.T(), 0, exitCode)
require.Contains(s.T(), s.stdout.String(), "clone") require.Contains(s.T(), s.stdout.String(), "clone")
@@ -159,7 +189,7 @@ func (s *CLISuite) TestRun_Help_UsesProgramNameAndDescription() {
s.T().Cleanup(func() { os.Args = originalArgs }) s.T().Cleanup(func() { os.Args = originalArgs })
os.Args = []string{"gosick"} 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.Equal(s.T(), 0, exitCode)
require.Contains(s.T(), s.stdout.String(), "Usage: gosick") require.Contains(s.T(), s.stdout.String(), "Usage: gosick")
@@ -174,7 +204,7 @@ func (s *CLISuite) TestRun_SymlinkAlias_MatchesLinkCommand() {
require.NoError(s.T(), os.MkdirAll(castleHome, 0o755)) require.NoError(s.T(), os.MkdirAll(castleHome, 0o755))
require.NoError(s.T(), os.WriteFile(filepath.Join(castleHome, ".vimrc"), []byte("set number\n"), 0o644)) require.NoError(s.T(), os.WriteFile(filepath.Join(castleHome, ".vimrc"), []byte("set number\n"), 0o644))
exitCode := cli.Run([]string{"symlink", "dotfiles"}, s.stdout, s.stderr) exitCode := cli.Run([]string{"symlink", "dotfiles"}, bytes.NewBuffer(nil), s.stdout, s.stderr)
require.Equal(s.T(), 0, exitCode) require.Equal(s.T(), 0, exitCode)
target := filepath.Join(s.homeDir, ".vimrc") target := filepath.Join(s.homeDir, ".vimrc")
@@ -185,9 +215,58 @@ func (s *CLISuite) TestRun_SymlinkAlias_MatchesLinkCommand() {
} }
func (s *CLISuite) TestRun_Commit_PositionalMessageCompatibility() { func (s *CLISuite) TestRun_Commit_PositionalMessageCompatibility() {
exitCode := cli.Run([]string{"--pretend", "commit", "dotfiles", "behavior-suite-commit"}, s.stdout, s.stderr) exitCode := cli.Run([]string{"--pretend", "commit", "dotfiles", "behavior-suite-commit"}, bytes.NewBuffer(nil), s.stdout, s.stderr)
require.Equal(s.T(), 0, exitCode) require.Equal(s.T(), 0, exitCode)
require.Contains(s.T(), s.stdout.String(), "git commit -m behavior-suite-commit") require.Contains(s.T(), s.stdout.String(), "git commit -m behavior-suite-commit")
require.Empty(s.T(), s.stderr.String()) require.Empty(s.T(), s.stderr.String())
} }
func (s *CLISuite) TestRun_List_NoArguments() {
s.createCastleRepo("dotfiles")
exitCode := cli.Run([]string{"list"}, bytes.NewBuffer(nil), s.stdout, s.stderr)
require.Equal(s.T(), 0, exitCode)
require.Contains(s.T(), s.stdout.String(), "dotfiles")
require.Empty(s.T(), s.stderr.String())
}
func (s *CLISuite) TestRun_Generate_CreatesNewCastle() {
castlePath := filepath.Join(s.T().TempDir(), "my-castle")
exitCode := cli.Run([]string{"generate", castlePath}, bytes.NewBuffer(nil), s.stdout, s.stderr)
require.Equal(s.T(), 0, exitCode)
require.DirExists(s.T(), filepath.Join(castlePath, ".git"))
require.DirExists(s.T(), filepath.Join(castlePath, "home"))
}
func (s *CLISuite) TestRun_Clone_WithoutArgs() {
exitCode := cli.Run([]string{"clone"}, bytes.NewBuffer(nil), s.stdout, s.stderr)
// Clone requires arguments, should fail
require.NotEqual(s.T(), 0, exitCode)
}
func (s *CLISuite) TestRun_Status_DefaultCastle() {
castleRoot := s.createCastleRepo("dotfiles")
require.NoError(s.T(), os.WriteFile(filepath.Join(castleRoot, "home", ".vimrc"), []byte("changed\n"), 0o644))
exitCode := cli.Run([]string{"status"}, bytes.NewBuffer(nil), s.stdout, s.stderr)
require.Equal(s.T(), 0, exitCode)
require.Contains(s.T(), s.stdout.String(), "modified:")
require.Empty(s.T(), s.stderr.String())
}
func (s *CLISuite) TestRun_Diff_DefaultCastle() {
castleRoot := s.createCastleRepo("dotfiles")
require.NoError(s.T(), os.WriteFile(filepath.Join(castleRoot, "home", ".vimrc"), []byte("changed\n"), 0o644))
exitCode := cli.Run([]string{"diff"}, bytes.NewBuffer(nil), s.stdout, s.stderr)
require.Equal(s.T(), 0, exitCode)
require.Contains(s.T(), s.stdout.String(), "diff --git")
require.Empty(s.T(), s.stderr.String())
}

View File

@@ -21,13 +21,15 @@ type App struct {
Stdin io.Reader Stdin io.Reader
Stdout io.Writer Stdout io.Writer
Stderr io.Writer Stderr io.Writer
Verbose bool
Force bool Force bool
Quiet bool Quiet bool
Pretend 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 { if stdout == nil {
return nil, errors.New("stdout writer cannot be nil") return nil, errors.New("stdout writer cannot be nil")
} }
@@ -43,7 +45,7 @@ func New(stdout io.Writer, stderr io.Writer) (*App, error) {
return &App{ return &App{
HomeDir: home, HomeDir: home,
ReposDir: filepath.Join(home, ".homesick", "repos"), ReposDir: filepath.Join(home, ".homesick", "repos"),
Stdin: os.Stdin, Stdin: stdin,
Stdout: stdout, Stdout: stdout,
Stderr: stderr, Stderr: stderr,
}, nil }, nil
@@ -97,7 +99,7 @@ func (a *App) Clone(uri string, destination string) error {
func (a *App) List() error { func (a *App) List() error {
if err := os.MkdirAll(a.ReposDir, 0o750); err != nil { if err := os.MkdirAll(a.ReposDir, 0o750); err != nil {
return err return fmt.Errorf("ensure repos directory: %w", err)
} }
var castles []string var castles []string
@@ -112,13 +114,13 @@ func (a *App) List() error {
castleRoot := filepath.Dir(path) castleRoot := filepath.Dir(path)
rel, err := filepath.Rel(a.ReposDir, castleRoot) rel, err := filepath.Rel(a.ReposDir, castleRoot)
if err != nil { if err != nil {
return err return fmt.Errorf("resolve castle path %q: %w", castleRoot, err)
} }
castles = append(castles, rel) castles = append(castles, rel)
return filepath.SkipDir return filepath.SkipDir
}) })
if err != nil { if err != nil {
return err return fmt.Errorf("scan repos directory: %w", err)
} }
sort.Strings(castles) sort.Strings(castles)
@@ -130,7 +132,7 @@ func (a *App) List() error {
} }
_, writeErr := fmt.Fprintf(a.Stdout, "%s %s\n", castle, strings.TrimSpace(remote)) _, writeErr := fmt.Fprintf(a.Stdout, "%s %s\n", castle, strings.TrimSpace(remote))
if writeErr != nil { if writeErr != nil {
return writeErr return fmt.Errorf("write castle listing: %w", writeErr)
} }
} }
@@ -232,13 +234,13 @@ func (a *App) Destroy(castle string) error {
if errors.Is(err, os.ErrNotExist) { if errors.Is(err, os.ErrNotExist) {
return fmt.Errorf("castle %q not found", castle) return fmt.Errorf("castle %q not found", castle)
} }
return err return fmt.Errorf("stat castle %q: %w", castle, err)
} }
if !a.Force { if !a.Force {
confirmed, confirmErr := a.confirmDestroy(castle) confirmed, confirmErr := a.confirmDestroy(castle)
if confirmErr != nil { if confirmErr != nil {
return confirmErr return fmt.Errorf("confirm destroy for %q: %w", castle, confirmErr)
} }
if !confirmed { if !confirmed {
return nil return nil
@@ -250,7 +252,7 @@ func (a *App) Destroy(castle string) error {
castleHome := filepath.Join(castleRoot, "home") castleHome := filepath.Join(castleRoot, "home")
if info, statErr := os.Stat(castleHome); statErr == nil && info.IsDir() { if info, statErr := os.Stat(castleHome); statErr == nil && info.IsDir() {
if unlinkErr := a.UnlinkCastle(castle); unlinkErr != nil { if unlinkErr := a.UnlinkCastle(castle); unlinkErr != nil {
return unlinkErr return fmt.Errorf("unlink castle %q before destroy: %w", castle, unlinkErr)
} }
} }
} }
@@ -265,12 +267,12 @@ func (a *App) confirmDestroy(castle string) (bool, error) {
} }
if _, err := fmt.Fprintf(a.Stdout, "Destroy castle %q? [y/N]: ", castle); err != nil { if _, err := fmt.Fprintf(a.Stdout, "Destroy castle %q? [y/N]: ", castle); err != nil {
return false, err return false, fmt.Errorf("write destroy prompt: %w", err)
} }
line, err := bufio.NewReader(reader).ReadString('\n') line, err := bufio.NewReader(reader).ReadString('\n')
if err != nil && !errors.Is(err, io.EOF) { if err != nil && !errors.Is(err, io.EOF) {
return false, err return false, fmt.Errorf("read destroy confirmation: %w", err)
} }
return isAffirmativeResponse(line), nil return isAffirmativeResponse(line), nil
@@ -297,7 +299,8 @@ func (a *App) Open(castle string) error {
} }
castleRoot := filepath.Join(a.ReposDir, castle) castleRoot := filepath.Join(a.ReposDir, castle)
cmd := exec.Command(editor, ".") // #nosec G204 EDITOR environment variable is user-set // #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.Dir = castleRoot
cmd.Stdout = a.Stdout cmd.Stdout = a.Stdout
cmd.Stderr = a.Stderr cmd.Stderr = a.Stderr
@@ -390,15 +393,15 @@ func (a *App) Generate(castlePath string) error {
absCastle, err := filepath.Abs(trimmed) absCastle, err := filepath.Abs(trimmed)
if err != nil { if err != nil {
return err return fmt.Errorf("resolve castle path %q: %w", trimmed, err)
} }
if err := os.MkdirAll(absCastle, 0o750); err != nil { if err := os.MkdirAll(absCastle, 0o750); err != nil {
return err return fmt.Errorf("create castle path %q: %w", absCastle, err)
} }
if err := a.runGit(absCastle, "init"); err != nil { if err := a.runGit(absCastle, "init"); err != nil {
return err return fmt.Errorf("initialize git repository %q: %w", absCastle, err)
} }
githubUser := "" githubUser := ""
@@ -410,11 +413,15 @@ func (a *App) Generate(castlePath string) error {
repoName := filepath.Base(absCastle) repoName := filepath.Base(absCastle)
url := fmt.Sprintf("git@github.com:%s/%s.git", githubUser, repoName) url := fmt.Sprintf("git@github.com:%s/%s.git", githubUser, repoName)
if err := a.runGit(absCastle, "remote", "add", "origin", url); err != nil { if err := a.runGit(absCastle, "remote", "add", "origin", url); err != nil {
return err return fmt.Errorf("add origin remote for %q: %w", absCastle, err)
} }
} }
return os.MkdirAll(filepath.Join(absCastle, "home"), 0o750) 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 { func (a *App) Link(castle string) error {
@@ -433,11 +440,11 @@ func (a *App) LinkCastle(castle string) error {
subdirs, err := readSubdirs(filepath.Join(a.ReposDir, castle, ".homesick_subdir")) subdirs, err := readSubdirs(filepath.Join(a.ReposDir, castle, ".homesick_subdir"))
if err != nil { 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 { if err := a.linkEach(castleHome, castleHome, subdirs); err != nil {
return err return fmt.Errorf("link castle %q: %w", castle, err)
} }
for _, subdir := range subdirs { for _, subdir := range subdirs {
@@ -446,11 +453,11 @@ func (a *App) LinkCastle(castle string) error {
if errors.Is(err, os.ErrNotExist) { if errors.Is(err, os.ErrNotExist) {
continue 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 { if err := a.linkEach(castleHome, base, subdirs); err != nil {
return err return fmt.Errorf("link subdir %q for castle %q: %w", subdir, castle, err)
} }
} }
@@ -473,11 +480,11 @@ func (a *App) UnlinkCastle(castle string) error {
subdirs, err := readSubdirs(filepath.Join(a.ReposDir, castle, ".homesick_subdir")) subdirs, err := readSubdirs(filepath.Join(a.ReposDir, castle, ".homesick_subdir"))
if err != nil { 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 { if err := a.unlinkEach(castleHome, castleHome, subdirs); err != nil {
return err return fmt.Errorf("unlink castle %q: %w", castle, err)
} }
for _, subdir := range subdirs { for _, subdir := range subdirs {
@@ -486,11 +493,11 @@ func (a *App) UnlinkCastle(castle string) error {
if errors.Is(err, os.ErrNotExist) { if errors.Is(err, os.ErrNotExist) {
continue 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 { if err := a.unlinkEach(castleHome, base, subdirs); err != nil {
return err return fmt.Errorf("unlink subdir %q for castle %q: %w", subdir, castle, err)
} }
} }
@@ -520,15 +527,15 @@ func (a *App) TrackPath(filePath string, castle string) error {
absolutePath, err := filepath.Abs(strings.TrimRight(trimmedFile, string(filepath.Separator))) absolutePath, err := filepath.Abs(strings.TrimRight(trimmedFile, string(filepath.Separator)))
if err != nil { if err != nil {
return err return fmt.Errorf("resolve tracked file %q: %w", trimmedFile, err)
} }
if _, err := os.Lstat(absolutePath); err != nil { 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)) relativeDir, err := filepath.Rel(a.HomeDir, filepath.Dir(absolutePath))
if err != nil { 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)) { if relativeDir == ".." || strings.HasPrefix(relativeDir, ".."+string(filepath.Separator)) {
return fmt.Errorf("track requires file under %s", a.HomeDir) return fmt.Errorf("track requires file under %s", a.HomeDir)
@@ -539,18 +546,18 @@ func (a *App) TrackPath(filePath string, castle string) error {
castleTargetDir = castleHome castleTargetDir = castleHome
} }
if err := os.MkdirAll(castleTargetDir, 0o750); err != nil { if err := os.MkdirAll(castleTargetDir, 0o750); err != nil {
return err return fmt.Errorf("create tracked file directory %q: %w", castleTargetDir, err)
} }
trackedPath := filepath.Join(castleTargetDir, filepath.Base(absolutePath)) trackedPath := filepath.Join(castleTargetDir, filepath.Base(absolutePath))
if _, err := os.Lstat(trackedPath); err == nil { if _, err := os.Lstat(trackedPath); err == nil {
return fmt.Errorf("%s already exists", trackedPath) return fmt.Errorf("%s already exists", trackedPath)
} else if !errors.Is(err, os.ErrNotExist) { } 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 { if err := os.Rename(absolutePath, trackedPath); err != nil {
return err return fmt.Errorf("move tracked file into castle %q: %w", trackedPath, err)
} }
subdirChanged := false subdirChanged := false
@@ -558,21 +565,21 @@ func (a *App) TrackPath(filePath string, castle string) error {
subdirPath := filepath.Join(castleRoot, ".homesick_subdir") subdirPath := filepath.Join(castleRoot, ".homesick_subdir")
subdirChanged, err = appendUniqueSubdir(subdirPath, relativeDir) subdirChanged, err = appendUniqueSubdir(subdirPath, relativeDir)
if err != nil { if err != nil {
return err return fmt.Errorf("record tracked subdir %q: %w", relativeDir, err)
} }
} }
if err := a.linkPath(trackedPath, absolutePath); err != nil { 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) repo, err := git.PlainOpen(castleRoot)
if err != nil { if err != nil {
return err return fmt.Errorf("open git repository for castle %q: %w", castle, err)
} }
worktree, err := repo.Worktree() worktree, err := repo.Worktree()
if err != nil { if err != nil {
return err return fmt.Errorf("open worktree for castle %q: %w", castle, err)
} }
trackedRelativePath := filepath.Join("home", relativeDir, filepath.Base(absolutePath)) trackedRelativePath := filepath.Join("home", relativeDir, filepath.Base(absolutePath))
@@ -580,12 +587,12 @@ func (a *App) TrackPath(filePath string, castle string) error {
trackedRelativePath = filepath.Join("home", filepath.Base(absolutePath)) trackedRelativePath = filepath.Join("home", filepath.Base(absolutePath))
} }
if _, err := worktree.Add(filepath.ToSlash(filepath.Clean(trackedRelativePath))); err != nil { 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 subdirChanged {
if _, err := worktree.Add(".homesick_subdir"); err != nil { if _, err := worktree.Add(".homesick_subdir"); err != nil {
return err return fmt.Errorf("stage subdir metadata: %w", err)
} }
} }
@@ -595,7 +602,7 @@ func (a *App) TrackPath(filePath string, castle string) error {
func appendUniqueSubdir(path string, subdir string) (bool, error) { func appendUniqueSubdir(path string, subdir string) (bool, error) {
existing, err := readSubdirs(path) existing, err := readSubdirs(path)
if err != nil { if err != nil {
return false, err return false, fmt.Errorf("load subdir metadata %q: %w", path, err)
} }
cleanSubdir := filepath.Clean(subdir) cleanSubdir := filepath.Clean(subdir)
@@ -607,12 +614,12 @@ func appendUniqueSubdir(path string, subdir string) (bool, error) {
file, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o600) // #nosec G304 — internal metadata file file, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o600) // #nosec G304 — internal metadata file
if err != nil { if err != nil {
return false, err return false, fmt.Errorf("open subdir metadata %q: %w", path, err)
} }
defer file.Close() defer file.Close()
if _, err := file.WriteString(cleanSubdir + "\n"); err != nil { 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 return true, nil
@@ -621,7 +628,7 @@ func appendUniqueSubdir(path string, subdir string) (bool, error) {
func (a *App) linkEach(castleHome string, baseDir string, subdirs []string) error { func (a *App) linkEach(castleHome string, baseDir string, subdirs []string) error {
entries, err := os.ReadDir(baseDir) entries, err := os.ReadDir(baseDir)
if err != nil { if err != nil {
return err return fmt.Errorf("read castle directory %q: %w", baseDir, err)
} }
for _, entry := range entries { for _, entry := range entries {
@@ -633,7 +640,7 @@ func (a *App) linkEach(castleHome string, baseDir string, subdirs []string) erro
source := filepath.Join(baseDir, name) source := filepath.Join(baseDir, name)
ignore, err := matchesIgnoredDir(castleHome, source, subdirs) ignore, err := matchesIgnoredDir(castleHome, source, subdirs)
if err != nil { if err != nil {
return err return fmt.Errorf("check ignored directory %q: %w", source, err)
} }
if ignore { if ignore {
continue continue
@@ -641,7 +648,7 @@ func (a *App) linkEach(castleHome string, baseDir string, subdirs []string) erro
relDir, err := filepath.Rel(castleHome, baseDir) relDir, err := filepath.Rel(castleHome, baseDir)
if err != nil { if err != nil {
return err return fmt.Errorf("resolve castle relative directory %q: %w", baseDir, err)
} }
destination := filepath.Join(a.HomeDir, relDir, name) destination := filepath.Join(a.HomeDir, relDir, name)
@@ -650,7 +657,7 @@ func (a *App) linkEach(castleHome string, baseDir string, subdirs []string) erro
} }
if err := a.linkPath(source, destination); err != nil { if err := a.linkPath(source, destination); err != nil {
return err return fmt.Errorf("link %q to %q: %w", source, destination, err)
} }
} }
@@ -660,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 { func (a *App) unlinkEach(castleHome string, baseDir string, subdirs []string) error {
entries, err := os.ReadDir(baseDir) entries, err := os.ReadDir(baseDir)
if err != nil { if err != nil {
return err return fmt.Errorf("read castle directory %q: %w", baseDir, err)
} }
for _, entry := range entries { for _, entry := range entries {
@@ -672,7 +679,7 @@ func (a *App) unlinkEach(castleHome string, baseDir string, subdirs []string) er
source := filepath.Join(baseDir, name) source := filepath.Join(baseDir, name)
ignore, err := matchesIgnoredDir(castleHome, source, subdirs) ignore, err := matchesIgnoredDir(castleHome, source, subdirs)
if err != nil { if err != nil {
return err return fmt.Errorf("check ignored directory %q: %w", source, err)
} }
if ignore { if ignore {
continue continue
@@ -680,7 +687,7 @@ func (a *App) unlinkEach(castleHome string, baseDir string, subdirs []string) er
relDir, err := filepath.Rel(castleHome, baseDir) relDir, err := filepath.Rel(castleHome, baseDir)
if err != nil { if err != nil {
return err return fmt.Errorf("resolve castle relative directory %q: %w", baseDir, err)
} }
destination := filepath.Join(a.HomeDir, relDir, name) destination := filepath.Join(a.HomeDir, relDir, name)
@@ -689,7 +696,7 @@ func (a *App) unlinkEach(castleHome string, baseDir string, subdirs []string) er
} }
if err := unlinkPath(destination); err != nil { if err := unlinkPath(destination); err != nil {
return err return fmt.Errorf("unlink %q: %w", destination, err)
} }
} }
@@ -715,11 +722,11 @@ func unlinkPath(destination string) error {
func (a *App) linkPath(source string, destination string) error { func (a *App) linkPath(source string, destination string) error {
absSource, err := filepath.Abs(source) absSource, err := filepath.Abs(source)
if err != nil { if err != nil {
return err return fmt.Errorf("resolve link source %q: %w", source, err)
} }
if err := os.MkdirAll(filepath.Dir(destination), 0o750); err != nil { if err := os.MkdirAll(filepath.Dir(destination), 0o750); err != nil {
return err return fmt.Errorf("create destination parent %q: %w", filepath.Dir(destination), err)
} }
info, err := os.Lstat(destination) info, err := os.Lstat(destination)
@@ -736,14 +743,14 @@ func (a *App) linkPath(source string, destination string) error {
} }
if rmErr := os.RemoveAll(destination); rmErr != nil { 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) { } 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 { if err := os.Symlink(absSource, destination); err != nil {
return err return fmt.Errorf("create symlink %q -> %q: %w", destination, absSource, err)
} }
return nil return nil
@@ -755,7 +762,7 @@ func readSubdirs(path string) ([]string, error) {
if errors.Is(err, os.ErrNotExist) { if errors.Is(err, os.ErrNotExist) {
return []string{}, nil return []string{}, nil
} }
return nil, err return nil, fmt.Errorf("read subdirs %q: %w", path, err)
} }
lines := strings.Split(string(data), "\n") lines := strings.Split(string(data), "\n")
@@ -774,7 +781,7 @@ func readSubdirs(path string) ([]string, error) {
func matchesIgnoredDir(castleHome string, candidate string, subdirs []string) (bool, error) { func matchesIgnoredDir(castleHome string, candidate string, subdirs []string) (bool, error) {
absCandidate, err := filepath.Abs(candidate) absCandidate, err := filepath.Abs(candidate)
if err != nil { if err != nil {
return false, err return false, fmt.Errorf("resolve candidate path %q: %w", candidate, err)
} }
ignoreSet := map[string]struct{}{} ignoreSet := map[string]struct{}{}
@@ -795,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 { 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 := exec.Command("git", args...)
cmd.Dir = dir cmd.Dir = dir
cmd.Stdout = stdout cmd.Stdout = stdout
@@ -828,6 +836,7 @@ func (a *App) sayStatus(action string, message string) {
} }
func gitOutput(dir string, args ...string) (string, error) { 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 := exec.Command("git", args...)
cmd.Dir = dir cmd.Dir = dir
out, err := cmd.Output() out, err := cmd.Output()
@@ -845,19 +854,19 @@ func gitOutput(dir string, args ...string) (string, error) {
// If a .homesickrc file exists in the castle root and no parity.rb wrapper // 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 // already exists in .homesick.d, a Ruby wrapper script named parity.rb is
// written there before execution so that it sorts first. // written there before execution so that it sorts first.
func (a *App) Rc(castle string) error { func (a *App) Rc(castle string, force bool) error {
castleRoot := filepath.Join(a.ReposDir, castle) castleRoot := filepath.Join(a.ReposDir, castle)
if _, err := os.Stat(castleRoot); err != nil { if _, err := os.Stat(castleRoot); err != nil {
if errors.Is(err, os.ErrNotExist) { if errors.Is(err, os.ErrNotExist) {
return fmt.Errorf("castle %q not found", castle) return fmt.Errorf("castle %q not found", castle)
} }
return err return fmt.Errorf("stat castle %q: %w", castle, err)
} }
homesickD := filepath.Join(castleRoot, ".homesick.d") homesickD := filepath.Join(castleRoot, ".homesick.d")
homesickRc := filepath.Join(castleRoot, ".homesickrc") homesickRc := filepath.Join(castleRoot, ".homesickrc")
if _, err := os.Stat(homesickRc); err == nil && !a.Force { if _, err := os.Stat(homesickRc); err == nil && !force {
return errors.New("refusing to run legacy .homesickrc without --force") return errors.New("refusing to run legacy .homesickrc without --force")
} }
@@ -888,12 +897,12 @@ func (a *App) Rc(castle string) error {
if errors.Is(err, os.ErrNotExist) { if errors.Is(err, os.ErrNotExist) {
return nil return nil
} }
return err return fmt.Errorf("stat rc hooks directory %q: %w", homesickD, err)
} }
entries, err := os.ReadDir(homesickD) entries, err := os.ReadDir(homesickD)
if err != nil { if err != nil {
return err return fmt.Errorf("read rc hooks %q: %w", homesickD, err)
} }
// ReadDir returns entries in sorted order already. // ReadDir returns entries in sorted order already.
@@ -903,7 +912,7 @@ func (a *App) Rc(castle string) error {
} }
info, infoErr := entry.Info() info, infoErr := entry.Info()
if infoErr != nil { if infoErr != nil {
return infoErr return fmt.Errorf("read rc hook metadata %q: %w", entry.Name(), infoErr)
} }
if info.Mode()&0o111 == 0 { if info.Mode()&0o111 == 0 {
// Not executable — skip. // Not executable — skip.

View File

@@ -2,12 +2,23 @@ package core
import ( import (
"bytes" "bytes"
"path/filepath"
"testing" "testing"
) )
func TestNewRejectsNilWriters(t *testing.T) { 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) { t.Run("nil stdout", func(t *testing.T) {
app, err := New(nil, &bytes.Buffer{}) app, err := NewApp(new(bytes.Buffer), nil, &bytes.Buffer{})
if err == nil { if err == nil {
t.Fatal("expected error for nil stdout") t.Fatal("expected error for nil stdout")
} }
@@ -17,7 +28,7 @@ func TestNewRejectsNilWriters(t *testing.T) {
}) })
t.Run("nil stderr", func(t *testing.T) { t.Run("nil stderr", func(t *testing.T) {
app, err := New(&bytes.Buffer{}, nil) app, err := NewApp(new(bytes.Buffer), &bytes.Buffer{}, nil)
if err == nil { if err == nil {
t.Fatal("expected error for nil stderr") t.Fatal("expected error for nil stderr")
} }
@@ -47,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)
}
}

View File

@@ -88,3 +88,22 @@ func (s *ExecSuite) TestExec_PretendDoesNotExecuteCommand() {
require.NoFileExists(s.T(), target) require.NoFileExists(s.T(), target)
require.Contains(s.T(), s.stdout.String(), "Would execute") require.Contains(s.T(), s.stdout.String(), "Would execute")
} }
func (s *ExecSuite) TestExecAll_RequiresCommand() {
err := s.app.ExecAll(nil)
require.Error(s.T(), err)
require.Contains(s.T(), err.Error(), "exec_all requires COMMAND")
}
func (s *ExecSuite) TestExecAll_NoReposDirIsNoop() {
missingRepos := filepath.Join(s.T().TempDir(), "missing", "repos")
app := &core.App{
HomeDir: s.homeDir,
ReposDir: missingRepos,
Stdout: s.stdout,
Stderr: s.stderr,
}
err := app.ExecAll([]string{"echo hi"})
require.NoError(s.T(), err)
}

View File

@@ -67,3 +67,12 @@ func (s *GenerateSuite) TestGenerate_DoesNotAddOriginWhenGitHubUserMissing() {
require.NoError(s.T(), err) require.NoError(s.T(), err)
require.NotContains(s.T(), string(content), "[remote \"origin\"]") require.NotContains(s.T(), string(content), "[remote \"origin\"]")
} }
func (s *GenerateSuite) TestGenerate_WrapsCastlePathCreationError() {
blocker := filepath.Join(s.tmpDir, "blocker")
require.NoError(s.T(), os.WriteFile(blocker, []byte("x"), 0o644))
err := s.app.Generate(filepath.Join(blocker, "castle"))
require.Error(s.T(), err)
require.Contains(s.T(), err.Error(), "create castle path")
}

View File

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

View File

@@ -70,3 +70,13 @@ func (s *ListSuite) TestList_OutputsSortedCastlesWithRemoteURLs() {
s.stdout.String(), s.stdout.String(),
) )
} }
func (s *ListSuite) TestList_WrapsReposDirCreationError() {
blocker := filepath.Join(s.tmpDir, "repos-blocker")
require.NoError(s.T(), os.WriteFile(blocker, []byte("x"), 0o644))
s.app.ReposDir = filepath.Join(blocker, "repos")
err := s.app.List()
require.Error(s.T(), err)
require.Contains(s.T(), err.Error(), "ensure repos directory")
}

View File

@@ -140,3 +140,17 @@ func (s *PullSuite) TestPullAll_PrintsCastlePrefixes() {
require.Contains(s.T(), stdout.String(), "alpha:") require.Contains(s.T(), stdout.String(), "alpha:")
require.Contains(s.T(), stdout.String(), "zeta:") require.Contains(s.T(), stdout.String(), "zeta:")
} }
func (s *PullSuite) TestPullAll_QuietSuppressesCastlePrefixes() {
require.NoError(s.T(), os.MkdirAll(filepath.Join(s.reposDir, "alpha", ".git"), 0o755))
require.NoError(s.T(), os.MkdirAll(filepath.Join(s.reposDir, "zeta", ".git"), 0o755))
stdout := &bytes.Buffer{}
s.app.Stdout = stdout
s.app.Quiet = true
s.app.Pretend = true
require.NoError(s.T(), s.app.PullAll())
require.NotContains(s.T(), stdout.String(), "alpha:")
require.NotContains(s.T(), stdout.String(), "zeta:")
}

View File

@@ -53,7 +53,7 @@ var _ io.Writer
// TestRc_UnknownCastleReturnsError ensures Rc returns an error when the // TestRc_UnknownCastleReturnsError ensures Rc returns an error when the
// castle directory does not exist. // castle directory does not exist.
func (s *RcSuite) TestRc_UnknownCastleReturnsError() { func (s *RcSuite) TestRc_UnknownCastleReturnsError() {
err := s.app.Rc("nonexistent") err := s.app.Rc("nonexistent", false)
require.Error(s.T(), err) require.Error(s.T(), err)
} }
@@ -61,7 +61,7 @@ func (s *RcSuite) TestRc_UnknownCastleReturnsError() {
// .homesickrc are present. // .homesickrc are present.
func (s *RcSuite) TestRc_NoScriptsAndNoHomesickrcIsNoop() { func (s *RcSuite) TestRc_NoScriptsAndNoHomesickrcIsNoop() {
s.createCastle("dotfiles") s.createCastle("dotfiles")
require.NoError(s.T(), s.app.Rc("dotfiles")) require.NoError(s.T(), s.app.Rc("dotfiles", false))
} }
// TestRc_HomesickrcRequiresForce ensures legacy .homesickrc does not run // TestRc_HomesickrcRequiresForce ensures legacy .homesickrc does not run
@@ -71,7 +71,7 @@ func (s *RcSuite) TestRc_HomesickrcRequiresForce() {
homesickRc := filepath.Join(castleRoot, ".homesickrc") homesickRc := filepath.Join(castleRoot, ".homesickrc")
require.NoError(s.T(), os.WriteFile(homesickRc, []byte("# ruby setup code\n"), 0o644)) require.NoError(s.T(), os.WriteFile(homesickRc, []byte("# ruby setup code\n"), 0o644))
err := s.app.Rc("dotfiles") err := s.app.Rc("dotfiles", false)
require.Error(s.T(), err) require.Error(s.T(), err)
require.Contains(s.T(), err.Error(), "--force") require.Contains(s.T(), err.Error(), "--force")
require.NoFileExists(s.T(), filepath.Join(castleRoot, ".homesick.d", "parity.rb")) require.NoFileExists(s.T(), filepath.Join(castleRoot, ".homesick.d", "parity.rb"))
@@ -84,8 +84,7 @@ func (s *RcSuite) TestRc_HomesickrcRunsWithForce() {
homesickRc := filepath.Join(castleRoot, ".homesickrc") homesickRc := filepath.Join(castleRoot, ".homesickrc")
require.NoError(s.T(), os.WriteFile(homesickRc, []byte("# ruby setup code\n"), 0o644)) require.NoError(s.T(), os.WriteFile(homesickRc, []byte("# ruby setup code\n"), 0o644))
s.app.Force = true require.NoError(s.T(), s.app.Rc("dotfiles", true))
require.NoError(s.T(), s.app.Rc("dotfiles"))
require.FileExists(s.T(), filepath.Join(castleRoot, ".homesick.d", "parity.rb")) require.FileExists(s.T(), filepath.Join(castleRoot, ".homesick.d", "parity.rb"))
} }
@@ -103,7 +102,7 @@ func (s *RcSuite) TestRc_ExecutesScriptsInSortedOrder() {
require.NoError(s.T(), os.WriteFile(scriptA, []byte("#!/bin/sh\necho a >> "+orderFile+"\n"), 0o755)) 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(), os.WriteFile(scriptB, []byte("#!/bin/sh\necho b >> "+orderFile+"\n"), 0o755))
require.NoError(s.T(), s.app.Rc("dotfiles")) require.NoError(s.T(), s.app.Rc("dotfiles", false))
content, err := os.ReadFile(orderFile) content, err := os.ReadFile(orderFile)
require.NoError(s.T(), err) require.NoError(s.T(), err)
@@ -121,7 +120,7 @@ func (s *RcSuite) TestRc_SkipsNonExecutableFiles() {
// Write a script that would exit 1 if actually run — verify it is skipped. // 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(), os.WriteFile(notExec, []byte("#!/bin/sh\nexit 1\n"), 0o644))
require.NoError(s.T(), s.app.Rc("dotfiles")) require.NoError(s.T(), s.app.Rc("dotfiles", false))
} }
// TestRc_HomesickrcCreatesRubyWrapper verifies that a .homesickrc file causes // TestRc_HomesickrcCreatesRubyWrapper verifies that a .homesickrc file causes
@@ -130,9 +129,7 @@ func (s *RcSuite) TestRc_HomesickrcCreatesRubyWrapper() {
castleRoot := s.createCastle("dotfiles") castleRoot := s.createCastle("dotfiles")
homesickRc := filepath.Join(castleRoot, ".homesickrc") homesickRc := filepath.Join(castleRoot, ".homesickrc")
require.NoError(s.T(), os.WriteFile(homesickRc, []byte("# ruby setup code\n"), 0o644)) require.NoError(s.T(), os.WriteFile(homesickRc, []byte("# ruby setup code\n"), 0o644))
s.app.Force = true require.NoError(s.T(), s.app.Rc("dotfiles", true))
require.NoError(s.T(), s.app.Rc("dotfiles"))
wrapperPath := filepath.Join(castleRoot, ".homesick.d", "parity.rb") wrapperPath := filepath.Join(castleRoot, ".homesick.d", "parity.rb")
require.FileExists(s.T(), wrapperPath) require.FileExists(s.T(), wrapperPath)
@@ -152,7 +149,6 @@ func (s *RcSuite) TestRc_HomesickrcWrapperNotOverwrittenIfExists() {
castleRoot := s.createCastle("dotfiles") castleRoot := s.createCastle("dotfiles")
homesickRc := filepath.Join(castleRoot, ".homesickrc") homesickRc := filepath.Join(castleRoot, ".homesickrc")
require.NoError(s.T(), os.WriteFile(homesickRc, []byte("# ruby setup code\n"), 0o644)) require.NoError(s.T(), os.WriteFile(homesickRc, []byte("# ruby setup code\n"), 0o644))
s.app.Force = true
homesickD := filepath.Join(castleRoot, ".homesick.d") homesickD := filepath.Join(castleRoot, ".homesick.d")
require.NoError(s.T(), os.MkdirAll(homesickD, 0o755)) require.NoError(s.T(), os.MkdirAll(homesickD, 0o755))
@@ -160,7 +156,7 @@ func (s *RcSuite) TestRc_HomesickrcWrapperNotOverwrittenIfExists() {
originalContent := []byte("#!/bin/sh\n# pre-existing wrapper\n") originalContent := []byte("#!/bin/sh\n# pre-existing wrapper\n")
require.NoError(s.T(), os.WriteFile(wrapperPath, originalContent, 0o755)) require.NoError(s.T(), os.WriteFile(wrapperPath, originalContent, 0o755))
require.NoError(s.T(), s.app.Rc("dotfiles")) require.NoError(s.T(), s.app.Rc("dotfiles", true))
content, err := os.ReadFile(wrapperPath) content, err := os.ReadFile(wrapperPath)
require.NoError(s.T(), err) require.NoError(s.T(), err)
@@ -173,7 +169,6 @@ func (s *RcSuite) TestRc_HomesickrcWrapperCreatedBeforeExecution() {
castleRoot := s.createCastle("dotfiles") castleRoot := s.createCastle("dotfiles")
homesickRc := filepath.Join(castleRoot, ".homesickrc") homesickRc := filepath.Join(castleRoot, ".homesickrc")
require.NoError(s.T(), os.WriteFile(homesickRc, []byte("# ruby setup code\n"), 0o644)) require.NoError(s.T(), os.WriteFile(homesickRc, []byte("# ruby setup code\n"), 0o644))
s.app.Force = true
homesickD := filepath.Join(castleRoot, ".homesick.d") homesickD := filepath.Join(castleRoot, ".homesick.d")
require.NoError(s.T(), os.MkdirAll(homesickD, 0o755)) require.NoError(s.T(), os.MkdirAll(homesickD, 0o755))
@@ -186,7 +181,7 @@ func (s *RcSuite) TestRc_HomesickrcWrapperCreatedBeforeExecution() {
"#!/bin/sh\n[ -f "+wrapperPath+" ] && echo present >> "+orderFile+"\n", "#!/bin/sh\n[ -f "+wrapperPath+" ] && echo present >> "+orderFile+"\n",
), 0o755)) ), 0o755))
require.NoError(s.T(), s.app.Rc("dotfiles")) require.NoError(s.T(), s.app.Rc("dotfiles", true))
content, err := os.ReadFile(orderFile) content, err := os.ReadFile(orderFile)
require.NoError(s.T(), err) require.NoError(s.T(), err)
@@ -203,7 +198,7 @@ func (s *RcSuite) TestRc_FailingScriptReturnsError() {
failing := filepath.Join(homesickD, "10_fail.sh") failing := filepath.Join(homesickD, "10_fail.sh")
require.NoError(s.T(), os.WriteFile(failing, []byte("#!/bin/sh\nexit 42\n"), 0o755)) require.NoError(s.T(), os.WriteFile(failing, []byte("#!/bin/sh\nexit 42\n"), 0o755))
err := s.app.Rc("dotfiles") err := s.app.Rc("dotfiles", false)
require.Error(s.T(), err) require.Error(s.T(), err)
} }
@@ -217,7 +212,7 @@ func (s *RcSuite) TestRc_ScriptOutputForwarded() {
script := filepath.Join(homesickD, "10_output.sh") 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(), os.WriteFile(script, []byte("#!/bin/sh\necho hello\necho world >&2\n"), 0o755))
require.NoError(s.T(), s.app.Rc("dotfiles")) require.NoError(s.T(), s.app.Rc("dotfiles", false))
require.Contains(s.T(), s.stdout.String(), "hello") require.Contains(s.T(), s.stdout.String(), "hello")
require.Contains(s.T(), s.stderr.String(), "world") require.Contains(s.T(), s.stderr.String(), "world")
} }
@@ -232,6 +227,16 @@ func (s *RcSuite) TestRc_ScriptsRunWithCwdSetToCastleRoot() {
script := filepath.Join(homesickD, "10_pwd.sh") script := filepath.Join(homesickD, "10_pwd.sh")
require.NoError(s.T(), os.WriteFile(script, []byte("#!/bin/sh\npwd\n"), 0o755)) require.NoError(s.T(), os.WriteFile(script, []byte("#!/bin/sh\npwd\n"), 0o755))
require.NoError(s.T(), s.app.Rc("dotfiles")) require.NoError(s.T(), s.app.Rc("dotfiles", false))
require.Contains(s.T(), s.stdout.String(), castleRoot) require.Contains(s.T(), s.stdout.String(), castleRoot)
} }
func (s *RcSuite) TestRc_ReadHooksErrorIncludesContext() {
castleRoot := s.createCastle("dotfiles")
homesickD := filepath.Join(castleRoot, ".homesick.d")
require.NoError(s.T(), os.WriteFile(homesickD, []byte("x"), 0o644))
err := s.app.Rc("dotfiles", false)
require.Error(s.T(), err)
require.Contains(s.T(), err.Error(), "read rc hooks")
}

View File

@@ -99,3 +99,15 @@ func (s *TrackSuite) TestTrack_DefaultCastleName() {
require.NoError(s.T(), err) require.NoError(s.T(), err)
require.Equal(s.T(), expectedTarget, linkTarget) require.Equal(s.T(), expectedTarget, linkTarget)
} }
func (s *TrackSuite) TestTrack_WrapsSubdirRecordingError() {
castleRoot := s.createCastleRepo("dotfiles")
require.NoError(s.T(), os.MkdirAll(filepath.Join(castleRoot, ".homesick_subdir"), 0o755))
filePath := filepath.Join(s.homeDir, ".config", "myapp", "config.toml")
s.writeFile(filePath, "ok=true\n")
err := s.app.Track(filePath, "dotfiles")
require.Error(s.T(), err)
require.Contains(s.T(), err.Error(), "record tracked subdir")
}

View File

@@ -0,0 +1,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)
}

132
script/prepare-release.sh Normal file
View 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
View 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