worktrunk 0.49.0

A CLI for Git worktree management, designed for parallel AI agent workflows
Documentation
name: ci

# Runner versions are pinned to avoid surprise breaking changes from -latest migrations.
# The tend-weekly workflow checks for updates and opens bump PRs (see
# .claude/skills/running-tend/SKILL.md โ†’ "Weekly Maintenance: CI Pin Bumps").
# - windows-2022: Pinned because windows-2025 lacks D: drive (actions/runner-images#12677)
# - ubuntu-24.04, macos-15: Pinned to current -latest equivalents

on:
  push:
    branches: [ main ]
  pull_request:
    branches: [ main ]
  workflow_dispatch:

concurrency:
  group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
  cancel-in-progress: ${{ github.event_name == 'pull_request' }}

env:
  CARGO_TERM_COLOR: always
  # CI does clean builds, so incremental compilation tracking adds I/O overhead
  # with no benefit. Especially impactful on Windows where I/O is the bottleneck.
  CARGO_INCREMENTAL: 0
  RUSTFLAGS: -C debuginfo=0
  # Authenticate lychee requests to GitHub to avoid rate limiting
  GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

jobs:
  lint:
    runs-on: ubuntu-24.04
    steps:
    - name: ๐Ÿ“‚ Checkout code
      uses: actions/checkout@v6

    - name: ๐Ÿ’ฐ Cache
      uses: Swatinem/rust-cache@v2

    - name: Install lychee
      uses: baptiste0928/cargo-install@v3
      with:
        crate: lychee
        version: "=0.23.0"

    - name: ๐Ÿ” Pre-commit hooks
      uses: pre-commit/action@v3.0.1

  feature-check:
    # Guards the library/CLI cleave: the `cli` feature gates clap, skim,
    # crossterm, termimad, env_logger, humantime. If anything reachable from
    # `pub mod` in src/lib.rs starts using one of those crates, library
    # consumers (e.g. worktrunk-sync) would silently regain the transitive
    # dependency graph. These checks fail before that can ship.
    runs-on: ubuntu-24.04
    steps:
    - name: ๐Ÿ“‚ Checkout code
      uses: actions/checkout@v6

    - name: ๐Ÿ’ฐ Cache
      uses: Swatinem/rust-cache@v2

    - name: Library builds without any features
      run: cargo check --lib --no-default-features

    - name: Library builds with syntax-highlighting only
      run: cargo check --lib --no-default-features --features syntax-highlighting

    - name: Binary builds without syntax-highlighting
      run: cargo check --bin wt --no-default-features --features cli

  test:
    strategy:
      fail-fast: false
      matrix:
        include:
          - os: ubuntu-24.04
            name: linux
          - os: macos-15
            name: macos
          - os: windows-2022
            name: windows
    name: test (${{ matrix.name }})
    runs-on: ${{ matrix.os }}

    steps:
    - name: ๐Ÿ“‚ Checkout code
      uses: actions/checkout@v6

    - uses: ./.github/actions/test-setup

    - name: Install wt
      uses: baptiste0928/cargo-install@v3
      with:
        crate: worktrunk
        version: "=0.35.2"

    - name: "Use fast D: drive for temp files (Windows)"
      if: runner.os == 'Windows'
      shell: pwsh
      run: |
        New-Item -ItemType Directory -Force -Path "D:\tmp" | Out-Null
        echo "TEMP=D:\tmp" >> $env:GITHUB_ENV
        echo "TMP=D:\tmp" >> $env:GITHUB_ENV

    - name: ๐Ÿงช Pre-merge hooks
      run: wt hook pre-merge --yes insta doctest doc

    - name: ๐Ÿ“Š Upload test timing
      if: always()
      uses: actions/upload-artifact@v7
      with:
        name: junit-${{ matrix.name }}
        path: target/nextest/default/junit.xml

    - name: ๐Ÿ“Š Upload test results to Codecov
      if: ${{ !cancelled() }}
      uses: codecov/codecov-action@v6.0.0
      with:
        token: ${{ secrets.CODECOV_TOKEN }}
        files: target/nextest/default/junit.xml
        report_type: test_results

  # Check if Cargo/CI files changed (for conditional jobs below)
  changes:
    runs-on: ubuntu-24.04
    permissions:
      contents: read
      pull-requests: read
    outputs:
      cargo: ${{ steps.filter.outputs.cargo }}
      docs: ${{ steps.filter.outputs.docs }}
    steps:
    - name: ๐Ÿ“‚ Checkout code
      uses: actions/checkout@v6
    - uses: dorny/paths-filter@v4
      id: filter
      with:
        filters: |
          cargo:
            - 'Cargo.toml'
            - 'Cargo.lock'
            - 'rust-toolchain.toml'
            - '.github/workflows/ci.yaml'
          docs:
            - 'docs/**'
            - 'src/cli/mod.rs'

  msrv:
    runs-on: ubuntu-24.04
    needs: changes
    if: needs.changes.outputs.cargo == 'true' || github.event_name == 'workflow_dispatch'
    steps:
    - name: ๐Ÿ“‚ Checkout code
      uses: actions/checkout@v6

    - name: ๐Ÿ’ฐ Cache
      uses: Swatinem/rust-cache@v2

    - uses: baptiste0928/cargo-install@v3
      with:
        crate: cargo-msrv
        version: "=0.19.2"

    - name: Verify minimum rust version
      run: cargo msrv verify

  check-docs:
    runs-on: ubuntu-24.04
    needs: changes
    if: needs.changes.outputs.docs == 'true' || github.event_name == 'workflow_dispatch'
    steps:
    - name: ๐Ÿ“‚ Checkout code
      uses: actions/checkout@v6

    - name: Setup Zola
      uses: taiki-e/install-action@v2.77.1
      with:
        tool: zola@0.22.1

    - name: ๐Ÿ•ท๏ธ Build docs
      run: zola build
      working-directory: docs

  # cargo-affected: runs only the tests whose recorded coverage overlaps the
  # diff. Advisory at this stage โ€” `test (linux/macos/windows)` still runs the
  # full suite. The signal is the runtime delta on the exact-match path and
  # whether selection misses any failures the full suite catches.
  #
  # TODO: once cargo-affected is trusted on this repo, gate the full test
  # matrix to run only on pushes to main and on PRs with a `full-tests` label
  # (or similar). PR pushes would default to affected-only. The full suite
  # could fold into the existing `nightly` workflow, which already hosts
  # slower integration-time checks. Periodic full runs remain essential โ€”
  # cargo-affected misses non-Rust inputs (`include_str!`, templates, SQL),
  # build-time inputs not in its fingerprint (build.rs, rust-toolchain.toml,
  # .cargo/config.toml), and proc-macro source edits.
  #
  # Cache strategy:
  # - `collect-affected` (push to main) saves `target/affected/coverage.db`
  #   to actions/cache keyed on the main commit sha. We cache only the DB,
  #   not the parent dir โ€” cargo-affected drops its profraw staging dir
  #   at the end of every successful collect, but caching the path
  #   explicitly keeps the contract obvious and ~10 GB of profile bundles
  #   from leaking into the cache if that cleanup ever regresses.
  # - `collect-affected` also restores the most recent prior main DB before
  #   collecting, so the new DB accumulates rows for up to FINGERPRINT_KEEP
  #   (=10) recent main-tip env_fingerprints (LRU-evicted in `Db::gc`).
  #   PRs whose manifests match any of those fingerprints get exact-match
  #   selection instead of the all-or-nothing single-fingerprint cache.
  #   Don't "simplify" by removing the restore โ€” it's load-bearing.
  # - `affected-tests` (PRs) restores the cache. Primary key is the
  #   PR/main merge-base โ€” when collect ran on that exact commit, we get a
  #   tight diff (`PR changes only`) and the smallest possible selection.
  # - Restore-keys fall back to the most recent main DB. Its `collect_sha`
  #   is typically a sibling of the PR's HEAD, not a strict ancestor.
  #   cargo-affected uses any sha still in the repo as a diff anchor, so
  #   the fallback drives normal selection โ€” over-includes tests touched
  #   by main commits between the merge-base and collect_sha (correct
  #   superset), but never widens to "run everything" unless the cache is
  #   missing entirely.
  # - Cache keys include `runner.os` (fingerprint embeds `rustc -vV`) and a
  #   manual `db-v{N}` marker. Bump the marker if cargo-affected ships an
  #   on-disk schema change; the cache is otherwise version-agnostic.
  collect-affected:
    name: collect affected coverage
    if: ${{ (github.event_name == 'push' && github.ref == 'refs/heads/main') || github.event_name == 'workflow_dispatch' }}
    runs-on: ubuntu-24.04
    steps:
    - name: ๐Ÿ“‚ Checkout code
      uses: actions/checkout@v6
      with:
        # `cargo affected run` later diffs PR HEAD against the sha that was
        # HEAD when collect ran. That sha must be reachable.
        fetch-depth: 0

    - uses: ./.github/actions/test-setup

    - name: Install cargo-affected
      uses: baptiste0928/cargo-install@v3
      with:
        crate: cargo-affected
        git: https://github.com/max-sixty/cargo-affected
        # No rev/branch/tag โ†’ action resolves to latest default-branch sha.

    - name: Install llvm-tools
      run: rustup component add llvm-tools

    - name: ๐Ÿ’พ Restore prior coverage DB
      uses: actions/cache/restore@v5
      with:
        path: target/affected/coverage.db
        # Primary key matches the current sha (no-op on first push of this
        # commit; on re-runs of the same sha, lets us skip rebuilding from
        # scratch).
        key: cargo-affected-db-v1-${{ runner.os }}-${{ github.sha }}
        # Fall back to any prior main DB. cargo-affected preserves rows for
        # up to FINGERPRINT_KEEP (=10) distinct fingerprints in one DB,
        # evicting LRU on each collect. By feeding a prior DB into the new
        # collect, we accumulate fingerprint snapshots across main commits โ€”
        # PRs whose manifests match any of the last ~10 main fingerprints
        # get exact-match selection, instead of the all-or-nothing
        # single-fingerprint cache.
        restore-keys: |
          cargo-affected-db-v1-${{ runner.os }}-

    - name: ๐Ÿ“Š Collect coverage data
      env:
        NEXTEST_NO_INPUT_HANDLER: 1
      run: cargo affected collect -- --features shell-integration-tests

    - name: ๐Ÿ’พ Save coverage DB
      uses: actions/cache/save@v5
      with:
        # Only the SQLite DB. `target/affected/profraw-<PID>/` holds raw
        # profile bundles (~5 GB on this repo) that aren't needed past the
        # current collect โ€” caching them blows past the 10 GB repo cache
        # cap and forces eviction of every prior cache.
        path: target/affected/coverage.db
        key: cargo-affected-db-v1-${{ runner.os }}-${{ github.sha }}

  affected-tests:
    name: affected tests (linux, advisory)
    if: github.event_name == 'pull_request'
    runs-on: ubuntu-24.04
    # Advisory while we calibrate selection accuracy against the full suite.
    # The full matrix (`test (linux/macos/windows)`) is still required for
    # merge.
    continue-on-error: true
    steps:
    - name: ๐Ÿ“‚ Checkout code
      uses: actions/checkout@v6
      with:
        # Need the cached `collect_sha` reachable from HEAD for the diff;
        # see collect-affected above.
        fetch-depth: 0

    - name: Compute merge-base for cache key
      id: mb
      env:
        PR_HEAD: ${{ github.event.pull_request.head.sha }}
      run: |
        git fetch --no-tags --depth=200 origin main
        sha=$(git merge-base origin/main "$PR_HEAD")
        echo "sha=$sha" >> "$GITHUB_OUTPUT"
        echo "merge-base with origin/main: $sha"

    - uses: ./.github/actions/test-setup

    # Restore AFTER test-setup so we land on top of rust-cache's tar
    # extraction. rust-cache's restore step calls `cleanTargetDir` on a
    # partial cache hit (`full match: false`), which deletes file children
    # of `target/affected/` โ€” including a `coverage.db` we'd just dropped
    # there. Order: rust-cache populates `target/` first; we then drop the
    # DB on top, where nothing else touches it.
    - name: ๐Ÿ’พ Restore coverage DB
      uses: actions/cache/restore@v5
      with:
        path: target/affected/coverage.db
        # Exact match: collect ran on the PR's merge-base โ†’ tight diff.
        key: cargo-affected-db-v1-${{ runner.os }}-${{ steps.mb.outputs.sha }}
        # Fallback: most recent main DB. cargo-affected runs all tests when
        # the cached `collect_sha` is missing from the repo (rebased and
        # pruned, beyond a shallow clone boundary). Sibling shas โ€” including
        # PR-vs-main-tip โ€” drive normal selection.
        restore-keys: |
          cargo-affected-db-v1-${{ runner.os }}-

    - name: Install cargo-affected
      uses: baptiste0928/cargo-install@v3
      with:
        crate: cargo-affected
        git: https://github.com/max-sixty/cargo-affected
        # No rev/branch/tag โ†’ action resolves to latest default-branch sha.

    - name: ๐ŸŽฏ Run affected tests
      env:
        NEXTEST_NO_INPUT_HANDLER: 1
      run: cargo affected run --report-json target/affected/report.json -- --features shell-integration-tests

    # Report writes BEFORE nextest runs, so it survives test failures โ€”
    # uploading on `!cancelled()` makes it the most useful diagnostic
    # when tests fail (cache state, fingerprint divergence, selection
    # reasons). Schema documented at:
    # https://github.com/max-sixty/cargo-affected/blob/main/docs/report-json.md
    - name: ๐Ÿ“Š Upload cargo-affected report
      if: ${{ !cancelled() }}
      uses: actions/upload-artifact@v7
      with:
        name: cargo-affected-report
        path: target/affected/report.json

  code-coverage:
    runs-on: ubuntu-24.04
    steps:
    - name: ๐Ÿ“‚ Checkout code
      uses: actions/checkout@v6

    - uses: baptiste0928/cargo-install@v3
      with:
        crate: cargo-llvm-cov
        version: "=0.8.5"

    - name: ๐Ÿ’ฐ Cache
      uses: Swatinem/rust-cache@v2
      with:
        save-if: ${{ github.ref == 'refs/heads/main' }}

    - name: Install shells (zsh, fish)
      uses: awalsh128/cache-apt-pkgs-action@v1.6.0
      with:
        packages: zsh fish
        version: 1.0

    - name: Install nushell
      uses: hustcer/setup-nu@v3
      with:
        version: '0.112.2'

    # Ensure nothing remains from caching
    - run: cargo llvm-cov clean --workspace

    - name: ๐Ÿ“Š Generate coverage report
      env:
        NEXTEST_NO_INPUT_HANDLER: 1
      run: cargo llvm-cov --features shell-integration-tests --cobertura --output-path=cobertura.xml

    - name: Upload code coverage results
      uses: actions/upload-artifact@v7
      with:
        name: code-coverage-report
        path: cobertura.xml

    - name: Upload to codecov.io
      uses: codecov/codecov-action@v6.0.0
      with:
        files: cobertura.xml
        # Soft-fail on fork PRs: secrets aren't available there, so we rely on
        # tokenless upload, which can hiccup. Keep hard-fail on the main repo.
        fail_ci_if_error: ${{ github.repository_owner == 'max-sixty' }}
        token: ${{ secrets.CODECOV_TOKEN }}