Compare commits
56 Commits
75f636f9ba
...
b235c6ca45
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b235c6ca45 | ||
|
|
5ecbad8f27 | ||
|
|
ef554dde2d | ||
|
|
55867df599 | ||
|
|
cd92a961bd | ||
|
|
7bc7ee4746 | ||
|
|
8a6a21811a | ||
|
|
001983b76e | ||
|
|
ad5196420e | ||
|
|
692e205a63 | ||
|
|
ca3215f2c4 | ||
|
|
0112d9a0a6 | ||
|
|
e68575f15a | ||
|
|
ce1d253814 | ||
|
|
8f51cf368a | ||
|
|
d73049baa4 | ||
|
|
abfd6b817b | ||
|
|
bbe41a6d72 | ||
|
|
310979d799 | ||
|
|
f7af294d30 | ||
|
|
1abf298c47 | ||
|
|
28ba4aab70 | ||
|
|
665a488c3b | ||
|
|
3dc7924de5 | ||
|
|
bbc64eb756 | ||
|
|
ad8ec1bd6c | ||
|
|
7f8a5d24e3 | ||
|
|
5307e4d35f | ||
|
|
b070267bde | ||
|
|
cd2258e267 | ||
|
|
c887a573e0 | ||
|
|
9e6f98948e | ||
|
|
edd1c4357a | ||
|
|
2fc3f3d006 | ||
|
|
58f70860ee | ||
|
|
79d4577083 | ||
|
|
59caa62ac6 | ||
|
|
043b859a42 | ||
|
|
c36cae2e33 | ||
|
|
5fe37a7f12 | ||
|
|
7f46ab43ac | ||
|
|
82dde43f24 | ||
|
|
88b07ea934 | ||
|
|
4901f7b664 | ||
|
|
f186286a7e | ||
|
|
d8eaf4d058 | ||
|
|
eeeb9f7d8e | ||
|
|
f0dc55159b | ||
|
|
8a451cbaee | ||
|
|
e60000680b | ||
|
|
4a2f0ff0b8 | ||
|
|
4fb028cd81 | ||
|
|
4a422bd241 | ||
|
|
6719fb170b | ||
|
|
f3b1a7707a | ||
|
|
a381746cef |
@@ -22,6 +22,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
|
||||||
|
SUMMARY_FILE: ${{ runner.temp }}/summary.md
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
@@ -34,6 +35,19 @@ jobs:
|
|||||||
cache: true
|
cache: true
|
||||||
cache-dependency-path: go.sum
|
cache-dependency-path: go.sum
|
||||||
|
|
||||||
|
- name: Verify module hygiene
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
go mod tidy
|
||||||
|
git diff --exit-code go.mod go.sum
|
||||||
|
go mod verify
|
||||||
|
|
||||||
|
- name: Install security tools
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
go install github.com/securego/gosec/v2/cmd/gosec@v2.22.3
|
||||||
|
go install golang.org/x/vuln/cmd/govulncheck@v1.1.4
|
||||||
|
|
||||||
- name: Install AWS CLI v2
|
- name: Install AWS CLI v2
|
||||||
uses: ankurk91/install-aws-cli-action@v1
|
uses: ankurk91/install-aws-cli-action@v1
|
||||||
|
|
||||||
@@ -53,13 +67,74 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
go test -covermode=atomic -coverprofile=coverage.out ./...
|
go test -covermode=atomic -coverprofile=coverage.out ./... | tee go-test-coverage.log
|
||||||
go tool cover -html=coverage.out -o coverage.html
|
go tool cover -html=coverage.out -o coverage.html
|
||||||
|
|
||||||
total="$(go tool cover -func=coverage.out | awk '/^total:/ {sub(/%/, "", $3); print $3}')"
|
total="$(go tool cover -func=coverage.out | awk '/^total:/ {sub(/%/, "", $3); print $3}')"
|
||||||
printf '{\n "total": "%s"\n}\n' "$total" > coverage-summary.json
|
printf '{\n "total": "%s"\n}\n' "$total" > coverage-summary.json
|
||||||
printf 'total=%s\n' "$total" >> "$GITHUB_OUTPUT"
|
printf 'total=%s\n' "$total" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
set +e
|
||||||
|
awk '
|
||||||
|
/^ok[[:space:]]/ && /coverage: [0-9.]+% of statements/ {
|
||||||
|
pkg = $2
|
||||||
|
cov = $0
|
||||||
|
sub(/^.*coverage: /, "", cov)
|
||||||
|
sub(/% of statements.*$/, "", cov)
|
||||||
|
status = "target"
|
||||||
|
if (cov + 0 < 50) {
|
||||||
|
status = "fail"
|
||||||
|
fail = 1
|
||||||
|
} else if (cov + 0 < 65) {
|
||||||
|
status = "high-risk"
|
||||||
|
} else if (cov + 0 < 80) {
|
||||||
|
status = "warning"
|
||||||
|
}
|
||||||
|
printf "%s %.1f %s\n", pkg, cov + 0, status
|
||||||
|
}
|
||||||
|
END {
|
||||||
|
if (fail) {
|
||||||
|
exit 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
' go-test-coverage.log > coverage-packages.raw
|
||||||
|
package_gate_status=$?
|
||||||
|
set -e
|
||||||
|
|
||||||
|
{
|
||||||
|
echo '| Package | Coverage | Status |'
|
||||||
|
echo '| --- | ---: | --- |'
|
||||||
|
} > coverage-packages.md
|
||||||
|
|
||||||
|
while read -r pkg cov status; do
|
||||||
|
case "$status" in
|
||||||
|
fail)
|
||||||
|
pretty='FAIL (<50%)'
|
||||||
|
;;
|
||||||
|
high-risk)
|
||||||
|
pretty='High risk (50%-64.99%)'
|
||||||
|
;;
|
||||||
|
warning)
|
||||||
|
pretty='Warning (65%-79.99%)'
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
pretty='Target (>=80%)'
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
printf '| `%s` | %.1f%% | %s |\n' "$pkg" "$cov" "$pretty" >> coverage-packages.md
|
||||||
|
done < coverage-packages.raw
|
||||||
|
|
||||||
|
if [[ "$package_gate_status" -ne 0 ]]; then
|
||||||
|
echo "Per-package coverage gate failed: one or more packages are below 50%." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Run security analysis
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
"$(go env GOPATH)/bin/gosec" ./...
|
||||||
|
"$(go env GOPATH)/bin/govulncheck" ./...
|
||||||
|
|
||||||
- name: Generate coverage badge
|
- name: Generate coverage badge
|
||||||
env:
|
env:
|
||||||
COVERAGE_TOTAL: ${{ steps.coverage.outputs.total }}
|
COVERAGE_TOTAL: ${{ steps.coverage.outputs.total }}
|
||||||
@@ -160,7 +235,17 @@ jobs:
|
|||||||
echo '- Total: `${{ steps.coverage.outputs.total }}%`'
|
echo '- Total: `${{ steps.coverage.outputs.total }}%`'
|
||||||
echo '- Report: ${{ steps.upload.outputs.report_url }}'
|
echo '- Report: ${{ steps.upload.outputs.report_url }}'
|
||||||
echo '- Badge: ${{ steps.upload.outputs.badge_url }}'
|
echo '- Badge: ${{ steps.upload.outputs.badge_url }}'
|
||||||
} >> "$GITHUB_STEP_SUMMARY"
|
echo
|
||||||
|
echo '### Package Coverage'
|
||||||
|
cat coverage-packages.md
|
||||||
|
} >> "$SUMMARY_FILE"
|
||||||
|
|
||||||
- name: Run behavior suite
|
- name: Run behavior suite
|
||||||
run: ./script/run-behavior-suite-docker.sh
|
run: ./script/run-behavior-suite-docker.sh
|
||||||
|
|
||||||
|
- name: Summary
|
||||||
|
if: ${{ always() }}
|
||||||
|
run: |
|
||||||
|
if [[ -f "$SUMMARY_FILE" ]]; then
|
||||||
|
cat "$SUMMARY_FILE" >> "$GITHUB_STEP_SUMMARY"
|
||||||
|
fi
|
||||||
|
|||||||
@@ -1,83 +1,46 @@
|
|||||||
name: Prepare Release
|
name: Release
|
||||||
|
|
||||||
on:
|
on:
|
||||||
workflow_dispatch:
|
push:
|
||||||
inputs:
|
branches: [main]
|
||||||
version:
|
|
||||||
description: Semantic version to release, with or without leading v.
|
permissions:
|
||||||
required: true
|
contents: write
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
prepare:
|
prepare:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
container: docker.io/catthehacker/ubuntu:act-latest
|
|
||||||
defaults:
|
|
||||||
run:
|
|
||||||
shell: bash
|
|
||||||
env:
|
|
||||||
RELEASE_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Setup Go
|
- name: Provide lowercase changelog compatibility
|
||||||
uses: actions/setup-go@v5
|
|
||||||
with:
|
|
||||||
go-version: '1.26.1'
|
|
||||||
check-latest: true
|
|
||||||
cache: true
|
|
||||||
cache-dependency-path: go.sum
|
|
||||||
|
|
||||||
- name: Prepare release files
|
|
||||||
env:
|
|
||||||
RELEASE_VERSION: ${{ inputs.version }}
|
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
./script/prepare-release.sh "$RELEASE_VERSION"
|
if [[ -f CHANGELOG.md && ! -e changelog.md ]]; then
|
||||||
|
ln -s CHANGELOG.md changelog.md
|
||||||
- name: Run tests
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
go test ./...
|
|
||||||
|
|
||||||
- name: Configure git author
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
git config user.name "gitea-actions[bot]"
|
|
||||||
git config user.email "gitea-actions[bot]@users.noreply.local"
|
|
||||||
|
|
||||||
- name: Commit release changes and push tag
|
|
||||||
env:
|
|
||||||
RELEASE_VERSION: ${{ inputs.version }}
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
normalized_version="${RELEASE_VERSION#v}"
|
|
||||||
tag="v${normalized_version}"
|
|
||||||
|
|
||||||
if git rev-parse "$tag" >/dev/null 2>&1; then
|
|
||||||
echo "Tag ${tag} already exists" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
case "$GITHUB_SERVER_URL" in
|
- name: Vociferate prepare
|
||||||
https://*)
|
uses: aether/vociferate/prepare@v1.0.1
|
||||||
authed_remote="https://oauth2:${RELEASE_TOKEN}@${GITHUB_SERVER_URL#https://}/${GITHUB_REPOSITORY}.git"
|
|
||||||
;;
|
|
||||||
http://*)
|
|
||||||
authed_remote="http://oauth2:${RELEASE_TOKEN}@${GITHUB_SERVER_URL#http://}/${GITHUB_REPOSITORY}.git"
|
|
||||||
;;
|
|
||||||
*)
|
|
||||||
echo "Unsupported GITHUB_SERVER_URL: ${GITHUB_SERVER_URL}" >&2
|
|
||||||
exit 1
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
|
|
||||||
git remote set-url origin "$authed_remote"
|
publish:
|
||||||
git add changelog.md internal/homesick/version/version.go
|
needs: prepare
|
||||||
git commit -m "release: prepare ${tag}"
|
runs-on: ubuntu-latest
|
||||||
git tag "$tag"
|
steps:
|
||||||
git push origin HEAD
|
- name: Checkout
|
||||||
git push origin "$tag"
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Provide lowercase changelog compatibility
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
if [[ -f CHANGELOG.md && ! -e changelog.md ]]; then
|
||||||
|
ln -s CHANGELOG.md changelog.md
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Vociferate publish
|
||||||
|
uses: aether/vociferate/publish@v1.0.1
|
||||||
@@ -22,6 +22,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
|
||||||
|
SUMMARY_FILE: ${{ runner.temp }}/summary.md
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
@@ -34,6 +35,19 @@ jobs:
|
|||||||
cache: true
|
cache: true
|
||||||
cache-dependency-path: go.sum
|
cache-dependency-path: go.sum
|
||||||
|
|
||||||
|
- name: Verify module hygiene
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
go mod tidy
|
||||||
|
git diff --exit-code go.mod go.sum
|
||||||
|
go mod verify
|
||||||
|
|
||||||
|
- name: Install security tools
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
go install github.com/securego/gosec/v2/cmd/gosec@v2.22.3
|
||||||
|
go install golang.org/x/vuln/cmd/govulncheck@v1.1.4
|
||||||
|
|
||||||
- name: Install AWS CLI v2
|
- name: Install AWS CLI v2
|
||||||
uses: ankurk91/install-aws-cli-action@v1
|
uses: ankurk91/install-aws-cli-action@v1
|
||||||
|
|
||||||
@@ -41,106 +55,99 @@ jobs:
|
|||||||
run: aws --version
|
run: aws --version
|
||||||
|
|
||||||
- name: Run full unit test suite with coverage
|
- name: Run full unit test suite with coverage
|
||||||
id: coverage
|
id: coverage-tests
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
go test -covermode=atomic -coverprofile=coverage.out ./...
|
go test -covermode=atomic -coverprofile=coverage.out ./... | tee go-test-coverage.log
|
||||||
go tool cover -html=coverage.out -o coverage.html
|
go tool cover -html=coverage.out -o coverage.html
|
||||||
|
|
||||||
total="$(go tool cover -func=coverage.out | awk '/^total:/ {sub(/%/, "", $3); print $3}')"
|
total="$(go tool cover -func=coverage.out | awk '/^total:/ {sub(/%/, "", $3); print $3}')"
|
||||||
printf '{\n "total": "%s"\n}\n' "$total" > coverage-summary.json
|
printf '{\n "total": "%s"\n}\n' "$total" > coverage-summary.json
|
||||||
printf 'total=%s\n' "$total" >> "$GITHUB_OUTPUT"
|
printf 'total=%s\n' "$total" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
- name: Generate coverage badge
|
set +e
|
||||||
env:
|
awk '
|
||||||
COVERAGE_TOTAL: ${{ steps.coverage.outputs.total }}
|
/^ok[[:space:]]/ && /coverage: [0-9.]+% of statements/ {
|
||||||
run: |
|
pkg = $2
|
||||||
set -euo pipefail
|
cov = $0
|
||||||
|
sub(/^.*coverage: /, "", cov)
|
||||||
|
sub(/% of statements.*$/, "", cov)
|
||||||
|
status = "target"
|
||||||
|
if (cov + 0 < 50) {
|
||||||
|
status = "fail"
|
||||||
|
fail = 1
|
||||||
|
} else if (cov + 0 < 65) {
|
||||||
|
status = "high-risk"
|
||||||
|
} else if (cov + 0 < 80) {
|
||||||
|
status = "warning"
|
||||||
|
}
|
||||||
|
printf "%s %.1f %s\n", pkg, cov + 0, status
|
||||||
|
}
|
||||||
|
END {
|
||||||
|
if (fail) {
|
||||||
|
exit 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
' go-test-coverage.log > coverage-packages.raw
|
||||||
|
package_gate_status=$?
|
||||||
|
set -e
|
||||||
|
|
||||||
color="$(awk -v total="$COVERAGE_TOTAL" 'BEGIN {
|
|
||||||
if (total >= 80) print "brightgreen";
|
|
||||||
else if (total >= 70) print "green";
|
|
||||||
else if (total >= 60) print "yellowgreen";
|
|
||||||
else if (total >= 50) print "yellow";
|
|
||||||
else print "red";
|
|
||||||
}')"
|
|
||||||
|
|
||||||
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 branch coverage artefacts
|
|
||||||
id: upload
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
aws configure set default.s3.addressing_style path
|
|
||||||
|
|
||||||
repo_name="${GITHUB_REPOSITORY##*/}"
|
|
||||||
prefix="${repo_name}/branch/${GITHUB_REF_NAME}"
|
|
||||||
report_url="${ARTEFACT_BUCKET_ENDPONT%/}/${ARTEFACT_BUCKET_NAME}/${prefix}/coverage.html"
|
|
||||||
badge_url="${ARTEFACT_BUCKET_ENDPONT%/}/${ARTEFACT_BUCKET_NAME}/${prefix}/coverage-badge.svg"
|
|
||||||
|
|
||||||
aws --endpoint-url "${ARTEFACT_BUCKET_ENDPONT}" s3 cp coverage.html "s3://${ARTEFACT_BUCKET_NAME}/${prefix}/coverage.html" --content-type text/html
|
|
||||||
aws --endpoint-url "${ARTEFACT_BUCKET_ENDPONT}" s3 cp coverage-badge.svg "s3://${ARTEFACT_BUCKET_NAME}/${prefix}/coverage-badge.svg" --content-type image/svg+xml
|
|
||||||
aws --endpoint-url "${ARTEFACT_BUCKET_ENDPONT}" s3 cp coverage-summary.json "s3://${ARTEFACT_BUCKET_NAME}/${prefix}/coverage-summary.json" --content-type application/json
|
|
||||||
|
|
||||||
printf 'report_url=%s\n' "$report_url" >> "$GITHUB_OUTPUT"
|
|
||||||
printf 'badge_url=%s\n' "$badge_url" >> "$GITHUB_OUTPUT"
|
|
||||||
|
|
||||||
- name: Add coverage summary
|
|
||||||
run: |
|
|
||||||
{
|
{
|
||||||
echo '## Coverage'
|
echo '| Package | Coverage | Status |'
|
||||||
echo
|
echo '| --- | ---: | --- |'
|
||||||
echo '- Total: `${{ steps.coverage.outputs.total }}%`'
|
} > coverage-packages.md
|
||||||
echo '- Report: ${{ steps.upload.outputs.report_url }}'
|
|
||||||
echo '- Badge: ${{ steps.upload.outputs.badge_url }}'
|
while read -r pkg cov status; do
|
||||||
} >> "$GITHUB_STEP_SUMMARY"
|
case "$status" in
|
||||||
|
fail)
|
||||||
|
pretty='FAIL (<50%)'
|
||||||
|
;;
|
||||||
|
high-risk)
|
||||||
|
pretty='High risk (50%-64.99%)'
|
||||||
|
;;
|
||||||
|
warning)
|
||||||
|
pretty='Warning (65%-79.99%)'
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
pretty='Target (>=80%)'
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
printf '| `%s` | %.1f%% | %s |\n' "$pkg" "$cov" "$pretty" >> coverage-packages.md
|
||||||
|
done < coverage-packages.raw
|
||||||
|
|
||||||
|
if [[ "$package_gate_status" -ne 0 ]]; then
|
||||||
|
echo "Per-package coverage gate failed: one or more packages are below 50%." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Publish coverage artefacts
|
||||||
|
id: coverage-badge
|
||||||
|
uses: git.hrafn.xyz/aether/vociferate/coverage-badge@v1.0.1
|
||||||
|
with:
|
||||||
|
coverage-profile: coverage.out
|
||||||
|
coverage-html: coverage.html
|
||||||
|
coverage-badge: coverage-badge.svg
|
||||||
|
coverage-summary: coverage-summary.json
|
||||||
|
artefact-bucket-name: ${{ vars.ARTEFACT_BUCKET_NAME }}
|
||||||
|
artefact-bucket-endpoint: ${{ vars.ARTEFACT_BUCKET_ENDPONT }}
|
||||||
|
branch-name: ${{ github.ref_name }}
|
||||||
|
repository-name: ${{ github.repository }}
|
||||||
|
summary-file: ${{ env.SUMMARY_FILE }}
|
||||||
|
|
||||||
|
- name: Run security analysis
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
"$(go env GOPATH)/bin/gosec" ./...
|
||||||
|
"$(go env GOPATH)/bin/govulncheck" ./...
|
||||||
|
|
||||||
- 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' }}
|
||||||
run: ./script/run-behavior-suite-docker.sh
|
run: ./script/run-behavior-suite-docker.sh
|
||||||
|
|
||||||
- name: Recommend next release tag on main pushes
|
- name: Summary
|
||||||
if: ${{ github.ref == 'refs/heads/main' }}
|
if: ${{ always() }}
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
if [[ -f "$SUMMARY_FILE" ]]; then
|
||||||
|
cat "$SUMMARY_FILE" >> "$GITHUB_STEP_SUMMARY"
|
||||||
if recommended_tag="$(go run ./cmd/releaseprep --recommend --root . 2>release-recommendation.err)"; then
|
|
||||||
{
|
|
||||||
echo
|
|
||||||
echo '## Release Recommendation'
|
|
||||||
echo
|
|
||||||
echo "- Recommended next tag: \\`${recommended_tag}\\`"
|
|
||||||
} >> "$GITHUB_STEP_SUMMARY"
|
|
||||||
else
|
|
||||||
recommendation_error="$(tr '\n' ' ' < release-recommendation.err | sed 's/[[:space:]]\+/ /g' | sed 's/^ //; s/ $//')"
|
|
||||||
echo "::warning::${recommendation_error}"
|
|
||||||
{
|
|
||||||
echo
|
|
||||||
echo '## Release Recommendation'
|
|
||||||
echo
|
|
||||||
echo "- No recommended tag emitted: ${recommendation_error}"
|
|
||||||
} >> "$GITHUB_STEP_SUMMARY"
|
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ jobs:
|
|||||||
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
|
||||||
@@ -74,58 +76,18 @@ jobs:
|
|||||||
release:
|
release:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: build
|
needs: build
|
||||||
env:
|
|
||||||
RELEASE_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
steps:
|
steps:
|
||||||
- name: Download build artifacts
|
- name: Checkout
|
||||||
uses: actions/download-artifact@v4
|
uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
path: dist
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Ensure jq is installed
|
- name: Provide lowercase changelog compatibility
|
||||||
run: |
|
|
||||||
if ! command -v jq >/dev/null 2>&1; then
|
|
||||||
sudo apt-get update
|
|
||||||
sudo apt-get install -y jq
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Create release if needed and upload assets
|
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
if [[ -f CHANGELOG.md && ! -e changelog.md ]]; then
|
||||||
if [[ -z "${RELEASE_TOKEN:-}" ]]; then
|
ln -s CHANGELOG.md changelog.md
|
||||||
echo "RELEASE_TOKEN is empty. Expected secrets.GITHUB_TOKEN to be available." >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
tag="${GITHUB_REF_NAME}"
|
- name: Vociferate publish
|
||||||
api_base="${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}"
|
uses: aether/vociferate/publish@v1.0.1
|
||||||
|
|
||||||
release_json="$(curl -sS -H "Authorization: token ${RELEASE_TOKEN}" "${api_base}/releases/tags/${tag}" || true)"
|
|
||||||
release_id="$(printf '%s' "${release_json}" | jq -r '.id // empty')"
|
|
||||||
|
|
||||||
if [[ -z "${release_id}" ]]; then
|
|
||||||
create_payload="$(jq -n --arg tag "${tag}" --arg name "${tag}" '{tag_name:$tag, name:$name, draft:false, prerelease:false}')"
|
|
||||||
release_json="$(curl -sS -X POST \
|
|
||||||
-H "Authorization: token ${RELEASE_TOKEN}" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d "${create_payload}" \
|
|
||||||
"${api_base}/releases")"
|
|
||||||
release_id="$(printf '%s' "${release_json}" | jq -r '.id // empty')"
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ -z "${release_id}" ]]; then
|
|
||||||
echo "Unable to determine or create release id for tag ${tag}" >&2
|
|
||||||
printf '%s\n' "${release_json}" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
find dist -type f -name '*.tar.gz' -print0 | while IFS= read -r -d '' file; do
|
|
||||||
asset_name="$(basename "${file}")"
|
|
||||||
curl -sS -X POST \
|
|
||||||
-H "Authorization: token ${RELEASE_TOKEN}" \
|
|
||||||
-H "Content-Type: application/octet-stream" \
|
|
||||||
--data-binary @"${file}" \
|
|
||||||
"${api_base}/releases/${release_id}/assets?name=${asset_name}"
|
|
||||||
echo "Uploaded ${asset_name}"
|
|
||||||
done
|
|
||||||
|
|||||||
@@ -13,18 +13,29 @@ A `### Breaking` section is used in addition to Keep a Changelog's standard sect
|
|||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- Native Go implementations for `clone`, `link`, `unlink`, and `track`.
|
- CI validation now runs `gosec` and `govulncheck` security scanning on push and pull request workflows.
|
||||||
|
- `cmd/homesick` now includes entrypoint-focused tests that exercise both the CLI run path and `main` process path.
|
||||||
|
- `rc` command: executes all executable scripts inside a castle's `.homesick.d/` directory in sorted order, with the castle root as the working directory. stdout/stderr from each script is forwarded to the caller.
|
||||||
|
- `rc` command: when a `.homesickrc` file exists and no `parity.rb` wrapper is present in `.homesick.d/`, a Ruby wrapper script (`parity.rb`) is generated automatically to preserve backwards compatibility. An existing `parity.rb` is never overwritten.
|
||||||
|
- `exec` command: runs a shell command inside the target castle root directory.
|
||||||
|
- `exec_all` command: runs a shell command inside each cloned castle root directory in sorted order.
|
||||||
|
- `pull --all` support: pulls updates for every cloned castle in sorted order.
|
||||||
|
- `rc --force` support: legacy `.homesickrc` compatibility hooks now require explicit force mode before execution.
|
||||||
|
- Global command flags restored: `--pretend` (with `--dry-run` alias) and `--quiet`.
|
||||||
|
- Native Go implementations for `clone`, `link`, `unlink`, `track`, `pull`, `push`, `commit`, `destroy`, `cd`, `open`, and `generate`.
|
||||||
- Containerized behavior test suite for command parity validation.
|
- Containerized behavior test suite for command parity validation.
|
||||||
- Dedicated test suites for `list`, `show_path`, `status`, `diff`, and `version`.
|
- Dedicated test suites for `list`, `show_path`, `status`, `diff`, and `version`.
|
||||||
- Just workflow support for building and running the Linux behavior binary.
|
- Just workflow support for building and running the Linux behavior binary.
|
||||||
- Coverage reports and badges published to shared object storage for branches and pull requests.
|
- Coverage reports and badges published to shared object storage for branches and pull requests.
|
||||||
- Pull requests now receive coverage report links in CI comments.
|
- Pull requests now receive coverage report links in CI comments.
|
||||||
- Manual release preparation workflow and script to bump the reported version, promote unreleased changelog notes, and create release tags.
|
- Automated release orchestration now runs through vociferate prepare and publish workflows.
|
||||||
- Main branch validation now emits a recommended next release tag based on unreleased changelog sections, and release preparation now rejects empty unreleased notes.
|
- `symlink` command alias compatibility for `link`.
|
||||||
- Release recommendations now support an explicit `### Breaking` section for major-version changes that would otherwise be described under `### Changed`.
|
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
|
- Release automation now uses `aether/vociferate` `prepare` and `publish` actions (pinned to `v1.0.1`) instead of repository-local releaseprep wrappers.
|
||||||
|
- Push and pull request validation now enforce per-package coverage gates (fail below 50%) and publish package-level coverage status tables in workflow summaries.
|
||||||
|
- Push and pull request validation now verify module hygiene (`go mod tidy`, `go mod verify`) and use a dedicated summary-file pattern with a final always-run summary step.
|
||||||
- CLI argument parsing migrated to Kong.
|
- CLI argument parsing migrated to Kong.
|
||||||
- Git operations for clone and track migrated to `go-git`.
|
- Git operations for clone and track migrated to `go-git`.
|
||||||
- Build and behavior workflows now produce and run the `gosick` binary name.
|
- Build and behavior workflows now produce and run the `gosick` binary name.
|
||||||
@@ -34,14 +45,20 @@ A `### Breaking` section is used in addition to Keep a Changelog's standard sect
|
|||||||
- CLI help now uses the invoked binary name (defaulting to `gosick`) in usage output.
|
- CLI help now uses the invoked binary name (defaulting to `gosick`) in usage output.
|
||||||
- CLI help description now reflects Homesick's purpose for managing precious dotfiles.
|
- CLI help description now reflects Homesick's purpose for managing precious dotfiles.
|
||||||
- Release notes standardized to Keep a Changelog format.
|
- Release notes standardized to Keep a Changelog format.
|
||||||
|
- `commit` command now accepts legacy positional form `commit <castle> <message>` in addition to `-m`.
|
||||||
|
- `destroy` now prompts for confirmation by default and preserves the castle when declined.
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- `status` and `diff` now consistently write through configured app output writers.
|
- `status` and `diff` now consistently write through configured app output writers.
|
||||||
|
- `pull --all` output now includes per-castle prefixes to match behavior expectations.
|
||||||
|
- Behavior-suite container now includes Ruby so `.homesickrc` parity wrapper execution works under `rc --force`.
|
||||||
|
|
||||||
### Removed
|
### Removed
|
||||||
|
|
||||||
|
- Legacy `script/prepare-release.sh` releaseprep wrapper and its dedicated script test.
|
||||||
- Legacy Ruby implementation and Ruby toolchain.
|
- Legacy Ruby implementation and Ruby toolchain.
|
||||||
|
- Legacy in-repository `releaseprep` package and command implementation, now superseded by the standalone `vociferate` tool.
|
||||||
|
|
||||||
## [1.1.6] - 2017-12-20
|
## [1.1.6] - 2017-12-20
|
||||||
|
|
||||||
43
README.md
43
README.md
@@ -1,8 +1,6 @@
|
|||||||
# homesick
|
# homesick
|
||||||
|
|
||||||
[](https://git.hrafn.xyz/aether/gosick/actions/workflows/push-validation.yml)
|
[](https://git.hrafn.xyz/aether/gosick/actions/workflows/push-validation.yml)
|
||||||
[](https://git.hrafn.xyz/aether/gosick/actions/workflows/pr-validation.yml)
|
|
||||||
[](https://git.hrafn.xyz/aether/gosick/actions/workflows/tag-build-artifacts.yml)
|
|
||||||
[](https://s3.hrafn.xyz/aether-workflow-report-artefacts/gosick/branch/main/coverage.html)
|
[](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.
|
||||||
@@ -35,20 +33,37 @@ Implemented commands:
|
|||||||
- `link [CASTLE]`
|
- `link [CASTLE]`
|
||||||
- `unlink [CASTLE]`
|
- `unlink [CASTLE]`
|
||||||
- `track FILE [CASTLE]`
|
- `track FILE [CASTLE]`
|
||||||
|
- `pull [--all|CASTLE]`
|
||||||
|
- `push [CASTLE]`
|
||||||
|
- `commit -m MESSAGE [CASTLE]`
|
||||||
|
- `destroy [CASTLE]`
|
||||||
|
- `cd [CASTLE]`
|
||||||
|
- `open [CASTLE]`
|
||||||
|
- `exec CASTLE COMMAND...`
|
||||||
|
- `exec_all COMMAND...`
|
||||||
|
- `generate PATH`
|
||||||
|
- `rc [--force] [CASTLE]`
|
||||||
- `version`
|
- `version`
|
||||||
|
|
||||||
Not yet implemented:
|
Global options:
|
||||||
|
|
||||||
- `pull`
|
- `--pretend` simulates command execution for shell/git-backed operations.
|
||||||
- `push`
|
- `--dry-run` is an alias for `--pretend`.
|
||||||
- `commit`
|
- `--quiet` suppresses status output.
|
||||||
- `destroy`
|
|
||||||
- `cd`
|
### rc behavior
|
||||||
- `open`
|
|
||||||
- `exec`
|
- Runs executable scripts in `<castle>/.homesick.d/` in lexicographic order.
|
||||||
- `exec_all`
|
- Executes scripts with the castle root as the current working directory.
|
||||||
- `rc`
|
- Forwards script stdout/stderr to command output.
|
||||||
- `generate`
|
- If `<castle>/.homesickrc` exists, `--force` is required before legacy Ruby compatibility hooks are run.
|
||||||
|
- If `<castle>/.homesickrc` exists and `<castle>/.homesick.d/parity.rb` does not, generates `parity.rb` before execution.
|
||||||
|
- Never overwrites an existing `parity.rb` wrapper.
|
||||||
|
|
||||||
|
### exec behavior
|
||||||
|
|
||||||
|
- `exec CASTLE COMMAND...` runs the shell command inside the target castle root.
|
||||||
|
- `exec_all COMMAND...` runs the same shell command inside each cloned castle root in sorted order.
|
||||||
|
|
||||||
## Behavior Suite
|
## Behavior Suite
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,17 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"io"
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
"git.hrafn.xyz/aether/gosick/internal/homesick/cli"
|
"git.hrafn.xyz/aether/gosick/internal/homesick/cli"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
exitCode := cli.Run(os.Args[1:], os.Stdout, os.Stderr)
|
exitCode := run(os.Args[1:], os.Stdout, os.Stderr)
|
||||||
os.Exit(exitCode)
|
os.Exit(exitCode)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func run(args []string, stdout io.Writer, stderr io.Writer) int {
|
||||||
|
return cli.Run(args, stdout, stderr)
|
||||||
|
}
|
||||||
|
|||||||
53
cmd/homesick/main_test.go
Normal file
53
cmd/homesick/main_test.go
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.hrafn.xyz/aether/gosick/internal/homesick/version"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRunVersionCommand(t *testing.T) {
|
||||||
|
stdout := &bytes.Buffer{}
|
||||||
|
stderr := &bytes.Buffer{}
|
||||||
|
|
||||||
|
exitCode := run([]string{"version"}, stdout, stderr)
|
||||||
|
if exitCode != 0 {
|
||||||
|
t.Fatalf("run(version) exit code = %d, want 0", exitCode)
|
||||||
|
}
|
||||||
|
if got := stdout.String(); got != version.String+"\n" {
|
||||||
|
t.Fatalf("stdout = %q, want %q", got, version.String+"\n")
|
||||||
|
}
|
||||||
|
if got := stderr.String(); got != "" {
|
||||||
|
t.Fatalf("stderr = %q, want empty", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMainVersionCommand(t *testing.T) {
|
||||||
|
if os.Getenv("GO_WANT_HELPER_PROCESS") == "1" {
|
||||||
|
os.Args = []string{"gosick", "version"}
|
||||||
|
main()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd := exec.Command(os.Args[0], "-test.run=TestMainVersionCommand")
|
||||||
|
cmd.Env = append(os.Environ(), "GO_WANT_HELPER_PROCESS=1")
|
||||||
|
|
||||||
|
stdout := &bytes.Buffer{}
|
||||||
|
stderr := &bytes.Buffer{}
|
||||||
|
cmd.Stdout = stdout
|
||||||
|
cmd.Stderr = stderr
|
||||||
|
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
t.Fatalf("helper process failed: %v, stderr: %s", err, stderr.String())
|
||||||
|
}
|
||||||
|
if got := stdout.String(); got != version.String+"\n" {
|
||||||
|
t.Fatalf("stdout = %q, want %q", got, version.String+"\n")
|
||||||
|
}
|
||||||
|
if got := stderr.String(); got != "" {
|
||||||
|
t.Fatalf("stderr = %q, want empty", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"flag"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
|
|
||||||
"git.hrafn.xyz/aether/gosick/internal/releaseprep"
|
|
||||||
)
|
|
||||||
|
|
||||||
func main() {
|
|
||||||
version := flag.String("version", "", "semantic version to release, with or without leading v")
|
|
||||||
date := flag.String("date", "", "release date in YYYY-MM-DD format")
|
|
||||||
recommend := flag.Bool("recommend", false, "print the recommended next release tag based on the changelog")
|
|
||||||
root := flag.String("root", ".", "repository root to update")
|
|
||||||
flag.Parse()
|
|
||||||
|
|
||||||
absRoot, err := filepath.Abs(*root)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Fprintf(os.Stderr, "resolve root: %v\n", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
if *recommend {
|
|
||||||
tag, err := releaseprep.RecommendedTag(absRoot)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Fprintf(os.Stderr, "recommend release: %v\n", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
fmt.Println(tag)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if *version == "" || *date == "" {
|
|
||||||
fmt.Fprintln(os.Stderr, "usage: releaseprep --version <version> --date <YYYY-MM-DD> [--root <dir>] | --recommend [--root <dir>]")
|
|
||||||
os.Exit(2)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := releaseprep.Prepare(absRoot, *version, *date); err != nil {
|
|
||||||
fmt.Fprintf(os.Stderr, "prepare release: %v\n", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -12,7 +12,8 @@ FROM alpine:3.21
|
|||||||
RUN apk add --no-cache \
|
RUN apk add --no-cache \
|
||||||
bash \
|
bash \
|
||||||
ca-certificates \
|
ca-certificates \
|
||||||
git
|
git \
|
||||||
|
ruby
|
||||||
|
|
||||||
WORKDIR /workspace
|
WORKDIR /workspace
|
||||||
COPY . /workspace
|
COPY . /workspace
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func Run(args []string, stdout io.Writer, stderr io.Writer) int {
|
func Run(args []string, stdout io.Writer, stderr io.Writer) int {
|
||||||
|
model := &cliModel{}
|
||||||
|
|
||||||
app, err := core.New(stdout, stderr)
|
app, err := core.New(stdout, stderr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
_, _ = fmt.Fprintf(stderr, "error: %v\n", err)
|
_, _ = fmt.Fprintf(stderr, "error: %v\n", err)
|
||||||
@@ -21,7 +23,7 @@ func Run(args []string, stdout io.Writer, stderr io.Writer) int {
|
|||||||
}
|
}
|
||||||
|
|
||||||
parser, err := kong.New(
|
parser, err := kong.New(
|
||||||
&cliModel{},
|
model,
|
||||||
kong.Name(programName()),
|
kong.Name(programName()),
|
||||||
kong.Description("Your home is your castle. Don't leave your precious dotfiles behind."),
|
kong.Description("Your home is your castle. Don't leave your precious dotfiles behind."),
|
||||||
kong.Writers(stdout, stderr),
|
kong.Writers(stdout, stderr),
|
||||||
@@ -51,6 +53,9 @@ func Run(args []string, stdout io.Writer, stderr io.Writer) int {
|
|||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
app.Quiet = model.Quiet
|
||||||
|
app.Pretend = model.Pretend || model.DryRun
|
||||||
|
|
||||||
if err := ctx.Run(app); err != nil {
|
if err := ctx.Run(app); err != nil {
|
||||||
var exitErr *cliExitError
|
var exitErr *cliExitError
|
||||||
if errors.As(err, &exitErr) {
|
if errors.As(err, &exitErr) {
|
||||||
@@ -64,6 +69,10 @@ func Run(args []string, stdout io.Writer, stderr io.Writer) int {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type cliModel struct {
|
type cliModel struct {
|
||||||
|
Pretend bool `help:"Preview actions without executing commands."`
|
||||||
|
DryRun bool `name:"dry-run" help:"Alias for --pretend."`
|
||||||
|
Quiet bool `help:"Suppress status output."`
|
||||||
|
|
||||||
Clone cloneCmd `cmd:"" help:"Clone a castle."`
|
Clone cloneCmd `cmd:"" help:"Clone a castle."`
|
||||||
List listCmd `cmd:"" help:"List castles."`
|
List listCmd `cmd:"" help:"List castles."`
|
||||||
ShowPath showPathCmd `cmd:"" name:"show_path" help:"Show the path of a castle."`
|
ShowPath showPathCmd `cmd:"" name:"show_path" help:"Show the path of a castle."`
|
||||||
@@ -155,36 +164,76 @@ func (c *versionCmd) Run(app *core.App) error {
|
|||||||
return app.Version(version.String)
|
return app.Version(version.String)
|
||||||
}
|
}
|
||||||
|
|
||||||
type pullCmd struct{}
|
type pullCmd struct {
|
||||||
|
All bool `help:"Pull all castles."`
|
||||||
|
Castle string `arg:"" optional:"" name:"CASTLE" help:"Castle name."`
|
||||||
|
}
|
||||||
|
|
||||||
type pushCmd struct{}
|
type pushCmd struct {
|
||||||
|
Castle string `arg:"" optional:"" name:"CASTLE" help:"Castle name."`
|
||||||
|
}
|
||||||
|
|
||||||
type commitCmd struct{}
|
type commitCmd struct {
|
||||||
|
Message string `short:"m" required:"" name:"MESSAGE" help:"Commit message."`
|
||||||
|
Castle string `arg:"" optional:"" name:"CASTLE" help:"Castle name."`
|
||||||
|
}
|
||||||
|
|
||||||
type destroyCmd struct{}
|
type destroyCmd struct {
|
||||||
|
Castle string `arg:"" optional:"" name:"CASTLE" help:"Castle name."`
|
||||||
|
}
|
||||||
|
|
||||||
type cdCmd struct{}
|
type cdCmd struct {
|
||||||
|
Castle string `arg:"" optional:"" name:"CASTLE" help:"Castle name."`
|
||||||
|
}
|
||||||
|
|
||||||
type openCmd struct{}
|
type openCmd struct {
|
||||||
|
Castle string `arg:"" optional:"" name:"CASTLE" help:"Castle name."`
|
||||||
|
}
|
||||||
|
|
||||||
type execCmd struct{}
|
type execCmd struct {
|
||||||
|
Castle string `arg:"" name:"CASTLE" help:"Castle name."`
|
||||||
|
Command []string `arg:"" name:"COMMAND" help:"Shell command to execute in the castle root."`
|
||||||
|
}
|
||||||
|
|
||||||
type execAllCmd struct{}
|
type execAllCmd struct {
|
||||||
|
Command []string `arg:"" name:"COMMAND" help:"Shell command to execute in each castle root."`
|
||||||
|
}
|
||||||
|
|
||||||
type rcCmd struct{}
|
type rcCmd struct {
|
||||||
|
Force bool `help:"Bypass legacy .homesickrc safety confirmation."`
|
||||||
|
Castle string `arg:"" optional:"" name:"CASTLE" help:"Castle name."`
|
||||||
|
}
|
||||||
|
|
||||||
type generateCmd struct{}
|
type generateCmd struct {
|
||||||
|
Path string `arg:"" name:"PATH" help:"Path to generate a homesick-ready castle repository."`
|
||||||
|
}
|
||||||
|
|
||||||
func (c *pullCmd) Run() error { return notImplemented("pull") }
|
func (c *pullCmd) Run(app *core.App) error {
|
||||||
func (c *pushCmd) Run() error { return notImplemented("push") }
|
if c.All {
|
||||||
func (c *commitCmd) Run() error { return notImplemented("commit") }
|
if strings.TrimSpace(c.Castle) != "" {
|
||||||
func (c *destroyCmd) Run() error { return notImplemented("destroy") }
|
return errors.New("pull accepts either --all or CASTLE, not both")
|
||||||
func (c *cdCmd) Run() error { return notImplemented("cd") }
|
}
|
||||||
func (c *openCmd) Run() error { return notImplemented("open") }
|
return app.PullAll()
|
||||||
func (c *execCmd) Run() error { return notImplemented("exec") }
|
}
|
||||||
func (c *execAllCmd) Run() error { return notImplemented("exec_all") }
|
return app.Pull(defaultCastle(c.Castle))
|
||||||
func (c *rcCmd) Run() error { return notImplemented("rc") }
|
}
|
||||||
func (c *generateCmd) Run() error { return notImplemented("generate") }
|
func (c *pushCmd) Run(app *core.App) error { return app.Push(defaultCastle(c.Castle)) }
|
||||||
|
func (c *commitCmd) Run(app *core.App) error {
|
||||||
|
return app.Commit(defaultCastle(c.Castle), c.Message)
|
||||||
|
}
|
||||||
|
func (c *destroyCmd) Run(app *core.App) error { return app.Destroy(defaultCastle(c.Castle)) }
|
||||||
|
func (c *cdCmd) Run(app *core.App) error { return app.ShowPath(defaultCastle(c.Castle)) }
|
||||||
|
func (c *openCmd) Run(app *core.App) error { return app.Open(defaultCastle(c.Castle)) }
|
||||||
|
func (c *execCmd) Run(app *core.App) error { return app.Exec(c.Castle, c.Command) }
|
||||||
|
func (c *execAllCmd) Run(app *core.App) error { return app.ExecAll(c.Command) }
|
||||||
|
func (c *rcCmd) Run(app *core.App) error {
|
||||||
|
originalForce := app.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 defaultCastle(castle string) string {
|
func defaultCastle(castle string) string {
|
||||||
if strings.TrimSpace(castle) == "" {
|
if strings.TrimSpace(castle) == "" {
|
||||||
@@ -208,21 +257,61 @@ func normalizeArgs(args []string) []string {
|
|||||||
return []string{"--help"}
|
return []string{"--help"}
|
||||||
}
|
}
|
||||||
|
|
||||||
switch args[0] {
|
prefix, rest := splitLeadingGlobalFlags(args)
|
||||||
|
if len(rest) == 0 {
|
||||||
|
return args
|
||||||
|
}
|
||||||
|
|
||||||
|
switch rest[0] {
|
||||||
case "-h", "--help":
|
case "-h", "--help":
|
||||||
return []string{"--help"}
|
return []string{"--help"}
|
||||||
case "help":
|
case "help":
|
||||||
if len(args) == 1 {
|
if len(rest) == 1 {
|
||||||
return []string{"--help"}
|
return []string{"--help"}
|
||||||
}
|
}
|
||||||
return append(args[1:], "--help")
|
normalized := append([]string{}, prefix...)
|
||||||
|
normalized = append(normalized, rest[1:]...)
|
||||||
|
return append(normalized, "--help")
|
||||||
case "-v", "--version":
|
case "-v", "--version":
|
||||||
return []string{"version"}
|
return []string{"version"}
|
||||||
|
case "symlink":
|
||||||
|
normalized := append([]string{}, prefix...)
|
||||||
|
normalized = append(normalized, "link")
|
||||||
|
return append(normalized, rest[1:]...)
|
||||||
|
case "commit":
|
||||||
|
if len(rest) == 3 && !hasCommitMessageFlag(rest[1:]) {
|
||||||
|
normalized := append([]string{}, prefix...)
|
||||||
|
return append(normalized, "commit", "-m", rest[2], rest[1])
|
||||||
|
}
|
||||||
|
return args
|
||||||
default:
|
default:
|
||||||
return args
|
return args
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func splitLeadingGlobalFlags(args []string) ([]string, []string) {
|
||||||
|
i := 0
|
||||||
|
for i < len(args) {
|
||||||
|
switch args[i] {
|
||||||
|
case "--pretend", "--dry-run", "--quiet":
|
||||||
|
i++
|
||||||
|
default:
|
||||||
|
return args[:i], args[i:]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return args, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func hasCommitMessageFlag(args []string) bool {
|
||||||
|
for _, arg := range args {
|
||||||
|
if arg == "-m" || strings.HasPrefix(arg, "--MESSAGE") || strings.HasPrefix(arg, "--message") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
func isHelpRequest(args []string) bool {
|
func isHelpRequest(args []string) bool {
|
||||||
for _, arg := range args {
|
for _, arg := range args {
|
||||||
if arg == "-h" || arg == "--help" {
|
if arg == "-h" || arg == "--help" {
|
||||||
|
|||||||
@@ -52,6 +52,99 @@ func (s *CLISuite) TestRun_ShowPath_DefaultCastle() {
|
|||||||
require.Empty(s.T(), s.stderr.String())
|
require.Empty(s.T(), s.stderr.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *CLISuite) TestRun_Cd_DefaultCastle() {
|
||||||
|
exitCode := cli.Run([]string{"cd"}, s.stdout, s.stderr)
|
||||||
|
|
||||||
|
require.Equal(s.T(), 0, exitCode)
|
||||||
|
require.Equal(s.T(), filepath.Join(s.homeDir, ".homesick", "repos", "dotfiles")+"\n", s.stdout.String())
|
||||||
|
require.Empty(s.T(), s.stderr.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *CLISuite) TestRun_Cd_ExplicitCastle() {
|
||||||
|
exitCode := cli.Run([]string{"cd", "work"}, s.stdout, s.stderr)
|
||||||
|
|
||||||
|
require.Equal(s.T(), 0, exitCode)
|
||||||
|
require.Equal(s.T(), filepath.Join(s.homeDir, ".homesick", "repos", "work")+"\n", s.stdout.String())
|
||||||
|
require.Empty(s.T(), s.stderr.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *CLISuite) TestRun_Exec_RunsCommandInCastleRoot() {
|
||||||
|
castleRoot := filepath.Join(s.homeDir, ".homesick", "repos", "dotfiles")
|
||||||
|
require.NoError(s.T(), os.MkdirAll(castleRoot, 0o755))
|
||||||
|
|
||||||
|
exitCode := cli.Run([]string{"exec", "dotfiles", "pwd"}, s.stdout, s.stderr)
|
||||||
|
|
||||||
|
require.Equal(s.T(), 0, exitCode)
|
||||||
|
require.Contains(s.T(), s.stdout.String(), castleRoot)
|
||||||
|
require.Empty(s.T(), s.stderr.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *CLISuite) TestRun_Exec_WithPretend_DoesNotExecute() {
|
||||||
|
castleRoot := filepath.Join(s.homeDir, ".homesick", "repos", "dotfiles")
|
||||||
|
require.NoError(s.T(), os.MkdirAll(castleRoot, 0o755))
|
||||||
|
target := filepath.Join(castleRoot, "should-not-exist")
|
||||||
|
|
||||||
|
exitCode := cli.Run([]string{"--pretend", "exec", "dotfiles", "touch should-not-exist"}, s.stdout, s.stderr)
|
||||||
|
|
||||||
|
require.Equal(s.T(), 0, exitCode)
|
||||||
|
require.NoFileExists(s.T(), target)
|
||||||
|
require.Contains(s.T(), s.stdout.String(), "Would execute")
|
||||||
|
require.Empty(s.T(), s.stderr.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *CLISuite) TestRun_Exec_WithDryRunAlias_DoesNotExecute() {
|
||||||
|
castleRoot := filepath.Join(s.homeDir, ".homesick", "repos", "dotfiles")
|
||||||
|
require.NoError(s.T(), os.MkdirAll(castleRoot, 0o755))
|
||||||
|
target := filepath.Join(castleRoot, "should-not-exist")
|
||||||
|
|
||||||
|
exitCode := cli.Run([]string{"--dry-run", "exec", "dotfiles", "touch should-not-exist"}, s.stdout, s.stderr)
|
||||||
|
|
||||||
|
require.Equal(s.T(), 0, exitCode)
|
||||||
|
require.NoFileExists(s.T(), target)
|
||||||
|
require.Contains(s.T(), s.stdout.String(), "Would execute")
|
||||||
|
require.Empty(s.T(), s.stderr.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *CLISuite) TestRun_Exec_WithQuiet_SuppressesStatusOutput() {
|
||||||
|
castleRoot := filepath.Join(s.homeDir, ".homesick", "repos", "dotfiles")
|
||||||
|
require.NoError(s.T(), os.MkdirAll(castleRoot, 0o755))
|
||||||
|
|
||||||
|
exitCode := cli.Run([]string{"--quiet", "exec", "dotfiles", "true"}, s.stdout, s.stderr)
|
||||||
|
|
||||||
|
require.Equal(s.T(), 0, exitCode)
|
||||||
|
require.Empty(s.T(), s.stdout.String())
|
||||||
|
require.Empty(s.T(), s.stderr.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *CLISuite) TestRun_PullAll_NoCastlesIsNoop() {
|
||||||
|
exitCode := cli.Run([]string{"pull", "--all"}, s.stdout, s.stderr)
|
||||||
|
|
||||||
|
require.Equal(s.T(), 0, exitCode)
|
||||||
|
require.Empty(s.T(), s.stderr.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *CLISuite) TestRun_Rc_HomesickrcRequiresForce() {
|
||||||
|
castleRoot := filepath.Join(s.homeDir, ".homesick", "repos", "dotfiles")
|
||||||
|
require.NoError(s.T(), os.MkdirAll(castleRoot, 0o755))
|
||||||
|
require.NoError(s.T(), os.WriteFile(filepath.Join(castleRoot, ".homesickrc"), []byte("# ruby\n"), 0o644))
|
||||||
|
|
||||||
|
exitCode := cli.Run([]string{"rc", "dotfiles"}, s.stdout, s.stderr)
|
||||||
|
|
||||||
|
require.NotEqual(s.T(), 0, exitCode)
|
||||||
|
require.Contains(s.T(), s.stderr.String(), "--force")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *CLISuite) TestRun_Rc_WithForceRuns() {
|
||||||
|
castleRoot := filepath.Join(s.homeDir, ".homesick", "repos", "dotfiles")
|
||||||
|
require.NoError(s.T(), os.MkdirAll(castleRoot, 0o755))
|
||||||
|
require.NoError(s.T(), os.WriteFile(filepath.Join(castleRoot, ".homesickrc"), []byte("# ruby\n"), 0o644))
|
||||||
|
|
||||||
|
exitCode := cli.Run([]string{"rc", "--force", "dotfiles"}, s.stdout, s.stderr)
|
||||||
|
|
||||||
|
require.Equal(s.T(), 0, exitCode)
|
||||||
|
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"}, s.stdout, s.stderr)
|
||||||
|
|
||||||
@@ -74,3 +167,27 @@ func (s *CLISuite) TestRun_Help_UsesProgramNameAndDescription() {
|
|||||||
require.Contains(s.T(), s.stdout.String(), "precious dotfiles")
|
require.Contains(s.T(), s.stdout.String(), "precious dotfiles")
|
||||||
require.Empty(s.T(), s.stderr.String())
|
require.Empty(s.T(), s.stderr.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *CLISuite) TestRun_SymlinkAlias_MatchesLinkCommand() {
|
||||||
|
castleRoot := filepath.Join(s.homeDir, ".homesick", "repos", "dotfiles")
|
||||||
|
castleHome := filepath.Join(castleRoot, "home")
|
||||||
|
require.NoError(s.T(), os.MkdirAll(castleHome, 0o755))
|
||||||
|
require.NoError(s.T(), os.WriteFile(filepath.Join(castleHome, ".vimrc"), []byte("set number\n"), 0o644))
|
||||||
|
|
||||||
|
exitCode := cli.Run([]string{"symlink", "dotfiles"}, s.stdout, s.stderr)
|
||||||
|
|
||||||
|
require.Equal(s.T(), 0, exitCode)
|
||||||
|
target := filepath.Join(s.homeDir, ".vimrc")
|
||||||
|
info, err := os.Lstat(target)
|
||||||
|
require.NoError(s.T(), err)
|
||||||
|
require.True(s.T(), info.Mode()&os.ModeSymlink != 0)
|
||||||
|
require.Empty(s.T(), s.stderr.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *CLISuite) TestRun_Commit_PositionalMessageCompatibility() {
|
||||||
|
exitCode := cli.Run([]string{"--pretend", "commit", "dotfiles", "behavior-suite-commit"}, s.stdout, s.stderr)
|
||||||
|
|
||||||
|
require.Equal(s.T(), 0, exitCode)
|
||||||
|
require.Contains(s.T(), s.stdout.String(), "git commit -m behavior-suite-commit")
|
||||||
|
require.Empty(s.T(), s.stderr.String())
|
||||||
|
}
|
||||||
|
|||||||
112
internal/homesick/core/commit_test.go
Normal file
112
internal/homesick/core/commit_test.go
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
package core_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.hrafn.xyz/aether/gosick/internal/homesick/core"
|
||||||
|
git "github.com/go-git/go-git/v5"
|
||||||
|
"github.com/go-git/go-git/v5/plumbing/object"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"github.com/stretchr/testify/suite"
|
||||||
|
)
|
||||||
|
|
||||||
|
type CommitSuite struct {
|
||||||
|
suite.Suite
|
||||||
|
tmpDir string
|
||||||
|
homeDir string
|
||||||
|
reposDir string
|
||||||
|
app *core.App
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCommitSuite(t *testing.T) {
|
||||||
|
suite.Run(t, new(CommitSuite))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *CommitSuite) SetupTest() {
|
||||||
|
s.tmpDir = s.T().TempDir()
|
||||||
|
s.homeDir = filepath.Join(s.tmpDir, "home")
|
||||||
|
s.reposDir = filepath.Join(s.homeDir, ".homesick", "repos")
|
||||||
|
require.NoError(s.T(), os.MkdirAll(s.reposDir, 0o755))
|
||||||
|
|
||||||
|
s.app = &core.App{
|
||||||
|
HomeDir: s.homeDir,
|
||||||
|
ReposDir: s.reposDir,
|
||||||
|
Stdout: io.Discard,
|
||||||
|
Stderr: io.Discard,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *CommitSuite) createCastleRepo(castle string) string {
|
||||||
|
castleRoot := filepath.Join(s.reposDir, castle)
|
||||||
|
repo, err := git.PlainInit(castleRoot, false)
|
||||||
|
require.NoError(s.T(), err)
|
||||||
|
|
||||||
|
filePath := filepath.Join(castleRoot, "home", ".vimrc")
|
||||||
|
require.NoError(s.T(), os.MkdirAll(filepath.Dir(filePath), 0o755))
|
||||||
|
require.NoError(s.T(), os.WriteFile(filePath, []byte("set number\n"), 0o644))
|
||||||
|
|
||||||
|
wt, err := repo.Worktree()
|
||||||
|
require.NoError(s.T(), err)
|
||||||
|
_, err = wt.Add("home/.vimrc")
|
||||||
|
require.NoError(s.T(), err)
|
||||||
|
_, err = wt.Commit("initial", &git.CommitOptions{Author: &object.Signature{
|
||||||
|
Name: "Commit Test",
|
||||||
|
Email: "commit@test.local",
|
||||||
|
When: time.Now(),
|
||||||
|
}})
|
||||||
|
require.NoError(s.T(), err)
|
||||||
|
|
||||||
|
return castleRoot
|
||||||
|
}
|
||||||
|
|
||||||
|
func gitOutputAt(dir string, args ...string) (string, error) {
|
||||||
|
cmd := exec.Command("git", args...)
|
||||||
|
cmd.Dir = dir
|
||||||
|
out, err := cmd.Output()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return string(out), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *CommitSuite) TestCommit_CreatesCommitWithMessage() {
|
||||||
|
castleRoot := s.createCastleRepo("dotfiles")
|
||||||
|
target := filepath.Join(castleRoot, "home", ".vimrc")
|
||||||
|
require.NoError(s.T(), os.WriteFile(target, []byte("set number\nsyntax on\n"), 0o644))
|
||||||
|
|
||||||
|
require.NoError(s.T(), s.app.Commit("dotfiles", "update vimrc"))
|
||||||
|
|
||||||
|
subject, err := gitOutputAt(castleRoot, "log", "-1", "--pretty=%s")
|
||||||
|
require.NoError(s.T(), err)
|
||||||
|
require.Equal(s.T(), "update vimrc\n", subject)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *CommitSuite) TestCommit_MessageEscaping() {
|
||||||
|
castleRoot := s.createCastleRepo("dotfiles")
|
||||||
|
target := filepath.Join(castleRoot, "home", ".vimrc")
|
||||||
|
require.NoError(s.T(), os.WriteFile(target, []byte("set number\nset relativenumber\n"), 0o644))
|
||||||
|
|
||||||
|
msg := "fix \"quoted\" message: keep spaces"
|
||||||
|
require.NoError(s.T(), s.app.Commit("dotfiles", msg))
|
||||||
|
|
||||||
|
subject, err := gitOutputAt(castleRoot, "log", "-1", "--pretty=%s")
|
||||||
|
require.NoError(s.T(), err)
|
||||||
|
require.Equal(s.T(), msg+"\n", subject)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *CommitSuite) TestCommit_RequiresMessage() {
|
||||||
|
err := s.app.Commit("dotfiles", " ")
|
||||||
|
require.Error(s.T(), err)
|
||||||
|
require.Contains(s.T(), strings.ToLower(err.Error()), "message")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *CommitSuite) TestCommit_MissingCastleReturnsError() {
|
||||||
|
err := s.app.Commit("missing", "msg")
|
||||||
|
require.Error(s.T(), err)
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package core
|
package core
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bufio"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
@@ -17,13 +18,23 @@ import (
|
|||||||
type App struct {
|
type App struct {
|
||||||
HomeDir string
|
HomeDir string
|
||||||
ReposDir string
|
ReposDir string
|
||||||
|
Stdin io.Reader
|
||||||
Stdout io.Writer
|
Stdout io.Writer
|
||||||
Stderr io.Writer
|
Stderr io.Writer
|
||||||
Verbose bool
|
Verbose bool
|
||||||
Force bool
|
Force bool
|
||||||
|
Quiet bool
|
||||||
|
Pretend bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(stdout io.Writer, stderr io.Writer) (*App, error) {
|
func New(stdout io.Writer, stderr io.Writer) (*App, error) {
|
||||||
|
if stdout == nil {
|
||||||
|
return nil, errors.New("stdout writer cannot be nil")
|
||||||
|
}
|
||||||
|
if stderr == nil {
|
||||||
|
return nil, errors.New("stderr writer cannot be nil")
|
||||||
|
}
|
||||||
|
|
||||||
home, err := os.UserHomeDir()
|
home, err := os.UserHomeDir()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("resolve home directory: %w", err)
|
return nil, fmt.Errorf("resolve home directory: %w", err)
|
||||||
@@ -32,6 +43,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,
|
||||||
Stdout: stdout,
|
Stdout: stdout,
|
||||||
Stderr: stderr,
|
Stderr: stderr,
|
||||||
}, nil
|
}, nil
|
||||||
@@ -126,11 +138,283 @@ func (a *App) List() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) Status(castle string) error {
|
func (a *App) Status(castle string) error {
|
||||||
return runGitWithIO(filepath.Join(a.ReposDir, castle), a.Stdout, a.Stderr, "status")
|
return a.runGit(filepath.Join(a.ReposDir, castle), "status")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) Diff(castle string) error {
|
func (a *App) Diff(castle string) error {
|
||||||
return runGitWithIO(filepath.Join(a.ReposDir, castle), a.Stdout, a.Stderr, "diff")
|
return a.runGit(filepath.Join(a.ReposDir, castle), "diff")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) Pull(castle string) error {
|
||||||
|
if strings.TrimSpace(castle) == "" {
|
||||||
|
castle = "dotfiles"
|
||||||
|
}
|
||||||
|
return a.runGit(filepath.Join(a.ReposDir, castle), "pull")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) PullAll() error {
|
||||||
|
if _, err := os.Stat(a.ReposDir); err != nil {
|
||||||
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
var castles []string
|
||||||
|
err := filepath.WalkDir(a.ReposDir, func(path string, d fs.DirEntry, walkErr error) error {
|
||||||
|
if walkErr != nil {
|
||||||
|
return walkErr
|
||||||
|
}
|
||||||
|
if !d.IsDir() || d.Name() != ".git" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
castleRoot := filepath.Dir(path)
|
||||||
|
rel, err := filepath.Rel(a.ReposDir, castleRoot)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
castles = append(castles, rel)
|
||||||
|
return filepath.SkipDir
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
sort.Strings(castles)
|
||||||
|
for _, castle := range castles {
|
||||||
|
if !a.Quiet {
|
||||||
|
if _, err := fmt.Fprintf(a.Stdout, "%s:\n", castle); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := a.runGit(filepath.Join(a.ReposDir, castle), "pull"); err != nil {
|
||||||
|
return fmt.Errorf("pull --all failed for %q: %w", castle, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) Push(castle string) error {
|
||||||
|
if strings.TrimSpace(castle) == "" {
|
||||||
|
castle = "dotfiles"
|
||||||
|
}
|
||||||
|
return a.runGit(filepath.Join(a.ReposDir, castle), "push")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) Commit(castle string, message string) error {
|
||||||
|
if strings.TrimSpace(castle) == "" {
|
||||||
|
castle = "dotfiles"
|
||||||
|
}
|
||||||
|
|
||||||
|
trimmedMessage := strings.TrimSpace(message)
|
||||||
|
if trimmedMessage == "" {
|
||||||
|
return errors.New("commit requires message")
|
||||||
|
}
|
||||||
|
|
||||||
|
castledir := filepath.Join(a.ReposDir, castle)
|
||||||
|
if err := a.runGit(castledir, "add", "--all"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return a.runGit(castledir, "commit", "-m", trimmedMessage)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) Destroy(castle string) error {
|
||||||
|
if strings.TrimSpace(castle) == "" {
|
||||||
|
castle = "dotfiles"
|
||||||
|
}
|
||||||
|
|
||||||
|
castleRoot := filepath.Join(a.ReposDir, castle)
|
||||||
|
castleInfo, err := os.Lstat(castleRoot)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
|
return fmt.Errorf("castle %q not found", castle)
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if !a.Force {
|
||||||
|
confirmed, confirmErr := a.confirmDestroy(castle)
|
||||||
|
if confirmErr != nil {
|
||||||
|
return confirmErr
|
||||||
|
}
|
||||||
|
if !confirmed {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only attempt unlinking managed home files for regular castle directories.
|
||||||
|
if castleInfo.Mode()&os.ModeSymlink == 0 {
|
||||||
|
castleHome := filepath.Join(castleRoot, "home")
|
||||||
|
if info, statErr := os.Stat(castleHome); statErr == nil && info.IsDir() {
|
||||||
|
if unlinkErr := a.UnlinkCastle(castle); unlinkErr != nil {
|
||||||
|
return unlinkErr
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return os.RemoveAll(castleRoot)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) confirmDestroy(castle string) (bool, error) {
|
||||||
|
reader := a.Stdin
|
||||||
|
if reader == nil {
|
||||||
|
reader = os.Stdin
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := fmt.Fprintf(a.Stdout, "Destroy castle %q? [y/N]: ", castle); err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
line, err := bufio.NewReader(reader).ReadString('\n')
|
||||||
|
if err != nil && !errors.Is(err, io.EOF) {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return isAffirmativeResponse(line), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func isAffirmativeResponse(input string) bool {
|
||||||
|
response := strings.ToLower(strings.TrimSpace(input))
|
||||||
|
return response == "y" || response == "yes"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) Open(castle string) error {
|
||||||
|
if strings.TrimSpace(castle) == "" {
|
||||||
|
castle = "dotfiles"
|
||||||
|
}
|
||||||
|
|
||||||
|
editor := strings.TrimSpace(os.Getenv("EDITOR"))
|
||||||
|
if editor == "" {
|
||||||
|
return errors.New("the $EDITOR environment variable must be set to use this command")
|
||||||
|
}
|
||||||
|
|
||||||
|
castleHome := filepath.Join(a.ReposDir, castle, "home")
|
||||||
|
if info, err := os.Stat(castleHome); err != nil || !info.IsDir() {
|
||||||
|
return fmt.Errorf("could not open %s, expected %s to exist and contain dotfiles", castle, castleHome)
|
||||||
|
}
|
||||||
|
|
||||||
|
castleRoot := filepath.Join(a.ReposDir, castle)
|
||||||
|
cmd := exec.Command("sh", "-c", editor+" .")
|
||||||
|
cmd.Dir = castleRoot
|
||||||
|
cmd.Stdout = a.Stdout
|
||||||
|
cmd.Stderr = a.Stderr
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
return fmt.Errorf("open failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) Exec(castle string, command []string) error {
|
||||||
|
commandString := strings.TrimSpace(strings.Join(command, " "))
|
||||||
|
if commandString == "" {
|
||||||
|
return errors.New("exec requires COMMAND")
|
||||||
|
}
|
||||||
|
|
||||||
|
castleRoot := filepath.Join(a.ReposDir, castle)
|
||||||
|
if _, err := os.Stat(castleRoot); err != nil {
|
||||||
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
|
return fmt.Errorf("castle %q not found", castle)
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
a.sayStatus("exec", fmt.Sprintf("%s command %q in castle %q", a.actionVerb(), commandString, castle))
|
||||||
|
if a.Pretend {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd := exec.Command("sh", "-c", commandString)
|
||||||
|
cmd.Dir = castleRoot
|
||||||
|
cmd.Stdout = a.Stdout
|
||||||
|
cmd.Stderr = a.Stderr
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
return fmt.Errorf("exec failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) ExecAll(command []string) error {
|
||||||
|
commandString := strings.TrimSpace(strings.Join(command, " "))
|
||||||
|
if commandString == "" {
|
||||||
|
return errors.New("exec_all requires COMMAND")
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := os.Stat(a.ReposDir); err != nil {
|
||||||
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
var castles []string
|
||||||
|
err := filepath.WalkDir(a.ReposDir, func(path string, d fs.DirEntry, walkErr error) error {
|
||||||
|
if walkErr != nil {
|
||||||
|
return walkErr
|
||||||
|
}
|
||||||
|
if !d.IsDir() || d.Name() != ".git" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
castleRoot := filepath.Dir(path)
|
||||||
|
rel, err := filepath.Rel(a.ReposDir, castleRoot)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
castles = append(castles, rel)
|
||||||
|
return filepath.SkipDir
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
sort.Strings(castles)
|
||||||
|
for _, castle := range castles {
|
||||||
|
if err := a.Exec(castle, []string{commandString}); err != nil {
|
||||||
|
return fmt.Errorf("exec_all failed for %q: %w", castle, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) Generate(castlePath string) error {
|
||||||
|
trimmed := strings.TrimSpace(castlePath)
|
||||||
|
if trimmed == "" {
|
||||||
|
return errors.New("generate requires PATH")
|
||||||
|
}
|
||||||
|
|
||||||
|
absCastle, err := filepath.Abs(trimmed)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.MkdirAll(absCastle, 0o755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := a.runGit(absCastle, "init"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
githubUser := ""
|
||||||
|
if out, cfgErr := gitOutput(absCastle, "config", "github.user"); cfgErr == nil {
|
||||||
|
githubUser = strings.TrimSpace(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
if githubUser != "" {
|
||||||
|
repoName := filepath.Base(absCastle)
|
||||||
|
url := fmt.Sprintf("git@github.com:%s/%s.git", githubUser, repoName)
|
||||||
|
if err := a.runGit(absCastle, "remote", "add", "origin", url); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return os.MkdirAll(filepath.Join(absCastle, "home"), 0o755)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) Link(castle string) error {
|
func (a *App) Link(castle string) error {
|
||||||
@@ -521,6 +805,28 @@ func runGitWithIO(dir string, stdout io.Writer, stderr io.Writer, args ...string
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (a *App) runGit(dir string, args ...string) error {
|
||||||
|
if a.Pretend {
|
||||||
|
a.sayStatus("git", fmt.Sprintf("%s git %s in %s", a.actionVerb(), strings.Join(args, " "), dir))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return runGitWithIO(dir, a.Stdout, a.Stderr, args...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) actionVerb() string {
|
||||||
|
if a.Pretend {
|
||||||
|
return "Would execute"
|
||||||
|
}
|
||||||
|
return "Executing"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) sayStatus(action string, message string) {
|
||||||
|
if a.Quiet {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, _ = fmt.Fprintf(a.Stdout, "%s: %s\n", action, message)
|
||||||
|
}
|
||||||
|
|
||||||
func gitOutput(dir string, args ...string) (string, error) {
|
func gitOutput(dir string, args ...string) (string, error) {
|
||||||
cmd := exec.Command("git", args...)
|
cmd := exec.Command("git", args...)
|
||||||
cmd.Dir = dir
|
cmd.Dir = dir
|
||||||
@@ -531,6 +837,86 @@ func gitOutput(dir string, args ...string) (string, error) {
|
|||||||
return string(out), nil
|
return string(out), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Rc runs the rc hooks for the given castle. It looks for executable files
|
||||||
|
// inside <castle>/.homesick.d and runs them in sorted (lexicographic) order
|
||||||
|
// with the castle root as the working directory, forwarding stdout and stderr
|
||||||
|
// to the App writers.
|
||||||
|
//
|
||||||
|
// If a .homesickrc file exists in the castle root and no parity.rb wrapper
|
||||||
|
// already exists in .homesick.d, a Ruby wrapper script named parity.rb is
|
||||||
|
// written there before execution so that it sorts first.
|
||||||
|
func (a *App) Rc(castle string) error {
|
||||||
|
castleRoot := filepath.Join(a.ReposDir, castle)
|
||||||
|
if _, err := os.Stat(castleRoot); err != nil {
|
||||||
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
|
return fmt.Errorf("castle %q not found", castle)
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
homesickD := filepath.Join(castleRoot, ".homesick.d")
|
||||||
|
homesickRc := filepath.Join(castleRoot, ".homesickrc")
|
||||||
|
|
||||||
|
if _, err := os.Stat(homesickRc); err == nil && !a.Force {
|
||||||
|
return errors.New("refusing to run legacy .homesickrc without --force")
|
||||||
|
}
|
||||||
|
|
||||||
|
// If .homesickrc exists, ensure .homesick.d/parity.rb wrapper is created
|
||||||
|
// (but do not overwrite an existing parity.rb).
|
||||||
|
if _, err := os.Stat(homesickRc); err == nil {
|
||||||
|
wrapperPath := filepath.Join(homesickD, "parity.rb")
|
||||||
|
if _, err := os.Stat(wrapperPath); errors.Is(err, os.ErrNotExist) {
|
||||||
|
if mkErr := os.MkdirAll(homesickD, 0o755); mkErr != nil {
|
||||||
|
return fmt.Errorf("create .homesick.d: %w", mkErr)
|
||||||
|
}
|
||||||
|
wrapperContent := "#!/usr/bin/env ruby\n" +
|
||||||
|
"# parity.rb — generated wrapper for legacy .homesickrc\n" +
|
||||||
|
"# Evaluates .homesickrc in the context of the castle root.\n" +
|
||||||
|
"rc_file = File.join(__dir__, '..', '.homesickrc')\n" +
|
||||||
|
"eval(File.read(rc_file), binding, rc_file) if File.exist?(rc_file)\n"
|
||||||
|
if writeErr := os.WriteFile(wrapperPath, []byte(wrapperContent), 0o755); writeErr != nil {
|
||||||
|
return fmt.Errorf("write parity.rb: %w", writeErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := os.Stat(homesickD); err != nil {
|
||||||
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
entries, err := os.ReadDir(homesickD)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReadDir returns entries in sorted order already.
|
||||||
|
for _, entry := range entries {
|
||||||
|
if entry.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
info, infoErr := entry.Info()
|
||||||
|
if infoErr != nil {
|
||||||
|
return infoErr
|
||||||
|
}
|
||||||
|
if info.Mode()&0o111 == 0 {
|
||||||
|
// Not executable — skip.
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
scriptPath := filepath.Join(homesickD, entry.Name())
|
||||||
|
cmd := exec.Command(scriptPath)
|
||||||
|
cmd.Dir = castleRoot
|
||||||
|
cmd.Stdout = a.Stdout
|
||||||
|
cmd.Stderr = a.Stderr
|
||||||
|
if runErr := cmd.Run(); runErr != nil {
|
||||||
|
return fmt.Errorf("rc script %q failed: %w", entry.Name(), runErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func deriveDestination(uri string) string {
|
func deriveDestination(uri string) string {
|
||||||
candidate := strings.TrimSpace(uri)
|
candidate := strings.TrimSpace(uri)
|
||||||
candidate = strings.TrimPrefix(candidate, "https://github.com/")
|
candidate = strings.TrimPrefix(candidate, "https://github.com/")
|
||||||
|
|||||||
@@ -1,6 +1,31 @@
|
|||||||
package core
|
package core
|
||||||
|
|
||||||
import "testing"
|
import (
|
||||||
|
"bytes"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNewRejectsNilWriters(t *testing.T) {
|
||||||
|
t.Run("nil stdout", func(t *testing.T) {
|
||||||
|
app, err := New(nil, &bytes.Buffer{})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for nil stdout")
|
||||||
|
}
|
||||||
|
if app != nil {
|
||||||
|
t.Fatal("expected nil app for nil stdout")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("nil stderr", func(t *testing.T) {
|
||||||
|
app, err := New(&bytes.Buffer{}, nil)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for nil stderr")
|
||||||
|
}
|
||||||
|
if app != nil {
|
||||||
|
t.Fatal("expected nil app for nil stderr")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func TestDeriveDestination(t *testing.T) {
|
func TestDeriveDestination(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
|
|||||||
105
internal/homesick/core/destroy_test.go
Normal file
105
internal/homesick/core/destroy_test.go
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
package core_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.hrafn.xyz/aether/gosick/internal/homesick/core"
|
||||||
|
git "github.com/go-git/go-git/v5"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"github.com/stretchr/testify/suite"
|
||||||
|
)
|
||||||
|
|
||||||
|
type DestroySuite struct {
|
||||||
|
suite.Suite
|
||||||
|
tmpDir string
|
||||||
|
homeDir string
|
||||||
|
reposDir string
|
||||||
|
app *core.App
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDestroySuite(t *testing.T) {
|
||||||
|
suite.Run(t, new(DestroySuite))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *DestroySuite) SetupTest() {
|
||||||
|
s.tmpDir = s.T().TempDir()
|
||||||
|
s.homeDir = filepath.Join(s.tmpDir, "home")
|
||||||
|
s.reposDir = filepath.Join(s.homeDir, ".homesick", "repos")
|
||||||
|
require.NoError(s.T(), os.MkdirAll(s.reposDir, 0o755))
|
||||||
|
|
||||||
|
s.app = &core.App{
|
||||||
|
HomeDir: s.homeDir,
|
||||||
|
ReposDir: s.reposDir,
|
||||||
|
Stdin: strings.NewReader("y\n"),
|
||||||
|
Stdout: io.Discard,
|
||||||
|
Stderr: io.Discard,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *DestroySuite) createCastleRepo(castle string) string {
|
||||||
|
castleRoot := filepath.Join(s.reposDir, castle)
|
||||||
|
require.NoError(s.T(), os.MkdirAll(filepath.Join(castleRoot, "home"), 0o755))
|
||||||
|
_, err := git.PlainInit(castleRoot, false)
|
||||||
|
require.NoError(s.T(), err)
|
||||||
|
return castleRoot
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *DestroySuite) TestDestroy_RemovesCastleDirectory() {
|
||||||
|
castleRoot := s.createCastleRepo("dotfiles")
|
||||||
|
require.DirExists(s.T(), castleRoot)
|
||||||
|
|
||||||
|
s.app.Stdin = strings.NewReader("y\n")
|
||||||
|
require.NoError(s.T(), s.app.Destroy("dotfiles"))
|
||||||
|
require.NoDirExists(s.T(), castleRoot)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *DestroySuite) TestDestroy_MissingCastleReturnsError() {
|
||||||
|
err := s.app.Destroy("missing")
|
||||||
|
require.Error(s.T(), err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *DestroySuite) TestDestroy_UnlinksDotfilesBeforeRemoval() {
|
||||||
|
castleRoot := s.createCastleRepo("dotfiles")
|
||||||
|
tracked := filepath.Join(castleRoot, "home", ".vimrc")
|
||||||
|
require.NoError(s.T(), os.WriteFile(tracked, []byte("set number\n"), 0o644))
|
||||||
|
|
||||||
|
require.NoError(s.T(), s.app.LinkCastle("dotfiles"))
|
||||||
|
homePath := filepath.Join(s.homeDir, ".vimrc")
|
||||||
|
info, err := os.Lstat(homePath)
|
||||||
|
require.NoError(s.T(), err)
|
||||||
|
require.NotZero(s.T(), info.Mode()&os.ModeSymlink)
|
||||||
|
|
||||||
|
s.app.Stdin = strings.NewReader("y\n")
|
||||||
|
require.NoError(s.T(), s.app.Destroy("dotfiles"))
|
||||||
|
|
||||||
|
_, err = os.Lstat(homePath)
|
||||||
|
require.Error(s.T(), err)
|
||||||
|
require.True(s.T(), os.IsNotExist(err))
|
||||||
|
require.NoDirExists(s.T(), castleRoot)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *DestroySuite) TestDestroy_RemovesSymlinkedCastleOnly() {
|
||||||
|
target := filepath.Join(s.tmpDir, "local-castle")
|
||||||
|
require.NoError(s.T(), os.MkdirAll(target, 0o755))
|
||||||
|
|
||||||
|
symlinkCastle := filepath.Join(s.reposDir, "dotfiles")
|
||||||
|
require.NoError(s.T(), os.Symlink(target, symlinkCastle))
|
||||||
|
|
||||||
|
s.app.Stdin = strings.NewReader("y\n")
|
||||||
|
require.NoError(s.T(), s.app.Destroy("dotfiles"))
|
||||||
|
require.NoFileExists(s.T(), symlinkCastle)
|
||||||
|
require.DirExists(s.T(), target)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *DestroySuite) TestDestroy_DeclineConfirmationKeepsCastle() {
|
||||||
|
castleRoot := s.createCastleRepo("dotfiles")
|
||||||
|
require.DirExists(s.T(), castleRoot)
|
||||||
|
|
||||||
|
s.app.Stdin = strings.NewReader("n\n")
|
||||||
|
require.NoError(s.T(), s.app.Destroy("dotfiles"))
|
||||||
|
require.DirExists(s.T(), castleRoot)
|
||||||
|
}
|
||||||
90
internal/homesick/core/exec_test.go
Normal file
90
internal/homesick/core/exec_test.go
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
package core_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.hrafn.xyz/aether/gosick/internal/homesick/core"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"github.com/stretchr/testify/suite"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ExecSuite struct {
|
||||||
|
suite.Suite
|
||||||
|
tmpDir string
|
||||||
|
homeDir string
|
||||||
|
reposDir string
|
||||||
|
stdout *bytes.Buffer
|
||||||
|
stderr *bytes.Buffer
|
||||||
|
app *core.App
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExecSuite(t *testing.T) {
|
||||||
|
suite.Run(t, new(ExecSuite))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ExecSuite) SetupTest() {
|
||||||
|
s.tmpDir = s.T().TempDir()
|
||||||
|
s.homeDir = filepath.Join(s.tmpDir, "home")
|
||||||
|
s.reposDir = filepath.Join(s.homeDir, ".homesick", "repos")
|
||||||
|
require.NoError(s.T(), os.MkdirAll(s.reposDir, 0o755))
|
||||||
|
|
||||||
|
s.stdout = &bytes.Buffer{}
|
||||||
|
s.stderr = &bytes.Buffer{}
|
||||||
|
s.app = &core.App{
|
||||||
|
HomeDir: s.homeDir,
|
||||||
|
ReposDir: s.reposDir,
|
||||||
|
Stdout: s.stdout,
|
||||||
|
Stderr: s.stderr,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ExecSuite) createCastle(name string) string {
|
||||||
|
castleRoot := filepath.Join(s.reposDir, name)
|
||||||
|
require.NoError(s.T(), os.MkdirAll(castleRoot, 0o755))
|
||||||
|
return castleRoot
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ExecSuite) TestExec_UnknownCastleReturnsError() {
|
||||||
|
err := s.app.Exec("nonexistent", []string{"pwd"})
|
||||||
|
require.Error(s.T(), err)
|
||||||
|
require.Contains(s.T(), err.Error(), "not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ExecSuite) TestExec_RunsCommandInCastleRoot() {
|
||||||
|
castleRoot := s.createCastle("dotfiles")
|
||||||
|
|
||||||
|
require.NoError(s.T(), s.app.Exec("dotfiles", []string{"pwd"}))
|
||||||
|
require.Contains(s.T(), s.stdout.String(), castleRoot)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ExecSuite) TestExec_ForwardsStdoutAndStderr() {
|
||||||
|
s.createCastle("dotfiles")
|
||||||
|
|
||||||
|
require.NoError(s.T(), s.app.Exec("dotfiles", []string{"echo out && echo err >&2"}))
|
||||||
|
require.Contains(s.T(), s.stdout.String(), "out")
|
||||||
|
require.Contains(s.T(), s.stderr.String(), "err")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ExecSuite) TestExecAll_RunsCommandForEachCastle() {
|
||||||
|
zeta := s.createCastle("zeta")
|
||||||
|
alpha := s.createCastle("alpha")
|
||||||
|
require.NoError(s.T(), os.MkdirAll(filepath.Join(zeta, ".git"), 0o755))
|
||||||
|
require.NoError(s.T(), os.MkdirAll(filepath.Join(alpha, ".git"), 0o755))
|
||||||
|
|
||||||
|
require.NoError(s.T(), s.app.ExecAll([]string{"basename \"$PWD\""}))
|
||||||
|
require.Contains(s.T(), s.stdout.String(), "alpha")
|
||||||
|
require.Contains(s.T(), s.stdout.String(), "zeta")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ExecSuite) TestExec_PretendDoesNotExecuteCommand() {
|
||||||
|
castleRoot := s.createCastle("dotfiles")
|
||||||
|
target := filepath.Join(castleRoot, "should-not-exist")
|
||||||
|
|
||||||
|
s.app.Pretend = true
|
||||||
|
require.NoError(s.T(), s.app.Exec("dotfiles", []string{"touch should-not-exist"}))
|
||||||
|
require.NoFileExists(s.T(), target)
|
||||||
|
require.Contains(s.T(), s.stdout.String(), "Would execute")
|
||||||
|
}
|
||||||
69
internal/homesick/core/generate_test.go
Normal file
69
internal/homesick/core/generate_test.go
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
package core_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.hrafn.xyz/aether/gosick/internal/homesick/core"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"github.com/stretchr/testify/suite"
|
||||||
|
)
|
||||||
|
|
||||||
|
type GenerateSuite struct {
|
||||||
|
suite.Suite
|
||||||
|
tmpDir string
|
||||||
|
app *core.App
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGenerateSuite(t *testing.T) {
|
||||||
|
suite.Run(t, new(GenerateSuite))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *GenerateSuite) SetupTest() {
|
||||||
|
s.tmpDir = s.T().TempDir()
|
||||||
|
s.app = &core.App{
|
||||||
|
HomeDir: filepath.Join(s.tmpDir, "home"),
|
||||||
|
ReposDir: filepath.Join(s.tmpDir, "home", ".homesick", "repos"),
|
||||||
|
Stdout: io.Discard,
|
||||||
|
Stderr: io.Discard,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *GenerateSuite) TestGenerate_CreatesGitRepoAndHomeDir() {
|
||||||
|
castlePath := filepath.Join(s.tmpDir, "my-castle")
|
||||||
|
|
||||||
|
require.NoError(s.T(), s.app.Generate(castlePath))
|
||||||
|
require.DirExists(s.T(), castlePath)
|
||||||
|
require.DirExists(s.T(), filepath.Join(castlePath, ".git"))
|
||||||
|
require.DirExists(s.T(), filepath.Join(castlePath, "home"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *GenerateSuite) TestGenerate_AddsOriginWhenGitHubUserConfigured() {
|
||||||
|
castlePath := filepath.Join(s.tmpDir, "my-castle")
|
||||||
|
gitConfig := filepath.Join(s.tmpDir, "gitconfig")
|
||||||
|
require.NoError(s.T(), os.WriteFile(gitConfig, []byte("[github]\n\tuser = octocat\n"), 0o644))
|
||||||
|
s.T().Setenv("GIT_CONFIG_GLOBAL", gitConfig)
|
||||||
|
s.T().Setenv("GIT_CONFIG_NOSYSTEM", "1")
|
||||||
|
|
||||||
|
require.NoError(s.T(), s.app.Generate(castlePath))
|
||||||
|
|
||||||
|
configPath := filepath.Join(castlePath, ".git", "config")
|
||||||
|
content, err := os.ReadFile(configPath)
|
||||||
|
require.NoError(s.T(), err)
|
||||||
|
require.Contains(s.T(), string(content), "git@github.com:octocat/my-castle.git")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *GenerateSuite) TestGenerate_DoesNotAddOriginWhenGitHubUserMissing() {
|
||||||
|
castlePath := filepath.Join(s.tmpDir, "my-castle")
|
||||||
|
s.T().Setenv("GIT_CONFIG_GLOBAL", filepath.Join(s.tmpDir, "nonexistent-config"))
|
||||||
|
s.T().Setenv("GIT_CONFIG_NOSYSTEM", "1")
|
||||||
|
|
||||||
|
require.NoError(s.T(), s.app.Generate(castlePath))
|
||||||
|
|
||||||
|
configPath := filepath.Join(castlePath, ".git", "config")
|
||||||
|
content, err := os.ReadFile(configPath)
|
||||||
|
require.NoError(s.T(), err)
|
||||||
|
require.NotContains(s.T(), string(content), "[remote \"origin\"]")
|
||||||
|
}
|
||||||
86
internal/homesick/core/open_test.go
Normal file
86
internal/homesick/core/open_test.go
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
package core_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.hrafn.xyz/aether/gosick/internal/homesick/core"
|
||||||
|
git "github.com/go-git/go-git/v5"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"github.com/stretchr/testify/suite"
|
||||||
|
)
|
||||||
|
|
||||||
|
type OpenSuite struct {
|
||||||
|
suite.Suite
|
||||||
|
tmpDir string
|
||||||
|
homeDir string
|
||||||
|
reposDir string
|
||||||
|
stdout *bytes.Buffer
|
||||||
|
stderr *bytes.Buffer
|
||||||
|
app *core.App
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOpenSuite(t *testing.T) {
|
||||||
|
suite.Run(t, new(OpenSuite))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *OpenSuite) SetupTest() {
|
||||||
|
s.tmpDir = s.T().TempDir()
|
||||||
|
s.homeDir = filepath.Join(s.tmpDir, "home")
|
||||||
|
s.reposDir = filepath.Join(s.homeDir, ".homesick", "repos")
|
||||||
|
require.NoError(s.T(), os.MkdirAll(s.reposDir, 0o755))
|
||||||
|
|
||||||
|
s.stdout = &bytes.Buffer{}
|
||||||
|
s.stderr = &bytes.Buffer{}
|
||||||
|
s.app = &core.App{
|
||||||
|
HomeDir: s.homeDir,
|
||||||
|
ReposDir: s.reposDir,
|
||||||
|
Stdout: s.stdout,
|
||||||
|
Stderr: s.stderr,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *OpenSuite) createCastleRepo(castle string) string {
|
||||||
|
castleRoot := filepath.Join(s.reposDir, castle)
|
||||||
|
require.NoError(s.T(), os.MkdirAll(filepath.Join(castleRoot, "home"), 0o755))
|
||||||
|
_, err := git.PlainInit(castleRoot, false)
|
||||||
|
require.NoError(s.T(), err)
|
||||||
|
return castleRoot
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *OpenSuite) TestOpen_RequiresEditorEnv() {
|
||||||
|
s.createCastleRepo("dotfiles")
|
||||||
|
s.T().Setenv("EDITOR", "")
|
||||||
|
|
||||||
|
err := s.app.Open("dotfiles")
|
||||||
|
require.Error(s.T(), err)
|
||||||
|
require.Contains(s.T(), err.Error(), "$EDITOR")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *OpenSuite) TestOpen_MissingCastleReturnsError() {
|
||||||
|
s.T().Setenv("EDITOR", "vim")
|
||||||
|
|
||||||
|
err := s.app.Open("missing")
|
||||||
|
require.Error(s.T(), err)
|
||||||
|
require.Contains(s.T(), err.Error(), "could not open")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *OpenSuite) TestOpen_RunsEditorInCastleRoot() {
|
||||||
|
castleRoot := s.createCastleRepo("dotfiles")
|
||||||
|
|
||||||
|
capture := filepath.Join(s.tmpDir, "open_capture.txt")
|
||||||
|
editorScript := filepath.Join(s.tmpDir, "editor.sh")
|
||||||
|
require.NoError(s.T(), os.WriteFile(editorScript, []byte("#!/bin/sh\npwd > \""+capture+"\"\necho \"$1\" >> \""+capture+"\"\n"), 0o755))
|
||||||
|
s.T().Setenv("EDITOR", editorScript)
|
||||||
|
|
||||||
|
require.NoError(s.T(), s.app.Open("dotfiles"))
|
||||||
|
|
||||||
|
content, err := os.ReadFile(capture)
|
||||||
|
require.NoError(s.T(), err)
|
||||||
|
require.Equal(s.T(), castleRoot+"\n.\n", string(content))
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ io.Writer
|
||||||
142
internal/homesick/core/pull_test.go
Normal file
142
internal/homesick/core/pull_test.go
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
package core_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.hrafn.xyz/aether/gosick/internal/homesick/core"
|
||||||
|
git "github.com/go-git/go-git/v5"
|
||||||
|
"github.com/go-git/go-git/v5/config"
|
||||||
|
"github.com/go-git/go-git/v5/plumbing/object"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"github.com/stretchr/testify/suite"
|
||||||
|
)
|
||||||
|
|
||||||
|
type PullSuite struct {
|
||||||
|
suite.Suite
|
||||||
|
tmpDir string
|
||||||
|
homeDir string
|
||||||
|
reposDir string
|
||||||
|
app *core.App
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPullSuite(t *testing.T) {
|
||||||
|
suite.Run(t, new(PullSuite))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *PullSuite) SetupTest() {
|
||||||
|
s.tmpDir = s.T().TempDir()
|
||||||
|
s.homeDir = filepath.Join(s.tmpDir, "home")
|
||||||
|
s.reposDir = filepath.Join(s.homeDir, ".homesick", "repos")
|
||||||
|
require.NoError(s.T(), os.MkdirAll(s.reposDir, 0o755))
|
||||||
|
|
||||||
|
s.app = &core.App{
|
||||||
|
HomeDir: s.homeDir,
|
||||||
|
ReposDir: s.reposDir,
|
||||||
|
Stdout: io.Discard,
|
||||||
|
Stderr: io.Discard,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *PullSuite) createRemoteWithClone(castle string) (string, string) {
|
||||||
|
remotePath := filepath.Join(s.tmpDir, castle+".git")
|
||||||
|
_, err := git.PlainInit(remotePath, true)
|
||||||
|
require.NoError(s.T(), err)
|
||||||
|
|
||||||
|
seedPath := filepath.Join(s.tmpDir, castle+"-seed")
|
||||||
|
seedRepo, err := git.PlainInit(seedPath, false)
|
||||||
|
require.NoError(s.T(), err)
|
||||||
|
|
||||||
|
seedFile := filepath.Join(seedPath, "home", ".vimrc")
|
||||||
|
require.NoError(s.T(), os.MkdirAll(filepath.Dir(seedFile), 0o755))
|
||||||
|
require.NoError(s.T(), os.WriteFile(seedFile, []byte("set number\n"), 0o644))
|
||||||
|
|
||||||
|
wt, err := seedRepo.Worktree()
|
||||||
|
require.NoError(s.T(), err)
|
||||||
|
_, err = wt.Add("home/.vimrc")
|
||||||
|
require.NoError(s.T(), err)
|
||||||
|
|
||||||
|
_, err = wt.Commit("initial", &git.CommitOptions{Author: &object.Signature{
|
||||||
|
Name: "Pull Test",
|
||||||
|
Email: "pull@test.local",
|
||||||
|
When: time.Now(),
|
||||||
|
}})
|
||||||
|
require.NoError(s.T(), err)
|
||||||
|
|
||||||
|
_, err = seedRepo.CreateRemote(&config.RemoteConfig{Name: "origin", URLs: []string{remotePath}})
|
||||||
|
require.NoError(s.T(), err)
|
||||||
|
require.NoError(s.T(), seedRepo.Push(&git.PushOptions{RemoteName: "origin"}))
|
||||||
|
|
||||||
|
clonePath := filepath.Join(s.reposDir, castle)
|
||||||
|
_, err = git.PlainClone(clonePath, false, &git.CloneOptions{URL: remotePath})
|
||||||
|
require.NoError(s.T(), err)
|
||||||
|
|
||||||
|
return remotePath, clonePath
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *PullSuite) addRemoteCommit(remotePath string, castle string) {
|
||||||
|
workPath := filepath.Join(s.tmpDir, castle+"-work")
|
||||||
|
repo, err := git.PlainClone(workPath, false, &git.CloneOptions{URL: remotePath})
|
||||||
|
require.NoError(s.T(), err)
|
||||||
|
|
||||||
|
filePath := filepath.Join(workPath, "home", ".zshrc")
|
||||||
|
require.NoError(s.T(), os.MkdirAll(filepath.Dir(filePath), 0o755))
|
||||||
|
require.NoError(s.T(), os.WriteFile(filePath, []byte("export EDITOR=vim\n"), 0o644))
|
||||||
|
|
||||||
|
wt, err := repo.Worktree()
|
||||||
|
require.NoError(s.T(), err)
|
||||||
|
_, err = wt.Add("home/.zshrc")
|
||||||
|
require.NoError(s.T(), err)
|
||||||
|
|
||||||
|
_, err = wt.Commit("add zshrc", &git.CommitOptions{Author: &object.Signature{
|
||||||
|
Name: "Pull Test",
|
||||||
|
Email: "pull@test.local",
|
||||||
|
When: time.Now(),
|
||||||
|
}})
|
||||||
|
require.NoError(s.T(), err)
|
||||||
|
require.NoError(s.T(), repo.Push(&git.PushOptions{RemoteName: "origin"}))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *PullSuite) TestPull_UpdatesCastleFromOrigin() {
|
||||||
|
remotePath, clonePath := s.createRemoteWithClone("dotfiles")
|
||||||
|
s.addRemoteCommit(remotePath, "dotfiles")
|
||||||
|
|
||||||
|
require.NoError(s.T(), s.app.Pull("dotfiles"))
|
||||||
|
require.FileExists(s.T(), filepath.Join(clonePath, "home", ".zshrc"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *PullSuite) TestPull_MissingCastleReturnsError() {
|
||||||
|
err := s.app.Pull("missing")
|
||||||
|
require.Error(s.T(), err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *PullSuite) TestPullAll_UpdatesAllCastlesFromOrigin() {
|
||||||
|
remoteA, cloneA := s.createRemoteWithClone("alpha")
|
||||||
|
remoteB, cloneB := s.createRemoteWithClone("zeta")
|
||||||
|
s.addRemoteCommit(remoteA, "alpha")
|
||||||
|
s.addRemoteCommit(remoteB, "zeta")
|
||||||
|
|
||||||
|
require.NoError(s.T(), s.app.PullAll())
|
||||||
|
require.FileExists(s.T(), filepath.Join(cloneA, "home", ".zshrc"))
|
||||||
|
require.FileExists(s.T(), filepath.Join(cloneB, "home", ".zshrc"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *PullSuite) TestPullAll_NoCastlesIsNoop() {
|
||||||
|
require.NoError(s.T(), s.app.PullAll())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *PullSuite) TestPullAll_PrintsCastlePrefixes() {
|
||||||
|
_, _ = s.createRemoteWithClone("alpha")
|
||||||
|
_, _ = s.createRemoteWithClone("zeta")
|
||||||
|
|
||||||
|
stdout := &bytes.Buffer{}
|
||||||
|
s.app.Stdout = stdout
|
||||||
|
|
||||||
|
require.NoError(s.T(), s.app.PullAll())
|
||||||
|
require.Contains(s.T(), stdout.String(), "alpha:")
|
||||||
|
require.Contains(s.T(), stdout.String(), "zeta:")
|
||||||
|
}
|
||||||
116
internal/homesick/core/push_test.go
Normal file
116
internal/homesick/core/push_test.go
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
package core_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.hrafn.xyz/aether/gosick/internal/homesick/core"
|
||||||
|
git "github.com/go-git/go-git/v5"
|
||||||
|
"github.com/go-git/go-git/v5/config"
|
||||||
|
"github.com/go-git/go-git/v5/plumbing/object"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"github.com/stretchr/testify/suite"
|
||||||
|
)
|
||||||
|
|
||||||
|
type PushSuite struct {
|
||||||
|
suite.Suite
|
||||||
|
tmpDir string
|
||||||
|
homeDir string
|
||||||
|
reposDir string
|
||||||
|
app *core.App
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPushSuite(t *testing.T) {
|
||||||
|
suite.Run(t, new(PushSuite))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *PushSuite) SetupTest() {
|
||||||
|
s.tmpDir = s.T().TempDir()
|
||||||
|
s.homeDir = filepath.Join(s.tmpDir, "home")
|
||||||
|
s.reposDir = filepath.Join(s.homeDir, ".homesick", "repos")
|
||||||
|
require.NoError(s.T(), os.MkdirAll(s.reposDir, 0o755))
|
||||||
|
|
||||||
|
s.app = &core.App{
|
||||||
|
HomeDir: s.homeDir,
|
||||||
|
ReposDir: s.reposDir,
|
||||||
|
Stdout: io.Discard,
|
||||||
|
Stderr: io.Discard,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *PushSuite) createRemoteAndClone(castle string) (string, string) {
|
||||||
|
remotePath := filepath.Join(s.tmpDir, castle+".git")
|
||||||
|
_, err := git.PlainInit(remotePath, true)
|
||||||
|
require.NoError(s.T(), err)
|
||||||
|
|
||||||
|
seedPath := filepath.Join(s.tmpDir, castle+"-seed")
|
||||||
|
seedRepo, err := git.PlainInit(seedPath, false)
|
||||||
|
require.NoError(s.T(), err)
|
||||||
|
|
||||||
|
seedFile := filepath.Join(seedPath, "home", ".vimrc")
|
||||||
|
require.NoError(s.T(), os.MkdirAll(filepath.Dir(seedFile), 0o755))
|
||||||
|
require.NoError(s.T(), os.WriteFile(seedFile, []byte("set number\n"), 0o644))
|
||||||
|
|
||||||
|
wt, err := seedRepo.Worktree()
|
||||||
|
require.NoError(s.T(), err)
|
||||||
|
_, err = wt.Add("home/.vimrc")
|
||||||
|
require.NoError(s.T(), err)
|
||||||
|
|
||||||
|
_, err = wt.Commit("initial", &git.CommitOptions{Author: &object.Signature{
|
||||||
|
Name: "Push Test",
|
||||||
|
Email: "push@test.local",
|
||||||
|
When: time.Now(),
|
||||||
|
}})
|
||||||
|
require.NoError(s.T(), err)
|
||||||
|
|
||||||
|
_, err = seedRepo.CreateRemote(&config.RemoteConfig{Name: "origin", URLs: []string{remotePath}})
|
||||||
|
require.NoError(s.T(), err)
|
||||||
|
require.NoError(s.T(), seedRepo.Push(&git.PushOptions{RemoteName: "origin"}))
|
||||||
|
|
||||||
|
clonePath := filepath.Join(s.reposDir, castle)
|
||||||
|
_, err = git.PlainClone(clonePath, false, &git.CloneOptions{URL: remotePath})
|
||||||
|
require.NoError(s.T(), err)
|
||||||
|
|
||||||
|
return remotePath, clonePath
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *PushSuite) createLocalCommit(clonePath string) {
|
||||||
|
repo, err := git.PlainOpen(clonePath)
|
||||||
|
require.NoError(s.T(), err)
|
||||||
|
|
||||||
|
localFile := filepath.Join(clonePath, "home", ".zshrc")
|
||||||
|
require.NoError(s.T(), os.MkdirAll(filepath.Dir(localFile), 0o755))
|
||||||
|
require.NoError(s.T(), os.WriteFile(localFile, []byte("export EDITOR=vim\n"), 0o644))
|
||||||
|
|
||||||
|
wt, err := repo.Worktree()
|
||||||
|
require.NoError(s.T(), err)
|
||||||
|
_, err = wt.Add("home/.zshrc")
|
||||||
|
require.NoError(s.T(), err)
|
||||||
|
|
||||||
|
_, err = wt.Commit("add zshrc", &git.CommitOptions{Author: &object.Signature{
|
||||||
|
Name: "Push Test",
|
||||||
|
Email: "push@test.local",
|
||||||
|
When: time.Now(),
|
||||||
|
}})
|
||||||
|
require.NoError(s.T(), err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *PushSuite) TestPush_UpdatesRemoteFromLocalChanges() {
|
||||||
|
remotePath, clonePath := s.createRemoteAndClone("dotfiles")
|
||||||
|
s.createLocalCommit(clonePath)
|
||||||
|
|
||||||
|
require.NoError(s.T(), s.app.Push("dotfiles"))
|
||||||
|
|
||||||
|
verifyPath := filepath.Join(s.tmpDir, "dotfiles-verify")
|
||||||
|
_, err := git.PlainClone(verifyPath, false, &git.CloneOptions{URL: remotePath})
|
||||||
|
require.NoError(s.T(), err)
|
||||||
|
require.FileExists(s.T(), filepath.Join(verifyPath, "home", ".zshrc"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *PushSuite) TestPush_MissingCastleReturnsError() {
|
||||||
|
err := s.app.Push("missing")
|
||||||
|
require.Error(s.T(), err)
|
||||||
|
}
|
||||||
@@ -64,6 +64,31 @@ func (s *RcSuite) TestRc_NoScriptsAndNoHomesickrcIsNoop() {
|
|||||||
require.NoError(s.T(), s.app.Rc("dotfiles"))
|
require.NoError(s.T(), s.app.Rc("dotfiles"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestRc_HomesickrcRequiresForce ensures legacy .homesickrc does not run
|
||||||
|
// unless force mode is enabled.
|
||||||
|
func (s *RcSuite) TestRc_HomesickrcRequiresForce() {
|
||||||
|
castleRoot := s.createCastle("dotfiles")
|
||||||
|
homesickRc := filepath.Join(castleRoot, ".homesickrc")
|
||||||
|
require.NoError(s.T(), os.WriteFile(homesickRc, []byte("# ruby setup code\n"), 0o644))
|
||||||
|
|
||||||
|
err := s.app.Rc("dotfiles")
|
||||||
|
require.Error(s.T(), err)
|
||||||
|
require.Contains(s.T(), err.Error(), "--force")
|
||||||
|
require.NoFileExists(s.T(), filepath.Join(castleRoot, ".homesick.d", "parity.rb"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRc_HomesickrcRunsWithForce ensures legacy .homesickrc handling proceeds
|
||||||
|
// when force mode is enabled.
|
||||||
|
func (s *RcSuite) TestRc_HomesickrcRunsWithForce() {
|
||||||
|
castleRoot := s.createCastle("dotfiles")
|
||||||
|
homesickRc := filepath.Join(castleRoot, ".homesickrc")
|
||||||
|
require.NoError(s.T(), os.WriteFile(homesickRc, []byte("# ruby setup code\n"), 0o644))
|
||||||
|
|
||||||
|
s.app.Force = true
|
||||||
|
require.NoError(s.T(), s.app.Rc("dotfiles"))
|
||||||
|
require.FileExists(s.T(), filepath.Join(castleRoot, ".homesick.d", "parity.rb"))
|
||||||
|
}
|
||||||
|
|
||||||
// TestRc_ExecutesScriptsInSortedOrder verifies that executable scripts inside
|
// TestRc_ExecutesScriptsInSortedOrder verifies that executable scripts inside
|
||||||
// .homesick.d are run in lexicographic (sorted) order.
|
// .homesick.d are run in lexicographic (sorted) order.
|
||||||
func (s *RcSuite) TestRc_ExecutesScriptsInSortedOrder() {
|
func (s *RcSuite) TestRc_ExecutesScriptsInSortedOrder() {
|
||||||
@@ -100,15 +125,16 @@ func (s *RcSuite) TestRc_SkipsNonExecutableFiles() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// TestRc_HomesickrcCreatesRubyWrapper verifies that a .homesickrc file causes
|
// TestRc_HomesickrcCreatesRubyWrapper verifies that a .homesickrc file causes
|
||||||
// a Ruby wrapper to be written into .homesick.d before execution.
|
// a Ruby wrapper called parity.rb to be written into .homesick.d before execution.
|
||||||
func (s *RcSuite) TestRc_HomesickrcCreatesRubyWrapper() {
|
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"))
|
require.NoError(s.T(), s.app.Rc("dotfiles"))
|
||||||
|
|
||||||
wrapperPath := filepath.Join(castleRoot, ".homesick.d", "00_homesickrc.rb")
|
wrapperPath := filepath.Join(castleRoot, ".homesick.d", "parity.rb")
|
||||||
require.FileExists(s.T(), wrapperPath)
|
require.FileExists(s.T(), wrapperPath)
|
||||||
|
|
||||||
info, err := os.Stat(wrapperPath)
|
info, err := os.Stat(wrapperPath)
|
||||||
@@ -120,13 +146,34 @@ func (s *RcSuite) TestRc_HomesickrcCreatesRubyWrapper() {
|
|||||||
require.Contains(s.T(), string(content), ".homesickrc")
|
require.Contains(s.T(), string(content), ".homesickrc")
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestRc_HomesickrcWrapperRunsBeforeOtherScripts ensures the wrapper file
|
// TestRc_HomesickrcWrapperNotOverwrittenIfExists verifies that an existing
|
||||||
// (00_homesickrc.rb) sorts before typical user scripts and is present in
|
// parity.rb is not overwritten when Rc is called again.
|
||||||
// .homesick.d after Rc returns.
|
func (s *RcSuite) TestRc_HomesickrcWrapperNotOverwrittenIfExists() {
|
||||||
|
castleRoot := s.createCastle("dotfiles")
|
||||||
|
homesickRc := filepath.Join(castleRoot, ".homesickrc")
|
||||||
|
require.NoError(s.T(), os.WriteFile(homesickRc, []byte("# ruby setup code\n"), 0o644))
|
||||||
|
s.app.Force = true
|
||||||
|
|
||||||
|
homesickD := filepath.Join(castleRoot, ".homesick.d")
|
||||||
|
require.NoError(s.T(), os.MkdirAll(homesickD, 0o755))
|
||||||
|
wrapperPath := filepath.Join(homesickD, "parity.rb")
|
||||||
|
originalContent := []byte("#!/bin/sh\n# pre-existing wrapper\n")
|
||||||
|
require.NoError(s.T(), os.WriteFile(wrapperPath, originalContent, 0o755))
|
||||||
|
|
||||||
|
require.NoError(s.T(), s.app.Rc("dotfiles"))
|
||||||
|
|
||||||
|
content, err := os.ReadFile(wrapperPath)
|
||||||
|
require.NoError(s.T(), err)
|
||||||
|
require.Equal(s.T(), originalContent, content, "existing parity.rb must not be overwritten")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRc_HomesickrcWrapperCreatedBeforeExecution ensures parity.rb is present
|
||||||
|
// in .homesick.d before any scripts in that directory are executed.
|
||||||
func (s *RcSuite) TestRc_HomesickrcWrapperCreatedBeforeExecution() {
|
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))
|
||||||
@@ -134,7 +181,7 @@ func (s *RcSuite) TestRc_HomesickrcWrapperCreatedBeforeExecution() {
|
|||||||
// A sentinel script that records whether the wrapper already exists.
|
// A sentinel script that records whether the wrapper already exists.
|
||||||
orderFile := filepath.Join(s.tmpDir, "check.txt")
|
orderFile := filepath.Join(s.tmpDir, "check.txt")
|
||||||
sentinel := filepath.Join(homesickD, "50_check.sh")
|
sentinel := filepath.Join(homesickD, "50_check.sh")
|
||||||
wrapperPath := filepath.Join(homesickD, "00_homesickrc.rb")
|
wrapperPath := filepath.Join(homesickD, "parity.rb")
|
||||||
require.NoError(s.T(), os.WriteFile(sentinel, []byte(
|
require.NoError(s.T(), os.WriteFile(sentinel, []byte(
|
||||||
"#!/bin/sh\n[ -f "+wrapperPath+" ] && echo present >> "+orderFile+"\n",
|
"#!/bin/sh\n[ -f "+wrapperPath+" ] && echo present >> "+orderFile+"\n",
|
||||||
), 0o755))
|
), 0o755))
|
||||||
|
|||||||
@@ -1,190 +0,0 @@
|
|||||||
package releaseprep
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"regexp"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
var versionPattern = regexp.MustCompile(`const String = "[^"]+"`)
|
|
||||||
|
|
||||||
type semver struct {
|
|
||||||
major int
|
|
||||||
minor int
|
|
||||||
patch int
|
|
||||||
}
|
|
||||||
|
|
||||||
func Prepare(rootDir, version, releaseDate string) error {
|
|
||||||
normalizedVersion := strings.TrimPrefix(strings.TrimSpace(version), "v")
|
|
||||||
if normalizedVersion == "" {
|
|
||||||
return fmt.Errorf("version must not be empty")
|
|
||||||
}
|
|
||||||
|
|
||||||
releaseDate = strings.TrimSpace(releaseDate)
|
|
||||||
if releaseDate == "" {
|
|
||||||
return fmt.Errorf("release date must not be empty")
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := updateVersionFile(rootDir, normalizedVersion); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := updateChangelog(rootDir, normalizedVersion, releaseDate); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func RecommendedTag(rootDir string) (string, error) {
|
|
||||||
currentVersion, err := readCurrentVersion(rootDir)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
unreleasedBody, err := readUnreleasedBody(rootDir)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
parsed, err := parseSemver(currentVersion)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
switch {
|
|
||||||
case strings.Contains(unreleasedBody, "### Breaking"), strings.Contains(unreleasedBody, "### Removed"):
|
|
||||||
parsed.major++
|
|
||||||
parsed.minor = 0
|
|
||||||
parsed.patch = 0
|
|
||||||
case strings.Contains(unreleasedBody, "### Added"):
|
|
||||||
parsed.minor++
|
|
||||||
parsed.patch = 0
|
|
||||||
default:
|
|
||||||
parsed.patch++
|
|
||||||
}
|
|
||||||
|
|
||||||
return fmt.Sprintf("v%d.%d.%d", parsed.major, parsed.minor, parsed.patch), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func updateVersionFile(rootDir, version string) error {
|
|
||||||
path := filepath.Join(rootDir, "internal", "homesick", "version", "version.go")
|
|
||||||
contents, err := os.ReadFile(path)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("read version file: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
updated := versionPattern.ReplaceAllString(string(contents), fmt.Sprintf(`const String = %q`, version))
|
|
||||||
if updated == string(contents) {
|
|
||||||
return fmt.Errorf("version constant not found in %s", path)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := os.WriteFile(path, []byte(updated), 0o644); err != nil {
|
|
||||||
return fmt.Errorf("write version file: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func updateChangelog(rootDir, version, releaseDate string) error {
|
|
||||||
unreleasedBody, text, afterHeader, nextSectionStart, err := readChangelogState(rootDir)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
if strings.TrimSpace(unreleasedBody) == "" {
|
|
||||||
return fmt.Errorf("unreleased section is empty")
|
|
||||||
}
|
|
||||||
|
|
||||||
path := filepath.Join(rootDir, "changelog.md")
|
|
||||||
newSection := fmt.Sprintf("## [%s] - %s\n", version, releaseDate)
|
|
||||||
newSection += "\n" + unreleasedBody
|
|
||||||
if !strings.HasSuffix(newSection, "\n") {
|
|
||||||
newSection += "\n"
|
|
||||||
}
|
|
||||||
|
|
||||||
updated := text[:afterHeader] + "\n" + newSection + text[nextSectionStart:]
|
|
||||||
if err := os.WriteFile(path, []byte(updated), 0o644); err != nil {
|
|
||||||
return fmt.Errorf("write changelog: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func readCurrentVersion(rootDir string) (string, error) {
|
|
||||||
path := filepath.Join(rootDir, "internal", "homesick", "version", "version.go")
|
|
||||||
contents, err := os.ReadFile(path)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("read version file: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
match := versionPattern.FindString(string(contents))
|
|
||||||
if match == "" {
|
|
||||||
return "", fmt.Errorf("version constant not found in %s", path)
|
|
||||||
}
|
|
||||||
|
|
||||||
return strings.TrimSuffix(strings.TrimPrefix(match, `const String = "`), `"`), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func readUnreleasedBody(rootDir string) (string, error) {
|
|
||||||
unreleasedBody, _, _, _, err := readChangelogState(rootDir)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
if strings.TrimSpace(unreleasedBody) == "" {
|
|
||||||
return "", fmt.Errorf("unreleased section is empty")
|
|
||||||
}
|
|
||||||
|
|
||||||
return unreleasedBody, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func readChangelogState(rootDir string) (string, string, int, int, error) {
|
|
||||||
path := filepath.Join(rootDir, "changelog.md")
|
|
||||||
contents, err := os.ReadFile(path)
|
|
||||||
if err != nil {
|
|
||||||
return "", "", 0, 0, fmt.Errorf("read changelog: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
text := string(contents)
|
|
||||||
unreleasedHeader := "## [Unreleased]\n"
|
|
||||||
start := strings.Index(text, unreleasedHeader)
|
|
||||||
if start == -1 {
|
|
||||||
return "", "", 0, 0, fmt.Errorf("unreleased section not found in changelog")
|
|
||||||
}
|
|
||||||
|
|
||||||
afterHeader := start + len(unreleasedHeader)
|
|
||||||
nextSectionRelative := strings.Index(text[afterHeader:], "\n## [")
|
|
||||||
if nextSectionRelative == -1 {
|
|
||||||
nextSectionRelative = len(text[afterHeader:])
|
|
||||||
}
|
|
||||||
nextSectionStart := afterHeader + nextSectionRelative
|
|
||||||
unreleasedBody := strings.TrimLeft(text[afterHeader:nextSectionStart], "\n")
|
|
||||||
|
|
||||||
return unreleasedBody, text, afterHeader, nextSectionStart, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseSemver(version string) (semver, error) {
|
|
||||||
parts := strings.Split(strings.TrimSpace(version), ".")
|
|
||||||
if len(parts) != 3 {
|
|
||||||
return semver{}, fmt.Errorf("version %q is not semantic major.minor.patch", version)
|
|
||||||
}
|
|
||||||
|
|
||||||
major, err := strconv.Atoi(parts[0])
|
|
||||||
if err != nil {
|
|
||||||
return semver{}, fmt.Errorf("parse major version: %w", err)
|
|
||||||
}
|
|
||||||
minor, err := strconv.Atoi(parts[1])
|
|
||||||
if err != nil {
|
|
||||||
return semver{}, fmt.Errorf("parse minor version: %w", err)
|
|
||||||
}
|
|
||||||
patch, err := strconv.Atoi(parts[2])
|
|
||||||
if err != nil {
|
|
||||||
return semver{}, fmt.Errorf("parse patch version: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return semver{major: major, minor: minor, patch: patch}, nil
|
|
||||||
}
|
|
||||||
@@ -1,122 +0,0 @@
|
|||||||
package releaseprep_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"git.hrafn.xyz/aether/gosick/internal/releaseprep"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
"github.com/stretchr/testify/suite"
|
|
||||||
)
|
|
||||||
|
|
||||||
type PrepareSuite struct {
|
|
||||||
suite.Suite
|
|
||||||
rootDir string
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPrepareSuite(t *testing.T) {
|
|
||||||
suite.Run(t, new(PrepareSuite))
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *PrepareSuite) SetupTest() {
|
|
||||||
s.rootDir = s.T().TempDir()
|
|
||||||
versionDir := filepath.Join(s.rootDir, "internal", "homesick", "version")
|
|
||||||
require.NoError(s.T(), os.MkdirAll(versionDir, 0o755))
|
|
||||||
|
|
||||||
require.NoError(s.T(), os.WriteFile(
|
|
||||||
filepath.Join(versionDir, "version.go"),
|
|
||||||
[]byte("package version\n\nconst String = \"1.1.6\"\n"),
|
|
||||||
0o644,
|
|
||||||
))
|
|
||||||
|
|
||||||
require.NoError(s.T(), os.WriteFile(
|
|
||||||
filepath.Join(s.rootDir, "changelog.md"),
|
|
||||||
[]byte("# Changelog\n\n## [Unreleased]\n\n### Added\n\n- New thing.\n\n### Fixed\n\n- Old thing.\n\n## [1.1.6] - 2017-12-20\n\n### Fixed\n\n- Historical note.\n"),
|
|
||||||
0o644,
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *PrepareSuite) TestPrepare_UpdatesVersionAndPromotesUnreleasedNotes() {
|
|
||||||
err := releaseprep.Prepare(s.rootDir, "v1.1.7", "2026-03-20")
|
|
||||||
|
|
||||||
require.NoError(s.T(), err)
|
|
||||||
|
|
||||||
versionBytes, err := os.ReadFile(filepath.Join(s.rootDir, "internal", "homesick", "version", "version.go"))
|
|
||||||
require.NoError(s.T(), err)
|
|
||||||
require.Equal(s.T(), "package version\n\nconst String = \"1.1.7\"\n", string(versionBytes))
|
|
||||||
|
|
||||||
changelogBytes, err := os.ReadFile(filepath.Join(s.rootDir, "changelog.md"))
|
|
||||||
require.NoError(s.T(), err)
|
|
||||||
require.Equal(s.T(), "# Changelog\n\n## [Unreleased]\n\n## [1.1.7] - 2026-03-20\n\n### Added\n\n- New thing.\n\n### Fixed\n\n- Old thing.\n\n## [1.1.6] - 2017-12-20\n\n### Fixed\n\n- Historical note.\n", string(changelogBytes))
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *PrepareSuite) TestPrepare_ReturnsErrorWhenUnreleasedSectionMissing() {
|
|
||||||
require.NoError(s.T(), os.WriteFile(
|
|
||||||
filepath.Join(s.rootDir, "changelog.md"),
|
|
||||||
[]byte("# Changelog\n\n## [1.1.6] - 2017-12-20\n"),
|
|
||||||
0o644,
|
|
||||||
))
|
|
||||||
|
|
||||||
err := releaseprep.Prepare(s.rootDir, "1.1.7", "2026-03-20")
|
|
||||||
|
|
||||||
require.ErrorContains(s.T(), err, "unreleased section")
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *PrepareSuite) TestPrepare_ReturnsErrorWhenUnreleasedSectionIsEmpty() {
|
|
||||||
require.NoError(s.T(), os.WriteFile(
|
|
||||||
filepath.Join(s.rootDir, "changelog.md"),
|
|
||||||
[]byte("# Changelog\n\n## [Unreleased]\n\n## [1.1.6] - 2017-12-20\n"),
|
|
||||||
0o644,
|
|
||||||
))
|
|
||||||
|
|
||||||
err := releaseprep.Prepare(s.rootDir, "1.1.7", "2026-03-20")
|
|
||||||
|
|
||||||
require.ErrorContains(s.T(), err, "unreleased section is empty")
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *PrepareSuite) TestRecommendedTag_UsesMinorBumpWhenAddedEntriesExist() {
|
|
||||||
tag, err := releaseprep.RecommendedTag(s.rootDir)
|
|
||||||
|
|
||||||
require.NoError(s.T(), err)
|
|
||||||
require.Equal(s.T(), "v1.2.0", tag)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *PrepareSuite) TestRecommendedTag_UsesPatchBumpForFixOnlyChanges() {
|
|
||||||
require.NoError(s.T(), os.WriteFile(
|
|
||||||
filepath.Join(s.rootDir, "changelog.md"),
|
|
||||||
[]byte("# Changelog\n\n## [Unreleased]\n\n### Fixed\n\n- Patch note.\n\n## [1.1.6] - 2017-12-20\n"),
|
|
||||||
0o644,
|
|
||||||
))
|
|
||||||
|
|
||||||
tag, err := releaseprep.RecommendedTag(s.rootDir)
|
|
||||||
|
|
||||||
require.NoError(s.T(), err)
|
|
||||||
require.Equal(s.T(), "v1.1.7", tag)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *PrepareSuite) TestRecommendedTag_UsesMajorBumpWhenRemovedEntriesExist() {
|
|
||||||
require.NoError(s.T(), os.WriteFile(
|
|
||||||
filepath.Join(s.rootDir, "changelog.md"),
|
|
||||||
[]byte("# Changelog\n\n## [Unreleased]\n\n### Removed\n\n- Breaking removal.\n\n## [1.1.6] - 2017-12-20\n"),
|
|
||||||
0o644,
|
|
||||||
))
|
|
||||||
|
|
||||||
tag, err := releaseprep.RecommendedTag(s.rootDir)
|
|
||||||
|
|
||||||
require.NoError(s.T(), err)
|
|
||||||
require.Equal(s.T(), "v2.0.0", tag)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *PrepareSuite) TestRecommendedTag_UsesMajorBumpWhenBreakingEntriesExist() {
|
|
||||||
require.NoError(s.T(), os.WriteFile(
|
|
||||||
filepath.Join(s.rootDir, "changelog.md"),
|
|
||||||
[]byte("# Changelog\n\n## [Unreleased]\n\n### Breaking\n\n- Changed API contract.\n\n### Changed\n\n- Updated defaults.\n\n## [1.1.6] - 2017-12-20\n"),
|
|
||||||
0o644,
|
|
||||||
))
|
|
||||||
|
|
||||||
tag, err := releaseprep.RecommendedTag(s.rootDir)
|
|
||||||
|
|
||||||
require.NoError(s.T(), err)
|
|
||||||
require.Equal(s.T(), "v2.0.0", tag)
|
|
||||||
}
|
|
||||||
11
justfile
11
justfile
@@ -14,6 +14,15 @@ go-build-linux:
|
|||||||
go-test:
|
go-test:
|
||||||
go test ./...
|
go test ./...
|
||||||
|
|
||||||
|
go-mod-hygiene:
|
||||||
|
go mod tidy
|
||||||
|
git diff --exit-code go.mod go.sum
|
||||||
|
go mod verify
|
||||||
|
|
||||||
|
go-security:
|
||||||
|
gosec ./...
|
||||||
|
govulncheck ./...
|
||||||
|
|
||||||
behavior:
|
behavior:
|
||||||
./script/run-behavior-suite-docker.sh
|
./script/run-behavior-suite-docker.sh
|
||||||
|
|
||||||
@@ -21,4 +30,4 @@ behavior-verbose:
|
|||||||
./script/run-behavior-suite-docker.sh --verbose
|
./script/run-behavior-suite-docker.sh --verbose
|
||||||
|
|
||||||
prepare-release version:
|
prepare-release version:
|
||||||
./script/prepare-release.sh "{{version}}"
|
@echo "Release preparation is handled by vociferate workflows."
|
||||||
|
|||||||
@@ -1,12 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
if [[ $# -ne 1 ]]; then
|
|
||||||
echo "usage: $0 <version>" >&2
|
|
||||||
exit 2
|
|
||||||
fi
|
|
||||||
|
|
||||||
repo_root="$(cd "$(dirname "$0")/.." && pwd)"
|
|
||||||
release_date="$(date -u +%F)"
|
|
||||||
|
|
||||||
go run ./cmd/releaseprep --root "$repo_root" --version "$1" --date "$release_date"
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
: "${HOMESICK_CMD:=/workspace/dist/gosick}"
|
: "${HOMESICK_CMD:=ruby /workspace/bin/homesick}"
|
||||||
: "${BEHAVIOR_VERBOSE:=0}"
|
: "${BEHAVIOR_VERBOSE:=0}"
|
||||||
|
|
||||||
RUN_OUTPUT=""
|
RUN_OUTPUT=""
|
||||||
@@ -80,6 +80,52 @@ run_homesick() {
|
|||||||
rm -f "$out_file"
|
rm -f "$out_file"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
run_homesick_with_stdin() {
|
||||||
|
local stdin_data="$1"
|
||||||
|
shift
|
||||||
|
|
||||||
|
local out_file
|
||||||
|
local output
|
||||||
|
out_file="$(mktemp)"
|
||||||
|
if ! printf '%b' "$stdin_data" | bash -lc "$HOMESICK_CMD $*" >"$out_file" 2>&1; then
|
||||||
|
cat "$out_file" >&2
|
||||||
|
rm -f "$out_file"
|
||||||
|
fail "homesick command failed: $*"
|
||||||
|
fi
|
||||||
|
|
||||||
|
output="$(cat "$out_file")"
|
||||||
|
RUN_OUTPUT="$output"
|
||||||
|
|
||||||
|
if [[ "$BEHAVIOR_VERBOSE" == "1" && -n "$output" ]]; then
|
||||||
|
printf '%s\n' "$output"
|
||||||
|
fi
|
||||||
|
|
||||||
|
rm -f "$out_file"
|
||||||
|
}
|
||||||
|
|
||||||
|
run_homesick_with_env() {
|
||||||
|
local env_prefix="$1"
|
||||||
|
shift
|
||||||
|
|
||||||
|
local out_file
|
||||||
|
local output
|
||||||
|
out_file="$(mktemp)"
|
||||||
|
if ! bash -lc "$env_prefix $HOMESICK_CMD $*" >"$out_file" 2>&1; then
|
||||||
|
cat "$out_file" >&2
|
||||||
|
rm -f "$out_file"
|
||||||
|
fail "homesick command failed: $*"
|
||||||
|
fi
|
||||||
|
|
||||||
|
output="$(cat "$out_file")"
|
||||||
|
RUN_OUTPUT="$output"
|
||||||
|
|
||||||
|
if [[ "$BEHAVIOR_VERBOSE" == "1" && -n "$output" ]]; then
|
||||||
|
printf '%s\n' "$output"
|
||||||
|
fi
|
||||||
|
|
||||||
|
rm -f "$out_file"
|
||||||
|
}
|
||||||
|
|
||||||
setup_remote_castle() {
|
setup_remote_castle() {
|
||||||
local remote_dir="$1"
|
local remote_dir="$1"
|
||||||
local work_dir="$2"
|
local work_dir="$2"
|
||||||
@@ -125,13 +171,26 @@ run_suite() {
|
|||||||
|
|
||||||
setup_remote_castle "$remote_root" "$work_root"
|
setup_remote_castle "$remote_root" "$work_root"
|
||||||
|
|
||||||
echo "[1/7] clone"
|
echo "[1/18] help"
|
||||||
run_homesick "clone file://$remote_root/base.git parity-castle"
|
run_homesick "help"
|
||||||
assert_path_exists "$HOME/.homesick/repos/parity-castle/.git"
|
[[ "$RUN_OUTPUT" == *"Usage:"* || "$RUN_OUTPUT" == *"Commands:"* ]] || fail "expected help output to include command usage information"
|
||||||
assert_path_exists "$HOME/.homesick/repos/parity-castle/home/.vimrc"
|
run_homesick "help clone"
|
||||||
|
[[ "$RUN_OUTPUT" == *"clone"* ]] || fail "expected command help output for clone"
|
||||||
pass
|
pass
|
||||||
|
|
||||||
echo "[2/7] link"
|
echo "[2/18] clone"
|
||||||
|
run_homesick "clone file://$remote_root/base.git parity-castle"
|
||||||
|
run_homesick "clone file://$remote_root/base.git parity-castle-2"
|
||||||
|
assert_path_exists "$HOME/.homesick/repos/parity-castle/.git"
|
||||||
|
assert_path_exists "$HOME/.homesick/repos/parity-castle/home/.vimrc"
|
||||||
|
assert_path_exists "$HOME/.homesick/repos/parity-castle-2/.git"
|
||||||
|
run_git -C "$HOME/.homesick/repos/parity-castle" config user.email "behavior@test.local"
|
||||||
|
run_git -C "$HOME/.homesick/repos/parity-castle" config user.name "Behavior Test"
|
||||||
|
run_git -C "$HOME/.homesick/repos/parity-castle-2" config user.email "behavior@test.local"
|
||||||
|
run_git -C "$HOME/.homesick/repos/parity-castle-2" config user.name "Behavior Test"
|
||||||
|
pass
|
||||||
|
|
||||||
|
echo "[3/18] link"
|
||||||
run_homesick "link parity-castle"
|
run_homesick "link parity-castle"
|
||||||
assert_symlink_target "$HOME/.vimrc" "$HOME/.homesick/repos/parity-castle/home/.vimrc"
|
assert_symlink_target "$HOME/.vimrc" "$HOME/.homesick/repos/parity-castle/home/.vimrc"
|
||||||
assert_symlink_target "$HOME/.zshrc" "$HOME/.homesick/repos/parity-castle/home/.zshrc"
|
assert_symlink_target "$HOME/.zshrc" "$HOME/.homesick/repos/parity-castle/home/.zshrc"
|
||||||
@@ -139,7 +198,7 @@ run_suite() {
|
|||||||
assert_symlink_target "$HOME/.config/myapp" "$HOME/.homesick/repos/parity-castle/home/.config/myapp"
|
assert_symlink_target "$HOME/.config/myapp" "$HOME/.homesick/repos/parity-castle/home/.config/myapp"
|
||||||
pass
|
pass
|
||||||
|
|
||||||
echo "[3/7] unlink"
|
echo "[4/18] unlink"
|
||||||
run_homesick "unlink parity-castle"
|
run_homesick "unlink parity-castle"
|
||||||
assert_path_missing "$HOME/.vimrc"
|
assert_path_missing "$HOME/.vimrc"
|
||||||
assert_path_missing "$HOME/.zshrc"
|
assert_path_missing "$HOME/.zshrc"
|
||||||
@@ -147,7 +206,12 @@ run_suite() {
|
|||||||
assert_path_missing "$HOME/.config/myapp"
|
assert_path_missing "$HOME/.config/myapp"
|
||||||
pass
|
pass
|
||||||
|
|
||||||
echo "[4/7] relink + track"
|
echo "[5/18] symlink alias"
|
||||||
|
run_homesick "symlink parity-castle"
|
||||||
|
assert_symlink_target "$HOME/.vimrc" "$HOME/.homesick/repos/parity-castle/home/.vimrc"
|
||||||
|
pass
|
||||||
|
|
||||||
|
echo "[6/18] relink + track"
|
||||||
run_homesick "link parity-castle"
|
run_homesick "link parity-castle"
|
||||||
setup_local_test_file
|
setup_local_test_file
|
||||||
run_homesick "track $HOME/.local/bin/tool parity-castle"
|
run_homesick "track $HOME/.local/bin/tool parity-castle"
|
||||||
@@ -156,7 +220,7 @@ run_suite() {
|
|||||||
grep -q "^.local/bin$" "$HOME/.homesick/repos/parity-castle/.homesick_subdir" || fail "expected .homesick_subdir to contain .local/bin"
|
grep -q "^.local/bin$" "$HOME/.homesick/repos/parity-castle/.homesick_subdir" || fail "expected .homesick_subdir to contain .local/bin"
|
||||||
pass
|
pass
|
||||||
|
|
||||||
echo "[5/7] list and show_path"
|
echo "[7/18] list and show_path"
|
||||||
local list_output
|
local list_output
|
||||||
run_homesick "list"
|
run_homesick "list"
|
||||||
list_output="$RUN_OUTPUT"
|
list_output="$RUN_OUTPUT"
|
||||||
@@ -164,10 +228,10 @@ run_suite() {
|
|||||||
local show_path_output
|
local show_path_output
|
||||||
run_homesick "show_path parity-castle"
|
run_homesick "show_path parity-castle"
|
||||||
show_path_output="$RUN_OUTPUT"
|
show_path_output="$RUN_OUTPUT"
|
||||||
[[ "$show_path_output" == *"$HOME/.homesick/repos/parity-castle"* ]] || fail "expected show_path output to include parity-castle path"
|
[[ "$show_path_output" == "$HOME/.homesick/repos/parity-castle" ]] || fail "expected show_path output to equal parity-castle root path"
|
||||||
pass
|
pass
|
||||||
|
|
||||||
echo "[6/7] status and diff"
|
echo "[8/18] status and diff"
|
||||||
echo "change" >> "$HOME/.vimrc"
|
echo "change" >> "$HOME/.vimrc"
|
||||||
local status_output
|
local status_output
|
||||||
run_homesick "status parity-castle"
|
run_homesick "status parity-castle"
|
||||||
@@ -179,7 +243,78 @@ run_suite() {
|
|||||||
[[ "$diff_output" == *"diff --git"* ]] || fail "expected diff output to include git diff"
|
[[ "$diff_output" == *"diff --git"* ]] || fail "expected diff output to include git diff"
|
||||||
pass
|
pass
|
||||||
|
|
||||||
echo "[7/7] version"
|
echo "[9/18] pull --all"
|
||||||
|
local pull_all_output
|
||||||
|
run_homesick "pull --all"
|
||||||
|
pull_all_output="$RUN_OUTPUT"
|
||||||
|
[[ "$pull_all_output" == *"parity-castle:"* ]] || fail "expected pull --all output to include parity-castle"
|
||||||
|
[[ "$pull_all_output" == *"parity-castle-2:"* ]] || fail "expected pull --all output to include parity-castle-2"
|
||||||
|
pass
|
||||||
|
|
||||||
|
echo "[10/18] single-castle pull"
|
||||||
|
pushd "$work_root/base" >/dev/null
|
||||||
|
echo "single-castle-pull" > home/.pull-single
|
||||||
|
run_git add .
|
||||||
|
run_git commit -m "single-castle pull fixture"
|
||||||
|
run_git push
|
||||||
|
popd >/dev/null
|
||||||
|
run_homesick "pull parity-castle"
|
||||||
|
assert_path_exists "$HOME/.homesick/repos/parity-castle/home/.pull-single"
|
||||||
|
pass
|
||||||
|
|
||||||
|
echo "[11/18] exec"
|
||||||
|
local exec_marker="$HOME/.homesick/repos/parity-castle/.exec-marker"
|
||||||
|
run_homesick "exec parity-castle touch .exec-marker"
|
||||||
|
assert_path_exists "$exec_marker"
|
||||||
|
pass
|
||||||
|
|
||||||
|
echo "[12/18] exec_all"
|
||||||
|
local exec_all_marker_a="$HOME/.homesick/repos/parity-castle/.exec-all-marker"
|
||||||
|
local exec_all_marker_b="$HOME/.homesick/repos/parity-castle-2/.exec-all-marker"
|
||||||
|
run_homesick "exec_all touch .exec-all-marker"
|
||||||
|
assert_path_exists "$exec_all_marker_a"
|
||||||
|
assert_path_exists "$exec_all_marker_b"
|
||||||
|
pass
|
||||||
|
|
||||||
|
echo "[13/18] generate"
|
||||||
|
local generated_castle="$HOME/generated-castle"
|
||||||
|
run_homesick "generate $generated_castle"
|
||||||
|
assert_path_exists "$generated_castle/.git"
|
||||||
|
assert_path_exists "$generated_castle/home"
|
||||||
|
pass
|
||||||
|
|
||||||
|
echo "[14/18] commit and push"
|
||||||
|
echo "commit-change" >> "$HOME/.zshrc"
|
||||||
|
run_homesick "commit parity-castle behavior-suite-commit"
|
||||||
|
run_homesick "push parity-castle"
|
||||||
|
local remote_head
|
||||||
|
remote_head="$(git --git-dir "$remote_root/base.git" log --oneline -1)"
|
||||||
|
[[ "$remote_head" == *"behavior-suite-commit"* ]] || fail "expected pushed commit in remote history"
|
||||||
|
pass
|
||||||
|
|
||||||
|
echo "[15/18] open"
|
||||||
|
run_homesick_with_env "EDITOR=true" "open parity-castle"
|
||||||
|
pass
|
||||||
|
|
||||||
|
echo "[16/18] cd"
|
||||||
|
run_homesick_with_env "SHELL=/bin/true" "cd parity-castle"
|
||||||
|
pass
|
||||||
|
|
||||||
|
echo "[17/18] rc --force"
|
||||||
|
local rc_marker="$HOME/rc-force-was-here"
|
||||||
|
cat > "$HOME/.homesick/repos/parity-castle/.homesickrc" <<EOF
|
||||||
|
File.write('$rc_marker', 'ok\n')
|
||||||
|
EOF
|
||||||
|
run_homesick "rc --force parity-castle"
|
||||||
|
assert_path_exists "$rc_marker"
|
||||||
|
pass
|
||||||
|
|
||||||
|
echo "[18/18] destroy confirmation + version"
|
||||||
|
run_homesick_with_stdin "n\n" "destroy parity-castle"
|
||||||
|
assert_path_exists "$HOME/.homesick/repos/parity-castle"
|
||||||
|
run_homesick_with_stdin "y\n" "destroy parity-castle"
|
||||||
|
assert_path_missing "$HOME/.homesick/repos/parity-castle"
|
||||||
|
assert_path_missing "$HOME/.vimrc"
|
||||||
local version_output
|
local version_output
|
||||||
run_homesick "version"
|
run_homesick "version"
|
||||||
version_output="$RUN_OUTPUT"
|
version_output="$RUN_OUTPUT"
|
||||||
|
|||||||
Reference in New Issue
Block a user