diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..132df0b --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,50 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +env: + CARGO_TERM_COLOR: always + RUSTFLAGS: -Dwarnings + +jobs: + check: + name: Check + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + - run: cargo check --all-targets + + test: + name: Test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + - run: cargo test + + fmt: + name: Format + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt + - run: cargo fmt --all -- --check + + clippy: + name: Clippy + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + components: clippy + - uses: Swatinem/rust-cache@v2 + - run: cargo clippy --all-targets diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..2a3a90a --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,343 @@ +# This file was autogenerated by dist: https://axodotdev.github.io/cargo-dist +# +# Copyright 2022-2024, axodotdev +# SPDX-License-Identifier: MIT or Apache-2.0 +# +# CI that: +# +# * checks for a Git Tag that looks like a release +# * builds artifacts with dist (archives, installers, hashes) +# * uploads those artifacts to temporary workflow zip +# * on success, uploads the artifacts to a GitHub Release +# +# Note that the GitHub Release will be created with a generated +# title/body based on your changelogs. + +name: Release +permissions: + "contents": "write" + +# This task will run whenever you push a git tag that looks like a version +# like "1.0.0", "v0.1.0-prerelease.1", "my-app/0.1.0", "releases/v1.0.0", etc. +# Various formats will be parsed into a VERSION and an optional PACKAGE_NAME, where +# PACKAGE_NAME must be the name of a Cargo package in your workspace, and VERSION +# must be a Cargo-style SemVer Version (must have at least major.minor.patch). +# +# If PACKAGE_NAME is specified, then the announcement will be for that +# package (erroring out if it doesn't have the given version or isn't dist-able). +# +# If PACKAGE_NAME isn't specified, then the announcement will be for all +# (dist-able) packages in the workspace with that version (this mode is +# intended for workspaces with only one dist-able package, or with all dist-able +# packages versioned/released in lockstep). +# +# If you push multiple tags at once, separate instances of this workflow will +# spin up, creating an independent announcement for each one. However, GitHub +# will hard limit this to 3 tags per commit, as it will assume more tags is a +# mistake. +# +# If there's a prerelease-style suffix to the version, then the release(s) +# will be marked as a prerelease. +on: + pull_request: + push: + tags: + - '**[0-9]+.[0-9]+.[0-9]+*' + +jobs: + # Run 'dist plan' (or host) to determine what tasks we need to do + plan: + runs-on: "ubuntu-22.04" + outputs: + val: ${{ steps.plan.outputs.manifest }} + tag: ${{ !github.event.pull_request && github.ref_name || '' }} + tag-flag: ${{ !github.event.pull_request && format('--tag={0}', github.ref_name) || '' }} + publishing: ${{ !github.event.pull_request }} + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + submodules: recursive + - name: Install dist + # we specify bash to get pipefail; it guards against the `curl` command + # failing. otherwise `sh` won't catch that `curl` returned non-0 + shell: bash + run: "curl --proto '=https' --tlsv1.2 -LsSf https://github.com/axodotdev/cargo-dist/releases/download/v0.31.0/cargo-dist-installer.sh | sh" + - name: Cache dist + uses: actions/upload-artifact@v6 + with: + name: cargo-dist-cache + path: ~/.cargo/bin/dist + # sure would be cool if github gave us proper conditionals... + # so here's a doubly-nested ternary-via-truthiness to try to provide the best possible + # functionality based on whether this is a pull_request, and whether it's from a fork. + # (PRs run on the *source* but secrets are usually on the *target* -- that's *good* + # but also really annoying to build CI around when it needs secrets to work right.) + - id: plan + run: | + dist ${{ (!github.event.pull_request && format('host --steps=create --tag={0}', github.ref_name)) || 'plan' }} --output-format=json > plan-dist-manifest.json + echo "dist ran successfully" + cat plan-dist-manifest.json + echo "manifest=$(jq -c "." plan-dist-manifest.json)" >> "$GITHUB_OUTPUT" + - name: "Upload dist-manifest.json" + uses: actions/upload-artifact@v6 + with: + name: artifacts-plan-dist-manifest + path: plan-dist-manifest.json + + # Build and packages all the platform-specific things + build-local-artifacts: + name: build-local-artifacts (${{ join(matrix.targets, ', ') }}) + # Let the initial task tell us to not run (currently very blunt) + needs: + - plan + if: ${{ fromJson(needs.plan.outputs.val).ci.github.artifacts_matrix.include != null && (needs.plan.outputs.publishing == 'true' || fromJson(needs.plan.outputs.val).ci.github.pr_run_mode == 'upload') }} + strategy: + fail-fast: false + # Target platforms/runners are computed by dist in create-release. + # Each member of the matrix has the following arguments: + # + # - runner: the github runner + # - dist-args: cli flags to pass to dist + # - install-dist: expression to run to install dist on the runner + # + # Typically there will be: + # - 1 "global" task that builds universal installers + # - N "local" tasks that build each platform's binaries and platform-specific installers + matrix: ${{ fromJson(needs.plan.outputs.val).ci.github.artifacts_matrix }} + runs-on: ${{ matrix.runner }} + container: ${{ matrix.container && matrix.container.image || null }} + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + BUILD_MANIFEST_NAME: target/distrib/${{ join(matrix.targets, '-') }}-dist-manifest.json + steps: + - name: enable windows longpaths + run: | + git config --global core.longpaths true + - uses: actions/checkout@v6 + with: + persist-credentials: false + submodules: recursive + - name: Install Rust non-interactively if not already installed + if: ${{ matrix.container }} + run: | + if ! command -v cargo > /dev/null 2>&1; then + curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y + echo "$HOME/.cargo/bin" >> $GITHUB_PATH + fi + - name: Install dist + run: ${{ matrix.install_dist.run }} + # Get the dist-manifest + - name: Fetch local artifacts + uses: actions/download-artifact@v7 + with: + pattern: artifacts-* + path: target/distrib/ + merge-multiple: true + - name: Install dependencies + run: | + ${{ matrix.packages_install }} + - name: Build artifacts + run: | + # Actually do builds and make zips and whatnot + dist build ${{ needs.plan.outputs.tag-flag }} --print=linkage --output-format=json ${{ matrix.dist_args }} > dist-manifest.json + echo "dist ran successfully" + - id: cargo-dist + name: Post-build + # We force bash here just because github makes it really hard to get values up + # to "real" actions without writing to env-vars, and writing to env-vars has + # inconsistent syntax between shell and powershell. + shell: bash + run: | + # Parse out what we just built and upload it to scratch storage + echo "paths<> "$GITHUB_OUTPUT" + dist print-upload-files-from-manifest --manifest dist-manifest.json >> "$GITHUB_OUTPUT" + echo "EOF" >> "$GITHUB_OUTPUT" + + cp dist-manifest.json "$BUILD_MANIFEST_NAME" + - name: "Upload artifacts" + uses: actions/upload-artifact@v6 + with: + name: artifacts-build-local-${{ join(matrix.targets, '_') }} + path: | + ${{ steps.cargo-dist.outputs.paths }} + ${{ env.BUILD_MANIFEST_NAME }} + + # Build and package all the platform-agnostic(ish) things + build-global-artifacts: + needs: + - plan + - build-local-artifacts + runs-on: "ubuntu-22.04" + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + BUILD_MANIFEST_NAME: target/distrib/global-dist-manifest.json + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + submodules: recursive + - name: Install cached dist + uses: actions/download-artifact@v7 + with: + name: cargo-dist-cache + path: ~/.cargo/bin/ + - run: chmod +x ~/.cargo/bin/dist + # Get all the local artifacts for the global tasks to use (for e.g. checksums) + - name: Fetch local artifacts + uses: actions/download-artifact@v7 + with: + pattern: artifacts-* + path: target/distrib/ + merge-multiple: true + - id: cargo-dist + shell: bash + run: | + dist build ${{ needs.plan.outputs.tag-flag }} --output-format=json "--artifacts=global" > dist-manifest.json + echo "dist ran successfully" + + # Parse out what we just built and upload it to scratch storage + echo "paths<> "$GITHUB_OUTPUT" + jq --raw-output ".upload_files[]" dist-manifest.json >> "$GITHUB_OUTPUT" + echo "EOF" >> "$GITHUB_OUTPUT" + + cp dist-manifest.json "$BUILD_MANIFEST_NAME" + - name: "Upload artifacts" + uses: actions/upload-artifact@v6 + with: + name: artifacts-build-global + path: | + ${{ steps.cargo-dist.outputs.paths }} + ${{ env.BUILD_MANIFEST_NAME }} + # Determines if we should publish/announce + host: + needs: + - plan + - build-local-artifacts + - build-global-artifacts + # Only run if we're "publishing", and only if plan, local and global didn't fail (skipped is fine) + if: ${{ always() && needs.plan.result == 'success' && needs.plan.outputs.publishing == 'true' && (needs.build-global-artifacts.result == 'skipped' || needs.build-global-artifacts.result == 'success') && (needs.build-local-artifacts.result == 'skipped' || needs.build-local-artifacts.result == 'success') }} + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + runs-on: "ubuntu-22.04" + outputs: + val: ${{ steps.host.outputs.manifest }} + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + submodules: recursive + - name: Install cached dist + uses: actions/download-artifact@v7 + with: + name: cargo-dist-cache + path: ~/.cargo/bin/ + - run: chmod +x ~/.cargo/bin/dist + # Fetch artifacts from scratch-storage + - name: Fetch artifacts + uses: actions/download-artifact@v7 + with: + pattern: artifacts-* + path: target/distrib/ + merge-multiple: true + - id: host + shell: bash + run: | + dist host ${{ needs.plan.outputs.tag-flag }} --steps=upload --steps=release --output-format=json > dist-manifest.json + echo "artifacts uploaded and released successfully" + cat dist-manifest.json + echo "manifest=$(jq -c "." dist-manifest.json)" >> "$GITHUB_OUTPUT" + - name: "Upload dist-manifest.json" + uses: actions/upload-artifact@v6 + with: + # Overwrite the previous copy + name: artifacts-dist-manifest + path: dist-manifest.json + # Create a GitHub Release while uploading all files to it + - name: "Download GitHub Artifacts" + uses: actions/download-artifact@v7 + with: + pattern: artifacts-* + path: artifacts + merge-multiple: true + - name: Cleanup + run: | + # Remove the granular manifests + rm -f artifacts/*-dist-manifest.json + - name: Create GitHub Release + env: + PRERELEASE_FLAG: "${{ fromJson(steps.host.outputs.manifest).announcement_is_prerelease && '--prerelease' || '' }}" + ANNOUNCEMENT_TITLE: "${{ fromJson(steps.host.outputs.manifest).announcement_title }}" + ANNOUNCEMENT_BODY: "${{ fromJson(steps.host.outputs.manifest).announcement_github_body }}" + RELEASE_COMMIT: "${{ github.sha }}" + run: | + # Write and read notes from a file to avoid quoting breaking things + echo "$ANNOUNCEMENT_BODY" > $RUNNER_TEMP/notes.txt + + gh release create "${{ needs.plan.outputs.tag }}" --target "$RELEASE_COMMIT" $PRERELEASE_FLAG --title "$ANNOUNCEMENT_TITLE" --notes-file "$RUNNER_TEMP/notes.txt" artifacts/* + + publish-homebrew-formula: + needs: + - plan + - host + runs-on: "ubuntu-22.04" + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PLAN: ${{ needs.plan.outputs.val }} + GITHUB_USER: "axo bot" + GITHUB_EMAIL: "admin+bot@axo.dev" + if: ${{ !fromJson(needs.plan.outputs.val).announcement_is_prerelease || fromJson(needs.plan.outputs.val).publish_prereleases }} + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: true + repository: "km-tr/worktree-link" + token: ${{ secrets.HOMEBREW_TAP_TOKEN }} + # So we have access to the formula + - name: Fetch homebrew formulae + uses: actions/download-artifact@v7 + with: + pattern: artifacts-* + path: Formula/ + merge-multiple: true + # This is extra complex because you can make your Formula name not match your app name + # so we need to find releases with a *.rb file, and publish with that filename. + - name: Commit formula files + run: | + git config --global user.name "${GITHUB_USER}" + git config --global user.email "${GITHUB_EMAIL}" + + for release in $(echo "$PLAN" | jq --compact-output '.releases[] | select([.artifacts[] | endswith(".rb")] | any)'); do + filename=$(echo "$release" | jq '.artifacts[] | select(endswith(".rb"))' --raw-output) + name=$(echo "$filename" | sed "s/\.rb$//") + version=$(echo "$release" | jq .app_version --raw-output) + + export PATH="/home/linuxbrew/.linuxbrew/bin:$PATH" + brew update + # We avoid reformatting user-provided data such as the app description and homepage. + brew style --except-cops FormulaAudit/Homepage,FormulaAudit/Desc,FormulaAuditStrict --fix "Formula/${filename}" || true + + git add "Formula/${filename}" + git commit -m "${name} ${version}" + done + git push + + announce: + needs: + - plan + - host + - publish-homebrew-formula + # use "always() && ..." to allow us to wait for all publish jobs while + # still allowing individual publish jobs to skip themselves (for prereleases). + # "host" however must run to completion, no skipping allowed! + if: ${{ always() && needs.host.result == 'success' && (needs.publish-homebrew-formula.result == 'skipped' || needs.publish-homebrew-formula.result == 'success') }} + runs-on: "ubuntu-22.04" + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + submodules: recursive diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ea8c4bf --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/target diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..4567ac4 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,572 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "anstream" +version = "0.6.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" + +[[package]] +name = "anstyle-parse" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "bstr" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab" +dependencies = [ + "memchr", + "serde", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "clap" +version = "4.5.60" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2797f34da339ce31042b27d23607e051786132987f595b02ba4f6a6dffb7030a" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.5.60" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24a241312cea5059b13574bb9b3861cabf758b879c15190b37b6d6fd63ab6876" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.5.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a822ea5bc7590f9d40f1ba12c0dc3c2760f3482c6984db1573ad11031420831" + +[[package]] +name = "colorchoice" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" + +[[package]] +name = "colored" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "117725a109d387c937a1533ce01b450cbde6b88abceea8473c4d7a85853cda3c" +dependencies = [ + "lazy_static", + "windows-sys 0.59.0", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "globset" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52dfc19153a48bde0cbd630453615c8151bce3a5adfac7a0aebfbf0a1e1f57e3" +dependencies = [ + "aho-corasick", + "bstr", + "log", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "ignore" +version = "0.4.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3d782a365a015e0f5c04902246139249abf769125006fbe7649e2ee88169b4a" +dependencies = [ + "crossbeam-deque", + "globset", + "log", + "memchr", + "regex-automata", + "same-file", + "walkdir", + "winapi-util", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "pin-project-lite" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a96887878f22d7bad8a3b6dc5b7440e0ada9a245242924394987b21cf2210a4c" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "worktree-link" +version = "0.1.0" +dependencies = [ + "anyhow", + "clap", + "colored", + "ignore", + "tracing", + "tracing-subscriber", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..fde2ca6 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "worktree-link" +version = "0.1.0" +edition = "2021" +description = "Automatically create symlinks from a main worktree to a new worktree based on .worktreelinks patterns" +license = "MIT" +repository = "https://github.com/km-tr/worktree-link" +homepage = "https://github.com/km-tr/worktree-link" + +[dependencies] +clap = { version = "4", features = ["derive"] } +ignore = "0.4" +anyhow = "1" +colored = "2" +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..6be441b --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 km-tr + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 5f5824f..ba59e2c 100644 --- a/README.md +++ b/README.md @@ -1 +1,154 @@ -# worktree-link \ No newline at end of file +# worktree-link + +A CLI tool that automatically creates symlinks from a main git worktree to a new worktree based on glob patterns. + +Short alias `wtl` is also available. + +## Use Cases + +- Share `node_modules` across worktrees (avoid installing dependencies per worktree) +- Share environment files like `.env` / `.env.local` +- Share build caches and artifacts like `.next/`, `tmp/`, `dist/` +- Share IDE settings (`.idea/`, `.vscode/`) + +## Installation + +### Homebrew (macOS / Linux) + +```bash +brew tap km-tr/worktree-link https://github.com/km-tr/worktree-link +brew install worktree-link +``` + +### Shell script (macOS / Linux) + +```bash +curl --proto '=https' --tlsv1.2 -LsSf https://github.com/km-tr/worktree-link/releases/latest/download/worktree-link-installer.sh | sh +``` + +### From source + +```bash +cargo install --path . +``` + +> **Note:** The `wtl` alias is only available when installed via Homebrew or the shell script installer. When building from source, only the `worktree-link` binary is installed. + +## Usage + +```text +worktree-link [OPTIONS] [TARGET] +wtl [OPTIONS] [TARGET] +``` + +### Arguments + +| Argument | Description | Default | +|----------|-------------|---------| +| `SOURCE` | Source directory (main worktree) | Required | +| `TARGET` | Target directory (new worktree) | `.` (current directory) | + +### Options + +| Option | Description | Default | +|--------|-------------|---------| +| `-c, --config ` | Path to config file | `/.worktreelinks` | +| `-n, --dry-run` | Show what would be done without making changes | `false` | +| `-f, --force` | Overwrite existing files/symlinks | `false` | +| `-v, --verbose` | Enable verbose logging | `false` | +| `--unlink` | Remove symlinks previously created by worktree-link | `false` | + +### Examples + +```bash +# Create symlinks from main worktree to the current directory +wtl /path/to/main + +# Specify the target directory explicitly +wtl /path/to/main ./feature-branch + +# Preview with dry-run before creating links +wtl --dry-run /path/to/main + +# Then actually create the links +wtl /path/to/main + +# Overwrite existing files/symlinks +wtl --force /path/to/main + +# Remove previously created symlinks +wtl --unlink /path/to/main +``` + +## Configuration (`.worktreelinks`) + +Create a `.worktreelinks` file in your project root and list the files/directories to link using gitignore-compatible glob patterns. + +```gitignore +# Dependencies +node_modules + +# Environment variables +.env +.env.* + +# Build artifacts and caches +.next/ +tmp/ +dist/ + +# IDE settings +.idea/ +.vscode/settings.json + +# Monorepo packages +packages/*/node_modules +``` + +### Pattern Rules + +- Lines starting with `#` are comments +- Blank lines are ignored +- Patterns ending with `/` match directories only +- `*` matches any character except `/` +- `**` matches across directory boundaries +- Patterns starting with `!` are negation (exclusion) patterns + +## Behavior + +### Directory Linking + +When a pattern matches a directory (e.g. `node_modules`), the entire directory is symlinked as a single unit rather than linking individual files inside it. + +### Absolute Paths + +Symlinks are created using absolute paths, making them resilient to worktree relocation. + +### Safety + +- The `.git/` directory is always excluded +- Existing files, symlinks, and directories are never overwritten unless `--force` is specified (directories are removed recursively) +- `--unlink` only removes symlinks that point into the source directory + +## Platform Support + +`worktree-link` uses Unix symlink APIs (`#[cfg(unix)]`). Non-Unix platforms (e.g. native Windows) are not supported. On Windows, use WSL or a similar Unix-like environment. + +Currently only tested on macOS. Linux should work but is not regularly tested. + +## Development + +```bash +# Build +cargo build + +# Test +cargo test + +# Debug run +cargo run -- --dry-run /path/to/source /path/to/target +``` + +## License + +MIT diff --git a/dist-workspace.toml b/dist-workspace.toml new file mode 100644 index 0000000..abc039e --- /dev/null +++ b/dist-workspace.toml @@ -0,0 +1,22 @@ +[workspace] +members = ["cargo:."] + +[dist] +cargo-dist-version = "0.31.0" +ci = "github" +installers = ["shell", "homebrew"] +tap = "km-tr/worktree-link" +publish-jobs = ["homebrew"] +targets = [ + "aarch64-apple-darwin", + "x86_64-apple-darwin", + "x86_64-unknown-linux-gnu", + "aarch64-unknown-linux-gnu", +] + +[dist.bin-aliases] +"worktree-link" = ["wtl"] + +[dist.github-custom-runners] +aarch64-unknown-linux-gnu = "ubuntu-24.04-arm" + diff --git a/lefthook.yml b/lefthook.yml new file mode 100644 index 0000000..f637a9e --- /dev/null +++ b/lefthook.yml @@ -0,0 +1,9 @@ +pre-commit: + parallel: true + commands: + fmt: + glob: "**/*.rs" + run: cargo fmt -- --check + clippy: + glob: "**/*.{rs,toml}" + run: cargo clippy --all-targets -- -D warnings diff --git a/mise.toml b/mise.toml new file mode 100644 index 0000000..71cadbd --- /dev/null +++ b/mise.toml @@ -0,0 +1,4 @@ +[tools] +"cargo:cargo-dist" = "0.31.0" +"cargo:cargo-release" = "1.1.0" +lefthook = "2.1.1" diff --git a/src/cli.rs b/src/cli.rs new file mode 100644 index 0000000..0c4cf9f --- /dev/null +++ b/src/cli.rs @@ -0,0 +1,34 @@ +use clap::Parser; +use std::path::PathBuf; + +/// Automatically create symlinks between git worktrees based on .worktreelinks patterns. +#[derive(Parser, Debug)] +#[command(name = "worktree-link", version, about)] +pub struct Cli { + /// Source directory (main worktree) + pub source: PathBuf, + + /// Target directory (new worktree) + #[arg(default_value = ".")] + pub target: PathBuf, + + /// Path to config file [default: /.worktreelinks] + #[arg(short, long = "config")] + pub config: Option, + + /// Show what would be done without making changes + #[arg(short = 'n', long)] + pub dry_run: bool, + + /// Overwrite existing files/symlinks + #[arg(short, long)] + pub force: bool, + + /// Enable verbose logging + #[arg(short, long)] + pub verbose: bool, + + /// Remove symlinks previously created by worktree-link + #[arg(long)] + pub unlink: bool, +} diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..1aa9133 --- /dev/null +++ b/src/config.rs @@ -0,0 +1,68 @@ +use anyhow::{Context, Result}; +use std::path::Path; + +/// Parsed configuration from a `.worktreelinks` file. +#[derive(Debug)] +pub struct Config { + /// Glob patterns that select files/directories to link. + pub patterns: Vec, +} + +impl Config { + /// Read and parse a `.worktreelinks` file. + pub fn from_file(path: &Path) -> Result { + let content = std::fs::read_to_string(path) + .with_context(|| format!("Failed to read config file: {}", path.display()))?; + Ok(Self::parse(&content)) + } + + /// Parse the content of a `.worktreelinks` file. + /// Lines starting with `#` are comments. Inline `#` is not stripped + /// and is treated as part of the pattern (matching `.gitignore` semantics). + fn parse(content: &str) -> Self { + let patterns = content + .lines() + .map(|line| line.trim()) + .filter(|line| !line.is_empty() && !line.starts_with('#')) + .map(|line| line.to_string()) + .collect(); + Config { patterns } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_ignores_comments_and_blank_lines() { + let input = r#" +# This is a comment +node_modules + +.env +.env.* + +# Build artifacts +.next/ +dist/ + "#; + let config = Config::parse(input); + assert_eq!( + config.patterns, + vec!["node_modules", ".env", ".env.*", ".next/", "dist/"] + ); + } + + #[test] + fn parse_empty_file() { + let config = Config::parse(""); + assert!(config.patterns.is_empty()); + } + + #[test] + fn parse_only_comments() { + let config = Config::parse("# comment\n# another"); + assert!(config.patterns.is_empty()); + } +} diff --git a/src/linker.rs b/src/linker.rs new file mode 100644 index 0000000..aa20de6 --- /dev/null +++ b/src/linker.rs @@ -0,0 +1,439 @@ +use anyhow::{Context, Result}; +use colored::Colorize; +use std::fs; +use std::path::{Path, PathBuf}; +use tracing::{debug, info, warn}; + +/// Describes what happened when attempting to create a link. +#[derive(Debug, PartialEq)] +pub enum LinkAction { + Created { source: PathBuf, target: PathBuf }, + Skipped { target: PathBuf, reason: String }, + Overwritten { source: PathBuf, target: PathBuf }, +} + +/// Describes what happened when attempting to unlink. +#[derive(Debug, PartialEq)] +pub enum UnlinkAction { + Removed(PathBuf), + Skipped { target: PathBuf, reason: String }, +} + +impl std::fmt::Display for LinkAction { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + LinkAction::Created { source, target } => { + write!( + f, + "{} {} -> {}", + "LINK".green().bold(), + target.display(), + source.display() + ) + } + LinkAction::Skipped { target, reason } => { + write!( + f, + "{} {} ({})", + "SKIP".yellow().bold(), + target.display(), + reason + ) + } + LinkAction::Overwritten { source, target } => { + write!( + f, + "{} {} -> {}", + "OVERWRITE".magenta().bold(), + target.display(), + source.display() + ) + } + } + } +} + +impl std::fmt::Display for UnlinkAction { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + UnlinkAction::Removed(path) => { + write!(f, "{} {}", "UNLINK".red().bold(), path.display()) + } + UnlinkAction::Skipped { target, reason } => { + write!( + f, + "{} {} ({})", + "SKIP".yellow().bold(), + target.display(), + reason + ) + } + } + } +} + +/// Create a symlink from `source_path` to `target_path`. +/// +/// `source_path` must be an absolute path (under the canonical source root). +/// We intentionally do NOT call `fs::canonicalize` on it so that symlinks +/// within the source tree are preserved as-is, keeping the link/unlink +/// round-trip consistent. +/// +/// `target_path` is where the symlink will be created (in the new worktree). +pub fn create_link( + source_path: &Path, + target_path: &Path, + force: bool, + dry_run: bool, +) -> Result { + anyhow::ensure!( + source_path.is_absolute(), + "source_path must be absolute: {}", + source_path.display() + ); + + debug!( + "create_link: {} -> {}", + target_path.display(), + source_path.display() + ); + + if target_path.exists() || target_path.is_symlink() { + if !force { + return Ok(LinkAction::Skipped { + target: target_path.to_path_buf(), + reason: "already exists (use --force to overwrite)".into(), + }); + } + + // Guard: if any parent of target_path is a symlink, removing it + // would delete through the symlink into the real source data. + if has_symlink_parent(target_path) { + return Ok(LinkAction::Skipped { + target: target_path.to_path_buf(), + reason: "parent directory is a symlink (remove it first)".into(), + }); + } + + if !dry_run { + remove_entry(target_path) + .with_context(|| format!("Failed to remove: {}", target_path.display()))?; + create_parent_dirs(target_path)?; + symlink(source_path, target_path)?; + } + + if dry_run { + info!("[dry-run] would overwrite: {}", target_path.display()); + } else { + info!("overwritten: {}", target_path.display()); + } + return Ok(LinkAction::Overwritten { + source: source_path.to_path_buf(), + target: target_path.to_path_buf(), + }); + } + + // Guard: if any parent of target_path is a symlink, creating a link + // underneath it would write into the symlink destination instead of + // the target worktree. + if has_symlink_parent(target_path) { + return Ok(LinkAction::Skipped { + target: target_path.to_path_buf(), + reason: "parent directory is a symlink (remove it first)".into(), + }); + } + + if !dry_run { + create_parent_dirs(target_path)?; + symlink(source_path, target_path)?; + } + + if dry_run { + info!("[dry-run] would link: {}", target_path.display()); + } else { + info!("linked: {}", target_path.display()); + } + Ok(LinkAction::Created { + source: source_path.to_path_buf(), + target: target_path.to_path_buf(), + }) +} + +/// Walk `target_dir` and remove any symlinks that point into `source_dir`. +/// +/// This walks the target side (not the source), so it also catches stale +/// symlinks whose source-side originals have been deleted or renamed. +/// Errors on individual entries are logged as warnings and skipped so that +/// the walk continues (best-effort). +pub fn unlink_targets( + source_dir: &Path, + target_dir: &Path, + dry_run: bool, +) -> Result> { + // Canonicalize source_dir so the starts_with comparison works correctly + // against fully-resolved link destinations. + let canonical_source = fs::canonicalize(source_dir).with_context(|| { + format!( + "Failed to canonicalize source dir: {}", + source_dir.display() + ) + })?; + + let mut actions = Vec::new(); + + walk_symlinks(target_dir, &mut |entry_path| { + if !entry_path.is_symlink() { + return Ok(()); + } + + let link_dest = match fs::read_link(&entry_path) { + Ok(dest) => dest, + Err(e) => { + warn!("Skipping {}: {e}", entry_path.display()); + actions.push(UnlinkAction::Skipped { + target: entry_path, + reason: format!("cannot read symlink: {e}"), + }); + return Ok(()); + } + }; + + // Resolve relative symlink targets to absolute paths for comparison. + // fs::read_link can return relative paths, while source_dir is canonical. + let resolved = if link_dest.is_absolute() { + link_dest + } else { + match entry_path.parent() { + Some(parent) => parent.join(&link_dest), + None => link_dest, + } + }; + + // Normalize and canonicalize for stable prefix checks. + // For dangling symlinks, fully canonicalizing the destination fails, so + // we canonicalize the deepest existing ancestor and append the remainder. + let resolved = canonicalize_with_ancestor_fallback(&resolved); + + // Only remove symlinks that point into the source directory + if !resolved.starts_with(&canonical_source) { + return Ok(()); + } + + if !dry_run { + if let Err(e) = remove_entry(&entry_path) { + warn!("Failed to remove {}: {e}", entry_path.display()); + actions.push(UnlinkAction::Skipped { + target: entry_path, + reason: format!("removal failed: {e}"), + }); + return Ok(()); + } + } + + if dry_run { + info!("[dry-run] would unlink: {}", entry_path.display()); + } else { + info!("unlinked: {}", entry_path.display()); + } + actions.push(UnlinkAction::Removed(entry_path)); + Ok(()) + })?; + + actions.sort_by(|a, b| { + let path_a = match a { + UnlinkAction::Removed(p) | UnlinkAction::Skipped { target: p, .. } => p, + }; + let path_b = match b { + UnlinkAction::Removed(p) | UnlinkAction::Skipped { target: p, .. } => p, + }; + path_a.cmp(path_b) + }); + + Ok(actions) +} + +/// Recursively walk a directory, calling `visitor` on each symlink found. +/// Does not follow symlinks (so symlinked directories are visited but not descended into). +/// Errors on individual entries are warned and skipped (best-effort). +fn walk_symlinks(dir: &Path, visitor: &mut dyn FnMut(PathBuf) -> Result<()>) -> Result<()> { + let entries = match fs::read_dir(dir) { + Ok(entries) => entries, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()), + Err(e) if e.kind() == std::io::ErrorKind::PermissionDenied => { + warn!("Skipping directory {}: {e}", dir.display()); + return Ok(()); + } + Err(e) => return Err(e).with_context(|| format!("Failed to read dir: {}", dir.display())), + }; + + for entry in entries { + let entry = match entry { + Ok(e) => e, + Err(e) => { + warn!("Skipping entry: {e}"); + continue; + } + }; + let path = entry.path(); + let meta = match fs::symlink_metadata(&path) { + Ok(m) => m, + Err(e) => { + warn!("Skipping {}: {e}", path.display()); + continue; + } + }; + + if meta.file_type().is_symlink() { + visitor(path)?; + } else if meta.is_dir() { + // Skip .git to avoid damaging repository internals. + if path.file_name().is_some_and(|n| n == ".git") { + continue; + } + walk_symlinks(&path, visitor)?; + } + } + + Ok(()) +} + +/// Normalize a path lexically by resolving `.` and `..` components without +/// touching the filesystem. Useful as a fallback when `fs::canonicalize` fails +/// (e.g. for dangling symlinks). +fn normalize_lexically(path: &Path) -> PathBuf { + use std::path::Component; + let mut parts = Vec::new(); + for component in path.components() { + match component { + Component::ParentDir => { + // Never pop past root or prefix components + match parts.last() { + Some(Component::RootDir | Component::Prefix(_)) | None => {} + _ => { + parts.pop(); + } + } + } + Component::CurDir => {} + other => parts.push(other), + } + } + parts.iter().collect() +} + +/// Canonicalize a path for prefix comparisons. +/// +/// If the full path cannot be canonicalized (e.g. dangling symlink target), +/// canonicalize the deepest existing ancestor and append the remaining suffix. +/// This avoids mismatches such as `/var` vs `/private/var` aliases on macOS. +fn canonicalize_with_ancestor_fallback(path: &Path) -> PathBuf { + let normalized = normalize_lexically(path); + if let Ok(canonical) = fs::canonicalize(&normalized) { + return canonical; + } + + for ancestor in normalized.ancestors() { + if ancestor.as_os_str().is_empty() { + continue; + } + + let Ok(canonical_ancestor) = fs::canonicalize(ancestor) else { + continue; + }; + let Ok(remainder) = normalized.strip_prefix(ancestor) else { + continue; + }; + + return if remainder.as_os_str().is_empty() { + canonical_ancestor + } else { + canonical_ancestor.join(remainder) + }; + } + + normalized +} + +/// Check if any parent component of `path` is a symlink. +fn has_symlink_parent(path: &Path) -> bool { + let mut current = path.to_path_buf(); + while let Some(parent) = current.parent() { + if parent.as_os_str().is_empty() { + break; + } + if parent.is_symlink() { + return true; + } + current = parent.to_path_buf(); + } + false +} + +fn create_parent_dirs(path: &Path) -> Result<()> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent) + .with_context(|| format!("Failed to create parent directory: {}", parent.display()))?; + } + Ok(()) +} + +fn remove_entry(path: &Path) -> Result<()> { + let meta = fs::symlink_metadata(path)?; + if meta.is_dir() { + fs::remove_dir_all(path)?; + } else { + fs::remove_file(path)?; + } + Ok(()) +} + +#[cfg(unix)] +fn symlink(source: &Path, target: &Path) -> Result<()> { + std::os::unix::fs::symlink(source, target).with_context(|| { + format!( + "Failed to create symlink: {} -> {}", + target.display(), + source.display() + ) + }) +} + +#[cfg(not(unix))] +fn symlink(source: &Path, target: &Path) -> Result<()> { + anyhow::bail!("Symlink creation is only supported on Unix systems") +} + +#[cfg(test)] +mod tests { + use super::*; + use std::time::{SystemTime, UNIX_EPOCH}; + + fn unique_temp_dir() -> PathBuf { + let mut dir = std::env::temp_dir(); + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system time before UNIX_EPOCH") + .as_nanos(); + dir.push(format!("worktree-link-test-{}-{nanos}", std::process::id())); + fs::create_dir_all(&dir).expect("failed to create test temp dir"); + dir + } + + #[cfg(unix)] + #[test] + fn canonicalize_with_ancestor_fallback_resolves_alias_for_dangling_path() { + let root = unique_temp_dir(); + let real = root.join("real"); + fs::create_dir_all(&real).expect("failed to create real dir"); + + let alias = root.join("alias"); + std::os::unix::fs::symlink(&real, &alias).expect("failed to create alias symlink"); + + let dangling = alias.join("missing").join("child"); + let resolved = canonicalize_with_ancestor_fallback(&dangling); + let canonical_real = fs::canonicalize(&real).expect("failed to canonicalize real dir"); + assert_eq!(resolved, canonical_real.join("missing").join("child")); + + fs::remove_dir_all(&root).expect("failed to cleanup temp dir"); + } +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..d3f20fe --- /dev/null +++ b/src/main.rs @@ -0,0 +1,142 @@ +mod cli; +mod config; +mod linker; +mod walker; + +use anyhow::{bail, Context, Result}; +use clap::Parser; +use colored::Colorize; +use std::fs; + +use cli::Cli; +use config::Config; + +fn main() -> Result<()> { + let cli = Cli::parse(); + + // Set up tracing + let level = if cli.verbose { "debug" } else { "warn" }; + tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new(level)), + ) + .without_time() + .init(); + + // Resolve source directory + let source = fs::canonicalize(&cli.source) + .with_context(|| format!("Source directory does not exist: {}", cli.source.display()))?; + + if !source.is_dir() { + bail!("Source is not a directory: {}", source.display()); + } + + // Resolve target directory + let target = fs::canonicalize(&cli.target) + .with_context(|| format!("Target directory does not exist: {}", cli.target.display()))?; + + if !target.is_dir() { + bail!("Target is not a directory: {}", target.display()); + } + + if source == target { + bail!("Source and target cannot be the same directory"); + } + + if target.starts_with(&source) || source.starts_with(&target) { + bail!("Source and target must not be nested"); + } + + if cli.dry_run { + println!("{}", "DRY RUN — no changes will be made".cyan().bold()); + } + + if cli.unlink { + // Unlink mode: walk the target directory looking for symlinks into source. + // No config file needed — we scan target for any symlink pointing into source. + let actions = linker::unlink_targets(&source, &target, cli.dry_run)?; + + let mut removed = 0; + let mut skipped = 0; + for action in &actions { + println!(" {action}"); + match action { + linker::UnlinkAction::Removed(_) => removed += 1, + linker::UnlinkAction::Skipped { .. } => skipped += 1, + } + } + + if actions.is_empty() { + println!( + " {} No symlinks pointing to source found", + "INFO".cyan().bold() + ); + } + + println!(); + println!( + "{}", + format!("Removed: {removed}, Skipped: {skipped}").bold() + ); + } else { + // Link mode: read config and collect matching files/directories from source + let config_path = cli + .config + .clone() + .unwrap_or_else(|| source.join(".worktreelinks")); + let config = Config::from_file(&config_path)?; + + if config.patterns.is_empty() { + println!( + "{} No patterns found in {}", + "WARN".yellow().bold(), + config_path.display() + ); + return Ok(()); + } + + let targets = walker::collect_targets(&source, &config.patterns)?; + + if targets.is_empty() { + println!( + "{} No files matched the patterns in {}", + "WARN".yellow().bold(), + config_path.display() + ); + return Ok(()); + } + + if cli.verbose { + println!("Found {} target(s) to link", targets.len()); + } + + let mut created = 0; + let mut overwritten = 0; + let mut skipped = 0; + + for source_path in &targets { + let rel = source_path + .strip_prefix(&source) + .with_context(|| "Path is not relative to source")?; + let target_path = target.join(rel); + + let action = linker::create_link(source_path, &target_path, cli.force, cli.dry_run)?; + + println!(" {action}"); + match action { + linker::LinkAction::Created { .. } => created += 1, + linker::LinkAction::Overwritten { .. } => overwritten += 1, + linker::LinkAction::Skipped { .. } => skipped += 1, + } + } + + println!(); + println!( + "{}", + format!("Created: {created}, Overwritten: {overwritten}, Skipped: {skipped}").bold() + ); + } + + Ok(()) +} diff --git a/src/walker.rs b/src/walker.rs new file mode 100644 index 0000000..c2ff618 --- /dev/null +++ b/src/walker.rs @@ -0,0 +1,167 @@ +use anyhow::{Context, Result}; +use ignore::overrides::{Override, OverrideBuilder}; +use ignore::{Match, WalkBuilder}; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use tracing::debug; + +/// Build an `Override` matcher from the given patterns. +pub fn build_overrides(source: &Path, patterns: &[String]) -> Result { + let mut builder = OverrideBuilder::new(source); + for pattern in patterns { + builder + .add(pattern) + .with_context(|| format!("Invalid pattern: {pattern}"))?; + } + builder.build().with_context(|| "Failed to build overrides") +} + +/// Collect files and directories in `source` that match the given patterns. +/// +/// Patterns follow gitignore syntax. When a directory matches, we include it +/// but do NOT descend into it — it will be symlinked as a whole. +pub fn collect_targets(source: &Path, patterns: &[String]) -> Result> { + let overrides = Arc::new(build_overrides(source, patterns)?); + + let mut targets: Vec = Vec::new(); + + // We use filter_entry to both skip .git and to prune matched directories + // (avoid descending into them). A matched directory is still yielded as + // an entry before filter_entry decides not to recurse into it, so we + // collect it from filter_entry via a shared Vec. + let matched_dirs: Arc>> = + Arc::new(std::sync::Mutex::new(Vec::new())); + + let overrides_clone = Arc::clone(&overrides); + let matched_dirs_clone = Arc::clone(&matched_dirs); + let source_owned = source.to_path_buf(); + + let walker = WalkBuilder::new(source) + .hidden(false) + .git_ignore(false) + .git_global(false) + .git_exclude(false) + .filter_entry(move |entry| { + // Always skip .git + if entry.file_name() == ".git" { + return false; + } + + let path = entry.path(); + + // Always allow the root itself + if path == source_owned { + return true; + } + + let is_dir = entry.file_type().map(|ft| ft.is_dir()).unwrap_or(false); + + if is_dir { + if let Match::Whitelist(_) = overrides_clone.matched(path, true) { + // This directory matches — record it and stop descent + matched_dirs_clone.lock().unwrap().push(path.to_path_buf()); + return false; + } + } + + true + }) + .build(); + + for entry in walker { + let entry = entry.with_context(|| "Error walking directory")?; + let path = entry.path(); + + // Skip the source root itself + if path == source { + continue; + } + + let is_dir = entry.file_type().map(|ft| ft.is_dir()).unwrap_or(false); + + // Files matched by pattern + if let Match::Whitelist(_) = overrides.matched(path, is_dir) { + debug!("matched: {}", path.display()); + targets.push(path.to_path_buf()); + } + } + + // Add matched directories that were pruned by filter_entry + let dirs = matched_dirs.lock().unwrap(); + for dir in dirs.iter() { + debug!("matched dir: {}", dir.display()); + targets.push(dir.clone()); + } + + // Sort for deterministic output + targets.sort(); + targets.dedup(); + + Ok(targets) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + + #[test] + fn collect_targets_skips_unmatched() { + let dir = tempdir("collect_skip"); + fs::write(dir.join(".env"), "SECRET=1").unwrap(); + fs::write(dir.join("main.rs"), "fn main(){}").unwrap(); + fs::create_dir_all(dir.join("src")).unwrap(); + fs::write(dir.join("src/lib.rs"), "").unwrap(); + + let targets = collect_targets(&dir, &[".env".into()]).unwrap(); + let rel: Vec<_> = targets + .iter() + .map(|p| p.strip_prefix(&dir).unwrap()) + .collect(); + assert_eq!(rel, vec![Path::new(".env")]); + } + + #[test] + fn collect_targets_matches_directory_without_descending() { + let dir = tempdir("collect_dir"); + let nm = dir.join("node_modules/pkg"); + fs::create_dir_all(&nm).unwrap(); + fs::write(nm.join("index.js"), "").unwrap(); + fs::write(dir.join("app.js"), "").unwrap(); + + let targets = collect_targets(&dir, &["node_modules".into()]).unwrap(); + let rel: Vec<_> = targets + .iter() + .map(|p| p.strip_prefix(&dir).unwrap()) + .collect(); + // Should only contain the directory itself, not its children + assert_eq!(rel, vec![Path::new("node_modules")]); + } + + #[test] + fn collect_targets_negation_pattern() { + let dir = tempdir("collect_neg"); + fs::write(dir.join(".env"), "A=1").unwrap(); + fs::write(dir.join(".env.local"), "B=2").unwrap(); + fs::write(dir.join(".env.production"), "C=3").unwrap(); + + let targets = collect_targets( + &dir, + &[".env".into(), ".env.*".into(), "!.env.production".into()], + ) + .unwrap(); + let rel: Vec<_> = targets + .iter() + .map(|p| p.strip_prefix(&dir).unwrap()) + .collect(); + assert_eq!(rel, vec![Path::new(".env"), Path::new(".env.local")]); + } + + fn tempdir(name: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!("worktree-link-test-{name}")); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir).unwrap(); + // Return canonical path so comparisons work + fs::canonicalize(&dir).unwrap() + } +}