Compare commits
74 Commits
v1.1.5
...
665401f2bd
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
665401f2bd | ||
|
|
d084abd636 | ||
|
|
a6034ce470 | ||
|
|
484db0781b | ||
|
|
4a8ef7e1f6 | ||
|
|
b3f66e9e2e | ||
|
|
9d6dacb0f8 | ||
|
|
195b936de6 | ||
|
|
f6b5186f31 | ||
|
|
ea16ba8430 | ||
|
|
96ce572792 | ||
|
|
d638f201fe | ||
|
|
e09bdd78c2 | ||
|
|
0034a6f4e2 | ||
|
|
aa66695665 | ||
|
|
a7e4c501e4 | ||
|
|
0dfacc31d4 | ||
|
|
1d26594010 | ||
|
|
c10ff251d5 | ||
|
|
8d34674415 | ||
|
|
8174c6a983 | ||
|
|
1d4c088edc | ||
|
|
040bf31b56 | ||
|
|
4355e7fd9d | ||
|
|
b7c353553a | ||
|
|
2f45d28acb | ||
|
|
904c1be192 | ||
|
|
f443e96f9e | ||
|
|
0076588e1f | ||
|
|
919f033c8b | ||
|
|
dbc77a1b34 | ||
|
|
d02d118b28 | ||
|
|
a952c4f6bf | ||
|
|
e733dff818 | ||
|
|
41584dec6a | ||
|
|
005209703e | ||
|
|
ee4388b0f4 | ||
|
|
a44a514007 | ||
|
|
9431cb78af | ||
|
|
46c52769a6 | ||
|
|
fdb57cd846 | ||
|
|
ff387280d5 | ||
|
|
f09c62d922 | ||
|
|
dd7d52a25d | ||
|
|
f1630ece79 | ||
|
|
11ee8cdc0d | ||
|
|
ceb08cbe22 | ||
|
|
057e1cfc59 | ||
|
|
89f3000d8b | ||
|
|
36e3cb6bbf | ||
|
|
9ebae75e7d | ||
|
|
35e1909790 | ||
|
|
3b633ed326 | ||
|
|
fdf2da84dd | ||
|
|
e561566b46 | ||
|
|
dcef34c17d | ||
|
|
72d11c4a47 | ||
|
|
c2457bae9f | ||
|
|
001bd32bb3 | ||
|
|
7080321081 | ||
|
|
9d9cf66de6 | ||
|
|
9e9a940825 | ||
|
|
257e974c38 | ||
|
|
615e31428c | ||
|
|
8c2a1d0f84 | ||
|
|
62c934774b | ||
|
|
d3d6974b7b | ||
|
|
474d69da0b | ||
|
|
db6a513d1d | ||
|
|
ae343c4cab | ||
|
|
a2b365fb6f | ||
|
|
4cb2006f41 | ||
|
|
66347d307f | ||
|
|
8f92a1b4f0 |
160
.gitea/workflows/pr-validation.yml
Normal file
160
.gitea/workflows/pr-validation.yml
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
name: Pull Request Validation
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
types:
|
||||||
|
- opened
|
||||||
|
- synchronize
|
||||||
|
- reopened
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
validate:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
container: docker.io/catthehacker/ubuntu:act-latest
|
||||||
|
env:
|
||||||
|
ARTEFACT_BUCKET_NAME: ${{ vars.ARTEFACT_BUCKET_NAME }}
|
||||||
|
ARTEFACT_BUCKET_ENDPONT: ${{ vars.ARTEFACT_BUCKET_ENDPONT }}
|
||||||
|
ARTEFACT_BUCKET_REGION: ${{ vars.ARTEFACT_BUCKET_REGION }}
|
||||||
|
AWS_ACCESS_KEY_ID: ${{ secrets.ARTEFACT_BUCKET_WRITE_ACCESS_KEY }}
|
||||||
|
AWS_SECRET_ACCESS_KEY: ${{ secrets.ARTEFACT_BUCKET_WRITE_ACCESS_SECRET }}
|
||||||
|
AWS_DEFAULT_REGION: ${{ vars.ARTEFACT_BUCKET_REGION }}
|
||||||
|
AWS_EC2_METADATA_DISABLED: true
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Go
|
||||||
|
uses: actions/setup-go@v5
|
||||||
|
with:
|
||||||
|
go-version-file: go.mod
|
||||||
|
cache: false
|
||||||
|
|
||||||
|
- name: Ensure tooling is available
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
if ! command -v aws >/dev/null 2>&1; then
|
||||||
|
pipx install awscli
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! command -v jq >/dev/null 2>&1; then
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y jq
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Run full unit test suite with coverage
|
||||||
|
id: coverage
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
go test -covermode=atomic -coverprofile=coverage.out ./...
|
||||||
|
go tool cover -html=coverage.out -o coverage.html
|
||||||
|
|
||||||
|
total="$(go tool cover -func=coverage.out | awk '/^total:/ {sub(/%/, "", $3); print $3}')"
|
||||||
|
printf '{\n "total": "%s"\n}\n' "$total" > coverage-summary.json
|
||||||
|
printf 'total=%s\n' "$total" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: Generate coverage badge
|
||||||
|
env:
|
||||||
|
COVERAGE_TOTAL: ${{ steps.coverage.outputs.total }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
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 PR coverage artefacts
|
||||||
|
id: upload
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
aws configure set default.s3.addressing_style path
|
||||||
|
|
||||||
|
repo_name="${GITHUB_REPOSITORY##*/}"
|
||||||
|
prefix="${repo_name}/pull-requests/${{ github.event.pull_request.number }}"
|
||||||
|
report_url="${ARTEFACT_BUCKET_ENDPONT%/}/${ARTEFACT_BUCKET_NAME}/${prefix}/coverage.html"
|
||||||
|
badge_url="${ARTEFACT_BUCKET_ENDPONT%/}/${ARTEFACT_BUCKET_NAME}/${prefix}/coverage-badge.svg"
|
||||||
|
|
||||||
|
aws --endpoint-url "${ARTEFACT_BUCKET_ENDPONT}" s3 cp coverage.html "s3://${ARTEFACT_BUCKET_NAME}/${prefix}/coverage.html" --content-type text/html
|
||||||
|
aws --endpoint-url "${ARTEFACT_BUCKET_ENDPONT}" s3 cp coverage-badge.svg "s3://${ARTEFACT_BUCKET_NAME}/${prefix}/coverage-badge.svg" --content-type image/svg+xml
|
||||||
|
aws --endpoint-url "${ARTEFACT_BUCKET_ENDPONT}" s3 cp coverage-summary.json "s3://${ARTEFACT_BUCKET_NAME}/${prefix}/coverage-summary.json" --content-type application/json
|
||||||
|
|
||||||
|
printf 'report_url=%s\n' "$report_url" >> "$GITHUB_OUTPUT"
|
||||||
|
printf 'badge_url=%s\n' "$badge_url" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: Comment coverage report on pull request
|
||||||
|
env:
|
||||||
|
COVERAGE_BADGE_URL: ${{ steps.upload.outputs.badge_url }}
|
||||||
|
COVERAGE_REPORT_URL: ${{ steps.upload.outputs.report_url }}
|
||||||
|
COVERAGE_TOTAL: ${{ steps.coverage.outputs.total }}
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
marker='<!-- gosick-coverage-report -->'
|
||||||
|
api_base="${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}"
|
||||||
|
|
||||||
|
payload="$(jq -n \
|
||||||
|
--arg marker "$marker" \
|
||||||
|
--arg total "$COVERAGE_TOTAL" \
|
||||||
|
--arg report "$COVERAGE_REPORT_URL" \
|
||||||
|
--arg badge "$COVERAGE_BADGE_URL" \
|
||||||
|
'{body: ($marker + "\n## Coverage Report\n\nCoverage total: **" + $total + "%**\n\n[HTML report](" + $report + ")\n\n")}')"
|
||||||
|
|
||||||
|
comments="$(curl -sS -H "Authorization: token ${GITHUB_TOKEN}" "${api_base}/issues/${{ github.event.pull_request.number }}/comments")"
|
||||||
|
comment_id="$(printf '%s' "$comments" | jq -r '.[] | select(.body | contains("<!-- gosick-coverage-report -->")) | .id' | tail -n 1)"
|
||||||
|
|
||||||
|
if [[ -n "$comment_id" ]]; then
|
||||||
|
curl -sS -X PATCH \
|
||||||
|
-H "Authorization: token ${GITHUB_TOKEN}" \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d "$payload" \
|
||||||
|
"${api_base}/issues/comments/${comment_id}" >/dev/null
|
||||||
|
else
|
||||||
|
curl -sS -X POST \
|
||||||
|
-H "Authorization: token ${GITHUB_TOKEN}" \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d "$payload" \
|
||||||
|
"${api_base}/issues/${{ github.event.pull_request.number }}/comments" >/dev/null
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Add coverage summary
|
||||||
|
run: |
|
||||||
|
{
|
||||||
|
echo '## Coverage'
|
||||||
|
echo
|
||||||
|
echo '- Total: `${{ steps.coverage.outputs.total }}%`'
|
||||||
|
echo '- Report: ${{ steps.upload.outputs.report_url }}'
|
||||||
|
echo '- Badge: ${{ steps.upload.outputs.badge_url }}'
|
||||||
|
} >> "$GITHUB_STEP_SUMMARY"
|
||||||
|
|
||||||
|
- name: Run behavior suite
|
||||||
|
run: ./script/run-behavior-suite-docker.sh
|
||||||
120
.gitea/workflows/push-validation.yml
Normal file
120
.gitea/workflows/push-validation.yml
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
name: Push Validation
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- "**"
|
||||||
|
tags-ignore:
|
||||||
|
- "*"
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
validate:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
container: docker.io/catthehacker/ubuntu:act-latest
|
||||||
|
env:
|
||||||
|
ARTEFACT_BUCKET_NAME: ${{ vars.ARTEFACT_BUCKET_NAME }}
|
||||||
|
ARTEFACT_BUCKET_ENDPONT: ${{ vars.ARTEFACT_BUCKET_ENDPONT }}
|
||||||
|
ARTEFACT_BUCKET_REGION: ${{ vars.ARTEFACT_BUCKET_REGION }}
|
||||||
|
AWS_ACCESS_KEY_ID: ${{ secrets.ARTEFACT_BUCKET_WRITE_ACCESS_KEY }}
|
||||||
|
AWS_SECRET_ACCESS_KEY: ${{ secrets.ARTEFACT_BUCKET_WRITE_ACCESS_SECRET }}
|
||||||
|
AWS_DEFAULT_REGION: ${{ vars.ARTEFACT_BUCKET_REGION }}
|
||||||
|
AWS_EC2_METADATA_DISABLED: true
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Go
|
||||||
|
uses: actions/setup-go@v5
|
||||||
|
with:
|
||||||
|
go-version-file: go.mod
|
||||||
|
cache: false
|
||||||
|
|
||||||
|
- name: Ensure tooling is available
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
if ! command -v aws >/dev/null 2>&1; then
|
||||||
|
pipx install awscli
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Run full unit test suite with coverage
|
||||||
|
id: coverage
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
go test -covermode=atomic -coverprofile=coverage.out ./...
|
||||||
|
go tool cover -html=coverage.out -o coverage.html
|
||||||
|
|
||||||
|
total="$(go tool cover -func=coverage.out | awk '/^total:/ {sub(/%/, "", $3); print $3}')"
|
||||||
|
printf '{\n "total": "%s"\n}\n' "$total" > coverage-summary.json
|
||||||
|
printf 'total=%s\n' "$total" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: Generate coverage badge
|
||||||
|
env:
|
||||||
|
COVERAGE_TOTAL: ${{ steps.coverage.outputs.total }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
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
|
||||||
|
echo '- Total: `${{ steps.coverage.outputs.total }}%`'
|
||||||
|
echo '- Report: ${{ steps.upload.outputs.report_url }}'
|
||||||
|
echo '- Badge: ${{ steps.upload.outputs.badge_url }}'
|
||||||
|
} >> "$GITHUB_STEP_SUMMARY"
|
||||||
|
|
||||||
|
- name: Run behavior suite on main pushes
|
||||||
|
if: ${{ github.ref == 'refs/heads/main' }}
|
||||||
|
run: ./script/run-behavior-suite-docker.sh
|
||||||
111
.gitea/workflows/tag-build-artifacts.yml
Normal file
111
.gitea/workflows/tag-build-artifacts.yml
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
name: Tag Build Artifacts
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- "v*"
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
container: docker.io/catthehacker/ubuntu:act-latest
|
||||||
|
env:
|
||||||
|
RUNNER_TOOL_CACHE: /cache/tools
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- goos: linux
|
||||||
|
goarch: amd64
|
||||||
|
- goos: linux
|
||||||
|
goarch: arm64
|
||||||
|
- goos: darwin
|
||||||
|
goarch: amd64
|
||||||
|
- goos: darwin
|
||||||
|
goarch: arm64
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Go
|
||||||
|
uses: actions/setup-go@v5
|
||||||
|
with:
|
||||||
|
go-version-file: go.mod
|
||||||
|
cache: false
|
||||||
|
|
||||||
|
- name: Build binary
|
||||||
|
run: |
|
||||||
|
mkdir -p dist
|
||||||
|
GOOS=${{ matrix.goos }} GOARCH=${{ matrix.goarch }} CGO_ENABLED=0 \
|
||||||
|
go build -o dist/gosick_${{ matrix.goos }}_${{ matrix.goarch }} ./cmd/homesick
|
||||||
|
|
||||||
|
- name: Package artifact
|
||||||
|
run: |
|
||||||
|
cd dist
|
||||||
|
tar -czf gosick_${{ matrix.goos }}_${{ matrix.goarch }}.tar.gz gosick_${{ matrix.goos }}_${{ matrix.goarch }}
|
||||||
|
|
||||||
|
- name: Publish workflow artifact
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: gosick_${{ matrix.goos }}_${{ matrix.goarch }}
|
||||||
|
path: dist/gosick_${{ matrix.goos }}_${{ matrix.goarch }}.tar.gz
|
||||||
|
|
||||||
|
release:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: build
|
||||||
|
env:
|
||||||
|
RELEASE_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
steps:
|
||||||
|
- name: Download build artifacts
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
path: dist
|
||||||
|
|
||||||
|
- name: Ensure jq is installed
|
||||||
|
run: |
|
||||||
|
if ! command -v jq >/dev/null 2>&1; then
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y jq
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Create release if needed and upload assets
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
if [[ -z "${RELEASE_TOKEN:-}" ]]; then
|
||||||
|
echo "RELEASE_TOKEN is empty. Expected secrets.GITHUB_TOKEN to be available." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
tag="${GITHUB_REF_NAME}"
|
||||||
|
api_base="${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}"
|
||||||
|
|
||||||
|
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
|
||||||
24
.gitignore
vendored
24
.gitignore
vendored
@@ -1,17 +1,3 @@
|
|||||||
# rcov generated
|
|
||||||
coverage
|
|
||||||
|
|
||||||
# rdoc generated
|
|
||||||
rdoc
|
|
||||||
|
|
||||||
# yard generated
|
|
||||||
doc
|
|
||||||
.yardoc
|
|
||||||
|
|
||||||
# jeweler generated
|
|
||||||
pkg
|
|
||||||
|
|
||||||
.bundle
|
|
||||||
|
|
||||||
# Have editor/IDE/OS specific files you need to ignore? Consider using a global gitignore:
|
# Have editor/IDE/OS specific files you need to ignore? Consider using a global gitignore:
|
||||||
#
|
#
|
||||||
@@ -44,10 +30,10 @@ pkg
|
|||||||
.idea/
|
.idea/
|
||||||
*.iml
|
*.iml
|
||||||
|
|
||||||
Gemfile.lock
|
|
||||||
vendor/
|
|
||||||
|
|
||||||
homesick*.gem
|
# Go scaffolding artifacts
|
||||||
|
dist/
|
||||||
|
*.test
|
||||||
|
*.out
|
||||||
|
|
||||||
# rbenv configuration
|
.github/*
|
||||||
.ruby-version
|
|
||||||
19
.rubocop.yml
19
.rubocop.yml
@@ -1,19 +0,0 @@
|
|||||||
# TODO: Eval is required for the .homesickrc feature. This should eventually be
|
|
||||||
# removed if the feature is implemented in a more secure way.
|
|
||||||
Eval:
|
|
||||||
Enabled: false
|
|
||||||
|
|
||||||
# TODO: The following settings disable reports about issues that can be fixed
|
|
||||||
# through refactoring. Remove these as offenses are removed from the code base.
|
|
||||||
|
|
||||||
ClassLength:
|
|
||||||
Enabled: false
|
|
||||||
|
|
||||||
CyclomaticComplexity:
|
|
||||||
Max: 13
|
|
||||||
|
|
||||||
LineLength:
|
|
||||||
Enabled: false
|
|
||||||
|
|
||||||
MethodLength:
|
|
||||||
Max: 36
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
language: ruby
|
|
||||||
rvm:
|
|
||||||
- 2.4.0
|
|
||||||
- 2.3.3
|
|
||||||
- 2.2.6
|
|
||||||
sudo: false
|
|
||||||
@@ -1,121 +0,0 @@
|
|||||||
#1.1.5
|
|
||||||
* Fixed problem with version number being incorrect.
|
|
||||||
|
|
||||||
#1.1.4
|
|
||||||
* Make sure symlink conflicts are explicitly communicated to a user and symlinks are not silently overwritten
|
|
||||||
* Use real paths of symlinks when linking a castle into home
|
|
||||||
* Fix a problem when in a diff when asking a user to resolve a conflict
|
|
||||||
* Some code refactoring and fixes
|
|
||||||
|
|
||||||
#1.1.3
|
|
||||||
* Allow a destination to be passed when cloning a castle
|
|
||||||
* Make sure `homesick edit` opens default editor in the root of the given castle
|
|
||||||
* Fixed bug when diffing edited files
|
|
||||||
* Fixed crashing bug when attempting to diff directories
|
|
||||||
* Ensure that messages are escaped correctly on `git commit all`
|
|
||||||
|
|
||||||
#1.1.2
|
|
||||||
* Added '--force' option to the rc command to bypass confirmation checks when running a .homesickrc file
|
|
||||||
* Added a check to make sure that a minimum of Git 1.8.0 is installed. This stops Homesick failing silently if Git is not installed.
|
|
||||||
* Code refactoring and fixes.
|
|
||||||
|
|
||||||
#1.1.0
|
|
||||||
* Added exec and exec_all commands to run commands inside one or all clones castles.
|
|
||||||
* Code refactoring.
|
|
||||||
|
|
||||||
#1.0.0
|
|
||||||
* Removed support for Ruby 1.8.7
|
|
||||||
* Added a version command
|
|
||||||
|
|
||||||
# 0.9.8
|
|
||||||
* Introduce new commands
|
|
||||||
* `homesick cd`
|
|
||||||
* `homesick open`
|
|
||||||
|
|
||||||
# 0.9.4
|
|
||||||
* Use https protocol instead of git protocol
|
|
||||||
* Introduce new commands
|
|
||||||
* `homesick unlink`
|
|
||||||
* `homesick rc`
|
|
||||||
|
|
||||||
# 0.9.3
|
|
||||||
* Add recursive option to `homesick clone`
|
|
||||||
|
|
||||||
# 0.9.2
|
|
||||||
* Set "dotfiles" as default castle name
|
|
||||||
* Introduce new commands
|
|
||||||
* `homesick show_path`
|
|
||||||
* `homesick status`
|
|
||||||
* `homesick diff`
|
|
||||||
|
|
||||||
# 0.9.1
|
|
||||||
* Fixed small bugs: #35, #40
|
|
||||||
|
|
||||||
# 0.9.0
|
|
||||||
* Introduce .homesick_subdir #39
|
|
||||||
|
|
||||||
# 0.8.1
|
|
||||||
* Fixed `homesick list` bug on ruby 2.0 #37
|
|
||||||
|
|
||||||
# 0.8.0
|
|
||||||
* Introduce commit & push command
|
|
||||||
* commit changes in castle and push to remote
|
|
||||||
* Enable recursive submodule update
|
|
||||||
* Git add when track
|
|
||||||
|
|
||||||
# 0.7.0
|
|
||||||
* Fixed double-cloning #14
|
|
||||||
* New option for pull command: --all
|
|
||||||
* pulls each castle, instead of just one
|
|
||||||
|
|
||||||
# 0.6.1
|
|
||||||
|
|
||||||
* Add a license
|
|
||||||
|
|
||||||
# 0.6.0
|
|
||||||
|
|
||||||
* Introduce .homesickrc
|
|
||||||
* Castles can now have a .homesickrc inside them
|
|
||||||
* On clone, this is eval'd inside the destination directory
|
|
||||||
* Introduce track command
|
|
||||||
* Allows easily moving an existing file into a castle, and symlinking it back
|
|
||||||
|
|
||||||
# 0.5.0
|
|
||||||
|
|
||||||
* Fixed listing of castles cloned using `homesick clone <github-user>/<github-repo>` (issue 3)
|
|
||||||
* Added `homesick pull <CASTLE>` for updating castles (thanks Jorge Dias!)
|
|
||||||
* Added a very basic `homesick generate <CASTLE>`
|
|
||||||
|
|
||||||
# 0.4.1
|
|
||||||
|
|
||||||
* Improved error message when a castle's home dir doesn't exist
|
|
||||||
|
|
||||||
# 0.4.0
|
|
||||||
|
|
||||||
* `homesick clone` can now take a path to a directory on the filesystem, which will be symlinked into place
|
|
||||||
* `homesick clone` now tries to `git submodule init` and `git submodule update` if git submodules are defined for a cloned repo
|
|
||||||
* Fixed missing dependency on thor and others
|
|
||||||
* Use HOME environment variable for where to store files, instead of assuming ~
|
|
||||||
|
|
||||||
# 0.3.0
|
|
||||||
|
|
||||||
* Renamed 'link' to 'symlink'
|
|
||||||
* Fixed conflict resolution when symlink destination exists and is a normal file
|
|
||||||
|
|
||||||
# 0.2.0
|
|
||||||
|
|
||||||
* Better support for recognizing git urls (thanks jacobat!)
|
|
||||||
* if it looks like a github user/repo, do that
|
|
||||||
* otherwise hand off to git clone
|
|
||||||
* Listing now displays in color, and show git remote
|
|
||||||
* Support pretend, force, and quiet modes
|
|
||||||
|
|
||||||
# 0.1.1
|
|
||||||
|
|
||||||
* Fixed trying to link against castles that don't exist
|
|
||||||
* Fixed linking, which tries to exclude . and .. from the list of files to
|
|
||||||
link (thanks Martinos!)
|
|
||||||
|
|
||||||
# 0.1.0
|
|
||||||
|
|
||||||
* Initial release
|
|
||||||
36
Gemfile
36
Gemfile
@@ -1,36 +0,0 @@
|
|||||||
source 'https://rubygems.org'
|
|
||||||
|
|
||||||
this_ruby = Gem::Version.new(RUBY_VERSION)
|
|
||||||
ruby_230 = Gem::Version.new('2.3.0')
|
|
||||||
|
|
||||||
# Add dependencies required to use your gem here.
|
|
||||||
gem 'thor', '>= 0.14.0'
|
|
||||||
|
|
||||||
# Add dependencies to develop your gem here.
|
|
||||||
# Include everything needed to run rake, tests, features, etc.
|
|
||||||
group :development do
|
|
||||||
gem 'capture-output', '~> 1.0.0'
|
|
||||||
gem 'coveralls', require: false
|
|
||||||
gem 'guard'
|
|
||||||
gem 'guard-rspec'
|
|
||||||
gem 'jeweler', '>= 1.6.2', '< 2.2' if this_ruby < ruby_230
|
|
||||||
gem 'jeweler', '>= 1.6.2' if this_ruby >= ruby_230
|
|
||||||
gem 'rake', '>= 0.8.7'
|
|
||||||
gem 'rb-readline', '~> 0.5.0'
|
|
||||||
gem 'rspec', '~> 3.5.0'
|
|
||||||
gem 'rubocop'
|
|
||||||
gem 'test_construct'
|
|
||||||
|
|
||||||
install_if -> { RUBY_PLATFORM =~ /linux|freebsd|openbsd|sunos|solaris/ } do
|
|
||||||
gem 'libnotify'
|
|
||||||
end
|
|
||||||
|
|
||||||
install_if -> { RUBY_PLATFORM =~ /darwin/ } do
|
|
||||||
gem 'terminal-notifier-guard', '~> 1.7.0'
|
|
||||||
end
|
|
||||||
|
|
||||||
install_if -> { this_ruby < ruby_230 } do
|
|
||||||
gem 'listen', '< 3'
|
|
||||||
gem 'rack', '< 2'
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
guard :rspec, :cmd => 'bundle exec rspec' do
|
|
||||||
watch(%r{^spec/.+_spec\.rb$})
|
|
||||||
watch(%r{^lib/(.+)\.rb$}) { |m| "spec/#{m[1]}_spec.rb" }
|
|
||||||
watch(%r{^lib/homesick/.*\.rb}) { "spec" }
|
|
||||||
watch('spec/spec_helper.rb') { "spec" }
|
|
||||||
end
|
|
||||||
185
README.markdown
185
README.markdown
@@ -1,185 +0,0 @@
|
|||||||
# homesick
|
|
||||||
|
|
||||||
[](http://badge.fury.io/rb/homesick)
|
|
||||||
[](https://travis-ci.org/technicalpickles/homesick)
|
|
||||||
[](https://gemnasium.com/technicalpickles/homesick)
|
|
||||||
[](https://coveralls.io/r/technicalpickles/homesick)
|
|
||||||
[](https://codeclimate.com/github/technicalpickles/homesick)
|
|
||||||
[](https://gitter.im/technicalpickles/homesick)
|
|
||||||
|
|
||||||
Your home directory is your castle. Don't leave your dotfiles behind.
|
|
||||||
|
|
||||||
Homesick is sorta like [rip](http://github.com/defunkt/rip), but for dotfiles. It uses git to clone a repository containing dotfiles, and saves them in `~/.homesick`. It then allows you to symlink all the dotfiles into place with a single command.
|
|
||||||
|
|
||||||
We call a repository that is compatible with homesick to be a 'castle'. To act as a castle, a repository must be organized like so:
|
|
||||||
|
|
||||||
* Contains a 'home' directory
|
|
||||||
* 'home' contains any number of files and directories that begin with '.'
|
|
||||||
|
|
||||||
To get started, install homesick first:
|
|
||||||
|
|
||||||
gem install homesick
|
|
||||||
|
|
||||||
Next, you use the homesick command to clone a castle:
|
|
||||||
|
|
||||||
homesick clone git://github.com/technicalpickles/pickled-vim.git
|
|
||||||
|
|
||||||
Alternatively, if it's on github, there's a slightly shorter way:
|
|
||||||
|
|
||||||
homesick clone technicalpickles/pickled-vim
|
|
||||||
|
|
||||||
With the castle cloned, you can now link its contents into your home dir:
|
|
||||||
|
|
||||||
homesick symlink pickled-vim
|
|
||||||
|
|
||||||
You can remove symlinks anytime when you don't need them anymore
|
|
||||||
|
|
||||||
homesick unlink pickled-vim
|
|
||||||
|
|
||||||
If you need to add further configuration steps you can add these in a file called '.homesickrc' in the root of a castle. Once you've cloned a castle with a .homesickrc run the configuration with:
|
|
||||||
|
|
||||||
homesick rc CASTLE
|
|
||||||
|
|
||||||
The contents of the .homesickrc file must be valid Ruby code as the file will be executed with Ruby's eval construct. The .homesickrc is also passed the current homesick object during its execution and this is available within the .homesickrc file as the 'self' variable. As the rc operation can be destructive the command normally asks for confirmation before proceeding. You can bypass this by passing the '--force' option, for example `homesick rc --force CASTLE`.
|
|
||||||
|
|
||||||
If you're not sure what castles you have around, you can easily list them:
|
|
||||||
|
|
||||||
homesick list
|
|
||||||
|
|
||||||
To pull your castle (or all castles):
|
|
||||||
|
|
||||||
homesick pull --all|CASTLE
|
|
||||||
|
|
||||||
To commit your castle's changes:
|
|
||||||
|
|
||||||
homesick commit CASTLE
|
|
||||||
|
|
||||||
To push your castle:
|
|
||||||
|
|
||||||
homesick push CASTLE
|
|
||||||
|
|
||||||
To open a terminal in the root of a castle:
|
|
||||||
|
|
||||||
homesick cd CASTLE
|
|
||||||
|
|
||||||
To open your default editor in the root of a castle (the $EDITOR environment variable must be set):
|
|
||||||
|
|
||||||
homesick open CASTLE
|
|
||||||
|
|
||||||
To execute a shell command inside the root directory of a given castle:
|
|
||||||
|
|
||||||
homesick exec CASTLE COMMAND
|
|
||||||
|
|
||||||
To execute a shell command inside the root directory of every cloned castle:
|
|
||||||
|
|
||||||
homesick exec_all COMMAND
|
|
||||||
|
|
||||||
Not sure what else homesick has up its sleeve? There's always the built in help:
|
|
||||||
|
|
||||||
homesick help
|
|
||||||
|
|
||||||
If you ever want to see what version of homesick you have type:
|
|
||||||
|
|
||||||
homesick version|-v|--version
|
|
||||||
|
|
||||||
## .homesick_subdir
|
|
||||||
|
|
||||||
`homesick symlink` basically makes symlink to only first depth in `castle/home`. If you want to link nested files/directories, please use .homesick_subdir.
|
|
||||||
|
|
||||||
For example, when you have castle like this:
|
|
||||||
|
|
||||||
castle/home
|
|
||||||
`-- .config
|
|
||||||
`-- fooapp
|
|
||||||
|-- config1
|
|
||||||
|-- config2
|
|
||||||
`-- config3
|
|
||||||
|
|
||||||
and have home like this:
|
|
||||||
|
|
||||||
$ tree -a
|
|
||||||
~
|
|
||||||
|-- .config
|
|
||||||
| `-- barapp
|
|
||||||
| |-- config1
|
|
||||||
| |-- config2
|
|
||||||
| `-- config3
|
|
||||||
`-- .emacs.d
|
|
||||||
|-- elisp
|
|
||||||
`-- inits
|
|
||||||
|
|
||||||
You may want to symlink only to `castle/home/.config/fooapp` instead of `castle/home/.config` because you already have `~/.config/barapp`. In this case, you can use .homesick_subdir. Please write "directories you want to look up sub directories (instead of just first depth)" in this file.
|
|
||||||
|
|
||||||
castle/.homesick_subdir
|
|
||||||
|
|
||||||
.config
|
|
||||||
|
|
||||||
and run `homesick symlink CASTLE`. The result is:
|
|
||||||
|
|
||||||
~
|
|
||||||
|-- .config
|
|
||||||
| |-- barapp
|
|
||||||
| | |-- config1
|
|
||||||
| | |-- config2
|
|
||||||
| | `-- config3
|
|
||||||
| `-- fooapp -> castle/home/.config/fooapp
|
|
||||||
`-- .emacs.d
|
|
||||||
|-- elisp
|
|
||||||
`-- inits
|
|
||||||
|
|
||||||
Or `homesick track NESTED_FILE CASTLE` adds a line automatically. For example:
|
|
||||||
|
|
||||||
homesick track .emacs.d/elisp castle
|
|
||||||
|
|
||||||
castle/.homesick_subdir
|
|
||||||
|
|
||||||
.config
|
|
||||||
.emacs.d
|
|
||||||
|
|
||||||
home directory
|
|
||||||
|
|
||||||
~
|
|
||||||
|-- .config
|
|
||||||
| |-- barapp
|
|
||||||
| | |-- config1
|
|
||||||
| | |-- config2
|
|
||||||
| | `-- config3
|
|
||||||
| `-- fooapp -> castle/home/.config/fooapp
|
|
||||||
`-- .emacs.d
|
|
||||||
|-- elisp -> castle/home/.emacs.d/elisp
|
|
||||||
`-- inits
|
|
||||||
|
|
||||||
and castle
|
|
||||||
|
|
||||||
castle/home
|
|
||||||
|-- .config
|
|
||||||
| `-- fooapp
|
|
||||||
| |-- config1
|
|
||||||
| |-- config2
|
|
||||||
| `-- config3
|
|
||||||
`-- .emacs.d
|
|
||||||
`-- elisp
|
|
||||||
|
|
||||||
## Supported Ruby Versions
|
|
||||||
|
|
||||||
Homesick is tested on the following Ruby versions:
|
|
||||||
|
|
||||||
* 2.2.6
|
|
||||||
* 2.3.3
|
|
||||||
* 2.4.0
|
|
||||||
|
|
||||||
## Note on Patches/Pull Requests
|
|
||||||
|
|
||||||
* Fork the project.
|
|
||||||
* Make your feature addition or bug fix.
|
|
||||||
* Add tests for it. This is important so I don't break it in a future version unintentionally.
|
|
||||||
* Commit, do not mess with rakefile, version, or history. (if you want to have your own version, that is fine but bump version in a commit by itself I can ignore when I pull)
|
|
||||||
* Send me a pull request. Bonus points for topic branches.
|
|
||||||
|
|
||||||
## Need homesick without the ruby dependency?
|
|
||||||
|
|
||||||
Check out [homeshick](https://github.com/andsens/homeshick).
|
|
||||||
|
|
||||||
## Copyright
|
|
||||||
|
|
||||||
Copyright (c) 2010 Joshua Nichols. See LICENSE for details.
|
|
||||||
78
README.md
Normal file
78
README.md
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
# homesick
|
||||||
|
|
||||||
|
[](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)
|
||||||
|
|
||||||
|
Your home directory is your castle. Don't leave your dotfiles behind.
|
||||||
|
|
||||||
|
This repository now contains a Go implementation of Homesick. A dotfiles repository is called a castle and should contain a `home/` directory with files to link into your `$HOME`.
|
||||||
|
|
||||||
|
## Build
|
||||||
|
|
||||||
|
Build with just:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
just go-build
|
||||||
|
```
|
||||||
|
|
||||||
|
Or directly with Go:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go build -o dist/gosick ./cmd/homesick
|
||||||
|
```
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
Implemented commands:
|
||||||
|
|
||||||
|
- `clone URI [CASTLE_NAME]`
|
||||||
|
- `list`
|
||||||
|
- `show_path [CASTLE]`
|
||||||
|
- `status [CASTLE]`
|
||||||
|
- `diff [CASTLE]`
|
||||||
|
- `link [CASTLE]`
|
||||||
|
- `unlink [CASTLE]`
|
||||||
|
- `track FILE [CASTLE]`
|
||||||
|
- `version`
|
||||||
|
|
||||||
|
Not yet implemented:
|
||||||
|
|
||||||
|
- `pull`
|
||||||
|
- `push`
|
||||||
|
- `commit`
|
||||||
|
- `destroy`
|
||||||
|
- `cd`
|
||||||
|
- `open`
|
||||||
|
- `exec`
|
||||||
|
- `exec_all`
|
||||||
|
- `rc`
|
||||||
|
- `generate`
|
||||||
|
|
||||||
|
## Behavior Suite
|
||||||
|
|
||||||
|
The repository includes a Docker-based behavior suite that validates filesystem and git outcomes for the implemented commands.
|
||||||
|
|
||||||
|
Run behavior suite:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
just behavior
|
||||||
|
```
|
||||||
|
|
||||||
|
Verbose behavior suite output:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
just behavior-verbose
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
Run all Go tests:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
just go-test
|
||||||
|
```
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
See `LICENSE`.
|
||||||
68
Rakefile
68
Rakefile
@@ -1,68 +0,0 @@
|
|||||||
require 'rubygems'
|
|
||||||
require 'bundler'
|
|
||||||
require_relative 'lib/homesick/version'
|
|
||||||
begin
|
|
||||||
Bundler.setup(:default, :development)
|
|
||||||
rescue Bundler::BundlerError => e
|
|
||||||
$stderr.puts e.message
|
|
||||||
$stderr.puts "Run `bundle install` to install missing gems"
|
|
||||||
exit e.status_code
|
|
||||||
end
|
|
||||||
require 'rake'
|
|
||||||
|
|
||||||
require 'jeweler'
|
|
||||||
Jeweler::Tasks.new do |gem|
|
|
||||||
gem.name = "homesick"
|
|
||||||
gem.summary = %Q{Your home directory is your castle. Don't leave your dotfiles behind.}
|
|
||||||
gem.description = %Q{
|
|
||||||
Your home directory is your castle. Don't leave your dotfiles behind.
|
|
||||||
|
|
||||||
|
|
||||||
Homesick is sorta like rip, but for dotfiles. It uses git to clone a repository containing dotfiles, and saves them in ~/.homesick. It then allows you to symlink all the dotfiles into place with a single command.
|
|
||||||
|
|
||||||
}
|
|
||||||
gem.email = ["josh@technicalpickles.com", "info@muratayusuke.com"]
|
|
||||||
gem.homepage = "http://github.com/technicalpickles/homesick"
|
|
||||||
gem.authors = ["Joshua Nichols", "Yusuke Murata"]
|
|
||||||
gem.version = Homesick::Version::STRING
|
|
||||||
gem.license = "MIT"
|
|
||||||
# Have dependencies? Add them to Gemfile
|
|
||||||
|
|
||||||
# gem is a Gem::Specification... see http://www.rubygems.org/read/chapter/20 for additional settings
|
|
||||||
end
|
|
||||||
Jeweler::GemcutterTasks.new
|
|
||||||
|
|
||||||
|
|
||||||
require 'rspec/core/rake_task'
|
|
||||||
RSpec::Core::RakeTask.new(:spec) do |spec|
|
|
||||||
spec.pattern = FileList['spec/**/*_spec.rb']
|
|
||||||
end
|
|
||||||
|
|
||||||
RSpec::Core::RakeTask.new(:rcov) do |spec|
|
|
||||||
spec.pattern = 'spec/**/*_spec.rb'
|
|
||||||
spec.rcov = true
|
|
||||||
end
|
|
||||||
|
|
||||||
task :rubocop do
|
|
||||||
if RUBY_VERSION >= '1.9.2'
|
|
||||||
system('rubocop')
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
task :test do
|
|
||||||
Rake::Task['spec'].execute
|
|
||||||
Rake::Task['rubocop'].execute
|
|
||||||
end
|
|
||||||
|
|
||||||
task :default => :test
|
|
||||||
|
|
||||||
require 'rdoc/task'
|
|
||||||
Rake::RDocTask.new do |rdoc|
|
|
||||||
version = File.exist?('VERSION') ? File.read('VERSION') : ""
|
|
||||||
|
|
||||||
rdoc.rdoc_dir = 'rdoc'
|
|
||||||
rdoc.title = "homesick #{version}"
|
|
||||||
rdoc.rdoc_files.include('README*')
|
|
||||||
rdoc.rdoc_files.include('lib/**/*.rb')
|
|
||||||
end
|
|
||||||
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
#!/usr/bin/env ruby
|
|
||||||
|
|
||||||
require 'pathname'
|
|
||||||
lib = Pathname.new(__FILE__).dirname.join('..', 'lib').expand_path
|
|
||||||
$LOAD_PATH.unshift lib.to_s
|
|
||||||
|
|
||||||
require 'homesick'
|
|
||||||
|
|
||||||
Homesick::CLI.start
|
|
||||||
269
changelog.md
Normal file
269
changelog.md
Normal file
@@ -0,0 +1,269 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
All notable changes to this project will be documented in this file.
|
||||||
|
|
||||||
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||||
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Native Go implementations for `clone`, `link`, `unlink`, and `track`.
|
||||||
|
- Containerized behavior test suite for command parity validation.
|
||||||
|
- Dedicated test suites for `list`, `show_path`, `status`, `diff`, and `version`.
|
||||||
|
- Just workflow support for building and running the Linux behavior binary.
|
||||||
|
- Coverage reports and badges published to shared object storage for branches and pull requests.
|
||||||
|
- Pull requests now receive coverage report links in CI comments.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- CLI argument parsing migrated to Kong.
|
||||||
|
- Git operations for clone and track migrated to `go-git`.
|
||||||
|
- Build and behavior workflows now produce and run the `gosick` binary name.
|
||||||
|
- CI validation is unified into push events, running behavior tests only on `main` pushes.
|
||||||
|
- Gitea CI workflows now cache Go modules and build artifacts using a shared runner tool cache.
|
||||||
|
- Gitea workflow and README badge updated from `push-unit-tests` to `push-validation`.
|
||||||
|
- CLI help now uses the invoked binary name (defaulting to `gosick`) in usage output.
|
||||||
|
- CLI help description now reflects Homesick's purpose for managing precious dotfiles.
|
||||||
|
- Release notes standardized to Keep a Changelog format.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- `status` and `diff` now consistently write through configured app output writers.
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
|
||||||
|
- Legacy Ruby implementation and Ruby toolchain.
|
||||||
|
|
||||||
|
## [1.1.6] - 2017-12-20
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Ensure `FileUtils` is imported correctly to avoid a potential error.
|
||||||
|
- Fix an issue where comparing a diff did not use the content of the new file.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Small documentation fixes.
|
||||||
|
|
||||||
|
## [1.1.5] - 2017-03-23
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Problem with version number being incorrect.
|
||||||
|
|
||||||
|
## [1.1.4] - 2017-03-22
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Ensure symlink conflicts are explicitly communicated to users and symlinks are not silently overwritten.
|
||||||
|
- Fix a problem in diff when asking a user to resolve a conflict.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Use real paths of symlinks when linking a castle into home.
|
||||||
|
- Code refactoring and fixes.
|
||||||
|
|
||||||
|
## [1.1.3] - 2015-10-31
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Allow a destination to be passed when cloning a castle.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Make sure `homesick edit` opens the default editor in the root of the given castle.
|
||||||
|
- Bug when diffing edited files.
|
||||||
|
- Crashing bug when attempting to diff directories.
|
||||||
|
- Ensure that messages are escaped correctly on `git commit all`.
|
||||||
|
|
||||||
|
## [1.1.2] - 2015-01-02
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- `--force` option to the rc command to bypass confirmation checks when running a `.homesickrc` file.
|
||||||
|
- Check to ensure that at least Git 1.8.0 is installed.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Stop Homesick failing silently when Git is not installed.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Code refactoring and fixes.
|
||||||
|
|
||||||
|
## [1.1.0] - 2014-04-28
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- `exec` and `exec_all` commands to run commands inside one or all cloned castles.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Code refactoring.
|
||||||
|
|
||||||
|
## [1.0.0] - 2014-01-15
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- `version` command.
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
|
||||||
|
- Support for Ruby 1.8.7.
|
||||||
|
|
||||||
|
## [0.9.8] - 2014-01-02
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- `homesick cd` command.
|
||||||
|
- `homesick open` command.
|
||||||
|
|
||||||
|
## [0.9.4] - 2013-07-31
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- `homesick unlink` command.
|
||||||
|
- `homesick rc` command.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Use HTTPS protocol instead of git protocol.
|
||||||
|
|
||||||
|
## [0.9.3] - 2013-07-07
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Recursive option to `homesick clone`.
|
||||||
|
|
||||||
|
## [0.9.2] - 2013-06-27
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- `homesick show_path` command.
|
||||||
|
- `homesick status` command.
|
||||||
|
- `homesick diff` command.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Set `dotfiles` as default castle name.
|
||||||
|
|
||||||
|
## [0.9.1] - 2013-06-17
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Small bugs: #35, #40.
|
||||||
|
|
||||||
|
## [0.9.0] - 2013-06-06
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- `.homesick_subdir` (#39).
|
||||||
|
|
||||||
|
## [0.8.1] - 2013-05-19
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- `homesick list` bug on Ruby 2.0 (#37).
|
||||||
|
|
||||||
|
## [0.8.0] - 2013-04-06
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- `commit` and `push` command.
|
||||||
|
- Commit changes in a castle and push to remote.
|
||||||
|
- Enable recursive submodule update.
|
||||||
|
- Git add when using track.
|
||||||
|
|
||||||
|
## [0.7.0] - 2012-05-28
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- New option for pull command: `--all`.
|
||||||
|
- Pull each castle instead of just one.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Double-cloning (#14).
|
||||||
|
|
||||||
|
## [0.6.1] - 2010-11-13
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- License.
|
||||||
|
|
||||||
|
## [0.6.0] - 2010-10-27
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- `.homesickrc` support.
|
||||||
|
- Castles can now have a `.homesickrc` inside them.
|
||||||
|
- On clone, this is eval'd inside the destination directory.
|
||||||
|
- `track` command.
|
||||||
|
- Allows easily moving an existing file into a castle and symlinking it back.
|
||||||
|
|
||||||
|
## [0.5.0] - 2010-05-18
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- `homesick pull <CASTLE>` for updating castles (thanks Jorge Dias).
|
||||||
|
- A very basic `homesick generate <CASTLE>`.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Listing of castles cloned using `homesick clone <github-user>/<github-repo>` (issue 3).
|
||||||
|
|
||||||
|
## [0.4.1] - 2010-04-02
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Improve error message when a castle's home dir does not exist.
|
||||||
|
|
||||||
|
## [0.4.0] - 2010-04-01
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- `homesick clone` can take a path to a directory on the filesystem, which is symlinked into place.
|
||||||
|
- `homesick clone` tries to run `git submodule init` and `git submodule update` if git submodules are defined for a cloned repo.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Use `HOME` environment variable for where to store files, instead of assuming `~`.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Missing dependency on thor and others.
|
||||||
|
|
||||||
|
## [0.3.0] - 2010-04-01
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Rename `link` to `symlink`.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Conflict resolution when symlink destination exists and is a normal file.
|
||||||
|
|
||||||
|
## [0.2.0] - 2010-03-19
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Better support for recognizing git URLs (thanks jacobat).
|
||||||
|
- If it looks like a GitHub user/repo, use that.
|
||||||
|
- Otherwise hand off to git clone.
|
||||||
|
- Listing now displays in color and shows git remote.
|
||||||
|
- Support pretend, force, and quiet modes.
|
||||||
|
|
||||||
|
## [0.1.1] - 2010-03-17
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Trying to link against castles that do not exist.
|
||||||
|
- Linking now excludes `.` and `..` from the list of files to link (thanks Martinos).
|
||||||
|
|
||||||
|
## [0.1.0] - 2010-03-10
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Initial release.
|
||||||
12
cmd/homesick/main.go
Normal file
12
cmd/homesick/main.go
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"git.hrafn.xyz/aether/gosick/internal/homesick/cli"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
exitCode := cli.Run(os.Args[1:], os.Stdout, os.Stderr)
|
||||||
|
os.Exit(exitCode)
|
||||||
|
}
|
||||||
21
docker/behavior/Dockerfile
Normal file
21
docker/behavior/Dockerfile
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
FROM golang:1.26-alpine AS builder
|
||||||
|
|
||||||
|
WORKDIR /workspace
|
||||||
|
COPY go.mod go.sum /workspace/
|
||||||
|
RUN go mod download
|
||||||
|
COPY . /workspace
|
||||||
|
RUN mkdir -p /workspace/dist && \
|
||||||
|
go build -o /workspace/dist/gosick ./cmd/homesick
|
||||||
|
|
||||||
|
FROM alpine:3.21
|
||||||
|
|
||||||
|
RUN apk add --no-cache \
|
||||||
|
bash \
|
||||||
|
ca-certificates \
|
||||||
|
git
|
||||||
|
|
||||||
|
WORKDIR /workspace
|
||||||
|
COPY . /workspace
|
||||||
|
COPY --from=builder /workspace/dist/gosick /workspace/dist/gosick
|
||||||
|
|
||||||
|
ENTRYPOINT ["/workspace/test/behavior/behavior_suite.sh"]
|
||||||
35
go.mod
Normal file
35
go.mod
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
module git.hrafn.xyz/aether/gosick
|
||||||
|
|
||||||
|
go 1.26
|
||||||
|
|
||||||
|
toolchain go1.26.1
|
||||||
|
|
||||||
|
require github.com/stretchr/testify v1.10.0
|
||||||
|
|
||||||
|
require github.com/go-git/go-git/v5 v5.14.0
|
||||||
|
|
||||||
|
require (
|
||||||
|
dario.cat/mergo v1.0.0 // indirect
|
||||||
|
github.com/Microsoft/go-winio v0.6.2 // indirect
|
||||||
|
github.com/ProtonMail/go-crypto v1.1.5 // indirect
|
||||||
|
github.com/alecthomas/kong v1.12.1 // indirect
|
||||||
|
github.com/cloudflare/circl v1.6.0 // indirect
|
||||||
|
github.com/cyphar/filepath-securejoin v0.4.1 // indirect
|
||||||
|
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||||
|
github.com/emirpasic/gods v1.18.1 // indirect
|
||||||
|
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
|
||||||
|
github.com/go-git/go-billy/v5 v5.6.2 // indirect
|
||||||
|
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
|
||||||
|
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
|
||||||
|
github.com/kevinburke/ssh_config v1.2.0 // indirect
|
||||||
|
github.com/pjbgf/sha1cd v0.3.2 // indirect
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||||
|
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect
|
||||||
|
github.com/skeema/knownhosts v1.3.1 // indirect
|
||||||
|
github.com/xanzy/ssh-agent v0.3.3 // indirect
|
||||||
|
golang.org/x/crypto v0.35.0 // indirect
|
||||||
|
golang.org/x/net v0.35.0 // indirect
|
||||||
|
golang.org/x/sys v0.30.0 // indirect
|
||||||
|
gopkg.in/warnings.v0 v0.1.2 // indirect
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
|
)
|
||||||
104
go.sum
Normal file
104
go.sum
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk=
|
||||||
|
dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk=
|
||||||
|
github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY=
|
||||||
|
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
||||||
|
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
||||||
|
github.com/ProtonMail/go-crypto v1.1.5 h1:eoAQfK2dwL+tFSFpr7TbOaPNUbPiJj4fLYwwGE1FQO4=
|
||||||
|
github.com/ProtonMail/go-crypto v1.1.5/go.mod h1:rA3QumHc/FZ8pAHreoekgiAbzpNsfQAosU5td4SnOrE=
|
||||||
|
github.com/alecthomas/kong v1.12.1 h1:iq6aMJDcFYP9uFrLdsiZQ2ZMmcshduyGv4Pek0MQPW0=
|
||||||
|
github.com/alecthomas/kong v1.12.1/go.mod h1:p2vqieVMeTAnaC83txKtXe8FLke2X07aruPWXyMPQrU=
|
||||||
|
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8=
|
||||||
|
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4=
|
||||||
|
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio=
|
||||||
|
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs=
|
||||||
|
github.com/cloudflare/circl v1.6.0 h1:cr5JKic4HI+LkINy2lg3W2jF8sHCVTBncJr5gIIq7qk=
|
||||||
|
github.com/cloudflare/circl v1.6.0/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs=
|
||||||
|
github.com/cyphar/filepath-securejoin v0.4.1 h1:JyxxyPEaktOD+GAnqIqTf9A8tHyAG22rowi7HkoSU1s=
|
||||||
|
github.com/cyphar/filepath-securejoin v0.4.1/go.mod h1:Sdj7gXlvMcPZsbhwhQ33GguGLDGQL7h7bg04C/+u9jI=
|
||||||
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o=
|
||||||
|
github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE=
|
||||||
|
github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc=
|
||||||
|
github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ=
|
||||||
|
github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c=
|
||||||
|
github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU=
|
||||||
|
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI=
|
||||||
|
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic=
|
||||||
|
github.com/go-git/go-billy/v5 v5.6.2 h1:6Q86EsPXMa7c3YZ3aLAQsMA0VlWmy43r6FHqa/UNbRM=
|
||||||
|
github.com/go-git/go-billy/v5 v5.6.2/go.mod h1:rcFC2rAsp/erv7CMz9GczHcuD0D32fWzH+MJAU+jaUU=
|
||||||
|
github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMje31YglSBqCdIqdhKBW8lokaMrL3uTkpGYlE2OOT4=
|
||||||
|
github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII=
|
||||||
|
github.com/go-git/go-git/v5 v5.14.0 h1:/MD3lCrGjCen5WfEAzKg00MJJffKhC8gzS80ycmCi60=
|
||||||
|
github.com/go-git/go-git/v5 v5.14.0/go.mod h1:Z5Xhoia5PcWA3NF8vRLURn9E5FRhSl7dGj9ItW3Wk5k=
|
||||||
|
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ=
|
||||||
|
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw=
|
||||||
|
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
|
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||||
|
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A=
|
||||||
|
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo=
|
||||||
|
github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4=
|
||||||
|
github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM=
|
||||||
|
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||||
|
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||||
|
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||||
|
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||||
|
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||||
|
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
|
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||||
|
github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k=
|
||||||
|
github.com/onsi/gomega v1.34.1/go.mod h1:kU1QgUvBDLXBJq618Xvm2LUX6rSAfRaFRTcdOeDLwwY=
|
||||||
|
github.com/pjbgf/sha1cd v0.3.2 h1:a9wb0bp1oC2TGwStyn0Umc/IGKQnEgF0vVaZ8QF8eo4=
|
||||||
|
github.com/pjbgf/sha1cd v0.3.2/go.mod h1:zQWigSxVmsHEZow5qaLtPYxpcKMMQpa09ixqBxuCS6A=
|
||||||
|
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||||
|
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||||
|
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||||
|
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8=
|
||||||
|
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
|
||||||
|
github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
|
||||||
|
github.com/skeema/knownhosts v1.3.1 h1:X2osQ+RAjK76shCbvhHHHVl3ZlgDm8apHEHFqRjnBY8=
|
||||||
|
github.com/skeema/knownhosts v1.3.1/go.mod h1:r7KTdC8l4uxWRyK2TpQZ/1o5HaSzh06ePQNxPwTcfiY=
|
||||||
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
|
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
||||||
|
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||||
|
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||||
|
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||||
|
github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM=
|
||||||
|
github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw=
|
||||||
|
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
||||||
|
golang.org/x/crypto v0.35.0 h1:b15kiHdrGCHrP6LvwaQ3c03kgNhhiMgvlhxHQhmg2Xs=
|
||||||
|
golang.org/x/crypto v0.35.0/go.mod h1:dy7dXNW32cAb/6/PRuTNsix8T+vJAqvuIy5Bli/x0YQ=
|
||||||
|
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8=
|
||||||
|
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY=
|
||||||
|
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||||
|
golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8=
|
||||||
|
golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk=
|
||||||
|
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
|
||||||
|
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
|
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
|
golang.org/x/term v0.29.0 h1:L6pJp37ocefwRRtYPKSWOWzOtWSxVajvz2ldH/xi3iU=
|
||||||
|
golang.org/x/term v0.29.0/go.mod h1:6bl4lRlvVuDgSf3179VpIxBF0o10JUpXWOnI7nErv7s=
|
||||||
|
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
|
golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
|
||||||
|
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
|
||||||
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||||
|
gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME=
|
||||||
|
gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI=
|
||||||
|
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||||
|
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
105
homesick.gemspec
105
homesick.gemspec
@@ -1,105 +0,0 @@
|
|||||||
# Generated by jeweler
|
|
||||||
# DO NOT EDIT THIS FILE DIRECTLY
|
|
||||||
# Instead, edit Jeweler::Tasks in Rakefile, and run 'rake gemspec'
|
|
||||||
# -*- encoding: utf-8 -*-
|
|
||||||
# stub: homesick 1.1.5 ruby lib
|
|
||||||
|
|
||||||
Gem::Specification.new do |s|
|
|
||||||
s.name = "homesick".freeze
|
|
||||||
s.version = "1.1.5"
|
|
||||||
|
|
||||||
s.required_rubygems_version = Gem::Requirement.new(">= 0".freeze) if s.respond_to? :required_rubygems_version=
|
|
||||||
s.require_paths = ["lib".freeze]
|
|
||||||
s.authors = ["Joshua Nichols".freeze, "Yusuke Murata".freeze]
|
|
||||||
s.date = "2017-03-23"
|
|
||||||
s.description = "\n Your home directory is your castle. Don't leave your dotfiles behind.\n \n\n Homesick is sorta like rip, but for dotfiles. It uses git to clone a repository containing dotfiles, and saves them in ~/.homesick. It then allows you to symlink all the dotfiles into place with a single command. \n\n ".freeze
|
|
||||||
s.email = ["josh@technicalpickles.com".freeze, "info@muratayusuke.com".freeze]
|
|
||||||
s.executables = ["homesick".freeze]
|
|
||||||
s.extra_rdoc_files = [
|
|
||||||
"ChangeLog.markdown",
|
|
||||||
"LICENSE",
|
|
||||||
"README.markdown"
|
|
||||||
]
|
|
||||||
s.files = [
|
|
||||||
".document",
|
|
||||||
".rspec",
|
|
||||||
".rubocop.yml",
|
|
||||||
".travis.yml",
|
|
||||||
"ChangeLog.markdown",
|
|
||||||
"Gemfile",
|
|
||||||
"Guardfile",
|
|
||||||
"LICENSE",
|
|
||||||
"README.markdown",
|
|
||||||
"Rakefile",
|
|
||||||
"bin/homesick",
|
|
||||||
"homesick.gemspec",
|
|
||||||
"lib/homesick.rb",
|
|
||||||
"lib/homesick/actions/file_actions.rb",
|
|
||||||
"lib/homesick/actions/git_actions.rb",
|
|
||||||
"lib/homesick/cli.rb",
|
|
||||||
"lib/homesick/utils.rb",
|
|
||||||
"lib/homesick/version.rb",
|
|
||||||
"spec/homesick_cli_spec.rb",
|
|
||||||
"spec/spec.opts",
|
|
||||||
"spec/spec_helper.rb"
|
|
||||||
]
|
|
||||||
s.homepage = "http://github.com/technicalpickles/homesick".freeze
|
|
||||||
s.licenses = ["MIT".freeze]
|
|
||||||
s.rubygems_version = "2.6.11".freeze
|
|
||||||
s.summary = "Your home directory is your castle. Don't leave your dotfiles behind.".freeze
|
|
||||||
|
|
||||||
if s.respond_to? :specification_version then
|
|
||||||
s.specification_version = 4
|
|
||||||
|
|
||||||
if Gem::Version.new(Gem::VERSION) >= Gem::Version.new('1.2.0') then
|
|
||||||
s.add_runtime_dependency(%q<thor>.freeze, [">= 0.14.0"])
|
|
||||||
s.add_development_dependency(%q<capture-output>.freeze, ["~> 1.0.0"])
|
|
||||||
s.add_development_dependency(%q<coveralls>.freeze, [">= 0"])
|
|
||||||
s.add_development_dependency(%q<guard>.freeze, [">= 0"])
|
|
||||||
s.add_development_dependency(%q<guard-rspec>.freeze, [">= 0"])
|
|
||||||
s.add_development_dependency(%q<jeweler>.freeze, [">= 1.6.2"])
|
|
||||||
s.add_development_dependency(%q<rake>.freeze, [">= 0.8.7"])
|
|
||||||
s.add_development_dependency(%q<rb-readline>.freeze, ["~> 0.5.0"])
|
|
||||||
s.add_development_dependency(%q<rspec>.freeze, ["~> 3.5.0"])
|
|
||||||
s.add_development_dependency(%q<rubocop>.freeze, [">= 0"])
|
|
||||||
s.add_development_dependency(%q<test_construct>.freeze, [">= 0"])
|
|
||||||
s.add_development_dependency(%q<libnotify>.freeze, [">= 0"])
|
|
||||||
s.add_development_dependency(%q<terminal-notifier-guard>.freeze, ["~> 1.7.0"])
|
|
||||||
s.add_development_dependency(%q<listen>.freeze, ["< 3"])
|
|
||||||
s.add_development_dependency(%q<rack>.freeze, ["< 2"])
|
|
||||||
else
|
|
||||||
s.add_dependency(%q<thor>.freeze, [">= 0.14.0"])
|
|
||||||
s.add_dependency(%q<capture-output>.freeze, ["~> 1.0.0"])
|
|
||||||
s.add_dependency(%q<coveralls>.freeze, [">= 0"])
|
|
||||||
s.add_dependency(%q<guard>.freeze, [">= 0"])
|
|
||||||
s.add_dependency(%q<guard-rspec>.freeze, [">= 0"])
|
|
||||||
s.add_dependency(%q<jeweler>.freeze, [">= 1.6.2"])
|
|
||||||
s.add_dependency(%q<rake>.freeze, [">= 0.8.7"])
|
|
||||||
s.add_dependency(%q<rb-readline>.freeze, ["~> 0.5.0"])
|
|
||||||
s.add_dependency(%q<rspec>.freeze, ["~> 3.5.0"])
|
|
||||||
s.add_dependency(%q<rubocop>.freeze, [">= 0"])
|
|
||||||
s.add_dependency(%q<test_construct>.freeze, [">= 0"])
|
|
||||||
s.add_dependency(%q<libnotify>.freeze, [">= 0"])
|
|
||||||
s.add_dependency(%q<terminal-notifier-guard>.freeze, ["~> 1.7.0"])
|
|
||||||
s.add_dependency(%q<listen>.freeze, ["< 3"])
|
|
||||||
s.add_dependency(%q<rack>.freeze, ["< 2"])
|
|
||||||
end
|
|
||||||
else
|
|
||||||
s.add_dependency(%q<thor>.freeze, [">= 0.14.0"])
|
|
||||||
s.add_dependency(%q<capture-output>.freeze, ["~> 1.0.0"])
|
|
||||||
s.add_dependency(%q<coveralls>.freeze, [">= 0"])
|
|
||||||
s.add_dependency(%q<guard>.freeze, [">= 0"])
|
|
||||||
s.add_dependency(%q<guard-rspec>.freeze, [">= 0"])
|
|
||||||
s.add_dependency(%q<jeweler>.freeze, [">= 1.6.2"])
|
|
||||||
s.add_dependency(%q<rake>.freeze, [">= 0.8.7"])
|
|
||||||
s.add_dependency(%q<rb-readline>.freeze, ["~> 0.5.0"])
|
|
||||||
s.add_dependency(%q<rspec>.freeze, ["~> 3.5.0"])
|
|
||||||
s.add_dependency(%q<rubocop>.freeze, [">= 0"])
|
|
||||||
s.add_dependency(%q<test_construct>.freeze, [">= 0"])
|
|
||||||
s.add_dependency(%q<libnotify>.freeze, [">= 0"])
|
|
||||||
s.add_dependency(%q<terminal-notifier-guard>.freeze, ["~> 1.7.0"])
|
|
||||||
s.add_dependency(%q<listen>.freeze, ["< 3"])
|
|
||||||
s.add_dependency(%q<rack>.freeze, ["< 2"])
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
250
internal/homesick/cli/cli.go
Normal file
250
internal/homesick/cli/cli.go
Normal file
@@ -0,0 +1,250 @@
|
|||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"git.hrafn.xyz/aether/gosick/internal/homesick/core"
|
||||||
|
"git.hrafn.xyz/aether/gosick/internal/homesick/version"
|
||||||
|
"github.com/alecthomas/kong"
|
||||||
|
)
|
||||||
|
|
||||||
|
func Run(args []string, stdout io.Writer, stderr io.Writer) int {
|
||||||
|
app, err := core.New(stdout, stderr)
|
||||||
|
if err != nil {
|
||||||
|
_, _ = fmt.Fprintf(stderr, "error: %v\n", err)
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
parser, err := kong.New(
|
||||||
|
&cliModel{},
|
||||||
|
kong.Name(programName()),
|
||||||
|
kong.Description("Your home is your castle. Don't leave your precious dotfiles behind."),
|
||||||
|
kong.Writers(stdout, stderr),
|
||||||
|
kong.Exit(func(int) {}),
|
||||||
|
kong.ConfigureHelp(kong.HelpOptions{Compact: true}),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
_, _ = fmt.Fprintf(stderr, "error: %v\n", err)
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
normalizedArgs := normalizeArgs(args)
|
||||||
|
ctx, err := parser.Parse(normalizedArgs)
|
||||||
|
if err != nil {
|
||||||
|
var parseErr *kong.ParseError
|
||||||
|
if errors.As(err, &parseErr) {
|
||||||
|
if parseErr.ExitCode() == 0 || isHelpRequest(normalizedArgs) {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
_, _ = fmt.Fprintf(stderr, "error: %v\n", err)
|
||||||
|
if parseErr.Context != nil {
|
||||||
|
_ = parseErr.Context.PrintUsage(false)
|
||||||
|
}
|
||||||
|
return parseErr.ExitCode()
|
||||||
|
}
|
||||||
|
_, _ = fmt.Fprintf(stderr, "error: %v\n", err)
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := ctx.Run(app); err != nil {
|
||||||
|
var exitErr *cliExitError
|
||||||
|
if errors.As(err, &exitErr) {
|
||||||
|
_, _ = fmt.Fprintf(stderr, "error: %v\n", exitErr.err)
|
||||||
|
return exitErr.code
|
||||||
|
}
|
||||||
|
_, _ = fmt.Fprintf(stderr, "error: %v\n", err)
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
type cliModel struct {
|
||||||
|
Clone cloneCmd `cmd:"" help:"Clone a castle."`
|
||||||
|
List listCmd `cmd:"" help:"List castles."`
|
||||||
|
ShowPath showPathCmd `cmd:"" name:"show_path" help:"Show the path of a castle."`
|
||||||
|
Status statusCmd `cmd:"" help:"Show git status for a castle."`
|
||||||
|
Diff diffCmd `cmd:"" help:"Show git diff for a castle."`
|
||||||
|
Link linkCmd `cmd:"" help:"Symlink all dotfiles from a castle."`
|
||||||
|
Unlink unlinkCmd `cmd:"" help:"Unsymlink all dotfiles from a castle."`
|
||||||
|
Track trackCmd `cmd:"" help:"Track a file in a castle."`
|
||||||
|
Version versionCmd `cmd:"" help:"Display the current version."`
|
||||||
|
Pull pullCmd `cmd:"" help:"Pull the specified castle."`
|
||||||
|
Push pushCmd `cmd:"" help:"Push the specified castle."`
|
||||||
|
Commit commitCmd `cmd:"" help:"Commit the specified castle's changes."`
|
||||||
|
Destroy destroyCmd `cmd:"" help:"Destroy a castle."`
|
||||||
|
Cd cdCmd `cmd:"" help:"Print the path to a castle."`
|
||||||
|
Open openCmd `cmd:"" help:"Open a castle."`
|
||||||
|
Exec execCmd `cmd:"" help:"Execute a command in a castle."`
|
||||||
|
ExecAll execAllCmd `cmd:"" name:"exec_all" help:"Execute a command in every castle."`
|
||||||
|
Rc rcCmd `cmd:"" help:"Run rc hooks for a castle."`
|
||||||
|
Generate generateCmd `cmd:"" help:"Generate a castle."`
|
||||||
|
}
|
||||||
|
|
||||||
|
type cloneCmd struct {
|
||||||
|
URI string `arg:"" name:"URI" help:"Castle URI to clone."`
|
||||||
|
Destination string `arg:"" optional:"" name:"CASTLE_NAME" help:"Optional local castle name."`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *cloneCmd) Run(app *core.App) error {
|
||||||
|
return app.Clone(c.URI, c.Destination)
|
||||||
|
}
|
||||||
|
|
||||||
|
type listCmd struct{}
|
||||||
|
|
||||||
|
func (c *listCmd) Run(app *core.App) error {
|
||||||
|
return app.List()
|
||||||
|
}
|
||||||
|
|
||||||
|
type showPathCmd struct {
|
||||||
|
Castle string `arg:"" optional:"" name:"CASTLE" help:"Castle name."`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *showPathCmd) Run(app *core.App) error {
|
||||||
|
return app.ShowPath(defaultCastle(c.Castle))
|
||||||
|
}
|
||||||
|
|
||||||
|
type statusCmd struct {
|
||||||
|
Castle string `arg:"" optional:"" name:"CASTLE" help:"Castle name."`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *statusCmd) Run(app *core.App) error {
|
||||||
|
return app.Status(defaultCastle(c.Castle))
|
||||||
|
}
|
||||||
|
|
||||||
|
type diffCmd struct {
|
||||||
|
Castle string `arg:"" optional:"" name:"CASTLE" help:"Castle name."`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *diffCmd) Run(app *core.App) error {
|
||||||
|
return app.Diff(defaultCastle(c.Castle))
|
||||||
|
}
|
||||||
|
|
||||||
|
type linkCmd struct {
|
||||||
|
Castle string `arg:"" optional:"" name:"CASTLE" help:"Castle name."`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *linkCmd) Run(app *core.App) error {
|
||||||
|
return app.LinkCastle(defaultCastle(c.Castle))
|
||||||
|
}
|
||||||
|
|
||||||
|
type unlinkCmd struct {
|
||||||
|
Castle string `arg:"" optional:"" name:"CASTLE" help:"Castle name."`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *unlinkCmd) Run(app *core.App) error {
|
||||||
|
return app.Unlink(defaultCastle(c.Castle))
|
||||||
|
}
|
||||||
|
|
||||||
|
type trackCmd struct {
|
||||||
|
File string `arg:"" name:"FILE" help:"File to track."`
|
||||||
|
Castle string `arg:"" optional:"" name:"CASTLE" help:"Castle name."`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *trackCmd) Run(app *core.App) error {
|
||||||
|
return app.Track(c.File, defaultCastle(c.Castle))
|
||||||
|
}
|
||||||
|
|
||||||
|
type versionCmd struct{}
|
||||||
|
|
||||||
|
func (c *versionCmd) Run(app *core.App) error {
|
||||||
|
return app.Version(version.String)
|
||||||
|
}
|
||||||
|
|
||||||
|
type pullCmd struct{}
|
||||||
|
|
||||||
|
type pushCmd struct{}
|
||||||
|
|
||||||
|
type commitCmd struct{}
|
||||||
|
|
||||||
|
type destroyCmd struct{}
|
||||||
|
|
||||||
|
type cdCmd struct{}
|
||||||
|
|
||||||
|
type openCmd struct{}
|
||||||
|
|
||||||
|
type execCmd struct{}
|
||||||
|
|
||||||
|
type execAllCmd struct{}
|
||||||
|
|
||||||
|
type rcCmd struct{}
|
||||||
|
|
||||||
|
type generateCmd struct{}
|
||||||
|
|
||||||
|
func (c *pullCmd) Run() error { return notImplemented("pull") }
|
||||||
|
func (c *pushCmd) Run() error { return notImplemented("push") }
|
||||||
|
func (c *commitCmd) Run() error { return notImplemented("commit") }
|
||||||
|
func (c *destroyCmd) Run() error { return notImplemented("destroy") }
|
||||||
|
func (c *cdCmd) Run() error { return notImplemented("cd") }
|
||||||
|
func (c *openCmd) Run() error { return notImplemented("open") }
|
||||||
|
func (c *execCmd) Run() error { return notImplemented("exec") }
|
||||||
|
func (c *execAllCmd) Run() error { return notImplemented("exec_all") }
|
||||||
|
func (c *rcCmd) Run() error { return notImplemented("rc") }
|
||||||
|
func (c *generateCmd) Run() error { return notImplemented("generate") }
|
||||||
|
|
||||||
|
func defaultCastle(castle string) string {
|
||||||
|
if strings.TrimSpace(castle) == "" {
|
||||||
|
return "dotfiles"
|
||||||
|
}
|
||||||
|
return castle
|
||||||
|
}
|
||||||
|
|
||||||
|
func programName() string {
|
||||||
|
if len(os.Args) > 0 {
|
||||||
|
if name := strings.TrimSpace(filepath.Base(os.Args[0])); name != "" {
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return "gosick"
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeArgs(args []string) []string {
|
||||||
|
if len(args) == 0 {
|
||||||
|
return []string{"--help"}
|
||||||
|
}
|
||||||
|
|
||||||
|
switch args[0] {
|
||||||
|
case "-h", "--help":
|
||||||
|
return []string{"--help"}
|
||||||
|
case "help":
|
||||||
|
if len(args) == 1 {
|
||||||
|
return []string{"--help"}
|
||||||
|
}
|
||||||
|
return append(args[1:], "--help")
|
||||||
|
case "-v", "--version":
|
||||||
|
return []string{"version"}
|
||||||
|
default:
|
||||||
|
return args
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func isHelpRequest(args []string) bool {
|
||||||
|
for _, arg := range args {
|
||||||
|
if arg == "-h" || arg == "--help" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
type cliExitError struct {
|
||||||
|
code int
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *cliExitError) Error() string {
|
||||||
|
return e.err.Error()
|
||||||
|
}
|
||||||
|
|
||||||
|
func notImplemented(command string) error {
|
||||||
|
return &cliExitError{code: 2, err: fmt.Errorf("%s is not implemented in Go yet", command)}
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
_ = os.Setenv("GIT_TERMINAL_PROMPT", "0")
|
||||||
|
}
|
||||||
76
internal/homesick/cli/cli_test.go
Normal file
76
internal/homesick/cli/cli_test.go
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
package cli_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.hrafn.xyz/aether/gosick/internal/homesick/cli"
|
||||||
|
"git.hrafn.xyz/aether/gosick/internal/homesick/version"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"github.com/stretchr/testify/suite"
|
||||||
|
)
|
||||||
|
|
||||||
|
type CLISuite struct {
|
||||||
|
suite.Suite
|
||||||
|
homeDir string
|
||||||
|
stdout *bytes.Buffer
|
||||||
|
stderr *bytes.Buffer
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCLISuite(t *testing.T) {
|
||||||
|
suite.Run(t, new(CLISuite))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *CLISuite) SetupTest() {
|
||||||
|
s.homeDir = filepath.Join(s.T().TempDir(), "home")
|
||||||
|
require.NoError(s.T(), os.MkdirAll(s.homeDir, 0o755))
|
||||||
|
require.NoError(s.T(), os.Setenv("HOME", s.homeDir))
|
||||||
|
|
||||||
|
s.stdout = &bytes.Buffer{}
|
||||||
|
s.stderr = &bytes.Buffer{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *CLISuite) TestRun_VersionAliases() {
|
||||||
|
for _, args := range [][]string{{"-v"}, {"--version"}, {"version"}} {
|
||||||
|
s.stdout.Reset()
|
||||||
|
s.stderr.Reset()
|
||||||
|
|
||||||
|
exitCode := cli.Run(args, s.stdout, s.stderr)
|
||||||
|
require.Equal(s.T(), 0, exitCode)
|
||||||
|
require.Equal(s.T(), version.String+"\n", s.stdout.String())
|
||||||
|
require.Empty(s.T(), s.stderr.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *CLISuite) TestRun_ShowPath_DefaultCastle() {
|
||||||
|
exitCode := cli.Run([]string{"show_path"}, 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_CloneSubcommandHelp() {
|
||||||
|
exitCode := cli.Run([]string{"clone", "--help"}, s.stdout, s.stderr)
|
||||||
|
|
||||||
|
require.Equal(s.T(), 0, exitCode)
|
||||||
|
require.Contains(s.T(), s.stdout.String(), "clone")
|
||||||
|
require.Contains(s.T(), s.stdout.String(), "URI")
|
||||||
|
require.Empty(s.T(), s.stderr.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *CLISuite) TestRun_Help_UsesProgramNameAndDescription() {
|
||||||
|
originalArgs := os.Args
|
||||||
|
s.T().Cleanup(func() { os.Args = originalArgs })
|
||||||
|
os.Args = []string{"gosick"}
|
||||||
|
|
||||||
|
exitCode := cli.Run([]string{"--help"}, s.stdout, s.stderr)
|
||||||
|
|
||||||
|
require.Equal(s.T(), 0, exitCode)
|
||||||
|
require.Contains(s.T(), s.stdout.String(), "Usage: gosick")
|
||||||
|
require.NotContains(s.T(), s.stdout.String(), "Usage: homesick")
|
||||||
|
require.Contains(s.T(), s.stdout.String(), "precious dotfiles")
|
||||||
|
require.Empty(s.T(), s.stderr.String())
|
||||||
|
}
|
||||||
103
internal/homesick/core/clone_test.go
Normal file
103
internal/homesick/core/clone_test.go
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
package core_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.hrafn.xyz/aether/gosick/internal/homesick/core"
|
||||||
|
git "github.com/go-git/go-git/v5"
|
||||||
|
"github.com/go-git/go-git/v5/config"
|
||||||
|
"github.com/go-git/go-git/v5/plumbing/object"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"github.com/stretchr/testify/suite"
|
||||||
|
)
|
||||||
|
|
||||||
|
type CloneSuite struct {
|
||||||
|
suite.Suite
|
||||||
|
tmpDir string
|
||||||
|
homeDir string
|
||||||
|
reposDir string
|
||||||
|
app *core.App
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCloneSuite(t *testing.T) {
|
||||||
|
suite.Run(t, new(CloneSuite))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *CloneSuite) SetupTest() {
|
||||||
|
s.tmpDir = s.T().TempDir()
|
||||||
|
s.homeDir = filepath.Join(s.tmpDir, "home")
|
||||||
|
s.reposDir = filepath.Join(s.homeDir, ".homesick", "repos")
|
||||||
|
require.NoError(s.T(), os.MkdirAll(s.reposDir, 0o755))
|
||||||
|
|
||||||
|
s.app = &core.App{
|
||||||
|
HomeDir: s.homeDir,
|
||||||
|
ReposDir: s.reposDir,
|
||||||
|
Stdout: io.Discard,
|
||||||
|
Stderr: io.Discard,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *CloneSuite) createBareRemote(name string) string {
|
||||||
|
remotePath := filepath.Join(s.tmpDir, name+".git")
|
||||||
|
_, err := git.PlainInit(remotePath, true)
|
||||||
|
require.NoError(s.T(), err)
|
||||||
|
|
||||||
|
workPath := filepath.Join(s.tmpDir, name+"-work")
|
||||||
|
repo, err := git.PlainInit(workPath, false)
|
||||||
|
require.NoError(s.T(), err)
|
||||||
|
|
||||||
|
castleFile := filepath.Join(workPath, "home", ".vimrc")
|
||||||
|
require.NoError(s.T(), os.MkdirAll(filepath.Dir(castleFile), 0o755))
|
||||||
|
require.NoError(s.T(), os.WriteFile(castleFile, []byte("set number\n"), 0o644))
|
||||||
|
|
||||||
|
wt, err := repo.Worktree()
|
||||||
|
require.NoError(s.T(), err)
|
||||||
|
_, err = wt.Add("home/.vimrc")
|
||||||
|
require.NoError(s.T(), err)
|
||||||
|
|
||||||
|
_, err = wt.Commit("initial", &git.CommitOptions{Author: &object.Signature{
|
||||||
|
Name: "Behavior Test",
|
||||||
|
Email: "behavior@test.local",
|
||||||
|
When: time.Now(),
|
||||||
|
}})
|
||||||
|
require.NoError(s.T(), err)
|
||||||
|
|
||||||
|
_, err = repo.CreateRemote(&config.RemoteConfig{Name: "origin", URLs: []string{remotePath}})
|
||||||
|
require.NoError(s.T(), err)
|
||||||
|
require.NoError(s.T(), repo.Push(&git.PushOptions{RemoteName: "origin"}))
|
||||||
|
|
||||||
|
return remotePath
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *CloneSuite) TestClone_FileURLWorks() {
|
||||||
|
remotePath := s.createBareRemote("castle")
|
||||||
|
|
||||||
|
err := s.app.Clone("file://"+remotePath, "parity-castle")
|
||||||
|
require.NoError(s.T(), err)
|
||||||
|
require.FileExists(s.T(), filepath.Join(s.reposDir, "parity-castle", "home", ".vimrc"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *CloneSuite) TestClone_DerivesDestinationWhenMissing() {
|
||||||
|
remotePath := s.createBareRemote("dotfiles")
|
||||||
|
|
||||||
|
err := s.app.Clone("file://"+remotePath, "")
|
||||||
|
require.NoError(s.T(), err)
|
||||||
|
require.DirExists(s.T(), filepath.Join(s.reposDir, "dotfiles"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *CloneSuite) TestClone_LocalPathSymlinksDirectory() {
|
||||||
|
localCastle := filepath.Join(s.tmpDir, "local-castle")
|
||||||
|
require.NoError(s.T(), os.MkdirAll(filepath.Join(localCastle, "home"), 0o755))
|
||||||
|
|
||||||
|
err := s.app.Clone(localCastle, "")
|
||||||
|
require.NoError(s.T(), err)
|
||||||
|
|
||||||
|
destination := filepath.Join(s.reposDir, "local-castle")
|
||||||
|
info, err := os.Lstat(destination)
|
||||||
|
require.NoError(s.T(), err)
|
||||||
|
require.True(s.T(), info.Mode()&os.ModeSymlink != 0)
|
||||||
|
}
|
||||||
555
internal/homesick/core/core.go
Normal file
555
internal/homesick/core/core.go
Normal file
@@ -0,0 +1,555 @@
|
|||||||
|
package core
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"io/fs"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
git "github.com/go-git/go-git/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
type App struct {
|
||||||
|
HomeDir string
|
||||||
|
ReposDir string
|
||||||
|
Stdout io.Writer
|
||||||
|
Stderr io.Writer
|
||||||
|
Verbose bool
|
||||||
|
Force bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(stdout io.Writer, stderr io.Writer) (*App, error) {
|
||||||
|
home, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("resolve home directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &App{
|
||||||
|
HomeDir: home,
|
||||||
|
ReposDir: filepath.Join(home, ".homesick", "repos"),
|
||||||
|
Stdout: stdout,
|
||||||
|
Stderr: stderr,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) Version(version string) error {
|
||||||
|
_, err := fmt.Fprintln(a.Stdout, version)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) ShowPath(castle string) error {
|
||||||
|
_, err := fmt.Fprintln(a.Stdout, filepath.Join(a.ReposDir, castle))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) Clone(uri string, destination string) error {
|
||||||
|
if uri == "" {
|
||||||
|
return errors.New("clone requires URI")
|
||||||
|
}
|
||||||
|
|
||||||
|
if destination == "" {
|
||||||
|
destination = deriveDestination(uri)
|
||||||
|
}
|
||||||
|
if destination == "" {
|
||||||
|
return fmt.Errorf("unable to derive destination from uri %q", uri)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.MkdirAll(a.ReposDir, 0o755); err != nil {
|
||||||
|
return fmt.Errorf("create repos directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
destinationPath := filepath.Join(a.ReposDir, destination)
|
||||||
|
|
||||||
|
if fi, err := os.Stat(uri); err == nil && fi.IsDir() {
|
||||||
|
if err := os.Symlink(uri, destinationPath); err != nil {
|
||||||
|
return fmt.Errorf("symlink local castle: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := git.PlainClone(destinationPath, false, &git.CloneOptions{
|
||||||
|
URL: uri,
|
||||||
|
RecurseSubmodules: git.DefaultSubmoduleRecursionDepth,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("clone %q into %q failed: %w", uri, destinationPath, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) List() error {
|
||||||
|
if err := os.MkdirAll(a.ReposDir, 0o755); err != 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 {
|
||||||
|
castleRoot := filepath.Join(a.ReposDir, castle)
|
||||||
|
remote, remoteErr := gitOutput(castleRoot, "config", "remote.origin.url")
|
||||||
|
if remoteErr != nil {
|
||||||
|
remote = ""
|
||||||
|
}
|
||||||
|
_, writeErr := fmt.Fprintf(a.Stdout, "%s %s\n", castle, strings.TrimSpace(remote))
|
||||||
|
if writeErr != nil {
|
||||||
|
return writeErr
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) Status(castle string) error {
|
||||||
|
return runGitWithIO(filepath.Join(a.ReposDir, castle), a.Stdout, a.Stderr, "status")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) Diff(castle string) error {
|
||||||
|
return runGitWithIO(filepath.Join(a.ReposDir, castle), a.Stdout, a.Stderr, "diff")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) Link(castle string) error {
|
||||||
|
if strings.TrimSpace(castle) == "" {
|
||||||
|
castle = "dotfiles"
|
||||||
|
}
|
||||||
|
return a.LinkCastle(castle)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) LinkCastle(castle string) error {
|
||||||
|
castleHome := filepath.Join(a.ReposDir, castle, "home")
|
||||||
|
info, err := os.Stat(castleHome)
|
||||||
|
if err != nil || !info.IsDir() {
|
||||||
|
return fmt.Errorf("could not symlink %s, expected %s to exist and contain dotfiles", castle, castleHome)
|
||||||
|
}
|
||||||
|
|
||||||
|
subdirs, err := readSubdirs(filepath.Join(a.ReposDir, castle, ".homesick_subdir"))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := a.linkEach(castleHome, castleHome, subdirs); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, subdir := range subdirs {
|
||||||
|
base := filepath.Join(castleHome, subdir)
|
||||||
|
if _, err := os.Stat(base); err != nil {
|
||||||
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := a.linkEach(castleHome, base, subdirs); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) Unlink(castle string) error {
|
||||||
|
if strings.TrimSpace(castle) == "" {
|
||||||
|
castle = "dotfiles"
|
||||||
|
}
|
||||||
|
return a.UnlinkCastle(castle)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) UnlinkCastle(castle string) error {
|
||||||
|
castleHome := filepath.Join(a.ReposDir, castle, "home")
|
||||||
|
info, err := os.Stat(castleHome)
|
||||||
|
if err != nil || !info.IsDir() {
|
||||||
|
return fmt.Errorf("could not symlink %s, expected %s to exist and contain dotfiles", castle, castleHome)
|
||||||
|
}
|
||||||
|
|
||||||
|
subdirs, err := readSubdirs(filepath.Join(a.ReposDir, castle, ".homesick_subdir"))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := a.unlinkEach(castleHome, castleHome, subdirs); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, subdir := range subdirs {
|
||||||
|
base := filepath.Join(castleHome, subdir)
|
||||||
|
if _, err := os.Stat(base); err != nil {
|
||||||
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := a.unlinkEach(castleHome, base, subdirs); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) Track(filePath string, castle string) error {
|
||||||
|
return a.TrackPath(filePath, castle)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) TrackPath(filePath string, castle string) error {
|
||||||
|
if strings.TrimSpace(castle) == "" {
|
||||||
|
castle = "dotfiles"
|
||||||
|
}
|
||||||
|
|
||||||
|
trimmedFile := strings.TrimSpace(filePath)
|
||||||
|
if trimmedFile == "" {
|
||||||
|
return errors.New("track requires FILE")
|
||||||
|
}
|
||||||
|
|
||||||
|
castleRoot := filepath.Join(a.ReposDir, castle)
|
||||||
|
castleHome := filepath.Join(castleRoot, "home")
|
||||||
|
info, err := os.Stat(castleHome)
|
||||||
|
if err != nil || !info.IsDir() {
|
||||||
|
return fmt.Errorf("could not track %s, expected %s to exist and contain dotfiles", castle, castleHome)
|
||||||
|
}
|
||||||
|
|
||||||
|
absolutePath, err := filepath.Abs(strings.TrimRight(trimmedFile, string(filepath.Separator)))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := os.Lstat(absolutePath); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
relativeDir, err := filepath.Rel(a.HomeDir, filepath.Dir(absolutePath))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if relativeDir == ".." || strings.HasPrefix(relativeDir, ".."+string(filepath.Separator)) {
|
||||||
|
return fmt.Errorf("track requires file under %s", a.HomeDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
castleTargetDir := filepath.Join(castleHome, relativeDir)
|
||||||
|
if relativeDir == "." {
|
||||||
|
castleTargetDir = castleHome
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(castleTargetDir, 0o755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
trackedPath := filepath.Join(castleTargetDir, filepath.Base(absolutePath))
|
||||||
|
if _, err := os.Lstat(trackedPath); err == nil {
|
||||||
|
return fmt.Errorf("%s already exists", trackedPath)
|
||||||
|
} else if !errors.Is(err, os.ErrNotExist) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.Rename(absolutePath, trackedPath); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
subdirChanged := false
|
||||||
|
if relativeDir != "." {
|
||||||
|
subdirPath := filepath.Join(castleRoot, ".homesick_subdir")
|
||||||
|
subdirChanged, err = appendUniqueSubdir(subdirPath, relativeDir)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := a.linkPath(trackedPath, absolutePath); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
repo, err := git.PlainOpen(castleRoot)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
worktree, err := repo.Worktree()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
trackedRelativePath := filepath.Join("home", relativeDir, filepath.Base(absolutePath))
|
||||||
|
if relativeDir == "." {
|
||||||
|
trackedRelativePath = filepath.Join("home", filepath.Base(absolutePath))
|
||||||
|
}
|
||||||
|
if _, err := worktree.Add(filepath.ToSlash(filepath.Clean(trackedRelativePath))); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if subdirChanged {
|
||||||
|
if _, err := worktree.Add(".homesick_subdir"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func appendUniqueSubdir(path string, subdir string) (bool, error) {
|
||||||
|
existing, err := readSubdirs(path)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanSubdir := filepath.Clean(subdir)
|
||||||
|
for _, line := range existing {
|
||||||
|
if filepath.Clean(line) == cleanSubdir {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
file, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
if _, err := file.WriteString(cleanSubdir + "\n"); err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) linkEach(castleHome string, baseDir string, subdirs []string) error {
|
||||||
|
entries, err := os.ReadDir(baseDir)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, entry := range entries {
|
||||||
|
name := entry.Name()
|
||||||
|
if name == "." || name == ".." {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
source := filepath.Join(baseDir, name)
|
||||||
|
ignore, err := matchesIgnoredDir(castleHome, source, subdirs)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if ignore {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
relDir, err := filepath.Rel(castleHome, baseDir)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
destination := filepath.Join(a.HomeDir, relDir, name)
|
||||||
|
if relDir == "." {
|
||||||
|
destination = filepath.Join(a.HomeDir, name)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := a.linkPath(source, destination); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) unlinkEach(castleHome string, baseDir string, subdirs []string) error {
|
||||||
|
entries, err := os.ReadDir(baseDir)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, entry := range entries {
|
||||||
|
name := entry.Name()
|
||||||
|
if name == "." || name == ".." {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
source := filepath.Join(baseDir, name)
|
||||||
|
ignore, err := matchesIgnoredDir(castleHome, source, subdirs)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if ignore {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
relDir, err := filepath.Rel(castleHome, baseDir)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
destination := filepath.Join(a.HomeDir, relDir, name)
|
||||||
|
if relDir == "." {
|
||||||
|
destination = filepath.Join(a.HomeDir, name)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := unlinkPath(destination); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func unlinkPath(destination string) error {
|
||||||
|
info, err := os.Lstat(destination)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if info.Mode()&os.ModeSymlink == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return os.Remove(destination)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) linkPath(source string, destination string) error {
|
||||||
|
absSource, err := filepath.Abs(source)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.MkdirAll(filepath.Dir(destination), 0o755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
info, err := os.Lstat(destination)
|
||||||
|
if err == nil {
|
||||||
|
if info.Mode()&os.ModeSymlink != 0 {
|
||||||
|
target, readErr := os.Readlink(destination)
|
||||||
|
if readErr == nil && target == absSource {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !a.Force {
|
||||||
|
return fmt.Errorf("%s exists", destination)
|
||||||
|
}
|
||||||
|
|
||||||
|
if rmErr := os.RemoveAll(destination); rmErr != nil {
|
||||||
|
return rmErr
|
||||||
|
}
|
||||||
|
} else if !errors.Is(err, os.ErrNotExist) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.Symlink(absSource, destination); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func readSubdirs(path string) ([]string, error) {
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
|
return []string{}, nil
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
lines := strings.Split(string(data), "\n")
|
||||||
|
result := make([]string, 0, len(lines))
|
||||||
|
for _, line := range lines {
|
||||||
|
trimmed := strings.TrimSpace(line)
|
||||||
|
if trimmed == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
result = append(result, filepath.Clean(trimmed))
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func matchesIgnoredDir(castleHome string, candidate string, subdirs []string) (bool, error) {
|
||||||
|
absCandidate, err := filepath.Abs(candidate)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
ignoreSet := map[string]struct{}{}
|
||||||
|
for _, subdir := range subdirs {
|
||||||
|
clean := filepath.Clean(subdir)
|
||||||
|
for clean != "." && clean != string(filepath.Separator) {
|
||||||
|
ignoreSet[filepath.Join(castleHome, clean)] = struct{}{}
|
||||||
|
next := filepath.Dir(clean)
|
||||||
|
if next == clean {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
clean = next
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_, ok := ignoreSet[absCandidate]
|
||||||
|
return ok, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func runGitWithIO(dir string, stdout io.Writer, stderr io.Writer, args ...string) error {
|
||||||
|
cmd := exec.Command("git", args...)
|
||||||
|
cmd.Dir = dir
|
||||||
|
cmd.Stdout = stdout
|
||||||
|
cmd.Stderr = stderr
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
return fmt.Errorf("git %s failed: %w", strings.Join(args, " "), err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func gitOutput(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 deriveDestination(uri string) string {
|
||||||
|
candidate := strings.TrimSpace(uri)
|
||||||
|
candidate = strings.TrimPrefix(candidate, "https://github.com/")
|
||||||
|
candidate = strings.TrimPrefix(candidate, "http://github.com/")
|
||||||
|
candidate = strings.TrimPrefix(candidate, "git://github.com/")
|
||||||
|
|
||||||
|
candidate = strings.TrimPrefix(candidate, "file://")
|
||||||
|
|
||||||
|
candidate = strings.TrimSuffix(candidate, ".git")
|
||||||
|
candidate = strings.TrimSuffix(candidate, "/")
|
||||||
|
if candidate == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
parts := strings.Split(candidate, "/")
|
||||||
|
last := parts[len(parts)-1]
|
||||||
|
if strings.Contains(last, ":") {
|
||||||
|
a := strings.Split(last, ":")
|
||||||
|
last = a[len(a)-1]
|
||||||
|
}
|
||||||
|
return last
|
||||||
|
}
|
||||||
24
internal/homesick/core/core_test.go
Normal file
24
internal/homesick/core/core_test.go
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
package core
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestDeriveDestination(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
uri string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{name: "github short", uri: "technicalpickles/pickled-vim", want: "pickled-vim"},
|
||||||
|
{name: "https", uri: "https://github.com/technicalpickles/pickled-vim.git", want: "pickled-vim"},
|
||||||
|
{name: "git ssh", uri: "git@github.com:technicalpickles/pickled-vim.git", want: "pickled-vim"},
|
||||||
|
{name: "file", uri: "file:///tmp/dotfiles.git", want: "dotfiles"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
if got := deriveDestination(tt.uri); got != tt.want {
|
||||||
|
t.Fatalf("deriveDestination(%q) = %q, want %q", tt.uri, got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
76
internal/homesick/core/diff_test.go
Normal file
76
internal/homesick/core/diff_test.go
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
package core_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.hrafn.xyz/aether/gosick/internal/homesick/core"
|
||||||
|
git "github.com/go-git/go-git/v5"
|
||||||
|
"github.com/go-git/go-git/v5/plumbing/object"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"github.com/stretchr/testify/suite"
|
||||||
|
)
|
||||||
|
|
||||||
|
type DiffSuite struct {
|
||||||
|
suite.Suite
|
||||||
|
tmpDir string
|
||||||
|
homeDir string
|
||||||
|
reposDir string
|
||||||
|
stdout *bytes.Buffer
|
||||||
|
stderr *bytes.Buffer
|
||||||
|
app *core.App
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDiffSuite(t *testing.T) {
|
||||||
|
suite.Run(t, new(DiffSuite))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *DiffSuite) SetupTest() {
|
||||||
|
s.tmpDir = s.T().TempDir()
|
||||||
|
s.homeDir = filepath.Join(s.tmpDir, "home")
|
||||||
|
s.reposDir = filepath.Join(s.homeDir, ".homesick", "repos")
|
||||||
|
require.NoError(s.T(), os.MkdirAll(s.reposDir, 0o755))
|
||||||
|
|
||||||
|
s.stdout = &bytes.Buffer{}
|
||||||
|
s.stderr = &bytes.Buffer{}
|
||||||
|
s.app = &core.App{
|
||||||
|
HomeDir: s.homeDir,
|
||||||
|
ReposDir: s.reposDir,
|
||||||
|
Stdout: s.stdout,
|
||||||
|
Stderr: s.stderr,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *DiffSuite) createCastleRepo(castle string) string {
|
||||||
|
castleRoot := filepath.Join(s.reposDir, castle)
|
||||||
|
repo, err := git.PlainInit(castleRoot, false)
|
||||||
|
require.NoError(s.T(), err)
|
||||||
|
|
||||||
|
filePath := filepath.Join(castleRoot, "home", ".vimrc")
|
||||||
|
require.NoError(s.T(), os.MkdirAll(filepath.Dir(filePath), 0o755))
|
||||||
|
require.NoError(s.T(), os.WriteFile(filePath, []byte("set number\n"), 0o644))
|
||||||
|
|
||||||
|
wt, err := repo.Worktree()
|
||||||
|
require.NoError(s.T(), err)
|
||||||
|
_, err = wt.Add("home/.vimrc")
|
||||||
|
require.NoError(s.T(), err)
|
||||||
|
_, err = wt.Commit("initial", &git.CommitOptions{Author: &object.Signature{
|
||||||
|
Name: "Behavior Test",
|
||||||
|
Email: "behavior@test.local",
|
||||||
|
When: time.Now(),
|
||||||
|
}})
|
||||||
|
require.NoError(s.T(), err)
|
||||||
|
|
||||||
|
return castleRoot
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *DiffSuite) TestDiff_WritesGitDiffToAppStdout() {
|
||||||
|
castleRoot := s.createCastleRepo("castle_repo")
|
||||||
|
require.NoError(s.T(), os.WriteFile(filepath.Join(castleRoot, "home", ".vimrc"), []byte("changed\n"), 0o644))
|
||||||
|
|
||||||
|
require.NoError(s.T(), s.app.Diff("castle_repo"))
|
||||||
|
require.Contains(s.T(), s.stdout.String(), "diff --git")
|
||||||
|
}
|
||||||
118
internal/homesick/core/link_test.go
Normal file
118
internal/homesick/core/link_test.go
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
package core_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.hrafn.xyz/aether/gosick/internal/homesick/core"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"github.com/stretchr/testify/suite"
|
||||||
|
)
|
||||||
|
|
||||||
|
type LinkSuite struct {
|
||||||
|
suite.Suite
|
||||||
|
tmpDir string
|
||||||
|
homeDir string
|
||||||
|
reposDir string
|
||||||
|
app *core.App
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLinkSuite(t *testing.T) {
|
||||||
|
suite.Run(t, new(LinkSuite))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *LinkSuite) SetupTest() {
|
||||||
|
s.tmpDir = s.T().TempDir()
|
||||||
|
s.homeDir = filepath.Join(s.tmpDir, "home")
|
||||||
|
s.reposDir = filepath.Join(s.homeDir, ".homesick", "repos")
|
||||||
|
|
||||||
|
require.NoError(s.T(), os.MkdirAll(s.reposDir, 0o755))
|
||||||
|
|
||||||
|
s.app = &core.App{
|
||||||
|
HomeDir: s.homeDir,
|
||||||
|
ReposDir: s.reposDir,
|
||||||
|
Stdout: io.Discard,
|
||||||
|
Stderr: io.Discard,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *LinkSuite) createCastle(castle string) string {
|
||||||
|
castleHome := filepath.Join(s.reposDir, castle, "home")
|
||||||
|
require.NoError(s.T(), os.MkdirAll(castleHome, 0o755))
|
||||||
|
return castleHome
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *LinkSuite) writeFile(path string, content string) {
|
||||||
|
require.NoError(s.T(), os.MkdirAll(filepath.Dir(path), 0o755))
|
||||||
|
require.NoError(s.T(), os.WriteFile(path, []byte(content), 0o644))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *LinkSuite) TestLink_SymlinksTopLevelFiles() {
|
||||||
|
castleHome := s.createCastle("glencairn")
|
||||||
|
dotfile := filepath.Join(castleHome, ".vimrc")
|
||||||
|
s.writeFile(dotfile, "set number\n")
|
||||||
|
|
||||||
|
err := s.app.Link("glencairn")
|
||||||
|
require.NoError(s.T(), err)
|
||||||
|
|
||||||
|
homePath := filepath.Join(s.homeDir, ".vimrc")
|
||||||
|
info, err := os.Lstat(homePath)
|
||||||
|
require.NoError(s.T(), err)
|
||||||
|
require.True(s.T(), info.Mode()&os.ModeSymlink != 0)
|
||||||
|
|
||||||
|
target, err := os.Readlink(homePath)
|
||||||
|
require.NoError(s.T(), err)
|
||||||
|
require.Equal(s.T(), dotfile, target)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *LinkSuite) TestLink_RespectsHomesickSubdir() {
|
||||||
|
castleHome := s.createCastle("glencairn")
|
||||||
|
configDir := filepath.Join(castleHome, ".config")
|
||||||
|
appDir := filepath.Join(configDir, "myapp")
|
||||||
|
s.writeFile(filepath.Join(appDir, "config.toml"), "ok=true\n")
|
||||||
|
s.writeFile(filepath.Join(s.reposDir, "glencairn", ".homesick_subdir"), ".config\n")
|
||||||
|
|
||||||
|
err := s.app.Link("glencairn")
|
||||||
|
require.NoError(s.T(), err)
|
||||||
|
|
||||||
|
configInfo, err := os.Lstat(filepath.Join(s.homeDir, ".config"))
|
||||||
|
require.NoError(s.T(), err)
|
||||||
|
require.False(s.T(), configInfo.Mode()&os.ModeSymlink != 0)
|
||||||
|
|
||||||
|
homeApp := filepath.Join(s.homeDir, ".config", "myapp")
|
||||||
|
appInfo, err := os.Lstat(homeApp)
|
||||||
|
require.NoError(s.T(), err)
|
||||||
|
require.True(s.T(), appInfo.Mode()&os.ModeSymlink != 0)
|
||||||
|
|
||||||
|
target, err := os.Readlink(homeApp)
|
||||||
|
require.NoError(s.T(), err)
|
||||||
|
require.Equal(s.T(), appDir, target)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *LinkSuite) TestLink_ForceReplacesExistingFile() {
|
||||||
|
castleHome := s.createCastle("glencairn")
|
||||||
|
dotfile := filepath.Join(castleHome, ".zshrc")
|
||||||
|
s.writeFile(dotfile, "export EDITOR=vim\n")
|
||||||
|
s.writeFile(filepath.Join(s.homeDir, ".zshrc"), "existing\n")
|
||||||
|
|
||||||
|
s.app.Force = true
|
||||||
|
err := s.app.Link("glencairn")
|
||||||
|
require.NoError(s.T(), err)
|
||||||
|
|
||||||
|
homePath := filepath.Join(s.homeDir, ".zshrc")
|
||||||
|
info, err := os.Lstat(homePath)
|
||||||
|
require.NoError(s.T(), err)
|
||||||
|
require.True(s.T(), info.Mode()&os.ModeSymlink != 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *LinkSuite) TestLink_NoForceErrorsOnConflict() {
|
||||||
|
castleHome := s.createCastle("glencairn")
|
||||||
|
dotfile := filepath.Join(castleHome, ".gitconfig")
|
||||||
|
s.writeFile(dotfile, "[user]\n")
|
||||||
|
s.writeFile(filepath.Join(s.homeDir, ".gitconfig"), "local\n")
|
||||||
|
|
||||||
|
err := s.app.Link("glencairn")
|
||||||
|
require.Error(s.T(), err)
|
||||||
|
}
|
||||||
72
internal/homesick/core/list_test.go
Normal file
72
internal/homesick/core/list_test.go
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
package core_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.hrafn.xyz/aether/gosick/internal/homesick/core"
|
||||||
|
git "github.com/go-git/go-git/v5"
|
||||||
|
"github.com/go-git/go-git/v5/config"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"github.com/stretchr/testify/suite"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ListSuite struct {
|
||||||
|
suite.Suite
|
||||||
|
tmpDir string
|
||||||
|
homeDir string
|
||||||
|
reposDir string
|
||||||
|
stdout *bytes.Buffer
|
||||||
|
app *core.App
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListSuite(t *testing.T) {
|
||||||
|
suite.Run(t, new(ListSuite))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ListSuite) SetupTest() {
|
||||||
|
s.tmpDir = s.T().TempDir()
|
||||||
|
s.homeDir = filepath.Join(s.tmpDir, "home")
|
||||||
|
s.reposDir = filepath.Join(s.homeDir, ".homesick", "repos")
|
||||||
|
require.NoError(s.T(), os.MkdirAll(s.reposDir, 0o755))
|
||||||
|
|
||||||
|
s.stdout = &bytes.Buffer{}
|
||||||
|
s.app = &core.App{
|
||||||
|
HomeDir: s.homeDir,
|
||||||
|
ReposDir: s.reposDir,
|
||||||
|
Stdout: s.stdout,
|
||||||
|
Stderr: io.Discard,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ListSuite) createCastleRepo(castle string, remoteURL string) {
|
||||||
|
castleRoot := filepath.Join(s.reposDir, filepath.FromSlash(castle))
|
||||||
|
require.NoError(s.T(), os.MkdirAll(castleRoot, 0o755))
|
||||||
|
|
||||||
|
repo, err := git.PlainInit(castleRoot, false)
|
||||||
|
require.NoError(s.T(), err)
|
||||||
|
|
||||||
|
if remoteURL != "" {
|
||||||
|
_, err = repo.CreateRemote(&config.RemoteConfig{Name: "origin", URLs: []string{remoteURL}})
|
||||||
|
require.NoError(s.T(), err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ListSuite) TestList_OutputsSortedCastlesWithRemoteURLs() {
|
||||||
|
s.createCastleRepo("zomg", "git://github.com/technicalpickles/zomg.git")
|
||||||
|
s.createCastleRepo("wtf/zomg", "git://github.com/technicalpickles/wtf-zomg.git")
|
||||||
|
s.createCastleRepo("alpha", "git://github.com/technicalpickles/alpha.git")
|
||||||
|
|
||||||
|
require.NoError(s.T(), s.app.List())
|
||||||
|
|
||||||
|
require.Equal(
|
||||||
|
s.T(),
|
||||||
|
"alpha git://github.com/technicalpickles/alpha.git\n"+
|
||||||
|
"wtf/zomg git://github.com/technicalpickles/wtf-zomg.git\n"+
|
||||||
|
"zomg git://github.com/technicalpickles/zomg.git\n",
|
||||||
|
s.stdout.String(),
|
||||||
|
)
|
||||||
|
}
|
||||||
51
internal/homesick/core/show_path_test.go
Normal file
51
internal/homesick/core/show_path_test.go
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
package core_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.hrafn.xyz/aether/gosick/internal/homesick/core"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"github.com/stretchr/testify/suite"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ShowPathSuite struct {
|
||||||
|
suite.Suite
|
||||||
|
tmpDir string
|
||||||
|
homeDir string
|
||||||
|
reposDir string
|
||||||
|
stdout *bytes.Buffer
|
||||||
|
app *core.App
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestShowPathSuite(t *testing.T) {
|
||||||
|
suite.Run(t, new(ShowPathSuite))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ShowPathSuite) SetupTest() {
|
||||||
|
s.tmpDir = s.T().TempDir()
|
||||||
|
s.homeDir = filepath.Join(s.tmpDir, "home")
|
||||||
|
s.reposDir = filepath.Join(s.homeDir, ".homesick", "repos")
|
||||||
|
require.NoError(s.T(), os.MkdirAll(s.reposDir, 0o755))
|
||||||
|
|
||||||
|
s.stdout = &bytes.Buffer{}
|
||||||
|
s.app = &core.App{
|
||||||
|
HomeDir: s.homeDir,
|
||||||
|
ReposDir: s.reposDir,
|
||||||
|
Stdout: s.stdout,
|
||||||
|
Stderr: io.Discard,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ShowPathSuite) TestShowPath_OutputsCastlePath() {
|
||||||
|
require.NoError(s.T(), s.app.ShowPath("castle_repo"))
|
||||||
|
|
||||||
|
require.Equal(
|
||||||
|
s.T(),
|
||||||
|
filepath.Join(s.reposDir, "castle_repo")+"\n",
|
||||||
|
s.stdout.String(),
|
||||||
|
)
|
||||||
|
}
|
||||||
79
internal/homesick/core/status_test.go
Normal file
79
internal/homesick/core/status_test.go
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
package core_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.hrafn.xyz/aether/gosick/internal/homesick/core"
|
||||||
|
git "github.com/go-git/go-git/v5"
|
||||||
|
"github.com/go-git/go-git/v5/plumbing/object"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"github.com/stretchr/testify/suite"
|
||||||
|
)
|
||||||
|
|
||||||
|
type StatusSuite struct {
|
||||||
|
suite.Suite
|
||||||
|
tmpDir string
|
||||||
|
homeDir string
|
||||||
|
reposDir string
|
||||||
|
stdout *bytes.Buffer
|
||||||
|
stderr *bytes.Buffer
|
||||||
|
app *core.App
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStatusSuite(t *testing.T) {
|
||||||
|
suite.Run(t, new(StatusSuite))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *StatusSuite) SetupTest() {
|
||||||
|
s.tmpDir = s.T().TempDir()
|
||||||
|
s.homeDir = filepath.Join(s.tmpDir, "home")
|
||||||
|
s.reposDir = filepath.Join(s.homeDir, ".homesick", "repos")
|
||||||
|
require.NoError(s.T(), os.MkdirAll(s.reposDir, 0o755))
|
||||||
|
|
||||||
|
s.stdout = &bytes.Buffer{}
|
||||||
|
s.stderr = &bytes.Buffer{}
|
||||||
|
s.app = &core.App{
|
||||||
|
HomeDir: s.homeDir,
|
||||||
|
ReposDir: s.reposDir,
|
||||||
|
Stdout: s.stdout,
|
||||||
|
Stderr: s.stderr,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *StatusSuite) createCastleRepo(castle string) string {
|
||||||
|
castleRoot := filepath.Join(s.reposDir, castle)
|
||||||
|
repo, err := git.PlainInit(castleRoot, false)
|
||||||
|
require.NoError(s.T(), err)
|
||||||
|
|
||||||
|
filePath := filepath.Join(castleRoot, "home", ".vimrc")
|
||||||
|
require.NoError(s.T(), os.MkdirAll(filepath.Dir(filePath), 0o755))
|
||||||
|
require.NoError(s.T(), os.WriteFile(filePath, []byte("set number\n"), 0o644))
|
||||||
|
|
||||||
|
wt, err := repo.Worktree()
|
||||||
|
require.NoError(s.T(), err)
|
||||||
|
_, err = wt.Add("home/.vimrc")
|
||||||
|
require.NoError(s.T(), err)
|
||||||
|
_, err = wt.Commit("initial", &git.CommitOptions{Author: &object.Signature{
|
||||||
|
Name: "Behavior Test",
|
||||||
|
Email: "behavior@test.local",
|
||||||
|
When: time.Now(),
|
||||||
|
}})
|
||||||
|
require.NoError(s.T(), err)
|
||||||
|
|
||||||
|
return castleRoot
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *StatusSuite) TestStatus_WritesGitStatusToAppStdout() {
|
||||||
|
castleRoot := s.createCastleRepo("castle_repo")
|
||||||
|
require.NoError(s.T(), os.WriteFile(filepath.Join(castleRoot, "home", ".vimrc"), []byte("changed\n"), 0o644))
|
||||||
|
|
||||||
|
require.NoError(s.T(), s.app.Status("castle_repo"))
|
||||||
|
require.Contains(s.T(), s.stdout.String(), "modified:")
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ io.Writer
|
||||||
101
internal/homesick/core/track_test.go
Normal file
101
internal/homesick/core/track_test.go
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
package core_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.hrafn.xyz/aether/gosick/internal/homesick/core"
|
||||||
|
git "github.com/go-git/go-git/v5"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"github.com/stretchr/testify/suite"
|
||||||
|
)
|
||||||
|
|
||||||
|
type TrackSuite struct {
|
||||||
|
suite.Suite
|
||||||
|
tmpDir string
|
||||||
|
homeDir string
|
||||||
|
reposDir string
|
||||||
|
app *core.App
|
||||||
|
}
|
||||||
|
|
||||||
|
//NB: this has nothing to do with jogging
|
||||||
|
func TestTrackSuite(t *testing.T) {
|
||||||
|
suite.Run(t, new(TrackSuite))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *TrackSuite) SetupTest() {
|
||||||
|
s.tmpDir = s.T().TempDir()
|
||||||
|
s.homeDir = filepath.Join(s.tmpDir, "home")
|
||||||
|
s.reposDir = filepath.Join(s.homeDir, ".homesick", "repos")
|
||||||
|
require.NoError(s.T(), os.MkdirAll(s.reposDir, 0o755))
|
||||||
|
|
||||||
|
s.app = &core.App{
|
||||||
|
HomeDir: s.homeDir,
|
||||||
|
ReposDir: s.reposDir,
|
||||||
|
Stdout: io.Discard,
|
||||||
|
Stderr: io.Discard,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *TrackSuite) createCastleRepo(castle string) string {
|
||||||
|
castleRoot := filepath.Join(s.reposDir, castle)
|
||||||
|
require.NoError(s.T(), os.MkdirAll(filepath.Join(castleRoot, "home"), 0o755))
|
||||||
|
_, err := git.PlainInit(castleRoot, false)
|
||||||
|
require.NoError(s.T(), err)
|
||||||
|
return castleRoot
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *TrackSuite) writeFile(path string, content string) {
|
||||||
|
require.NoError(s.T(), os.MkdirAll(filepath.Dir(path), 0o755))
|
||||||
|
require.NoError(s.T(), os.WriteFile(path, []byte(content), 0o644))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *TrackSuite) TestTrack_AfterRelinkTracksFileAndUpdatesSubdir() {
|
||||||
|
castleRoot := s.createCastleRepo("parity-castle")
|
||||||
|
castleHome := filepath.Join(castleRoot, "home")
|
||||||
|
|
||||||
|
s.writeFile(filepath.Join(castleHome, ".vimrc"), "set number\n")
|
||||||
|
s.writeFile(filepath.Join(castleRoot, ".homesick_subdir"), ".config\n")
|
||||||
|
s.writeFile(filepath.Join(castleHome, ".config", "myapp", "config.toml"), "ok=true\n")
|
||||||
|
|
||||||
|
require.NoError(s.T(), s.app.Link("parity-castle"))
|
||||||
|
require.NoError(s.T(), s.app.Unlink("parity-castle"))
|
||||||
|
require.NoError(s.T(), s.app.Link("parity-castle"))
|
||||||
|
|
||||||
|
toolPath := filepath.Join(s.homeDir, ".local", "bin", "tool")
|
||||||
|
s.writeFile(toolPath, "#!/usr/bin/env bash\n")
|
||||||
|
|
||||||
|
require.NoError(s.T(), s.app.Track(toolPath, "parity-castle"))
|
||||||
|
|
||||||
|
expectedTarget := filepath.Join(castleHome, ".local", "bin", "tool")
|
||||||
|
info, err := os.Lstat(toolPath)
|
||||||
|
require.NoError(s.T(), err)
|
||||||
|
require.True(s.T(), info.Mode()&os.ModeSymlink != 0)
|
||||||
|
|
||||||
|
target, err := os.Readlink(toolPath)
|
||||||
|
require.NoError(s.T(), err)
|
||||||
|
require.Equal(s.T(), expectedTarget, target)
|
||||||
|
|
||||||
|
subdirData, err := os.ReadFile(filepath.Join(castleRoot, ".homesick_subdir"))
|
||||||
|
require.NoError(s.T(), err)
|
||||||
|
require.Contains(s.T(), string(subdirData), ".local/bin\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *TrackSuite) TestTrack_DefaultCastleName() {
|
||||||
|
castleRoot := s.createCastleRepo("dotfiles")
|
||||||
|
castleHome := filepath.Join(castleRoot, "home")
|
||||||
|
|
||||||
|
filePath := filepath.Join(s.homeDir, ".tmux.conf")
|
||||||
|
s.writeFile(filePath, "set -g mouse on\n")
|
||||||
|
|
||||||
|
require.NoError(s.T(), s.app.Track(filePath, ""))
|
||||||
|
|
||||||
|
expectedTarget := filepath.Join(castleHome, ".tmux.conf")
|
||||||
|
require.FileExists(s.T(), expectedTarget)
|
||||||
|
|
||||||
|
linkTarget, err := os.Readlink(filePath)
|
||||||
|
require.NoError(s.T(), err)
|
||||||
|
require.Equal(s.T(), expectedTarget, linkTarget)
|
||||||
|
}
|
||||||
106
internal/homesick/core/unlink_test.go
Normal file
106
internal/homesick/core/unlink_test.go
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
package core_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.hrafn.xyz/aether/gosick/internal/homesick/core"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"github.com/stretchr/testify/suite"
|
||||||
|
)
|
||||||
|
|
||||||
|
type UnlinkSuite struct {
|
||||||
|
suite.Suite
|
||||||
|
tmpDir string
|
||||||
|
homeDir string
|
||||||
|
reposDir string
|
||||||
|
app *core.App
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUnlinkSuite(t *testing.T) {
|
||||||
|
suite.Run(t, new(UnlinkSuite))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *UnlinkSuite) SetupTest() {
|
||||||
|
s.tmpDir = s.T().TempDir()
|
||||||
|
s.homeDir = filepath.Join(s.tmpDir, "home")
|
||||||
|
s.reposDir = filepath.Join(s.homeDir, ".homesick", "repos")
|
||||||
|
|
||||||
|
require.NoError(s.T(), os.MkdirAll(s.reposDir, 0o755))
|
||||||
|
|
||||||
|
s.app = &core.App{
|
||||||
|
HomeDir: s.homeDir,
|
||||||
|
ReposDir: s.reposDir,
|
||||||
|
Stdout: io.Discard,
|
||||||
|
Stderr: io.Discard,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *UnlinkSuite) createCastle(castle string) string {
|
||||||
|
castleHome := filepath.Join(s.reposDir, castle, "home")
|
||||||
|
require.NoError(s.T(), os.MkdirAll(castleHome, 0o755))
|
||||||
|
return castleHome
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *UnlinkSuite) writeFile(path string, content string) {
|
||||||
|
require.NoError(s.T(), os.MkdirAll(filepath.Dir(path), 0o755))
|
||||||
|
require.NoError(s.T(), os.WriteFile(path, []byte(content), 0o644))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *UnlinkSuite) TestUnlink_RemovesTopLevelSymlinks() {
|
||||||
|
castleHome := s.createCastle("glencairn")
|
||||||
|
dotfile := filepath.Join(castleHome, ".vimrc")
|
||||||
|
s.writeFile(dotfile, "set number\n")
|
||||||
|
|
||||||
|
require.NoError(s.T(), s.app.Link("glencairn"))
|
||||||
|
require.NoError(s.T(), s.app.Unlink("glencairn"))
|
||||||
|
|
||||||
|
require.NoFileExists(s.T(), filepath.Join(s.homeDir, ".vimrc"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *UnlinkSuite) TestUnlink_RemovesNonDotfileSymlinks() {
|
||||||
|
castleHome := s.createCastle("glencairn")
|
||||||
|
binFile := filepath.Join(castleHome, "bin")
|
||||||
|
s.writeFile(binFile, "#!/usr/bin/env bash\n")
|
||||||
|
|
||||||
|
require.NoError(s.T(), s.app.Link("glencairn"))
|
||||||
|
require.NoError(s.T(), s.app.Unlink("glencairn"))
|
||||||
|
|
||||||
|
require.NoFileExists(s.T(), filepath.Join(s.homeDir, "bin"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *UnlinkSuite) TestUnlink_RespectsHomesickSubdir() {
|
||||||
|
castleHome := s.createCastle("glencairn")
|
||||||
|
appDir := filepath.Join(castleHome, ".config", "myapp")
|
||||||
|
s.writeFile(filepath.Join(appDir, "config.toml"), "ok=true\n")
|
||||||
|
s.writeFile(filepath.Join(s.reposDir, "glencairn", ".homesick_subdir"), ".config\n")
|
||||||
|
|
||||||
|
require.NoError(s.T(), s.app.Link("glencairn"))
|
||||||
|
require.NoError(s.T(), s.app.Unlink("glencairn"))
|
||||||
|
|
||||||
|
require.DirExists(s.T(), filepath.Join(s.homeDir, ".config"))
|
||||||
|
require.NoFileExists(s.T(), filepath.Join(s.homeDir, ".config", "myapp"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *UnlinkSuite) TestUnlink_DefaultCastleName() {
|
||||||
|
castleHome := s.createCastle("dotfiles")
|
||||||
|
dotfile := filepath.Join(castleHome, ".zshrc")
|
||||||
|
s.writeFile(dotfile, "export EDITOR=vim\n")
|
||||||
|
|
||||||
|
require.NoError(s.T(), s.app.Link("dotfiles"))
|
||||||
|
require.NoError(s.T(), s.app.Unlink(""))
|
||||||
|
|
||||||
|
require.NoFileExists(s.T(), filepath.Join(s.homeDir, ".zshrc"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *UnlinkSuite) TestUnlink_IgnoresNonSymlinkDestination() {
|
||||||
|
castleHome := s.createCastle("glencairn")
|
||||||
|
dotfile := filepath.Join(castleHome, ".gitconfig")
|
||||||
|
s.writeFile(dotfile, "[user]\n")
|
||||||
|
s.writeFile(filepath.Join(s.homeDir, ".gitconfig"), "local\n")
|
||||||
|
|
||||||
|
require.NoError(s.T(), s.app.Unlink("glencairn"))
|
||||||
|
require.FileExists(s.T(), filepath.Join(s.homeDir, ".gitconfig"))
|
||||||
|
}
|
||||||
46
internal/homesick/core/version_test.go
Normal file
46
internal/homesick/core/version_test.go
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
package core_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.hrafn.xyz/aether/gosick/internal/homesick/core"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"github.com/stretchr/testify/suite"
|
||||||
|
)
|
||||||
|
|
||||||
|
type VersionSuite struct {
|
||||||
|
suite.Suite
|
||||||
|
tmpDir string
|
||||||
|
homeDir string
|
||||||
|
reposDir string
|
||||||
|
stdout *bytes.Buffer
|
||||||
|
app *core.App
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVersionSuite(t *testing.T) {
|
||||||
|
suite.Run(t, new(VersionSuite))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *VersionSuite) SetupTest() {
|
||||||
|
s.tmpDir = s.T().TempDir()
|
||||||
|
s.homeDir = filepath.Join(s.tmpDir, "home")
|
||||||
|
s.reposDir = filepath.Join(s.homeDir, ".homesick", "repos")
|
||||||
|
require.NoError(s.T(), os.MkdirAll(s.reposDir, 0o755))
|
||||||
|
|
||||||
|
s.stdout = &bytes.Buffer{}
|
||||||
|
s.app = &core.App{
|
||||||
|
HomeDir: s.homeDir,
|
||||||
|
ReposDir: s.reposDir,
|
||||||
|
Stdout: s.stdout,
|
||||||
|
Stderr: io.Discard,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *VersionSuite) TestVersion_WritesVersionToAppStdout() {
|
||||||
|
require.NoError(s.T(), s.app.Version("1.2.3"))
|
||||||
|
require.Equal(s.T(), "1.2.3\n", s.stdout.String())
|
||||||
|
}
|
||||||
3
internal/homesick/version/version.go
Normal file
3
internal/homesick/version/version.go
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
package version
|
||||||
|
|
||||||
|
const String = "1.1.6"
|
||||||
21
justfile
Normal file
21
justfile
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
set shell := ["bash", "-eu", "-o", "pipefail", "-c"]
|
||||||
|
|
||||||
|
default:
|
||||||
|
@just --list
|
||||||
|
|
||||||
|
go-build:
|
||||||
|
@mkdir -p dist
|
||||||
|
go build -o dist/gosick ./cmd/homesick
|
||||||
|
|
||||||
|
go-build-linux:
|
||||||
|
@mkdir -p dist
|
||||||
|
GOOS=linux GOARCH="$(go env GOARCH)" go build -o dist/gosick ./cmd/homesick
|
||||||
|
|
||||||
|
go-test:
|
||||||
|
go test ./...
|
||||||
|
|
||||||
|
behavior:
|
||||||
|
./script/run-behavior-suite-docker.sh
|
||||||
|
|
||||||
|
behavior-verbose:
|
||||||
|
./script/run-behavior-suite-docker.sh --verbose
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
# -*- encoding : utf-8 -*-
|
|
||||||
require 'homesick/actions/file_actions'
|
|
||||||
require 'homesick/actions/git_actions'
|
|
||||||
require 'homesick/version'
|
|
||||||
require 'homesick/utils'
|
|
||||||
require 'homesick/cli'
|
|
||||||
|
|
||||||
# Homesick's top-level module
|
|
||||||
module Homesick
|
|
||||||
GITHUB_NAME_REPO_PATTERN = %r{\A([A-Za-z0-9_-]+/[A-Za-z0-9_-]+)\Z}
|
|
||||||
SUBDIR_FILENAME = '.homesick_subdir'
|
|
||||||
|
|
||||||
DEFAULT_CASTLE_NAME = 'dotfiles'
|
|
||||||
end
|
|
||||||
@@ -1,87 +0,0 @@
|
|||||||
# -*- encoding : utf-8 -*-
|
|
||||||
module Homesick
|
|
||||||
module Actions
|
|
||||||
# File-related helper methods for Homesick
|
|
||||||
module FileActions
|
|
||||||
def mv(source, destination)
|
|
||||||
source = Pathname.new(source)
|
|
||||||
destination = Pathname.new(destination + source.basename)
|
|
||||||
case
|
|
||||||
when destination.exist? && (options[:force] || shell.file_collision(destination) { source })
|
|
||||||
say_status :conflict, "#{destination} exists", :red
|
|
||||||
FileUtils.mv source, destination unless options[:pretend]
|
|
||||||
else
|
|
||||||
FileUtils.mv source, destination unless options[:pretend]
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def rm_rf(dir)
|
|
||||||
say_status "rm -rf #{dir}", '', :green
|
|
||||||
FileUtils.rm_r dir, force: true
|
|
||||||
end
|
|
||||||
|
|
||||||
def rm_link(target)
|
|
||||||
target = Pathname.new(target)
|
|
||||||
|
|
||||||
if target.symlink?
|
|
||||||
say_status :unlink, "#{target.expand_path}", :green
|
|
||||||
FileUtils.rm_rf target
|
|
||||||
else
|
|
||||||
say_status :conflict, "#{target} is not a symlink", :red
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def rm(file)
|
|
||||||
say_status "rm #{file}", '', :green
|
|
||||||
FileUtils.rm file, force: true
|
|
||||||
end
|
|
||||||
|
|
||||||
def rm_r(dir)
|
|
||||||
say_status "rm -r #{dir}", '', :green
|
|
||||||
FileUtils.rm_r dir
|
|
||||||
end
|
|
||||||
|
|
||||||
def ln_s(source, destination)
|
|
||||||
source = Pathname.new(source).realpath
|
|
||||||
destination = Pathname.new(destination)
|
|
||||||
FileUtils.mkdir_p destination.dirname
|
|
||||||
|
|
||||||
action = if destination.symlink? && destination.readlink == source
|
|
||||||
:identical
|
|
||||||
elsif destination.symlink?
|
|
||||||
:symlink_conflict
|
|
||||||
elsif destination.exist?
|
|
||||||
:conflict
|
|
||||||
else
|
|
||||||
:success
|
|
||||||
end
|
|
||||||
|
|
||||||
handle_symlink_action action, source, destination
|
|
||||||
end
|
|
||||||
|
|
||||||
def handle_symlink_action(action, source, destination)
|
|
||||||
case action
|
|
||||||
when :identical
|
|
||||||
say_status :identical, destination.expand_path, :blue
|
|
||||||
when :symlink_conflict, :conflict
|
|
||||||
if action == :conflict
|
|
||||||
say_status :conflict, "#{destination} exists", :red
|
|
||||||
else
|
|
||||||
say_status :conflict,
|
|
||||||
"#{destination} exists and points to #{destination.readlink}",
|
|
||||||
:red
|
|
||||||
end
|
|
||||||
if collision_accepted?(destination, source)
|
|
||||||
FileUtils.rm_r destination, force: true unless options[:pretend]
|
|
||||||
FileUtils.ln_s source, destination, force: true unless options[:pretend]
|
|
||||||
end
|
|
||||||
else
|
|
||||||
say_status :symlink,
|
|
||||||
"#{source.expand_path} to #{destination.expand_path}",
|
|
||||||
:green
|
|
||||||
FileUtils.ln_s source, destination unless options[:pretend]
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -1,113 +0,0 @@
|
|||||||
# -*- encoding : utf-8 -*-
|
|
||||||
module Homesick
|
|
||||||
module Actions
|
|
||||||
# Git-related helper methods for Homesick
|
|
||||||
module GitActions
|
|
||||||
# Information on the minimum git version required for Homesick
|
|
||||||
MIN_VERSION = {
|
|
||||||
major: 1,
|
|
||||||
minor: 8,
|
|
||||||
patch: 0
|
|
||||||
}
|
|
||||||
STRING = MIN_VERSION.values.join('.')
|
|
||||||
|
|
||||||
def git_version_correct?
|
|
||||||
info = `git --version`.scan(/(\d+)\.(\d+)\.(\d+)/).flatten.map(&:to_i)
|
|
||||||
return false unless info.count == 3
|
|
||||||
current_version = Hash[[:major, :minor, :patch].zip(info)]
|
|
||||||
return true if current_version.eql?(MIN_VERSION)
|
|
||||||
return true if current_version[:major] > MIN_VERSION[:major]
|
|
||||||
return true if current_version[:major] == MIN_VERSION[:major] && current_version[:minor] > MIN_VERSION[:minor]
|
|
||||||
return true if current_version[:major] == MIN_VERSION[:major] && current_version[:minor] == MIN_VERSION[:minor] && current_version[:patch] >= MIN_VERSION[:patch]
|
|
||||||
false
|
|
||||||
end
|
|
||||||
|
|
||||||
# TODO: move this to be more like thor's template, empty_directory, etc
|
|
||||||
def git_clone(repo, config = {})
|
|
||||||
config ||= {}
|
|
||||||
destination = config[:destination] || File.basename(repo, '.git')
|
|
||||||
|
|
||||||
destination = Pathname.new(destination) unless destination.is_a?(Pathname)
|
|
||||||
FileUtils.mkdir_p destination.dirname
|
|
||||||
|
|
||||||
if destination.directory?
|
|
||||||
say_status :exist, destination.expand_path, :blue
|
|
||||||
else
|
|
||||||
say_status 'git clone',
|
|
||||||
"#{repo} to #{destination.expand_path}",
|
|
||||||
:green
|
|
||||||
system "git clone -q --config push.default=upstream --recursive #{repo} #{destination}"
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def git_init(path = '.')
|
|
||||||
path = Pathname.new(path)
|
|
||||||
|
|
||||||
inside path do
|
|
||||||
if path.join('.git').exist?
|
|
||||||
say_status 'git init', 'already initialized', :blue
|
|
||||||
else
|
|
||||||
say_status 'git init', ''
|
|
||||||
system 'git init >/dev/null'
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def git_remote_add(name, url)
|
|
||||||
existing_remote = `git config remote.#{name}.url`.chomp
|
|
||||||
existing_remote = nil if existing_remote == ''
|
|
||||||
|
|
||||||
if existing_remote
|
|
||||||
say_status 'git remote', "#{name} already exists", :blue
|
|
||||||
else
|
|
||||||
say_status 'git remote', "add #{name} #{url}"
|
|
||||||
system "git remote add #{name} #{url}"
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def git_submodule_init
|
|
||||||
say_status 'git submodule', 'init', :green
|
|
||||||
system 'git submodule --quiet init'
|
|
||||||
end
|
|
||||||
|
|
||||||
def git_submodule_update
|
|
||||||
say_status 'git submodule', 'update', :green
|
|
||||||
system 'git submodule --quiet update --init --recursive >/dev/null 2>&1'
|
|
||||||
end
|
|
||||||
|
|
||||||
def git_pull
|
|
||||||
say_status 'git pull', '', :green
|
|
||||||
system 'git pull --quiet'
|
|
||||||
end
|
|
||||||
|
|
||||||
def git_push
|
|
||||||
say_status 'git push', '', :green
|
|
||||||
system 'git push'
|
|
||||||
end
|
|
||||||
|
|
||||||
def git_commit_all(config = {})
|
|
||||||
say_status 'git commit all', '', :green
|
|
||||||
if config[:message]
|
|
||||||
system %(git commit -a -m "#{config[:message]}")
|
|
||||||
else
|
|
||||||
system 'git commit -v -a'
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def git_add(file)
|
|
||||||
say_status 'git add file', '', :green
|
|
||||||
system "git add '#{file}'"
|
|
||||||
end
|
|
||||||
|
|
||||||
def git_status
|
|
||||||
say_status 'git status', '', :green
|
|
||||||
system 'git status'
|
|
||||||
end
|
|
||||||
|
|
||||||
def git_diff
|
|
||||||
say_status 'git diff', '', :green
|
|
||||||
system 'git diff'
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -1,330 +0,0 @@
|
|||||||
# -*- encoding : utf-8 -*-
|
|
||||||
require 'thor'
|
|
||||||
|
|
||||||
module Homesick
|
|
||||||
# Homesick's command line interface
|
|
||||||
class CLI < Thor
|
|
||||||
include Thor::Actions
|
|
||||||
include Homesick::Actions::FileActions
|
|
||||||
include Homesick::Actions::GitActions
|
|
||||||
include Homesick::Version
|
|
||||||
include Homesick::Utils
|
|
||||||
|
|
||||||
add_runtime_options!
|
|
||||||
|
|
||||||
map '-v' => :version
|
|
||||||
map '--version' => :version
|
|
||||||
# Retain a mapped version of the symlink command for compatibility.
|
|
||||||
map symlink: :link
|
|
||||||
|
|
||||||
def initialize(args = [], options = {}, config = {})
|
|
||||||
super
|
|
||||||
# Check if git is installed
|
|
||||||
unless git_version_correct?
|
|
||||||
say_status :error, "Git version >= #{Homesick::Actions::GitActions::STRING} must be installed to use Homesick", :red
|
|
||||||
exit(1)
|
|
||||||
end
|
|
||||||
# Hack in support for diffing symlinks
|
|
||||||
# Also adds support for checking if destination or content is a directory
|
|
||||||
shell_metaclass = class << shell; self; end
|
|
||||||
shell_metaclass.send(:define_method, :show_diff) do |destination, content|
|
|
||||||
destination = Pathname.new(destination)
|
|
||||||
content = Pathname.new(content)
|
|
||||||
return 'Unable to create diff: destination or content is a directory' if destination.directory? || content.directory?
|
|
||||||
return super(destination, content) unless destination.symlink?
|
|
||||||
say "- #{destination.readlink}", :red, true
|
|
||||||
say "+ #{content.expand_path}", :green, true
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
desc 'clone URI CASTLE_NAME', 'Clone +uri+ as a castle with name CASTLE_NAME for homesick'
|
|
||||||
def clone(uri, destination=nil)
|
|
||||||
destination = Pathname.new(destination) unless destination.nil?
|
|
||||||
|
|
||||||
inside repos_dir do
|
|
||||||
if File.exist?(uri)
|
|
||||||
uri = Pathname.new(uri).expand_path
|
|
||||||
fail "Castle already cloned to #{uri}" if uri.to_s.start_with?(repos_dir.to_s)
|
|
||||||
|
|
||||||
destination = uri.basename if destination.nil?
|
|
||||||
|
|
||||||
ln_s uri, destination
|
|
||||||
elsif uri =~ GITHUB_NAME_REPO_PATTERN
|
|
||||||
destination = Pathname.new(uri).basename if destination.nil?
|
|
||||||
git_clone "https://github.com/#{Regexp.last_match[1]}.git",
|
|
||||||
destination: destination
|
|
||||||
elsif uri =~ /%r([^%r]*?)(\.git)?\Z/ || uri =~ /[^:]+:([^:]+)(\.git)?\Z/
|
|
||||||
destination = Pathname.new(Regexp.last_match[1].gsub(/\.git$/, '')).basename if destination.nil?
|
|
||||||
git_clone uri, destination: destination
|
|
||||||
else
|
|
||||||
fail "Unknown URI format: #{uri}"
|
|
||||||
end
|
|
||||||
|
|
||||||
setup_castle(destination)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
desc 'rc CASTLE', 'Run the .homesickrc for the specified castle'
|
|
||||||
method_option :force,
|
|
||||||
type: :boolean,
|
|
||||||
default: false,
|
|
||||||
desc: 'Evaluate .homesickrc without prompting.'
|
|
||||||
def rc(name = DEFAULT_CASTLE_NAME)
|
|
||||||
inside repos_dir do
|
|
||||||
destination = Pathname.new(name)
|
|
||||||
homesickrc = destination.join('.homesickrc').expand_path
|
|
||||||
return unless homesickrc.exist?
|
|
||||||
proceed = options[:force] || shell.yes?("#{name} has a .homesickrc. Proceed with evaling it? (This could be destructive)")
|
|
||||||
return say_status 'eval skip', "not evaling #{homesickrc}, #{destination} may need manual configuration", :blue unless proceed
|
|
||||||
say_status 'eval', homesickrc
|
|
||||||
inside destination do
|
|
||||||
eval homesickrc.read, binding, homesickrc.expand_path.to_s
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
desc 'pull CASTLE', 'Update the specified castle'
|
|
||||||
method_option :all,
|
|
||||||
type: :boolean,
|
|
||||||
default: false,
|
|
||||||
required: false,
|
|
||||||
desc: 'Update all cloned castles'
|
|
||||||
def pull(name = DEFAULT_CASTLE_NAME)
|
|
||||||
if options[:all]
|
|
||||||
inside_each_castle do |castle|
|
|
||||||
say castle.to_s.gsub(repos_dir.to_s + '/', '') + ':'
|
|
||||||
update_castle castle
|
|
||||||
end
|
|
||||||
else
|
|
||||||
update_castle name
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
desc 'commit CASTLE MESSAGE', "Commit the specified castle's changes"
|
|
||||||
def commit(name = DEFAULT_CASTLE_NAME, message = nil)
|
|
||||||
commit_castle name, message
|
|
||||||
end
|
|
||||||
|
|
||||||
desc 'push CASTLE', 'Push the specified castle'
|
|
||||||
def push(name = DEFAULT_CASTLE_NAME)
|
|
||||||
push_castle name
|
|
||||||
end
|
|
||||||
|
|
||||||
desc 'unlink CASTLE', 'Unsymlinks all dotfiles from the specified castle'
|
|
||||||
def unlink(name = DEFAULT_CASTLE_NAME)
|
|
||||||
check_castle_existance(name, 'symlink')
|
|
||||||
|
|
||||||
inside castle_dir(name) do
|
|
||||||
subdirs = subdirs(name)
|
|
||||||
|
|
||||||
# unlink files
|
|
||||||
unsymlink_each(name, castle_dir(name), subdirs)
|
|
||||||
|
|
||||||
# unlink files in subdirs
|
|
||||||
subdirs.each do |subdir|
|
|
||||||
unsymlink_each(name, subdir, subdirs)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
desc 'link CASTLE', 'Symlinks all dotfiles from the specified castle'
|
|
||||||
method_option :force,
|
|
||||||
type: :boolean,
|
|
||||||
default: false,
|
|
||||||
desc: 'Overwrite existing conflicting symlinks without prompting.'
|
|
||||||
def link(name = DEFAULT_CASTLE_NAME)
|
|
||||||
check_castle_existance(name, 'symlink')
|
|
||||||
|
|
||||||
inside castle_dir(name) do
|
|
||||||
subdirs = subdirs(name)
|
|
||||||
|
|
||||||
# link files
|
|
||||||
symlink_each(name, castle_dir(name), subdirs)
|
|
||||||
|
|
||||||
# link files in subdirs
|
|
||||||
subdirs.each do |subdir|
|
|
||||||
symlink_each(name, subdir, subdirs)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
desc 'track FILE CASTLE', 'add a file to a castle'
|
|
||||||
def track(file, castle = DEFAULT_CASTLE_NAME)
|
|
||||||
castle = Pathname.new(castle)
|
|
||||||
file = Pathname.new(file.chomp('/'))
|
|
||||||
check_castle_existance(castle, 'track')
|
|
||||||
|
|
||||||
absolute_path = file.expand_path
|
|
||||||
relative_dir = absolute_path.relative_path_from(home_dir).dirname
|
|
||||||
castle_path = Pathname.new(castle_dir(castle)).join(relative_dir)
|
|
||||||
FileUtils.mkdir_p castle_path
|
|
||||||
|
|
||||||
# Are we already tracking this or anything inside it?
|
|
||||||
target = Pathname.new(castle_path.join(file.basename))
|
|
||||||
if target.exist?
|
|
||||||
if absolute_path.directory?
|
|
||||||
move_dir_contents(target, absolute_path)
|
|
||||||
absolute_path.rmtree
|
|
||||||
subdir_remove(castle, relative_dir + file.basename)
|
|
||||||
|
|
||||||
elsif more_recent? absolute_path, target
|
|
||||||
target.delete
|
|
||||||
mv absolute_path, castle_path
|
|
||||||
else
|
|
||||||
say_status(:track,
|
|
||||||
"#{target} already exists, and is more recent than #{file}. Run 'homesick SYMLINK CASTLE' to create symlinks.",
|
|
||||||
:blue)
|
|
||||||
end
|
|
||||||
else
|
|
||||||
mv absolute_path, castle_path
|
|
||||||
end
|
|
||||||
|
|
||||||
inside home_dir do
|
|
||||||
absolute_path = castle_path + file.basename
|
|
||||||
home_path = home_dir + relative_dir + file.basename
|
|
||||||
ln_s absolute_path, home_path
|
|
||||||
end
|
|
||||||
|
|
||||||
inside castle_path do
|
|
||||||
git_add absolute_path
|
|
||||||
end
|
|
||||||
|
|
||||||
# are we tracking something nested? Add the parent dir to the manifest
|
|
||||||
subdir_add(castle, relative_dir) unless relative_dir.eql?(Pathname.new('.'))
|
|
||||||
end
|
|
||||||
|
|
||||||
desc 'list', 'List cloned castles'
|
|
||||||
def list
|
|
||||||
inside_each_castle do |castle|
|
|
||||||
say_status castle.relative_path_from(repos_dir).to_s,
|
|
||||||
`git config remote.origin.url`.chomp,
|
|
||||||
:cyan
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
desc 'status CASTLE', 'Shows the git status of a castle'
|
|
||||||
def status(castle = DEFAULT_CASTLE_NAME)
|
|
||||||
check_castle_existance(castle, 'status')
|
|
||||||
inside repos_dir.join(castle) do
|
|
||||||
git_status
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
desc 'diff CASTLE', 'Shows the git diff of uncommitted changes in a castle'
|
|
||||||
def diff(castle = DEFAULT_CASTLE_NAME)
|
|
||||||
check_castle_existance(castle, 'diff')
|
|
||||||
inside repos_dir.join(castle) do
|
|
||||||
git_diff
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
desc 'show_path CASTLE', 'Prints the path of a castle'
|
|
||||||
def show_path(castle = DEFAULT_CASTLE_NAME)
|
|
||||||
check_castle_existance(castle, 'show_path')
|
|
||||||
say repos_dir.join(castle)
|
|
||||||
end
|
|
||||||
|
|
||||||
desc 'generate PATH', 'generate a homesick-ready git repo at PATH'
|
|
||||||
def generate(castle)
|
|
||||||
castle = Pathname.new(castle).expand_path
|
|
||||||
|
|
||||||
github_user = `git config github.user`.chomp
|
|
||||||
github_user = nil if github_user == ''
|
|
||||||
github_repo = castle.basename
|
|
||||||
|
|
||||||
empty_directory castle
|
|
||||||
inside castle do
|
|
||||||
git_init
|
|
||||||
if github_user
|
|
||||||
url = "git@github.com:#{github_user}/#{github_repo}.git"
|
|
||||||
git_remote_add 'origin', url
|
|
||||||
end
|
|
||||||
|
|
||||||
empty_directory 'home'
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
desc 'destroy CASTLE', 'Delete all symlinks and remove the cloned repository'
|
|
||||||
def destroy(name)
|
|
||||||
check_castle_existance name, 'destroy'
|
|
||||||
return unless shell.yes?('This will destroy your castle irreversible! Are you sure?')
|
|
||||||
|
|
||||||
unlink(name)
|
|
||||||
rm_rf repos_dir.join(name)
|
|
||||||
end
|
|
||||||
|
|
||||||
desc 'cd CASTLE', 'Open a new shell in the root of the given castle'
|
|
||||||
def cd(castle = DEFAULT_CASTLE_NAME)
|
|
||||||
check_castle_existance castle, 'cd'
|
|
||||||
castle_dir = repos_dir.join(castle)
|
|
||||||
say_status "cd #{castle_dir.realpath}",
|
|
||||||
"Opening a new shell in castle '#{castle}'. To return to the original one exit from the new shell.",
|
|
||||||
:green
|
|
||||||
inside castle_dir do
|
|
||||||
system(ENV['SHELL'])
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
desc 'open CASTLE',
|
|
||||||
'Open your default editor in the root of the given castle'
|
|
||||||
def open(castle = DEFAULT_CASTLE_NAME)
|
|
||||||
unless ENV['EDITOR']
|
|
||||||
say_status :error,
|
|
||||||
'The $EDITOR environment variable must be set to use this command',
|
|
||||||
:red
|
|
||||||
|
|
||||||
exit(1)
|
|
||||||
end
|
|
||||||
check_castle_existance castle, 'open'
|
|
||||||
castle_dir = repos_dir.join(castle)
|
|
||||||
say_status "#{castle_dir.realpath}: #{ENV['EDITOR']} .",
|
|
||||||
"Opening the root directory of castle '#{castle}' in editor '#{ENV['EDITOR']}'.",
|
|
||||||
:green
|
|
||||||
inside castle_dir do
|
|
||||||
system("#{ENV['EDITOR']} .")
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
desc 'exec CASTLE COMMAND',
|
|
||||||
'Execute a single shell command inside the root of a castle'
|
|
||||||
def exec(castle, *args)
|
|
||||||
check_castle_existance castle, 'exec'
|
|
||||||
unless args.count > 0
|
|
||||||
say_status :error,
|
|
||||||
'You must pass a shell command to execute',
|
|
||||||
:red
|
|
||||||
exit(1)
|
|
||||||
end
|
|
||||||
full_command = args.join(' ')
|
|
||||||
say_status "exec '#{full_command}'",
|
|
||||||
"#{options[:pretend] ? 'Would execute' : 'Executing command'} '#{full_command}' in castle '#{castle}'",
|
|
||||||
:green
|
|
||||||
inside repos_dir.join(castle) do
|
|
||||||
system(full_command)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
desc 'exec_all COMMAND',
|
|
||||||
'Execute a single shell command inside the root of every cloned castle'
|
|
||||||
def exec_all(*args)
|
|
||||||
unless args.count > 0
|
|
||||||
say_status :error,
|
|
||||||
'You must pass a shell command to execute',
|
|
||||||
:red
|
|
||||||
exit(1)
|
|
||||||
end
|
|
||||||
full_command = args.join(' ')
|
|
||||||
inside_each_castle do |castle|
|
|
||||||
say_status "exec '#{full_command}'",
|
|
||||||
"#{options[:pretend] ? 'Would execute' : 'Executing command'} '#{full_command}' in castle '#{castle}'",
|
|
||||||
:green
|
|
||||||
system(full_command)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
desc 'version', 'Display the current version of homesick'
|
|
||||||
def version
|
|
||||||
say Homesick::Version::STRING
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -1,216 +0,0 @@
|
|||||||
# -*- encoding : utf-8 -*-
|
|
||||||
require 'pathname'
|
|
||||||
|
|
||||||
module Homesick
|
|
||||||
# Various utility methods that are used by Homesick
|
|
||||||
module Utils
|
|
||||||
QUIETABLE = [:say_status]
|
|
||||||
|
|
||||||
PRETENDABLE = [:system]
|
|
||||||
|
|
||||||
QUIETABLE.each do |method_name|
|
|
||||||
define_method(method_name) do |*args|
|
|
||||||
super(*args) unless options[:quiet]
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
PRETENDABLE.each do |method_name|
|
|
||||||
define_method(method_name) do |*args|
|
|
||||||
super(*args) unless options[:pretend]
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
protected
|
|
||||||
|
|
||||||
def home_dir
|
|
||||||
@home_dir ||= Pathname.new(ENV['HOME'] || '~').realpath
|
|
||||||
end
|
|
||||||
|
|
||||||
def repos_dir
|
|
||||||
@repos_dir ||= home_dir.join('.homesick', 'repos').expand_path
|
|
||||||
end
|
|
||||||
|
|
||||||
def castle_dir(name)
|
|
||||||
repos_dir.join(name, 'home')
|
|
||||||
end
|
|
||||||
|
|
||||||
def check_castle_existance(name, action)
|
|
||||||
return if castle_dir(name).exist?
|
|
||||||
say_status :error,
|
|
||||||
"Could not #{action} #{name}, expected #{castle_dir(name)} exist and contain dotfiles",
|
|
||||||
:red
|
|
||||||
exit(1)
|
|
||||||
end
|
|
||||||
|
|
||||||
def all_castles
|
|
||||||
dirs = Pathname.glob("#{repos_dir}/**/.git", File::FNM_DOTMATCH)
|
|
||||||
# reject paths that lie inside another castle, like git submodules
|
|
||||||
dirs.reject do |dir|
|
|
||||||
dirs.any? do |other|
|
|
||||||
dir != other && dir.fnmatch(other.parent.join('*').to_s)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def inside_each_castle
|
|
||||||
all_castles.each do |git_dir|
|
|
||||||
castle = git_dir.dirname
|
|
||||||
Dir.chdir castle do # so we can call git config from the right contxt
|
|
||||||
yield castle
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def update_castle(castle)
|
|
||||||
check_castle_existance(castle, 'pull')
|
|
||||||
inside repos_dir.join(castle) do
|
|
||||||
git_pull
|
|
||||||
git_submodule_init
|
|
||||||
git_submodule_update
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def commit_castle(castle, message)
|
|
||||||
check_castle_existance(castle, 'commit')
|
|
||||||
inside repos_dir.join(castle) do
|
|
||||||
git_commit_all message: message
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def push_castle(castle)
|
|
||||||
check_castle_existance(castle, 'push')
|
|
||||||
inside repos_dir.join(castle) do
|
|
||||||
git_push
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def subdir_file(castle)
|
|
||||||
repos_dir.join(castle, SUBDIR_FILENAME)
|
|
||||||
end
|
|
||||||
|
|
||||||
def subdirs(castle)
|
|
||||||
subdir_filepath = subdir_file(castle)
|
|
||||||
subdirs = []
|
|
||||||
if subdir_filepath.exist?
|
|
||||||
subdir_filepath.readlines.each do |subdir|
|
|
||||||
subdirs.push(subdir.chomp)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
subdirs
|
|
||||||
end
|
|
||||||
|
|
||||||
def subdir_add(castle, path)
|
|
||||||
subdir_filepath = subdir_file(castle)
|
|
||||||
File.open(subdir_filepath, 'a+') do |subdir|
|
|
||||||
subdir.puts path unless subdir.readlines.reduce(false) do |memo, line|
|
|
||||||
line.eql?("#{path}\n") || memo
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
inside castle_dir(castle) do
|
|
||||||
git_add subdir_filepath
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def subdir_remove(castle, path)
|
|
||||||
subdir_filepath = subdir_file(castle)
|
|
||||||
if subdir_filepath.exist?
|
|
||||||
lines = IO.readlines(subdir_filepath).delete_if do |line|
|
|
||||||
line == "#{path}\n"
|
|
||||||
end
|
|
||||||
File.open(subdir_filepath, 'w') { |manfile| manfile.puts lines }
|
|
||||||
end
|
|
||||||
|
|
||||||
inside castle_dir(castle) do
|
|
||||||
git_add subdir_filepath
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def move_dir_contents(target, dir_path)
|
|
||||||
child_files = dir_path.children
|
|
||||||
child_files.each do |child|
|
|
||||||
target_path = target.join(child.basename)
|
|
||||||
if target_path.exist?
|
|
||||||
if more_recent?(child, target_path) && target.file?
|
|
||||||
target_path.delete
|
|
||||||
mv child, target
|
|
||||||
end
|
|
||||||
next
|
|
||||||
end
|
|
||||||
|
|
||||||
mv child, target
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def more_recent?(first, second)
|
|
||||||
first_p = Pathname.new(first)
|
|
||||||
second_p = Pathname.new(second)
|
|
||||||
first_p.mtime > second_p.mtime && !first_p.symlink?
|
|
||||||
end
|
|
||||||
|
|
||||||
def collision_accepted?(destination, source)
|
|
||||||
fail "Arguments must be instances of Pathname, #{destination.class.name} and #{source.class.name} given" unless destination.instance_of?(Pathname) && source.instance_of?(Pathname)
|
|
||||||
options[:force] || shell.file_collision(destination) { File.binread(source) }
|
|
||||||
end
|
|
||||||
|
|
||||||
def each_file(castle, basedir, subdirs)
|
|
||||||
absolute_basedir = Pathname.new(basedir).expand_path
|
|
||||||
inside basedir do
|
|
||||||
files = Pathname.glob('{.*,*}').reject do |a|
|
|
||||||
['.', '..'].include?(a.to_s)
|
|
||||||
end
|
|
||||||
files.each do |path|
|
|
||||||
absolute_path = path.expand_path
|
|
||||||
castle_home = castle_dir(castle)
|
|
||||||
|
|
||||||
# make ignore dirs
|
|
||||||
ignore_dirs = []
|
|
||||||
subdirs.each do |subdir|
|
|
||||||
# ignore all parent of each line in subdir file
|
|
||||||
Pathname.new(subdir).ascend do |p|
|
|
||||||
ignore_dirs.push(p)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
# ignore dirs written in subdir file
|
|
||||||
matched = false
|
|
||||||
ignore_dirs.uniq.each do |ignore_dir|
|
|
||||||
if absolute_path == castle_home.join(ignore_dir)
|
|
||||||
matched = true
|
|
||||||
break
|
|
||||||
end
|
|
||||||
end
|
|
||||||
next if matched
|
|
||||||
|
|
||||||
relative_dir = absolute_basedir.relative_path_from(castle_home)
|
|
||||||
home_path = home_dir.join(relative_dir).join(path)
|
|
||||||
|
|
||||||
yield(absolute_path, home_path)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def unsymlink_each(castle, basedir, subdirs)
|
|
||||||
each_file(castle, basedir, subdirs) do |_absolute_path, home_path|
|
|
||||||
rm_link home_path
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def symlink_each(castle, basedir, subdirs)
|
|
||||||
each_file(castle, basedir, subdirs) do |absolute_path, home_path|
|
|
||||||
ln_s absolute_path, home_path
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def setup_castle(path)
|
|
||||||
if path.join('.gitmodules').exist?
|
|
||||||
inside path do
|
|
||||||
git_submodule_init
|
|
||||||
git_submodule_update
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
rc(path)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
# -*- encoding : utf-8 -*-
|
|
||||||
module Homesick
|
|
||||||
# A representation of Homesick's version number in constants, including a
|
|
||||||
# String of the entire version number
|
|
||||||
module Version
|
|
||||||
MAJOR = 1
|
|
||||||
MINOR = 1
|
|
||||||
PATCH = 5
|
|
||||||
|
|
||||||
STRING = [MAJOR, MINOR, PATCH].compact.join('.')
|
|
||||||
end
|
|
||||||
end
|
|
||||||
57
script/run-behavior-suite-docker.sh
Executable file
57
script/run-behavior-suite-docker.sh
Executable file
@@ -0,0 +1,57 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
: "${HOMESICK_CMD:=/workspace/dist/gosick}"
|
||||||
|
behavior_verbose="${BEHAVIOR_VERBOSE:-0}"
|
||||||
|
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
-v|--verbose)
|
||||||
|
echo "Enabling verbose output for behavior suite"
|
||||||
|
behavior_verbose=1
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "Unknown argument: $1" >&2
|
||||||
|
echo "Usage: $0 [--verbose]" >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
shift
|
||||||
|
done
|
||||||
|
|
||||||
|
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
repo_root="$(cd "$script_dir/.." && pwd)"
|
||||||
|
|
||||||
|
run_docker_build() {
|
||||||
|
echo "Building Docker image for behavior suite..."
|
||||||
|
local build_log
|
||||||
|
local -a build_cmd
|
||||||
|
|
||||||
|
if docker buildx version >/dev/null 2>&1; then
|
||||||
|
build_cmd=(docker buildx build --load -f "$repo_root/docker/behavior/Dockerfile" -t homesick-behavior:latest "$repo_root")
|
||||||
|
else
|
||||||
|
build_cmd=(docker build -f "$repo_root/docker/behavior/Dockerfile" -t homesick-behavior:latest "$repo_root")
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$behavior_verbose" == "1" ]]; then
|
||||||
|
"${build_cmd[@]}"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
build_log="$(mktemp)"
|
||||||
|
if ! "${build_cmd[@]}" >"$build_log" 2>&1; then
|
||||||
|
cat "$build_log" >&2
|
||||||
|
rm -f "$build_log"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
rm -f "$build_log"
|
||||||
|
}
|
||||||
|
|
||||||
|
run_docker_build
|
||||||
|
|
||||||
|
echo "Running behavior suite in Docker container..."
|
||||||
|
docker run --rm \
|
||||||
|
-e HOMESICK_CMD="$HOMESICK_CMD" \
|
||||||
|
-e BEHAVIOR_VERBOSE="$behavior_verbose" \
|
||||||
|
homesick-behavior:latest
|
||||||
@@ -1,824 +0,0 @@
|
|||||||
# -*- encoding : utf-8 -*-
|
|
||||||
require 'spec_helper'
|
|
||||||
require 'capture-output'
|
|
||||||
require 'pathname'
|
|
||||||
|
|
||||||
describe Homesick::CLI do
|
|
||||||
let(:home) { create_construct }
|
|
||||||
after { home.destroy! }
|
|
||||||
|
|
||||||
let(:castles) { home.directory('.homesick/repos') }
|
|
||||||
|
|
||||||
let(:homesick) { Homesick::CLI.new }
|
|
||||||
|
|
||||||
before { allow(homesick).to receive(:repos_dir).and_return(castles) }
|
|
||||||
|
|
||||||
describe 'smoke tests' do
|
|
||||||
context 'when running bin/homesick' do
|
|
||||||
before do
|
|
||||||
bin_path = Pathname.new(__FILE__).parent.parent
|
|
||||||
@output = `#{bin_path.expand_path}/bin/homesick`
|
|
||||||
end
|
|
||||||
it 'should output some text when bin/homesick is called' do
|
|
||||||
expect(@output.length).to be > 0
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
context 'when a git version that doesn\'t meet the minimum required is installed' do
|
|
||||||
before do
|
|
||||||
expect_any_instance_of(Homesick::Actions::GitActions).to receive(:`).and_return('git version 1.7.6')
|
|
||||||
end
|
|
||||||
it 'should raise an exception' do
|
|
||||||
output = Capture.stdout { expect { Homesick::CLI.new }.to raise_error SystemExit }
|
|
||||||
expect(output.chomp).to include(Homesick::Actions::GitActions::STRING)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
context 'when a git version that is the same as the minimum required is installed' do
|
|
||||||
before do
|
|
||||||
expect_any_instance_of(Homesick::Actions::GitActions).to receive(:`).at_least(:once).and_return("git version #{Homesick::Actions::GitActions::STRING}")
|
|
||||||
end
|
|
||||||
it 'should not raise an exception' do
|
|
||||||
output = Capture.stdout { expect { Homesick::CLI.new }.not_to raise_error }
|
|
||||||
expect(output.chomp).not_to include(Homesick::Actions::GitActions::STRING)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
context 'when a git version that is greater than the minimum required is installed' do
|
|
||||||
before do
|
|
||||||
expect_any_instance_of(Homesick::Actions::GitActions).to receive(:`).at_least(:once).and_return('git version 3.9.8')
|
|
||||||
end
|
|
||||||
it 'should not raise an exception' do
|
|
||||||
output = Capture.stdout { expect { Homesick::CLI.new }.not_to raise_error }
|
|
||||||
expect(output.chomp).not_to include(Homesick::Actions::GitActions::STRING)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
describe 'clone' do
|
|
||||||
context 'has a .homesickrc' do
|
|
||||||
it 'runs the .homesickrc' do
|
|
||||||
somewhere = create_construct
|
|
||||||
local_repo = somewhere.directory('some_repo')
|
|
||||||
local_repo.file('.homesickrc') do |file|
|
|
||||||
file << "File.open(Dir.pwd + '/testing', 'w') do |f|
|
|
||||||
f.print 'testing'
|
|
||||||
end"
|
|
||||||
end
|
|
||||||
|
|
||||||
expect_any_instance_of(Thor::Shell::Basic).to receive(:yes?).with(be_a(String)).and_return(true)
|
|
||||||
expect(homesick).to receive(:say_status).with('eval', kind_of(Pathname))
|
|
||||||
homesick.clone local_repo
|
|
||||||
|
|
||||||
expect(castles.join('some_repo').join('testing')).to exist
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
context 'of a file' do
|
|
||||||
it 'symlinks existing directories' do
|
|
||||||
somewhere = create_construct
|
|
||||||
local_repo = somewhere.directory('wtf')
|
|
||||||
|
|
||||||
homesick.clone local_repo
|
|
||||||
|
|
||||||
expect(castles.join('wtf').readlink).to eq(local_repo)
|
|
||||||
end
|
|
||||||
|
|
||||||
context 'when it exists in a repo directory' do
|
|
||||||
before do
|
|
||||||
existing_castle = given_castle('existing_castle')
|
|
||||||
@existing_dir = existing_castle.parent
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'raises an error' do
|
|
||||||
expect(homesick).not_to receive(:git_clone)
|
|
||||||
expect { homesick.clone @existing_dir.to_s }.to raise_error(/already cloned/i)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'clones git repo like file:///path/to.git' do
|
|
||||||
bare_repo = File.join(create_construct.to_s, 'dotfiles.git')
|
|
||||||
system "git init --bare #{bare_repo} >/dev/null 2>&1"
|
|
||||||
|
|
||||||
# Capture stderr to suppress message about cloning an empty repo.
|
|
||||||
Capture.stderr do
|
|
||||||
homesick.clone "file://#{bare_repo}"
|
|
||||||
end
|
|
||||||
expect(File.directory?(File.join(home.to_s, '.homesick/repos/dotfiles')))
|
|
||||||
.to be_truthy
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'clones git repo like git://host/path/to.git' do
|
|
||||||
expect(homesick).to receive(:git_clone)
|
|
||||||
.with('git://github.com/technicalpickles/pickled-vim.git', destination: Pathname.new('pickled-vim'))
|
|
||||||
|
|
||||||
homesick.clone 'git://github.com/technicalpickles/pickled-vim.git'
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'clones git repo like git@host:path/to.git' do
|
|
||||||
expect(homesick).to receive(:git_clone)
|
|
||||||
.with('git@github.com:technicalpickles/pickled-vim.git', destination: Pathname.new('pickled-vim'))
|
|
||||||
|
|
||||||
homesick.clone 'git@github.com:technicalpickles/pickled-vim.git'
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'clones git repo like http://host/path/to.git' do
|
|
||||||
expect(homesick).to receive(:git_clone)
|
|
||||||
.with('http://github.com/technicalpickles/pickled-vim.git', destination: Pathname.new('pickled-vim'))
|
|
||||||
|
|
||||||
homesick.clone 'http://github.com/technicalpickles/pickled-vim.git'
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'clones git repo like http://host/path/to' do
|
|
||||||
expect(homesick).to receive(:git_clone)
|
|
||||||
.with('http://github.com/technicalpickles/pickled-vim', destination: Pathname.new('pickled-vim'))
|
|
||||||
|
|
||||||
homesick.clone 'http://github.com/technicalpickles/pickled-vim'
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'clones git repo like host-alias:repos.git' do
|
|
||||||
expect(homesick).to receive(:git_clone).with('gitolite:pickled-vim.git',
|
|
||||||
destination: Pathname.new('pickled-vim'))
|
|
||||||
|
|
||||||
homesick.clone 'gitolite:pickled-vim.git'
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'throws an exception when trying to clone a malformed uri like malformed' do
|
|
||||||
expect(homesick).not_to receive(:git_clone)
|
|
||||||
expect { homesick.clone 'malformed' }.to raise_error(RuntimeError)
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'clones a github repo' do
|
|
||||||
expect(homesick).to receive(:git_clone)
|
|
||||||
.with('https://github.com/wfarr/dotfiles.git', destination: Pathname.new('dotfiles'))
|
|
||||||
|
|
||||||
homesick.clone 'wfarr/dotfiles'
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'accepts a destination', :focus do
|
|
||||||
expect(homesick).to receive(:git_clone)
|
|
||||||
.with('https://github.com/wfarr/dotfiles.git',
|
|
||||||
destination: Pathname.new('other-name'))
|
|
||||||
|
|
||||||
homesick.clone 'wfarr/dotfiles', 'other-name'
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
describe 'rc' do
|
|
||||||
let(:castle) { given_castle('glencairn') }
|
|
||||||
|
|
||||||
context 'when told to do so' do
|
|
||||||
before do
|
|
||||||
expect_any_instance_of(Thor::Shell::Basic).to receive(:yes?).with(be_a(String)).and_return(true)
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'executes the .homesickrc' do
|
|
||||||
castle.file('.homesickrc') do |file|
|
|
||||||
file << "File.open(Dir.pwd + '/testing', 'w') do |f|
|
|
||||||
f.print 'testing'
|
|
||||||
end"
|
|
||||||
end
|
|
||||||
|
|
||||||
expect(homesick).to receive(:say_status).with('eval', kind_of(Pathname))
|
|
||||||
homesick.rc castle
|
|
||||||
|
|
||||||
expect(castle.join('testing')).to exist
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
context 'when options[:force] == true' do
|
|
||||||
let(:homesick) { Homesick::CLI.new [], force: true }
|
|
||||||
before do
|
|
||||||
expect_any_instance_of(Thor::Shell::Basic).to_not receive(:yes?)
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'executes the .homesickrc' do
|
|
||||||
castle.file('.homesickrc') do |file|
|
|
||||||
file << "File.open(Dir.pwd + '/testing', 'w') do |f|
|
|
||||||
f.print 'testing'
|
|
||||||
end"
|
|
||||||
end
|
|
||||||
|
|
||||||
expect(homesick).to receive(:say_status).with('eval', kind_of(Pathname))
|
|
||||||
homesick.rc castle
|
|
||||||
|
|
||||||
expect(castle.join('testing')).to exist
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
context 'when told not to do so' do
|
|
||||||
before do
|
|
||||||
expect_any_instance_of(Thor::Shell::Basic).to receive(:yes?).with(be_a(String)).and_return(false)
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'does not execute the .homesickrc' do
|
|
||||||
castle.file('.homesickrc') do |file|
|
|
||||||
file << "File.open(Dir.pwd + '/testing', 'w') do |f|
|
|
||||||
f.print 'testing'
|
|
||||||
end"
|
|
||||||
end
|
|
||||||
|
|
||||||
expect(homesick).to receive(:say_status).with('eval skip', /not evaling.+/, :blue)
|
|
||||||
homesick.rc castle
|
|
||||||
|
|
||||||
expect(castle.join('testing')).not_to exist
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
describe 'link_castle' do
|
|
||||||
let(:castle) { given_castle('glencairn') }
|
|
||||||
|
|
||||||
it 'links dotfiles from a castle to the home folder' do
|
|
||||||
dotfile = castle.file('.some_dotfile')
|
|
||||||
|
|
||||||
homesick.link('glencairn')
|
|
||||||
|
|
||||||
expect(home.join('.some_dotfile').readlink).to eq(dotfile)
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'links non-dotfiles from a castle to the home folder' do
|
|
||||||
dotfile = castle.file('bin')
|
|
||||||
|
|
||||||
homesick.link('glencairn')
|
|
||||||
|
|
||||||
expect(home.join('bin').readlink).to eq(dotfile)
|
|
||||||
end
|
|
||||||
|
|
||||||
context 'when forced' do
|
|
||||||
let(:homesick) { Homesick::CLI.new [], force: true }
|
|
||||||
|
|
||||||
it 'can override symlinks to directories' do
|
|
||||||
somewhere_else = create_construct
|
|
||||||
existing_dotdir_link = home.join('.vim')
|
|
||||||
FileUtils.ln_s somewhere_else, existing_dotdir_link
|
|
||||||
|
|
||||||
dotdir = castle.directory('.vim')
|
|
||||||
|
|
||||||
homesick.link('glencairn')
|
|
||||||
|
|
||||||
expect(existing_dotdir_link.readlink).to eq(dotdir)
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'can override existing directory' do
|
|
||||||
existing_dotdir = home.directory('.vim')
|
|
||||||
|
|
||||||
dotdir = castle.directory('.vim')
|
|
||||||
|
|
||||||
homesick.link('glencairn')
|
|
||||||
|
|
||||||
expect(existing_dotdir.readlink).to eq(dotdir)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
context "with '.config' in .homesick_subdir" do
|
|
||||||
let(:castle) { given_castle('glencairn', ['.config']) }
|
|
||||||
it 'can symlink in sub directory' do
|
|
||||||
dotdir = castle.directory('.config')
|
|
||||||
dotfile = dotdir.file('.some_dotfile')
|
|
||||||
|
|
||||||
homesick.link('glencairn')
|
|
||||||
|
|
||||||
home_dotdir = home.join('.config')
|
|
||||||
expect(home_dotdir.symlink?).to eq(false)
|
|
||||||
expect(home_dotdir.join('.some_dotfile').readlink).to eq(dotfile)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
context "with '.config/appA' in .homesick_subdir" do
|
|
||||||
let(:castle) { given_castle('glencairn', ['.config/appA']) }
|
|
||||||
it 'can symlink in nested sub directory' do
|
|
||||||
dotdir = castle.directory('.config').directory('appA')
|
|
||||||
dotfile = dotdir.file('.some_dotfile')
|
|
||||||
|
|
||||||
homesick.link('glencairn')
|
|
||||||
|
|
||||||
home_dotdir = home.join('.config').join('appA')
|
|
||||||
expect(home_dotdir.symlink?).to eq(false)
|
|
||||||
expect(home_dotdir.join('.some_dotfile').readlink).to eq(dotfile)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
context "with '.config' and '.config/someapp' in .homesick_subdir" do
|
|
||||||
let(:castle) do
|
|
||||||
given_castle('glencairn', ['.config', '.config/someapp'])
|
|
||||||
end
|
|
||||||
it 'can symlink under both of .config and .config/someapp' do
|
|
||||||
config_dir = castle.directory('.config')
|
|
||||||
config_dotfile = config_dir.file('.some_dotfile')
|
|
||||||
someapp_dir = config_dir.directory('someapp')
|
|
||||||
someapp_dotfile = someapp_dir.file('.some_appfile')
|
|
||||||
|
|
||||||
homesick.link('glencairn')
|
|
||||||
|
|
||||||
home_config_dir = home.join('.config')
|
|
||||||
home_someapp_dir = home_config_dir.join('someapp')
|
|
||||||
expect(home_config_dir.symlink?).to eq(false)
|
|
||||||
expect(home_config_dir.join('.some_dotfile').readlink).to eq(config_dotfile)
|
|
||||||
expect(home_someapp_dir.symlink?).to eq(false)
|
|
||||||
expect(home_someapp_dir.join('.some_appfile').readlink).to eq(someapp_dotfile)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
context 'when call with no castle name' do
|
|
||||||
let(:castle) { given_castle('dotfiles') }
|
|
||||||
it 'using default castle name: "dotfiles"' do
|
|
||||||
dotfile = castle.file('.some_dotfile')
|
|
||||||
|
|
||||||
homesick.link
|
|
||||||
|
|
||||||
expect(home.join('.some_dotfile').readlink).to eq(dotfile)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
describe 'unlink' do
|
|
||||||
let(:castle) { given_castle('glencairn') }
|
|
||||||
|
|
||||||
it 'unlinks dotfiles in the home folder' do
|
|
||||||
castle.file('.some_dotfile')
|
|
||||||
|
|
||||||
homesick.link('glencairn')
|
|
||||||
homesick.unlink('glencairn')
|
|
||||||
|
|
||||||
expect(home.join('.some_dotfile')).not_to exist
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'unlinks non-dotfiles from the home folder' do
|
|
||||||
castle.file('bin')
|
|
||||||
|
|
||||||
homesick.link('glencairn')
|
|
||||||
homesick.unlink('glencairn')
|
|
||||||
|
|
||||||
expect(home.join('bin')).not_to exist
|
|
||||||
end
|
|
||||||
|
|
||||||
context "with '.config' in .homesick_subdir" do
|
|
||||||
let(:castle) { given_castle('glencairn', ['.config']) }
|
|
||||||
|
|
||||||
it 'can unlink sub directories' do
|
|
||||||
castle.directory('.config').file('.some_dotfile')
|
|
||||||
|
|
||||||
homesick.link('glencairn')
|
|
||||||
homesick.unlink('glencairn')
|
|
||||||
|
|
||||||
home_dotdir = home.join('.config')
|
|
||||||
expect(home_dotdir).to exist
|
|
||||||
expect(home_dotdir.join('.some_dotfile')).not_to exist
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
context "with '.config/appA' in .homesick_subdir" do
|
|
||||||
let(:castle) { given_castle('glencairn', ['.config/appA']) }
|
|
||||||
|
|
||||||
it 'can unsymlink in nested sub directory' do
|
|
||||||
castle.directory('.config').directory('appA').file('.some_dotfile')
|
|
||||||
|
|
||||||
homesick.link('glencairn')
|
|
||||||
homesick.unlink('glencairn')
|
|
||||||
|
|
||||||
home_dotdir = home.join('.config').join('appA')
|
|
||||||
expect(home_dotdir).to exist
|
|
||||||
expect(home_dotdir.join('.some_dotfile')).not_to exist
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
context "with '.config' and '.config/someapp' in .homesick_subdir" do
|
|
||||||
let(:castle) do
|
|
||||||
given_castle('glencairn', ['.config', '.config/someapp'])
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'can unsymlink under both of .config and .config/someapp' do
|
|
||||||
config_dir = castle.directory('.config')
|
|
||||||
config_dir.file('.some_dotfile')
|
|
||||||
config_dir.directory('someapp').file('.some_appfile')
|
|
||||||
|
|
||||||
homesick.link('glencairn')
|
|
||||||
homesick.unlink('glencairn')
|
|
||||||
|
|
||||||
home_config_dir = home.join('.config')
|
|
||||||
home_someapp_dir = home_config_dir.join('someapp')
|
|
||||||
expect(home_config_dir).to exist
|
|
||||||
expect(home_config_dir.join('.some_dotfile')).not_to exist
|
|
||||||
expect(home_someapp_dir).to exist
|
|
||||||
expect(home_someapp_dir.join('.some_appfile')).not_to exist
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
context 'when call with no castle name' do
|
|
||||||
let(:castle) { given_castle('dotfiles') }
|
|
||||||
|
|
||||||
it 'using default castle name: "dotfiles"' do
|
|
||||||
castle.file('.some_dotfile')
|
|
||||||
|
|
||||||
homesick.link
|
|
||||||
homesick.unlink
|
|
||||||
|
|
||||||
expect(home.join('.some_dotfile')).not_to exist
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
describe 'list' do
|
|
||||||
it 'says each castle in the castle directory' do
|
|
||||||
given_castle('zomg')
|
|
||||||
given_castle('wtf/zomg')
|
|
||||||
|
|
||||||
expect(homesick).to receive(:say_status)
|
|
||||||
.with('zomg', 'git://github.com/technicalpickles/zomg.git', :cyan)
|
|
||||||
expect(homesick).to receive(:say_status)
|
|
||||||
.with('wtf/zomg', 'git://github.com/technicalpickles/zomg.git', :cyan)
|
|
||||||
|
|
||||||
homesick.list
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
describe 'status' do
|
|
||||||
it 'says "nothing to commit" when there are no changes' do
|
|
||||||
given_castle('castle_repo')
|
|
||||||
text = Capture.stdout { homesick.status('castle_repo') }
|
|
||||||
expect(text).to match(%r{nothing to commit \(create/copy files and use "git add" to track\)$})
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'says "Changes to be committed" when there are changes' do
|
|
||||||
given_castle('castle_repo')
|
|
||||||
some_rc_file = home.file '.some_rc_file'
|
|
||||||
homesick.track(some_rc_file.to_s, 'castle_repo')
|
|
||||||
text = Capture.stdout { homesick.status('castle_repo') }
|
|
||||||
expect(text).to match(%r{Changes to be committed:.*new file:\s*home\/.some_rc_file}m)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
describe 'diff' do
|
|
||||||
it 'outputs an empty message when there are no changes to commit' do
|
|
||||||
given_castle('castle_repo')
|
|
||||||
some_rc_file = home.file '.some_rc_file'
|
|
||||||
homesick.track(some_rc_file.to_s, 'castle_repo')
|
|
||||||
Capture.stdout do
|
|
||||||
homesick.commit 'castle_repo', 'Adding a file to the test'
|
|
||||||
end
|
|
||||||
text = Capture.stdout { homesick.diff('castle_repo') }
|
|
||||||
expect(text).to eq('')
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'outputs a diff message when there are changes to commit' do
|
|
||||||
given_castle('castle_repo')
|
|
||||||
some_rc_file = home.file '.some_rc_file'
|
|
||||||
homesick.track(some_rc_file.to_s, 'castle_repo')
|
|
||||||
Capture.stdout do
|
|
||||||
homesick.commit 'castle_repo', 'Adding a file to the test'
|
|
||||||
end
|
|
||||||
File.open(some_rc_file.to_s, 'w') do |file|
|
|
||||||
file.puts 'Some test text'
|
|
||||||
end
|
|
||||||
text = Capture.stdout { homesick.diff('castle_repo') }
|
|
||||||
expect(text).to match(/diff --git.+Some test text$/m)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
describe 'show_path' do
|
|
||||||
it 'says the path of a castle' do
|
|
||||||
castle = given_castle('castle_repo')
|
|
||||||
|
|
||||||
expect(homesick).to receive(:say).with(castle.dirname)
|
|
||||||
|
|
||||||
homesick.show_path('castle_repo')
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
describe 'pull' do
|
|
||||||
it 'performs a pull, submodule init and update when the given castle exists' do
|
|
||||||
given_castle('castle_repo')
|
|
||||||
allow(homesick).to receive(:system).once.with('git pull --quiet')
|
|
||||||
allow(homesick).to receive(:system).once.with('git submodule --quiet init')
|
|
||||||
allow(homesick).to receive(:system).once.with('git submodule --quiet update --init --recursive >/dev/null 2>&1')
|
|
||||||
homesick.pull 'castle_repo'
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'prints an error message when trying to pull a non-existant castle' do
|
|
||||||
expect(homesick).to receive('say_status').once
|
|
||||||
.with(:error,
|
|
||||||
/Could not pull castle_repo, expected .* exist and contain dotfiles/,
|
|
||||||
:red)
|
|
||||||
expect { homesick.pull 'castle_repo' }.to raise_error(SystemExit)
|
|
||||||
end
|
|
||||||
|
|
||||||
describe '--all' do
|
|
||||||
it 'pulls each castle when invoked with --all' do
|
|
||||||
given_castle('castle_repo')
|
|
||||||
given_castle('glencairn')
|
|
||||||
allow(homesick).to receive(:system).exactly(2).times.with('git pull --quiet')
|
|
||||||
allow(homesick).to receive(:system).exactly(2).times
|
|
||||||
.with('git submodule --quiet init')
|
|
||||||
allow(homesick).to receive(:system).exactly(2).times
|
|
||||||
.with('git submodule --quiet update --init --recursive >/dev/null 2>&1')
|
|
||||||
Capture.stdout do
|
|
||||||
Capture.stderr { homesick.invoke 'pull', [], all: true }
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
describe 'push' do
|
|
||||||
it 'performs a git push on the given castle' do
|
|
||||||
given_castle('castle_repo')
|
|
||||||
allow(homesick).to receive(:system).once.with('git push')
|
|
||||||
homesick.push 'castle_repo'
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'prints an error message when trying to push a non-existant castle' do
|
|
||||||
expect(homesick).to receive('say_status').once
|
|
||||||
.with(:error, /Could not push castle_repo, expected .* exist and contain dotfiles/, :red)
|
|
||||||
expect { homesick.push 'castle_repo' }.to raise_error(SystemExit)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
describe 'track' do
|
|
||||||
it 'moves the tracked file into the castle' do
|
|
||||||
castle = given_castle('castle_repo')
|
|
||||||
|
|
||||||
some_rc_file = home.file '.some_rc_file'
|
|
||||||
|
|
||||||
homesick.track(some_rc_file.to_s, 'castle_repo')
|
|
||||||
|
|
||||||
tracked_file = castle.join('.some_rc_file')
|
|
||||||
expect(tracked_file).to exist
|
|
||||||
|
|
||||||
expect(some_rc_file.readlink).to eq(tracked_file)
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'handles files with parens' do
|
|
||||||
castle = given_castle('castle_repo')
|
|
||||||
|
|
||||||
some_rc_file = home.file 'Default (Linux).sublime-keymap'
|
|
||||||
|
|
||||||
homesick.track(some_rc_file.to_s, 'castle_repo')
|
|
||||||
|
|
||||||
tracked_file = castle.join('Default (Linux).sublime-keymap')
|
|
||||||
expect(tracked_file).to exist
|
|
||||||
|
|
||||||
expect(some_rc_file.readlink).to eq(tracked_file)
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'tracks a file in nested folder structure' do
|
|
||||||
castle = given_castle('castle_repo')
|
|
||||||
|
|
||||||
some_nested_file = home.file('some/nested/file.txt')
|
|
||||||
homesick.track(some_nested_file.to_s, 'castle_repo')
|
|
||||||
|
|
||||||
tracked_file = castle.join('some/nested/file.txt')
|
|
||||||
expect(tracked_file).to exist
|
|
||||||
expect(some_nested_file.readlink).to eq(tracked_file)
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'tracks a nested directory' do
|
|
||||||
castle = given_castle('castle_repo')
|
|
||||||
|
|
||||||
some_nested_dir = home.directory('some/nested/directory/')
|
|
||||||
homesick.track(some_nested_dir.to_s, 'castle_repo')
|
|
||||||
|
|
||||||
tracked_file = castle.join('some/nested/directory/')
|
|
||||||
expect(tracked_file).to exist
|
|
||||||
expect(some_nested_dir.realpath).to eq(tracked_file.realpath)
|
|
||||||
end
|
|
||||||
|
|
||||||
context 'when call with no castle name' do
|
|
||||||
it 'using default castle name: "dotfiles"' do
|
|
||||||
castle = given_castle('dotfiles')
|
|
||||||
|
|
||||||
some_rc_file = home.file '.some_rc_file'
|
|
||||||
|
|
||||||
homesick.track(some_rc_file.to_s)
|
|
||||||
|
|
||||||
tracked_file = castle.join('.some_rc_file')
|
|
||||||
expect(tracked_file).to exist
|
|
||||||
|
|
||||||
expect(some_rc_file.readlink).to eq(tracked_file)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
describe 'commit' do
|
|
||||||
it 'has a commit message when the commit succeeds' do
|
|
||||||
given_castle('castle_repo')
|
|
||||||
some_rc_file = home.file '.a_random_rc_file'
|
|
||||||
homesick.track(some_rc_file.to_s, 'castle_repo')
|
|
||||||
text = Capture.stdout do
|
|
||||||
homesick.commit('castle_repo', 'Test message')
|
|
||||||
end
|
|
||||||
expect(text).to match(/^\[master \(root-commit\) \w+\] Test message/)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
# Note that this is a test for the subdir_file related feature of track,
|
|
||||||
# not for the subdir_file method itself.
|
|
||||||
describe 'subdir_file' do
|
|
||||||
it 'adds the nested files parent to the subdir_file' do
|
|
||||||
castle = given_castle('castle_repo')
|
|
||||||
|
|
||||||
some_nested_file = home.file('some/nested/file.txt')
|
|
||||||
homesick.track(some_nested_file.to_s, 'castle_repo')
|
|
||||||
|
|
||||||
subdir_file = castle.parent.join(Homesick::SUBDIR_FILENAME)
|
|
||||||
File.open(subdir_file, 'r') do |f|
|
|
||||||
expect(f.readline).to eq("some/nested\n")
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'does NOT add anything if the files parent is already listed' do
|
|
||||||
castle = given_castle('castle_repo')
|
|
||||||
|
|
||||||
some_nested_file = home.file('some/nested/file.txt')
|
|
||||||
other_nested_file = home.file('some/nested/other.txt')
|
|
||||||
homesick.track(some_nested_file.to_s, 'castle_repo')
|
|
||||||
homesick.track(other_nested_file.to_s, 'castle_repo')
|
|
||||||
|
|
||||||
subdir_file = castle.parent.join(Homesick::SUBDIR_FILENAME)
|
|
||||||
File.open(subdir_file, 'r') do |f|
|
|
||||||
expect(f.readlines.size).to eq(1)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'removes the parent of a tracked file from the subdir_file if the parent itself is tracked' do
|
|
||||||
castle = given_castle('castle_repo')
|
|
||||||
|
|
||||||
some_nested_file = home.file('some/nested/file.txt')
|
|
||||||
nested_parent = home.directory('some/nested/')
|
|
||||||
homesick.track(some_nested_file.to_s, 'castle_repo')
|
|
||||||
homesick.track(nested_parent.to_s, 'castle_repo')
|
|
||||||
|
|
||||||
subdir_file = castle.parent.join(Homesick::SUBDIR_FILENAME)
|
|
||||||
File.open(subdir_file, 'r') do |f|
|
|
||||||
f.each_line { |line| expect(line).not_to eq("some/nested\n") }
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
describe 'destroy' do
|
|
||||||
it 'removes the symlink files' do
|
|
||||||
expect_any_instance_of(Thor::Shell::Basic).to receive(:yes?).and_return('y')
|
|
||||||
given_castle('stronghold')
|
|
||||||
some_rc_file = home.file '.some_rc_file'
|
|
||||||
homesick.track(some_rc_file.to_s, 'stronghold')
|
|
||||||
homesick.destroy('stronghold')
|
|
||||||
|
|
||||||
expect(some_rc_file).not_to be_exist
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'deletes the cloned repository' do
|
|
||||||
expect_any_instance_of(Thor::Shell::Basic).to receive(:yes?).and_return('y')
|
|
||||||
castle = given_castle('stronghold')
|
|
||||||
some_rc_file = home.file '.some_rc_file'
|
|
||||||
homesick.track(some_rc_file.to_s, 'stronghold')
|
|
||||||
homesick.destroy('stronghold')
|
|
||||||
|
|
||||||
expect(castle).not_to be_exist
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
describe 'cd' do
|
|
||||||
it "cd's to the root directory of the given castle" do
|
|
||||||
given_castle('castle_repo')
|
|
||||||
expect(homesick).to receive('inside').once.with(kind_of(Pathname)).and_yield
|
|
||||||
expect(homesick).to receive('system').once.with(ENV['SHELL'])
|
|
||||||
Capture.stdout { homesick.cd 'castle_repo' }
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'returns an error message when the given castle does not exist' do
|
|
||||||
expect(homesick).to receive('say_status').once
|
|
||||||
.with(:error, /Could not cd castle_repo, expected .* exist and contain dotfiles/, :red)
|
|
||||||
expect { homesick.cd 'castle_repo' }.to raise_error(SystemExit)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
describe 'open' do
|
|
||||||
it 'opens the system default editor in the root of the given castle' do
|
|
||||||
# Make sure calls to ENV use default values for most things...
|
|
||||||
allow(ENV).to receive(:[]).and_call_original
|
|
||||||
# Set a default value for 'EDITOR' just in case none is set
|
|
||||||
allow(ENV).to receive(:[]).with('EDITOR').and_return('vim')
|
|
||||||
given_castle 'castle_repo'
|
|
||||||
expect(homesick).to receive('inside').once.with(kind_of(Pathname)).and_yield
|
|
||||||
expect(homesick).to receive('system').once.with('vim .')
|
|
||||||
Capture.stdout { homesick.open 'castle_repo' }
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'returns an error message when the $EDITOR environment variable is not set' do
|
|
||||||
# Set the default editor to make sure it fails.
|
|
||||||
allow(ENV).to receive(:[]).with('EDITOR').and_return(nil)
|
|
||||||
expect(homesick).to receive('say_status').once
|
|
||||||
.with(:error, 'The $EDITOR environment variable must be set to use this command', :red)
|
|
||||||
expect { homesick.open 'castle_repo' }.to raise_error(SystemExit)
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'returns an error message when the given castle does not exist' do
|
|
||||||
# Set a default just in case none is set
|
|
||||||
allow(ENV).to receive(:[]).with('EDITOR').and_return('vim')
|
|
||||||
allow(homesick).to receive('say_status').once
|
|
||||||
.with(:error, /Could not open castle_repo, expected .* exist and contain dotfiles/, :red)
|
|
||||||
expect { homesick.open 'castle_repo' }.to raise_error(SystemExit)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
describe 'version' do
|
|
||||||
it 'prints the current version of homesick' do
|
|
||||||
text = Capture.stdout { homesick.version }
|
|
||||||
expect(text.chomp).to match(/#{Regexp.escape(Homesick::Version::STRING)}/)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
describe 'exec' do
|
|
||||||
before do
|
|
||||||
given_castle 'castle_repo'
|
|
||||||
end
|
|
||||||
it 'executes a single command with no arguments inside a given castle' do
|
|
||||||
allow(homesick).to receive('inside').once.with(kind_of(Pathname)).and_yield
|
|
||||||
allow(homesick).to receive('say_status').once
|
|
||||||
.with(be_a(String), be_a(String), :green)
|
|
||||||
allow(homesick).to receive('system').once.with('ls')
|
|
||||||
Capture.stdout { homesick.exec 'castle_repo', 'ls' }
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'executes a single command with arguments inside a given castle' do
|
|
||||||
allow(homesick).to receive('inside').once.with(kind_of(Pathname)).and_yield
|
|
||||||
allow(homesick).to receive('say_status').once
|
|
||||||
.with(be_a(String), be_a(String), :green)
|
|
||||||
allow(homesick).to receive('system').once.with('ls -la')
|
|
||||||
Capture.stdout { homesick.exec 'castle_repo', 'ls', '-la' }
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'raises an error when the method is called without a command' do
|
|
||||||
allow(homesick).to receive('say_status').once
|
|
||||||
.with(:error, be_a(String), :red)
|
|
||||||
allow(homesick).to receive('exit').once.with(1)
|
|
||||||
Capture.stdout { homesick.exec 'castle_repo' }
|
|
||||||
end
|
|
||||||
|
|
||||||
context 'pretend' do
|
|
||||||
it 'does not execute a command when the pretend option is passed' do
|
|
||||||
allow(homesick).to receive('say_status').once
|
|
||||||
.with(be_a(String), match(/.*Would execute.*/), :green)
|
|
||||||
expect(homesick).to receive('system').never
|
|
||||||
Capture.stdout { homesick.invoke 'exec', %w(castle_repo ls -la), pretend: true }
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
context 'quiet' do
|
|
||||||
it 'does not print status information when quiet is passed' do
|
|
||||||
expect(homesick).to receive('say_status').never
|
|
||||||
allow(homesick).to receive('system').once
|
|
||||||
.with('ls -la')
|
|
||||||
Capture.stdout { homesick.invoke 'exec', %w(castle_repo ls -la), quiet: true }
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
describe 'exec_all' do
|
|
||||||
before do
|
|
||||||
given_castle 'castle_repo'
|
|
||||||
given_castle 'another_castle_repo'
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'executes a command without arguments inside the root of each cloned castle' do
|
|
||||||
allow(homesick).to receive('inside_each_castle').exactly(:twice).and_yield('castle_repo').and_yield('another_castle_repo')
|
|
||||||
allow(homesick).to receive('say_status').at_least(:once)
|
|
||||||
.with(be_a(String), be_a(String), :green)
|
|
||||||
allow(homesick).to receive('system').at_least(:once).with('ls')
|
|
||||||
Capture.stdout { homesick.exec_all 'ls' }
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'executes a command with arguments inside the root of each cloned castle' do
|
|
||||||
allow(homesick).to receive('inside_each_castle').exactly(:twice).and_yield('castle_repo').and_yield('another_castle_repo')
|
|
||||||
allow(homesick).to receive('say_status').at_least(:once)
|
|
||||||
.with(be_a(String), be_a(String), :green)
|
|
||||||
allow(homesick).to receive('system').at_least(:once).with('ls -la')
|
|
||||||
Capture.stdout { homesick.exec_all 'ls', '-la' }
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'raises an error when the method is called without a command' do
|
|
||||||
allow(homesick).to receive('say_status').once
|
|
||||||
.with(:error, be_a(String), :red)
|
|
||||||
allow(homesick).to receive('exit').once.with(1)
|
|
||||||
Capture.stdout { homesick.exec_all }
|
|
||||||
end
|
|
||||||
|
|
||||||
context 'pretend' do
|
|
||||||
it 'does not execute a command when the pretend option is passed' do
|
|
||||||
allow(homesick).to receive('say_status').at_least(:once)
|
|
||||||
.with(be_a(String), match(/.*Would execute.*/), :green)
|
|
||||||
expect(homesick).to receive('system').never
|
|
||||||
Capture.stdout { homesick.invoke 'exec_all', %w(ls -la), pretend: true }
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
context 'quiet' do
|
|
||||||
it 'does not print status information when quiet is passed' do
|
|
||||||
expect(homesick).to receive('say_status').never
|
|
||||||
allow(homesick).to receive('system').at_least(:once)
|
|
||||||
.with('ls -la')
|
|
||||||
Capture.stdout { homesick.invoke 'exec_all', %w(ls -la), quiet: true }
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
--color
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
require 'coveralls'
|
|
||||||
Coveralls.wear!
|
|
||||||
|
|
||||||
$LOAD_PATH.unshift(File.dirname(__FILE__))
|
|
||||||
$LOAD_PATH.unshift(File.join(File.dirname(__FILE__), '..', 'lib'))
|
|
||||||
require 'homesick'
|
|
||||||
require 'rspec'
|
|
||||||
require 'test_construct'
|
|
||||||
require 'tempfile'
|
|
||||||
|
|
||||||
RSpec.configure do |config|
|
|
||||||
config.include TestConstruct::Helpers
|
|
||||||
|
|
||||||
config.expect_with(:rspec) { |c| c.syntax = :expect }
|
|
||||||
|
|
||||||
config.before { ENV['HOME'] = home.to_s }
|
|
||||||
|
|
||||||
config.before { silence! }
|
|
||||||
|
|
||||||
def silence!
|
|
||||||
allow(homesick).to receive(:say_status)
|
|
||||||
end
|
|
||||||
|
|
||||||
def given_castle(path, subdirs = [])
|
|
||||||
name = Pathname.new(path).basename
|
|
||||||
castles.directory(path) do |castle|
|
|
||||||
Dir.chdir(castle) do
|
|
||||||
system 'git init >/dev/null 2>&1'
|
|
||||||
system 'git config user.email "test@test.com"'
|
|
||||||
system 'git config user.name "Test Name"'
|
|
||||||
system "git remote add origin git://github.com/technicalpickles/#{name}.git >/dev/null 2>&1"
|
|
||||||
if subdirs
|
|
||||||
subdir_file = castle.join(Homesick::SUBDIR_FILENAME)
|
|
||||||
subdirs.each do |subdir|
|
|
||||||
File.open(subdir_file, 'a') { |file| file.write "\n#{subdir}\n" }
|
|
||||||
end
|
|
||||||
end
|
|
||||||
return castle.directory('home')
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
193
test/behavior/behavior_suite.sh
Executable file
193
test/behavior/behavior_suite.sh
Executable file
@@ -0,0 +1,193 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
: "${HOMESICK_CMD:=/workspace/dist/gosick}"
|
||||||
|
: "${BEHAVIOR_VERBOSE:=0}"
|
||||||
|
|
||||||
|
RUN_OUTPUT=""
|
||||||
|
|
||||||
|
fail() {
|
||||||
|
echo "FAIL: $*" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
pass() {
|
||||||
|
if [[ -t 1 ]]; then
|
||||||
|
printf ' \033[32mPassed\033[0m\n'
|
||||||
|
else
|
||||||
|
echo " Passed"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
parse_args() {
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
-v|--verbose)
|
||||||
|
BEHAVIOR_VERBOSE=1
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
fail "unknown argument: $1"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
shift
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
run_git() {
|
||||||
|
if [[ "$BEHAVIOR_VERBOSE" == "1" ]]; then
|
||||||
|
git "$@"
|
||||||
|
else
|
||||||
|
git "$@" >/dev/null 2>&1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_path_exists() {
|
||||||
|
local path="$1"
|
||||||
|
[[ -e "$path" ]] || fail "expected path to exist: $path"
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_path_missing() {
|
||||||
|
local path="$1"
|
||||||
|
[[ ! -e "$path" ]] || fail "expected path to be missing: $path"
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_symlink_target() {
|
||||||
|
local link_path="$1"
|
||||||
|
local expected_target="$2"
|
||||||
|
[[ -L "$link_path" ]] || fail "expected symlink: $link_path"
|
||||||
|
local actual_target
|
||||||
|
actual_target="$(readlink "$link_path")"
|
||||||
|
[[ "$actual_target" == "$expected_target" ]] || fail "expected symlink target '$expected_target' but got '$actual_target'"
|
||||||
|
}
|
||||||
|
|
||||||
|
run_homesick() {
|
||||||
|
local out_file
|
||||||
|
local output
|
||||||
|
out_file="$(mktemp)"
|
||||||
|
if ! bash -lc "$HOMESICK_CMD $*" >"$out_file" 2>&1; then
|
||||||
|
cat "$out_file" >&2
|
||||||
|
rm -f "$out_file"
|
||||||
|
fail "homesick command failed: $*"
|
||||||
|
fi
|
||||||
|
|
||||||
|
output="$(cat "$out_file")"
|
||||||
|
RUN_OUTPUT="$output"
|
||||||
|
|
||||||
|
if [[ "$BEHAVIOR_VERBOSE" == "1" && -n "$output" ]]; then
|
||||||
|
printf '%s\n' "$output"
|
||||||
|
fi
|
||||||
|
|
||||||
|
rm -f "$out_file"
|
||||||
|
}
|
||||||
|
|
||||||
|
setup_remote_castle() {
|
||||||
|
local remote_dir="$1"
|
||||||
|
local work_dir="$2"
|
||||||
|
|
||||||
|
mkdir -p "$remote_dir"
|
||||||
|
run_git init --bare "$remote_dir/base.git"
|
||||||
|
|
||||||
|
mkdir -p "$work_dir/base"
|
||||||
|
pushd "$work_dir/base" >/dev/null
|
||||||
|
run_git init
|
||||||
|
run_git config user.email "behavior@test.local"
|
||||||
|
run_git config user.name "Behavior Test"
|
||||||
|
|
||||||
|
mkdir -p home/.config/myapp
|
||||||
|
echo "set number" > home/.vimrc
|
||||||
|
echo "export PATH=\"$PATH:$HOME/bin\"" > home/.zshrc
|
||||||
|
echo "option=true" > home/.config/myapp/config.toml
|
||||||
|
printf '.config\n' > .homesick_subdir
|
||||||
|
|
||||||
|
run_git add .
|
||||||
|
run_git commit -m "initial castle"
|
||||||
|
run_git remote add origin "$remote_dir/base.git"
|
||||||
|
run_git push -u origin master
|
||||||
|
popd >/dev/null
|
||||||
|
}
|
||||||
|
|
||||||
|
setup_local_test_file() {
|
||||||
|
mkdir -p "$HOME/.local/bin"
|
||||||
|
echo "#!/usr/bin/env bash" > "$HOME/.local/bin/tool"
|
||||||
|
chmod +x "$HOME/.local/bin/tool"
|
||||||
|
}
|
||||||
|
|
||||||
|
run_suite() {
|
||||||
|
local tmp_root
|
||||||
|
tmp_root="$(mktemp -d)"
|
||||||
|
trap "rm -rf '$tmp_root'" EXIT
|
||||||
|
|
||||||
|
export HOME="$tmp_root/home"
|
||||||
|
mkdir -p "$HOME"
|
||||||
|
|
||||||
|
local remote_root="$tmp_root/remote"
|
||||||
|
local work_root="$tmp_root/work"
|
||||||
|
|
||||||
|
setup_remote_castle "$remote_root" "$work_root"
|
||||||
|
|
||||||
|
echo "[1/7] clone"
|
||||||
|
run_homesick "clone file://$remote_root/base.git parity-castle"
|
||||||
|
assert_path_exists "$HOME/.homesick/repos/parity-castle/.git"
|
||||||
|
assert_path_exists "$HOME/.homesick/repos/parity-castle/home/.vimrc"
|
||||||
|
pass
|
||||||
|
|
||||||
|
echo "[2/7] link"
|
||||||
|
run_homesick "link parity-castle"
|
||||||
|
assert_symlink_target "$HOME/.vimrc" "$HOME/.homesick/repos/parity-castle/home/.vimrc"
|
||||||
|
assert_symlink_target "$HOME/.zshrc" "$HOME/.homesick/repos/parity-castle/home/.zshrc"
|
||||||
|
assert_path_exists "$HOME/.config/myapp"
|
||||||
|
assert_symlink_target "$HOME/.config/myapp" "$HOME/.homesick/repos/parity-castle/home/.config/myapp"
|
||||||
|
pass
|
||||||
|
|
||||||
|
echo "[3/7] unlink"
|
||||||
|
run_homesick "unlink parity-castle"
|
||||||
|
assert_path_missing "$HOME/.vimrc"
|
||||||
|
assert_path_missing "$HOME/.zshrc"
|
||||||
|
assert_path_exists "$HOME/.config"
|
||||||
|
assert_path_missing "$HOME/.config/myapp"
|
||||||
|
pass
|
||||||
|
|
||||||
|
echo "[4/7] relink + track"
|
||||||
|
run_homesick "link parity-castle"
|
||||||
|
setup_local_test_file
|
||||||
|
run_homesick "track $HOME/.local/bin/tool parity-castle"
|
||||||
|
assert_symlink_target "$HOME/.local/bin/tool" "$HOME/.homesick/repos/parity-castle/home/.local/bin/tool"
|
||||||
|
assert_path_exists "$HOME/.homesick/repos/parity-castle/.homesick_subdir"
|
||||||
|
grep -q "^.local/bin$" "$HOME/.homesick/repos/parity-castle/.homesick_subdir" || fail "expected .homesick_subdir to contain .local/bin"
|
||||||
|
pass
|
||||||
|
|
||||||
|
echo "[5/7] list and show_path"
|
||||||
|
local list_output
|
||||||
|
run_homesick "list"
|
||||||
|
list_output="$RUN_OUTPUT"
|
||||||
|
[[ "$list_output" == *"parity-castle"* ]] || fail "expected list output to include parity-castle"
|
||||||
|
local show_path_output
|
||||||
|
run_homesick "show_path parity-castle"
|
||||||
|
show_path_output="$RUN_OUTPUT"
|
||||||
|
[[ "$show_path_output" == *"$HOME/.homesick/repos/parity-castle"* ]] || fail "expected show_path output to include parity-castle path"
|
||||||
|
pass
|
||||||
|
|
||||||
|
echo "[6/7] status and diff"
|
||||||
|
echo "change" >> "$HOME/.vimrc"
|
||||||
|
local status_output
|
||||||
|
run_homesick "status parity-castle"
|
||||||
|
status_output="$RUN_OUTPUT"
|
||||||
|
[[ "$status_output" == *"modified:"* ]] || fail "expected status output to include modified file"
|
||||||
|
local diff_output
|
||||||
|
run_homesick "diff parity-castle"
|
||||||
|
diff_output="$RUN_OUTPUT"
|
||||||
|
[[ "$diff_output" == *"diff --git"* ]] || fail "expected diff output to include git diff"
|
||||||
|
pass
|
||||||
|
|
||||||
|
echo "[7/7] version"
|
||||||
|
local version_output
|
||||||
|
run_homesick "version"
|
||||||
|
version_output="$RUN_OUTPUT"
|
||||||
|
[[ "$version_output" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || fail "expected semantic version output, got: $version_output"
|
||||||
|
pass
|
||||||
|
|
||||||
|
echo "PASS: behavior suite completed for command: $HOMESICK_CMD"
|
||||||
|
}
|
||||||
|
|
||||||
|
parse_args "$@"
|
||||||
|
run_suite
|
||||||
Reference in New Issue
Block a user