diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml deleted file mode 100644 index bc35c75d..00000000 --- a/.github/workflows/build.yml +++ /dev/null @@ -1,143 +0,0 @@ -name: Build Test - -on: - push: - branches: [ main, develop ] - pull_request: - branches: [ main ] - workflow_dispatch: - -env: - CARGO_TERM_COLOR: always - -jobs: - build-debug: - name: Build Debug - runs-on: ubuntu-latest - steps: - - name: Checkout code - uses: actions/checkout@v7 - - - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@stable - - - name: Cache cargo registry - uses: actions/cache@v6 - with: - path: ~/.cargo/registry - key: ubuntu-cargo-registry-${{ hashFiles('**/Cargo.lock') }} - restore-keys: | - ubuntu-cargo-registry- - - - name: Cache cargo index - uses: actions/cache@v6 - with: - path: ~/.cargo/git - key: ubuntu-cargo-git-${{ hashFiles('**/Cargo.lock') }} - restore-keys: | - ubuntu-cargo-git- - - - name: Cache cargo build - uses: actions/cache@v6 - with: - path: target - key: ubuntu-cargo-debug-${{ hashFiles('**/Cargo.lock') }} - restore-keys: | - ubuntu-cargo-debug- - - - name: Build debug binary - run: cargo build --verbose --all-features - - - name: Check binary size - run: | - ls -lh target/debug/claude-code-sync - du -h target/debug/claude-code-sync - - build-release: - name: Build Release - runs-on: ubuntu-latest - steps: - - name: Checkout code - uses: actions/checkout@v7 - - - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@stable - - - name: Cache cargo registry - uses: actions/cache@v6 - with: - path: ~/.cargo/registry - key: ubuntu-cargo-registry-${{ hashFiles('**/Cargo.lock') }} - restore-keys: | - ubuntu-cargo-registry- - - - name: Cache cargo index - uses: actions/cache@v6 - with: - path: ~/.cargo/git - key: ubuntu-cargo-git-${{ hashFiles('**/Cargo.lock') }} - restore-keys: | - ubuntu-cargo-git- - - - name: Cache cargo build - uses: actions/cache@v6 - with: - path: target - key: ubuntu-cargo-release-${{ hashFiles('**/Cargo.lock') }} - restore-keys: | - ubuntu-cargo-release- - - - name: Build release binary - run: cargo build --release --verbose - - - name: Check binary size - run: | - ls -lh target/release/claude-code-sync - du -h target/release/claude-code-sync - - - name: Upload build artifact - uses: actions/upload-artifact@v7 - with: - name: claude-code-sync-binary - path: target/release/claude-code-sync - retention-days: 7 - - security-audit: - name: Security Audit - runs-on: ubuntu-latest - steps: - - name: Checkout code - uses: actions/checkout@v7 - - - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@stable - - - name: Cache cargo-audit - uses: actions/cache@v6 - with: - path: ~/.cargo/bin/cargo-audit - key: cargo-audit-${{ runner.os }} - - - name: Install cargo-audit - run: cargo install cargo-audit --locked || true - - - name: Run security audit - run: cargo audit - - dependency-check: - name: Dependency Check - runs-on: ubuntu-latest - steps: - - name: Checkout code - uses: actions/checkout@v7 - - - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@stable - - - name: Check dependencies - run: cargo tree - - - name: Check for outdated dependencies - run: | - cargo install cargo-outdated --locked - cargo outdated --exit-code 1 || echo "Some dependencies are outdated" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 19e9d738..73158ac7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,143 +2,160 @@ name: CI on: push: - branches: [ main, develop ] + branches: [main, develop] pull_request: - branches: [ main ] - -env: - CARGO_TERM_COLOR: always - RUST_BACKTRACE: 1 + branches: [main] + workflow_dispatch: jobs: test: name: Test runs-on: ${{ matrix.os }} strategy: + fail-fast: false matrix: os: [ubuntu-latest, macos-latest, windows-latest] - rust: [stable, beta] steps: - - uses: actions/checkout@v7 + - name: Checkout code + uses: actions/checkout@v7 - - name: Install Rust - uses: dtolnay/rust-toolchain@master - with: - toolchain: ${{ matrix.rust }} + - name: Install mise + uses: jdx/mise-action@v4 + + - name: Cache cargo build + uses: Swatinem/rust-cache@v2 - - name: Install Mercurial (Ubuntu only) - if: matrix.os == 'ubuntu-latest' + - name: Install Mercurial (for SCM backend tests) + if: runner.os == 'Linux' run: | sudo apt-get update sudo apt-get install -y mercurial hg --version - - name: Cache cargo registry - uses: actions/cache@v6 - with: - path: ~/.cargo/registry - key: ${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }} + - name: Configure git identity for tests + run: | + git config --global user.email "ci@github.actions" + git config --global user.name "GitHub Actions CI" - - name: Cache cargo index - uses: actions/cache@v6 - with: - path: ~/.cargo/git - key: ${{ runner.os }}-cargo-index-${{ hashFiles('**/Cargo.lock') }} + - name: Build + run: mise run build + + - name: Run tests + run: mise run test + + quality: + name: Format & Lint + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v7 + + - name: Install mise + uses: jdx/mise-action@v4 - name: Cache cargo build - uses: actions/cache@v6 - with: - path: target - key: ${{ runner.os }}-cargo-build-target-${{ hashFiles('**/Cargo.lock') }} + uses: Swatinem/rust-cache@v2 + + # On a mise cache hit the toolchain is lazily reinstalled by the + # runner's rustup with profile=minimal, which lacks these components. + - name: Ensure rustfmt and clippy components + run: rustup component add rustfmt clippy - name: Check formatting - run: cargo fmt -- --check || true - if: matrix.os == 'ubuntu-latest' && matrix.rust == 'stable' + run: mise run fmt-check - name: Run clippy - run: cargo clippy -- -D warnings - if: matrix.os == 'ubuntu-latest' && matrix.rust == 'stable' + run: mise run lint - - name: Build - run: cargo build --verbose + test-scm: + name: SCM Backend Tests + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v7 - - name: Run tests - run: cargo test --verbose -- --test-threads=1 + - name: Install mise + uses: jdx/mise-action@v4 + + - name: Cache cargo build + uses: Swatinem/rust-cache@v2 + + - name: Install Mercurial + run: | + sudo apt-get update + sudo apt-get install -y mercurial + hg --version + + - name: Configure git identity for tests + run: | + git config --global user.email "ci@github.actions" + git config --global user.name "GitHub Actions CI" + + - name: Run SCM backend tests (with Mercurial guard) + run: mise run test-scm + + e2e: + name: End-to-End Tests + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v7 + + - name: Install mise + uses: jdx/mise-action@v4 + + - name: Cache cargo build + uses: Swatinem/rust-cache@v2 + + - name: Configure git identity for tests + run: | + git config --global user.email "ci@github.actions" + git config --global user.name "GitHub Actions CI" - - name: Build release - run: cargo build --release --verbose + - name: Build and smoke-test release binary + run: mise run e2e coverage: name: Code Coverage runs-on: ubuntu-latest steps: - - uses: actions/checkout@v7 + - name: Checkout code + uses: actions/checkout@v7 + + - name: Install mise + uses: jdx/mise-action@v4 - - name: Install Rust - uses: dtolnay/rust-toolchain@stable + - name: Cache cargo build + uses: Swatinem/rust-cache@v2 + + - name: Install Mercurial (for SCM backend tests) + run: | + sudo apt-get update + sudo apt-get install -y mercurial - - name: Install tarpaulin - run: cargo install cargo-tarpaulin + - name: Configure git identity for tests + run: | + git config --global user.email "ci@github.actions" + git config --global user.name "GitHub Actions CI" - - name: Generate coverage - run: cargo tarpaulin --out xml --verbose + - name: Generate coverage report + run: mise run coverage - - name: Upload coverage to Codecov + - name: Upload to Codecov uses: codecov/codecov-action@v7 with: files: ./cobertura.xml fail_ci_if_error: false - security-audit: + audit: name: Security Audit runs-on: ubuntu-latest steps: - - uses: actions/checkout@v7 - - - name: Install Rust - uses: dtolnay/rust-toolchain@stable + - name: Checkout code + uses: actions/checkout@v7 - - name: Install cargo-audit - run: cargo install cargo-audit || true + - name: Install mise + uses: jdx/mise-action@v4 - name: Run security audit - run: cargo audit - - build-binary: - name: Build Release Binaries - runs-on: ${{ matrix.os }} - strategy: - matrix: - include: - - os: ubuntu-latest - target: x86_64-unknown-linux-gnu - artifact_name: claude-code-sync - asset_name: claude-code-sync-linux-x86_64 - - os: macos-latest - target: x86_64-apple-darwin - artifact_name: claude-code-sync - asset_name: claude-code-sync-macos-x86_64 - - os: macos-latest - target: aarch64-apple-darwin - artifact_name: claude-code-sync - asset_name: claude-code-sync-macos-aarch64 - - os: windows-latest - target: x86_64-pc-windows-msvc - artifact_name: claude-code-sync.exe - asset_name: claude-code-sync-windows-x86_64.exe - steps: - - uses: actions/checkout@v7 - - - name: Install Rust - uses: dtolnay/rust-toolchain@stable - with: - targets: ${{ matrix.target }} - - - name: Build release binary - run: cargo build --release --target ${{ matrix.target }} - - - name: Upload binary as artifact - uses: actions/upload-artifact@v7 - with: - name: ${{ matrix.asset_name }} - path: target/${{ matrix.target }}/release/${{ matrix.artifact_name }} + run: mise run audit diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 75961491..3d931384 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -2,7 +2,7 @@ name: Documentation on: push: - branches: [ main ] + branches: [main] workflow_dispatch: permissions: @@ -13,36 +13,17 @@ jobs: name: Build and Deploy Documentation runs-on: ubuntu-latest steps: - - uses: actions/checkout@v7 + - name: Checkout code + uses: actions/checkout@v7 - - name: Install Rust - uses: dtolnay/rust-toolchain@stable - - - name: Cache cargo registry - uses: actions/cache@v6 - with: - path: ~/.cargo/registry - key: ${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }} - - - name: Cache cargo index - uses: actions/cache@v6 - with: - path: ~/.cargo/git - key: ${{ runner.os }}-cargo-index-${{ hashFiles('**/Cargo.lock') }} + - name: Install mise + uses: jdx/mise-action@v4 - name: Cache cargo build - uses: actions/cache@v6 - with: - path: target - key: ${{ runner.os }}-cargo-docs-target-${{ hashFiles('**/Cargo.lock') }} + uses: Swatinem/rust-cache@v2 - name: Build documentation - run: cargo doc --no-deps --all-features - env: - RUSTDOCFLAGS: "--default-theme ayu" - - - name: Add redirect index - run: echo '' > target/doc/index.html + run: mise run doc - name: Deploy to GitHub Pages uses: peaceiris/actions-gh-pages@v4 diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml deleted file mode 100644 index 1159d29a..00000000 --- a/.github/workflows/integration-tests.yml +++ /dev/null @@ -1,105 +0,0 @@ -name: Integration Tests - -on: - push: - branches: [ main, develop ] - pull_request: - branches: [ main ] - workflow_dispatch: - -env: - CARGO_TERM_COLOR: always - RUST_BACKTRACE: 1 - -jobs: - integration-tests: - name: Run Integration Tests - runs-on: ubuntu-latest - steps: - - name: Checkout code - uses: actions/checkout@v7 - - - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@stable - - - name: Cache cargo registry - uses: actions/cache@v6 - with: - path: ~/.cargo/registry - key: ubuntu-cargo-registry-${{ hashFiles('**/Cargo.lock') }} - restore-keys: | - ubuntu-cargo-registry- - - - name: Cache cargo index - uses: actions/cache@v6 - with: - path: ~/.cargo/git - key: ubuntu-cargo-git-${{ hashFiles('**/Cargo.lock') }} - restore-keys: | - ubuntu-cargo-git- - - - name: Cache cargo build - uses: actions/cache@v6 - with: - path: target - key: ubuntu-cargo-build-${{ hashFiles('**/Cargo.lock') }} - restore-keys: | - ubuntu-cargo-build- - - - name: Setup git config for tests - run: | - git config --global user.email "ci@github.actions" - git config --global user.name "GitHub Actions CI" - - - name: Run integration tests - run: cargo test --test '*' --verbose -- --test-threads=1 - - - name: Run all tests with integration flag - run: cargo test --verbose --all-features -- --test-threads=1 - - end-to-end-tests: - name: End-to-End Tests - runs-on: ubuntu-latest - steps: - - name: Checkout code - uses: actions/checkout@v7 - - - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@stable - - - name: Cache cargo registry - uses: actions/cache@v6 - with: - path: ~/.cargo/registry - key: ubuntu-cargo-registry-${{ hashFiles('**/Cargo.lock') }} - restore-keys: | - ubuntu-cargo-registry- - - - name: Cache cargo index - uses: actions/cache@v6 - with: - path: ~/.cargo/git - key: ubuntu-cargo-git-${{ hashFiles('**/Cargo.lock') }} - restore-keys: | - ubuntu-cargo-git- - - - name: Cache cargo build - uses: actions/cache@v6 - with: - path: target - key: ubuntu-cargo-build-${{ hashFiles('**/Cargo.lock') }} - restore-keys: | - ubuntu-cargo-build- - - - name: Setup git config - run: | - git config --global user.email "ci@github.actions" - git config --global user.name "GitHub Actions CI" - - - name: Build binary - run: cargo build --release --verbose - - - name: Test binary execution - run: | - ./target/release/claude-code-sync --version - ./target/release/claude-code-sync --help diff --git a/.github/workflows/release-new.yml b/.github/workflows/release-new.yml deleted file mode 100644 index 833fb4c3..00000000 --- a/.github/workflows/release-new.yml +++ /dev/null @@ -1,283 +0,0 @@ -name: Release - -on: - push: - tags: - - 'v*.*.*' - workflow_dispatch: - inputs: - tag: - description: 'Tag to release (e.g., v0.1.1)' - required: true - type: string - -env: - CARGO_TERM_COLOR: always - -jobs: - create-release: - name: Create GitHub Release - runs-on: ubuntu-latest - outputs: - upload_url: ${{ steps.create_release.outputs.upload_url }} - release_id: ${{ steps.create_release.outputs.id }} - steps: - - name: Checkout code - uses: actions/checkout@v7 - with: - fetch-depth: 0 - - - name: Get version from tag - id: get_version - run: | - if [ "${{ github.event_name }}" == "workflow_dispatch" ]; then - echo "VERSION=${{ github.event.inputs.tag }}" >> $GITHUB_OUTPUT - else - echo "VERSION=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT - fi - - - name: Verify tag matches Cargo.toml version - run: | - TAG="${{ steps.get_version.outputs.VERSION }}" - CARGO_VERSION=$(grep -m1 '^version = ' Cargo.toml | cut -d '"' -f 2) - if [ "${TAG#v}" != "$CARGO_VERSION" ]; then - echo "::error::Tag $TAG does not match Cargo.toml version $CARGO_VERSION. Bump the version in Cargo.toml (and commit the updated Cargo.lock) before tagging." - exit 1 - fi - - - name: Generate changelog - id: changelog - run: | - # Get the previous tag - PREV_TAG=$(git describe --abbrev=0 --tags $(git rev-list --tags --skip=1 --max-count=1) 2>/dev/null || echo "") - - if [ -z "$PREV_TAG" ]; then - CHANGELOG=$(git log --pretty=format:"- %s" --no-merges) - else - CHANGELOG=$(git log $PREV_TAG..HEAD --pretty=format:"- %s" --no-merges) - fi - - echo "CHANGELOG<> $GITHUB_OUTPUT - echo "$CHANGELOG" >> $GITHUB_OUTPUT - echo "EOF" >> $GITHUB_OUTPUT - - - name: Create Release - id: create_release - uses: softprops/action-gh-release@v3 - with: - tag_name: ${{ steps.get_version.outputs.VERSION }} - name: Release ${{ steps.get_version.outputs.VERSION }} - body: | - ## Changes - ${{ steps.changelog.outputs.CHANGELOG }} - - ## Installation - - Download the appropriate binary for your platform below. - - ### Linux - ```bash - wget https://github.com/${{ github.repository }}/releases/download/${{ steps.get_version.outputs.VERSION }}/claude-code-sync-linux-x86_64.tar.gz - tar -xzf claude-code-sync-linux-x86_64.tar.gz - sudo mv claude-code-sync /usr/local/bin/ - ``` - - ### macOS - ```bash - # For Intel Macs - wget https://github.com/${{ github.repository }}/releases/download/${{ steps.get_version.outputs.VERSION }}/claude-code-sync-macos-x86_64.tar.gz - - # For Apple Silicon Macs - wget https://github.com/${{ github.repository }}/releases/download/${{ steps.get_version.outputs.VERSION }}/claude-code-sync-macos-aarch64.tar.gz - - tar -xzf claude-code-sync-macos-*.tar.gz - sudo mv claude-code-sync /usr/local/bin/ - ``` - - ### Windows - Download the `.zip` file and extract it to a directory in your PATH. - draft: false - prerelease: ${{ contains(steps.get_version.outputs.VERSION, 'alpha') || contains(steps.get_version.outputs.VERSION, 'beta') || contains(steps.get_version.outputs.VERSION, 'rc') }} - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - build-release: - name: Build Release Binaries - needs: create-release - runs-on: ${{ matrix.os }} - strategy: - matrix: - include: - - os: ubuntu-latest - target: x86_64-unknown-linux-gnu - artifact_name: claude-code-sync - asset_name: claude-code-sync-linux-x86_64 - - os: ubuntu-latest - target: x86_64-unknown-linux-musl - artifact_name: claude-code-sync - asset_name: claude-code-sync-linux-x86_64-musl - - os: macos-latest - target: x86_64-apple-darwin - artifact_name: claude-code-sync - asset_name: claude-code-sync-macos-x86_64 - - os: macos-latest - target: aarch64-apple-darwin - artifact_name: claude-code-sync - asset_name: claude-code-sync-macos-aarch64 - - os: windows-latest - target: x86_64-pc-windows-msvc - artifact_name: claude-code-sync.exe - asset_name: claude-code-sync-windows-x86_64.exe - - steps: - - name: Checkout code - uses: actions/checkout@v7 - - - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@stable - with: - targets: ${{ matrix.target }} - - - name: Install musl tools (Linux musl only) - if: matrix.target == 'x86_64-unknown-linux-musl' - run: | - sudo apt-get update - sudo apt-get install -y musl-tools - - - name: Cache cargo registry - uses: actions/cache@v6 - with: - path: ~/.cargo/registry - key: ${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }} - restore-keys: | - ${{ runner.os }}-cargo-registry- - - - name: Cache cargo index - uses: actions/cache@v6 - with: - path: ~/.cargo/git - key: ${{ runner.os }}-cargo-git-${{ hashFiles('**/Cargo.lock') }} - restore-keys: | - ${{ runner.os }}-cargo-git- - - - name: Build release binary - run: cargo build --release --target ${{ matrix.target }} --verbose - - - name: Strip binary (Linux and macOS) - if: matrix.os != 'windows-latest' - run: strip target/${{ matrix.target }}/release/${{ matrix.artifact_name }} - - - name: Create tarball (Linux and macOS) - if: matrix.os != 'windows-latest' - run: | - cd target/${{ matrix.target }}/release - tar czf ${{ matrix.asset_name }}.tar.gz ${{ matrix.artifact_name }} - cd - - - - name: Create zip (Windows) - if: matrix.os == 'windows-latest' - run: | - cd target/${{ matrix.target }}/release - 7z a ${{ matrix.asset_name }}.zip ${{ matrix.artifact_name }} - cd - - - - name: Generate checksums (Unix) - if: matrix.os != 'windows-latest' - run: | - cd target/${{ matrix.target }}/release - shasum -a 256 ${{ matrix.asset_name }}.tar.gz > ${{ matrix.asset_name }}.tar.gz.sha256 - cd - - - - name: Generate checksums (Windows) - if: matrix.os == 'windows-latest' - run: | - cd target/${{ matrix.target }}/release - $hash = Get-FileHash ${{ matrix.asset_name }}.zip -Algorithm SHA256 - "$($hash.Hash.ToLower()) ${{ matrix.asset_name }}.zip" | Out-File -FilePath ${{ matrix.asset_name }}.zip.sha256 -Encoding ASCII - cd - - shell: pwsh - - - name: Upload Release Asset (tarball) - if: matrix.os != 'windows-latest' - uses: softprops/action-gh-release@v3 - with: - tag_name: ${{ github.ref_name }} - files: | - target/${{ matrix.target }}/release/${{ matrix.asset_name }}.tar.gz - target/${{ matrix.target }}/release/${{ matrix.asset_name }}.tar.gz.sha256 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - - name: Upload Release Asset (zip) - if: matrix.os == 'windows-latest' - uses: softprops/action-gh-release@v3 - with: - tag_name: ${{ github.ref_name }} - files: | - target/${{ matrix.target }}/release/${{ matrix.asset_name }}.zip - target/${{ matrix.target }}/release/${{ matrix.asset_name }}.zip.sha256 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - publish-crate: - name: Publish to crates.io - needs: build-release - runs-on: ubuntu-latest - steps: - - name: Checkout code - uses: actions/checkout@v7 - - - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@stable - - - name: Verify package - run: cargo package --verbose - - - name: Publish to crates.io - if: ${{ !contains(github.ref_name, 'alpha') && !contains(github.ref_name, 'beta') && !contains(github.ref_name, 'rc') }} - env: - CARGO_REGISTRY_TOKEN: ${{ secrets.CRATES_IO_TOKEN }} - run: cargo publish - - docker-release: - name: Build and Push Docker Image - needs: create-release - runs-on: ubuntu-latest - permissions: - contents: read - packages: write - steps: - - name: Checkout code - uses: actions/checkout@v7 - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v4 - - - name: Login to GitHub Container Registry - if: github.event_name != 'pull_request' - uses: docker/login-action@v4 - with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - - name: Extract metadata - id: meta - uses: docker/metadata-action@v6 - with: - images: ghcr.io/${{ github.repository }} - tags: | - type=semver,pattern={{version}} - type=semver,pattern={{major}}.{{minor}} - type=semver,pattern={{major}} - type=raw,value=latest - - - name: Build and push - uses: docker/build-push-action@v7 - with: - context: . - push: ${{ github.event_name != 'pull_request' }} - tags: ${{ steps.meta.outputs.tags }} - labels: ${{ steps.meta.outputs.labels }} - cache-from: type=gha - cache-to: type=gha,mode=max diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..4507b989 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,178 @@ +name: Release + +on: + push: + branches: [main] + workflow_dispatch: + +permissions: + contents: write + pull-requests: write + +jobs: + release-please: + name: Release Please + runs-on: ubuntu-latest + outputs: + release_created: ${{ steps.rp.outputs.release_created }} + tag_name: ${{ steps.rp.outputs.tag_name }} + steps: + - name: Run release-please + id: rp + uses: googleapis/release-please-action@v4 + with: + config-file: release-please-config.json + manifest-file: .release-please-manifest.json + + build-binaries: + name: Build Release Binaries + needs: release-please + if: needs.release-please.outputs.release_created == 'true' + runs-on: ${{ matrix.os }} + permissions: + contents: write + strategy: + fail-fast: false + matrix: + include: + - os: ubuntu-latest + target: x86_64-unknown-linux-gnu + artifact_name: claude-code-sync + asset_name: claude-code-sync-linux-x86_64 + - os: ubuntu-latest + target: x86_64-unknown-linux-musl + artifact_name: claude-code-sync + asset_name: claude-code-sync-linux-x86_64-musl + - os: macos-latest + target: x86_64-apple-darwin + artifact_name: claude-code-sync + asset_name: claude-code-sync-macos-x86_64 + - os: macos-latest + target: aarch64-apple-darwin + artifact_name: claude-code-sync + asset_name: claude-code-sync-macos-aarch64 + - os: windows-latest + target: x86_64-pc-windows-msvc + artifact_name: claude-code-sync.exe + asset_name: claude-code-sync-windows-x86_64.exe + steps: + - name: Checkout release tag + uses: actions/checkout@v7 + with: + ref: ${{ needs.release-please.outputs.tag_name }} + + - name: Install mise + uses: jdx/mise-action@v4 + + - name: Cache cargo build + uses: Swatinem/rust-cache@v2 + with: + key: ${{ matrix.target }} + + - name: Add compilation target + run: rustup target add ${{ matrix.target }} + + - name: Install musl tools (Linux musl only) + if: matrix.target == 'x86_64-unknown-linux-musl' + run: | + sudo apt-get update + sudo apt-get install -y musl-tools + + - name: Build release binary + run: mise run build-release -- --target ${{ matrix.target }} + + - name: Package binary with checksum + shell: bash + run: | + cd target/${{ matrix.target }}/release + if [[ "${{ runner.os }}" == "Windows" ]]; then + 7z a ${{ matrix.asset_name }}.zip ${{ matrix.artifact_name }} + sha256sum ${{ matrix.asset_name }}.zip > ${{ matrix.asset_name }}.zip.sha256 + else + strip ${{ matrix.artifact_name }} + tar czf ${{ matrix.asset_name }}.tar.gz ${{ matrix.artifact_name }} + shasum -a 256 ${{ matrix.asset_name }}.tar.gz > ${{ matrix.asset_name }}.tar.gz.sha256 + fi + + - name: Upload release assets + shell: bash + env: + GH_TOKEN: ${{ github.token }} + run: | + gh release upload ${{ needs.release-please.outputs.tag_name }} \ + target/${{ matrix.target }}/release/${{ matrix.asset_name }}.* \ + --clobber + + publish-crate: + name: Publish to crates.io + needs: [release-please, build-binaries] + if: needs.release-please.outputs.release_created == 'true' + runs-on: ubuntu-latest + steps: + - name: Checkout release tag + uses: actions/checkout@v7 + with: + ref: ${{ needs.release-please.outputs.tag_name }} + + - name: Install mise + uses: jdx/mise-action@v4 + + - name: Cache cargo build + uses: Swatinem/rust-cache@v2 + + - name: Verify package + run: cargo package --verbose + + # No prerelease gating: release-please's default versioning never emits + # alpha/beta/rc tags, so every release it creates is publishable. + - name: Publish to crates.io + run: cargo publish + env: + CARGO_REGISTRY_TOKEN: ${{ secrets.CRATES_IO_TOKEN }} + + docker: + name: Build and Push Docker Image + needs: release-please + if: needs.release-please.outputs.release_created == 'true' + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + steps: + - name: Checkout release tag + uses: actions/checkout@v7 + with: + ref: ${{ needs.release-please.outputs.tag_name }} + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v4 + + - name: Login to GitHub Container Registry + uses: docker/login-action@v4 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + # The triggering event is a branch push, not a tag push, so semver tags + # must be derived explicitly from the release-please tag. + - name: Extract metadata + id: meta + uses: docker/metadata-action@v6 + with: + images: ghcr.io/${{ github.repository }} + tags: | + type=semver,pattern={{version}},value=${{ needs.release-please.outputs.tag_name }} + type=semver,pattern={{major}}.{{minor}},value=${{ needs.release-please.outputs.tag_name }} + type=semver,pattern={{major}},value=${{ needs.release-please.outputs.tag_name }} + type=raw,value=latest + + - name: Build and push + uses: docker/build-push-action@v7 + with: + context: . + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml deleted file mode 100644 index 170aa6d4..00000000 --- a/.github/workflows/unit-tests.yml +++ /dev/null @@ -1,127 +0,0 @@ -name: Unit Tests - -on: - push: - branches: [ main, develop ] - pull_request: - branches: [ main ] - workflow_dispatch: - -env: - CARGO_TERM_COLOR: always - RUST_BACKTRACE: 1 - -jobs: - unit-tests: - name: Run Unit Tests - runs-on: ubuntu-latest - steps: - - name: Checkout code - uses: actions/checkout@v7 - - - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@stable - - - name: Install Mercurial - run: | - sudo apt-get update - sudo apt-get install -y mercurial - hg --version - - - name: Cache cargo registry - uses: actions/cache@v6 - with: - path: ~/.cargo/registry - key: ubuntu-cargo-registry-${{ hashFiles('**/Cargo.lock') }} - restore-keys: | - ubuntu-cargo-registry- - - - name: Cache cargo index - uses: actions/cache@v6 - with: - path: ~/.cargo/git - key: ubuntu-cargo-git-${{ hashFiles('**/Cargo.lock') }} - restore-keys: | - ubuntu-cargo-git- - - - name: Cache cargo build - uses: actions/cache@v6 - with: - path: target - key: ubuntu-cargo-build-${{ hashFiles('**/Cargo.lock') }} - restore-keys: | - ubuntu-cargo-build- - - - name: Run unit tests - run: cargo test --lib --verbose -- --test-threads=1 - - - name: Run doc tests - run: cargo test --doc --verbose - - - name: Run SCM backend tests and verify Mercurial tests executed - run: | - cargo test --test scm_backend_tests --verbose -- --test-threads=1 2>&1 | tee test_output.txt - # Verify mercurial tests ran (not skipped) - if grep -q "case_2_mercurial" test_output.txt; then - echo "✓ Mercurial tests executed successfully" - else - echo "ERROR: Mercurial tests did not run!" - echo "Test output:" - cat test_output.txt - exit 1 - fi - - code-quality: - name: Code Quality Checks - runs-on: ubuntu-latest - steps: - - name: Checkout code - uses: actions/checkout@v7 - - - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@stable - with: - components: rustfmt, clippy - - - name: Cache cargo registry - uses: actions/cache@v6 - with: - path: ~/.cargo/registry - key: ubuntu-cargo-registry-${{ hashFiles('**/Cargo.lock') }} - restore-keys: | - ubuntu-cargo-registry- - - - name: Cache cargo index - uses: actions/cache@v6 - with: - path: ~/.cargo/git - key: ubuntu-cargo-git-${{ hashFiles('**/Cargo.lock') }} - restore-keys: | - ubuntu-cargo-git- - - - name: Check code formatting - run: cargo fmt -- --check - continue-on-error: true - - - name: Run clippy - run: cargo clippy --all-targets --all-features -- -D warnings - continue-on-error: true - - coverage: - name: Code Coverage - runs-on: ubuntu-latest - steps: - - name: Checkout code - uses: actions/checkout@v7 - - - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@stable - - - name: Install tarpaulin - run: cargo install cargo-tarpaulin - - - name: Generate coverage report - run: cargo tarpaulin --lib --out xml --verbose --timeout 300 - - - name: Display coverage summary - run: cargo tarpaulin --lib --out stdout --verbose --timeout 300 diff --git a/.release-please-manifest.json b/.release-please-manifest.json new file mode 100644 index 00000000..04779995 --- /dev/null +++ b/.release-please-manifest.json @@ -0,0 +1,3 @@ +{ + ".": "0.3.2" +} diff --git a/README.md b/README.md index 5314a1d9..05f204a9 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,7 @@ # claude-code-sync -[![Unit Tests](https://github.com/perfectra1n/claude-code-sync/actions/workflows/unit-tests.yml/badge.svg)](https://github.com/perfectra1n/claude-code-sync/actions/workflows/unit-tests.yml) -[![Integration Tests](https://github.com/perfectra1n/claude-code-sync/actions/workflows/integration-tests.yml/badge.svg)](https://github.com/perfectra1n/claude-code-sync/actions/workflows/integration-tests.yml) -[![Build](https://github.com/perfectra1n/claude-code-sync/actions/workflows/build.yml/badge.svg)](https://github.com/perfectra1n/claude-code-sync/actions/workflows/build.yml) +[![CI](https://github.com/perfectra1n/claude-code-sync/actions/workflows/ci.yml/badge.svg)](https://github.com/perfectra1n/claude-code-sync/actions/workflows/ci.yml) +[![Release](https://github.com/perfectra1n/claude-code-sync/actions/workflows/release.yml/badge.svg)](https://github.com/perfectra1n/claude-code-sync/actions/workflows/release.yml) [![Documentation](https://github.com/perfectra1n/claude-code-sync/actions/workflows/docs.yml/badge.svg)](https://github.com/perfectra1n/claude-code-sync/actions/workflows/docs.yml) A Rust CLI tool for syncing Claude Code conversation history across machines using git repositories. diff --git a/mise.toml b/mise.toml new file mode 100644 index 00000000..eefd2646 --- /dev/null +++ b/mise.toml @@ -0,0 +1,92 @@ +# Dev toolchain + tasks (https://mise.jdx.dev). CI runs these same tasks via jdx/mise-action. +[tools] +# Must stay in lockstep with the rust:1.96-slim-trixie builder image in ./Dockerfile. +rust = { version = "1.96", profile = "default" } # default profile includes rustfmt + clippy +# cargo-binstall lets the cargo: backend below install prebuilt binaries instead of compiling. +cargo-binstall = "1.20" +"cargo:cargo-tarpaulin" = "0.37" +"cargo:cargo-audit" = "0.22" + +[env] +CARGO_TERM_COLOR = "always" +RUST_BACKTRACE = "1" + +[tasks.fmt] +description = "Format all code" +run = "cargo fmt --all" + +[tasks.fmt-check] +description = "Check formatting without modifying" +run = "cargo fmt --all -- --check" + +[tasks.lint] +description = "Clippy with warnings as errors" +run = "cargo clippy --all-targets --all-features -- -D warnings" + +[tasks.build] +description = "Debug build" +run = "cargo build --verbose" + +[tasks.build-release] +description = "Release build; pass a target with: mise run build-release -- --target " +run = "cargo build --release --verbose" + +[tasks.test] +description = "Full test suite; --test-threads=1 is REQUIRED (tests share CLAUDE_CODE_SYNC_CONFIG_DIR)" +run = "cargo test --verbose -- --test-threads=1" + +[tasks.test-unit] +description = "Library unit tests" +run = "cargo test --lib --verbose -- --test-threads=1" + +[tasks.test-doc] +description = "Documentation tests" +run = "cargo test --doc --verbose" + +[tasks.test-integration] +description = "Integration test binaries (needs git identity configured; not Windows-portable)" +run = [ + "cargo test --test '*' --verbose -- --test-threads=1", + "cargo test --all-features --verbose -- --test-threads=1", +] + +[tasks.test-scm] +description = "SCM backend tests; asserts the Mercurial cases actually ran (requires hg)" +run = """ +#!/usr/bin/env bash +set -euo pipefail +out=$(mktemp) +cargo test --test scm_backend_tests --verbose -- --test-threads=1 2>&1 | tee "$out" +if ! grep -q "case_2_mercurial" "$out"; then + echo "ERROR: Mercurial tests did not run! Is hg installed?" >&2 + exit 1 +fi +""" + +[tasks.audit] +description = "Security audit of dependencies" +run = "cargo audit" + +[tasks.coverage] +description = "Coverage report (cobertura.xml) via tarpaulin; single-threaded like every test run here" +run = "cargo tarpaulin --out xml --verbose --timeout 300 -- --test-threads=1" + +[tasks.doc] +description = "Build rustdoc with redirect index (published by docs.yml)" +env = { RUSTDOCFLAGS = "--default-theme ayu" } +run = [ + "cargo doc --no-deps --all-features", + """echo '' > target/doc/index.html""", +] + +[tasks.e2e] +description = "Release build smoke test" +run = [ + "cargo build --release --verbose", + "./target/release/claude-code-sync --version", + "./target/release/claude-code-sync --help", +] + +[tasks.ci] +description = "Everything the CI quality+test gates run" +depends = ["fmt-check", "lint", "build", "test"] diff --git a/release-please-config.json b/release-please-config.json new file mode 100644 index 00000000..278aad02 --- /dev/null +++ b/release-please-config.json @@ -0,0 +1,9 @@ +{ + "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json", + "release-type": "rust", + "include-component-in-tag": false, + "include-v-in-tag": true, + "packages": { + ".": {} + } +} diff --git a/renovate.json b/renovate.json index b2d55bab..0456ceae 100644 --- a/renovate.json +++ b/renovate.json @@ -28,6 +28,12 @@ "automerge": true, "automergeType": "pr", "minimumReleaseAge": "3 days" + }, + { + "description": "Bump the mise.toml rust pin and the Dockerfile rust image together so the toolchains never drift", + "matchDepNames": ["rust"], + "matchManagers": ["mise", "dockerfile"], + "groupName": "rust toolchain" } ] } diff --git a/src/artifacts/engine.rs b/src/artifacts/engine.rs index 32dbb926..9913e9b0 100644 --- a/src/artifacts/engine.rs +++ b/src/artifacts/engine.rs @@ -13,8 +13,7 @@ use crate::scm::Backend; use super::denylist::{is_denied, is_unsafe_rel_path}; use super::registry::{ - CategoryDescriptor, CategoryId, DestRoot, MergeStrategy, SourceSpec, ARTIFACTS_SUBDIR, - REGISTRY, + CategoryDescriptor, CategoryId, DestRoot, MergeStrategy, SourceSpec, ARTIFACTS_SUBDIR, REGISTRY, }; use super::union_jsonl::merge_history_lines; @@ -28,12 +27,18 @@ pub fn is_category_enabled(desc: &CategoryDescriptor, filter: &FilterConfig) -> } /// All registry rows active under this configuration. -fn active_categories(filter: &FilterConfig) -> impl Iterator + '_ { +fn active_categories( + filter: &FilterConfig, +) -> impl Iterator + '_ { REGISTRY.iter().filter(|d| is_category_enabled(d, filter)) } /// The sync-repo root directory for one category. -fn category_repo_root(desc: &CategoryDescriptor, repo_root: &Path, filter: &FilterConfig) -> PathBuf { +fn category_repo_root( + desc: &CategoryDescriptor, + repo_root: &Path, + filter: &FilterConfig, +) -> PathBuf { match desc.dest { DestRoot::Artifacts => repo_root.join(ARTIFACTS_SUBDIR).join(desc.repo_subdir), DestRoot::SessionTree => repo_root.join(&filter.sync_subdirectory), @@ -156,10 +161,7 @@ fn collect( continue; } if entry.metadata().map(|m| m.len()).unwrap_or(0) > max_file_size { - log::warn!( - "Skipping {} (exceeds max_file_size_bytes)", - abs.display() - ); + log::warn!("Skipping {} (exceeds max_file_size_bytes)", abs.display()); *skipped += 1; continue; } @@ -393,10 +395,8 @@ fn local_destination( let mut parts = rel.components(); let name = parts.next()?.as_os_str().to_str()?.to_string(); let projects_dir = claude_dir.join(dir); - let local_project = crate::sync::discovery::find_local_project_by_name( - &projects_dir, - &name, - )?; + let local_project = + crate::sync::discovery::find_local_project_by_name(&projects_dir, &name)?; return Some(local_project.join(parts.as_path())); } Some(claude_dir.join(dir).join(rel)) @@ -406,11 +406,7 @@ fn local_destination( /// Classify what a pull would write, without writing. Remote (repo) bytes win /// for raw categories; union targets are compared against local ∪ remote. -pub fn plan_pull( - claude_dir: &Path, - repo_root: &Path, - filter: &FilterConfig, -) -> Result { +pub fn plan_pull(claude_dir: &Path, repo_root: &Path, filter: &FilterConfig) -> Result { let mut plan = PullPlan::default(); for desc in active_categories(filter) { @@ -597,12 +593,20 @@ pub fn ensure_ignore_files(repo_root: &Path, backend: Backend) -> Result { // Replace the existing block in place. let end = end + IGNORE_BLOCK_END.len(); // Include the trailing newline of the old block if present. - let end = if existing[end..].starts_with('\n') { end + 1 } else { end }; + let end = if existing[end..].starts_with('\n') { + end + 1 + } else { + end + }; format!("{}{}{}", &existing[..start], block, &existing[end..]) } else if existing.is_empty() { block } else { - let sep = if existing.ends_with('\n') { "\n" } else { "\n\n" }; + let sep = if existing.ends_with('\n') { + "\n" + } else { + "\n\n" + }; format!("{existing}{sep}{block}") }; diff --git a/src/artifacts/registry.rs b/src/artifacts/registry.rs index 3cddabb4..b930e025 100644 --- a/src/artifacts/registry.rs +++ b/src/artifacts/registry.rs @@ -310,7 +310,10 @@ mod tests { #[test] fn test_plugins_category_is_exact_file_allowlist() { - let plugins = REGISTRY.iter().find(|d| d.id == CategoryId::Plugins).unwrap(); + let plugins = REGISTRY + .iter() + .find(|d| d.id == CategoryId::Plugins) + .unwrap(); match plugins.source { SourceSpec::Files(files) => { assert_eq!( @@ -334,14 +337,20 @@ mod tests { assert_eq!(ph.merge, MergeStrategy::UnionJsonl); assert_eq!(ph.source, SourceSpec::Files(&["history.jsonl"])); // Everything else raw-overwrites - for d in REGISTRY.iter().filter(|d| d.id != CategoryId::PromptHistory) { + for d in REGISTRY + .iter() + .filter(|d| d.id != CategoryId::PromptHistory) + { assert_eq!(d.merge, MergeStrategy::RawOverwrite, "{}", d.name); } } #[test] fn test_settings_never_includes_local_overrides() { - let settings = REGISTRY.iter().find(|d| d.id == CategoryId::Settings).unwrap(); + let settings = REGISTRY + .iter() + .find(|d| d.id == CategoryId::Settings) + .unwrap(); match settings.source { SourceSpec::Files(files) => { assert!(files.contains(&"settings.json")); diff --git a/src/artifacts/union_jsonl.rs b/src/artifacts/union_jsonl.rs index 85ebf812..5e57deca 100644 --- a/src/artifacts/union_jsonl.rs +++ b/src/artifacts/union_jsonl.rs @@ -16,7 +16,9 @@ fn line_key(line: &str) -> String { if map.contains_key("timestamp") || map.contains_key("display") { return format!( "{}\u{1f}{}\u{1f}{}", - map.get("timestamp").map(Value::to_string).unwrap_or_default(), + map.get("timestamp") + .map(Value::to_string) + .unwrap_or_default(), map.get("project").map(Value::to_string).unwrap_or_default(), map.get("display").map(Value::to_string).unwrap_or_default(), ); @@ -99,8 +101,16 @@ mod tests { #[test] fn test_union_dedups_shared_lines() { - let a = format!("{}\n{}\n", line(1000, "/p1", "first"), line(2000, "/p1", "second")); - let b = format!("{}\n{}\n", line(2000, "/p1", "second"), line(3000, "/p2", "third")); + let a = format!( + "{}\n{}\n", + line(1000, "/p1", "first"), + line(2000, "/p1", "second") + ); + let b = format!( + "{}\n{}\n", + line(2000, "/p1", "second"), + line(3000, "/p2", "third") + ); let (merged, added) = merge_history_lines(&a, &b); assert_eq!(added, 1, "only the genuinely new line counts"); @@ -111,14 +121,22 @@ mod tests { #[test] fn test_union_orders_chronologically() { // Machine A has 1000 and 3000; machine B has 2000. - let a = format!("{}\n{}\n", line(1000, "/p", "one"), line(3000, "/p", "three")); + let a = format!( + "{}\n{}\n", + line(1000, "/p", "one"), + line(3000, "/p", "three") + ); let b = format!("{}\n", line(2000, "/p", "two")); let (merged, added) = merge_history_lines(&a, &b); assert_eq!(added, 1); let order: Vec<_> = merged .lines() - .map(|l| serde_json::from_str::(l).unwrap()["timestamp"].as_u64().unwrap()) + .map(|l| { + serde_json::from_str::(l).unwrap()["timestamp"] + .as_u64() + .unwrap() + }) .collect(); assert_eq!(order, vec![1000, 2000, 3000]); } @@ -179,6 +197,9 @@ mod tests { let a = line(1000, "/p", "one"); // no trailing newline on input let (merged, _) = merge_history_lines(&a, ""); - assert!(merged.ends_with('\n'), "JSONL output must be newline-terminated"); + assert!( + merged.ends_with('\n'), + "JSONL output must be newline-terminated" + ); } } diff --git a/src/conflict.rs b/src/conflict.rs index 5229989d..059b1d02 100644 --- a/src/conflict.rs +++ b/src/conflict.rs @@ -262,9 +262,7 @@ impl Conflict { let parent = self.remote_file.parent().unwrap_or_else(|| Path::new(".")); - let new_name = format!( - "{remote_file_name}-{conflict_suffix}.{remote_file_ext}" - ); + let new_name = format!("{remote_file_name}-{conflict_suffix}.{remote_file_ext}"); let renamed_path = parent.join(new_name); self.resolution = ConflictResolution::KeepBoth { diff --git a/src/filter.rs b/src/filter.rs index 8f33d95d..0507a008 100644 --- a/src/filter.rs +++ b/src/filter.rs @@ -205,7 +205,10 @@ impl FilterConfig { match self.scm_backend.to_lowercase().as_str() { "git" => Ok(Backend::Git), "mercurial" | "hg" => Ok(Backend::Mercurial), - other => bail!("Unknown SCM backend: '{}'. Use 'git' or 'mercurial'.", other), + other => bail!( + "Unknown SCM backend: '{}'. Use 'git' or 'mercurial'.", + other + ), } } @@ -316,10 +319,7 @@ pub fn update_config( if let Some(exclude_att) = exclude_attachments { config.exclude_attachments = exclude_att; - println!( - "{}", - format!("Exclude attachments: {exclude_att}").green() - ); + println!("{}", format!("Exclude attachments: {exclude_att}").green()); } if let Some(lfs) = enable_lfs { @@ -345,7 +345,10 @@ pub fn update_config( if let Some(backend) = scm_backend { let backend_lower = backend.to_lowercase(); if backend_lower != "git" && backend_lower != "mercurial" && backend_lower != "hg" { - bail!("Invalid SCM backend: '{}'. Use 'git' or 'mercurial'.", backend); + bail!( + "Invalid SCM backend: '{}'. Use 'git' or 'mercurial'.", + backend + ); } config.scm_backend = backend_lower; println!( @@ -417,9 +420,7 @@ fn apply_artifact_toggles(config: &mut FilterConfig, names: &str, value: bool) - continue; } if name == "attachments" { - bail!( - "Attachments are controlled by --exclude-attachments, not an artifact toggle" - ); + bail!("Attachments are controlled by --exclude-attachments, not an artifact toggle"); } let Some(desc) = find_by_name(name) else { let valid: Vec<&str> = crate::artifacts::registry::toggleable() @@ -493,11 +494,7 @@ pub fn show_config() -> Result<()> { "Disabled".yellow() } ); - println!( - " {}: {}", - "SCM backend".cyan(), - config.scm_backend.green() - ); + println!(" {}: {}", "SCM backend".cyan(), config.scm_backend.green()); println!( " {}: {}", "Sync subdirectory".cyan(), @@ -520,7 +517,12 @@ pub fn show_config() -> Result<()> { } else { "disabled".yellow() }; - println!(" {}: {} — {}", desc.name, state, desc.description.dimmed()); + println!( + " {}: {} — {}", + desc.name, + state, + desc.description.dimmed() + ); } Ok(()) diff --git a/src/handlers/cleanup.rs b/src/handlers/cleanup.rs index fc13b68b..5941b64e 100644 --- a/src/handlers/cleanup.rs +++ b/src/handlers/cleanup.rs @@ -7,9 +7,9 @@ use colored::Colorize; use inquire::Confirm; use std::fs; -use crate::undo; use crate::history::OperationType; use crate::interactive_conflict; +use crate::undo; /// Handle cleanup snapshots command pub fn handle_cleanup_snapshots( @@ -25,7 +25,10 @@ pub fn handle_cleanup_snapshots( } else { println!("{}", "Cleaning up old snapshots...".cyan().bold()); } - println!(" Keeping: last {} snapshots per type OR last {} days", max_count, max_age_days); + println!( + " Keeping: last {} snapshots per type OR last {} days", + max_count, max_age_days + ); println!(); } @@ -74,11 +77,7 @@ pub fn handle_cleanup_snapshots( } } else { if deleted_count > 0 { - println!( - "{} Deleted {} old snapshots", - "✓".green(), - deleted_count - ); + println!("{} Deleted {} old snapshots", "✓".green(), deleted_count); } else { println!("{}", "No old snapshots to delete".dimmed()); } @@ -89,7 +88,11 @@ pub fn handle_cleanup_snapshots( } /// Show detailed information about snapshots before cleanup -fn show_snapshot_details(max_count: usize, max_age_days: i64, verbosity: crate::VerbosityLevel) -> Result<()> { +fn show_snapshot_details( + max_count: usize, + max_age_days: i64, + verbosity: crate::VerbosityLevel, +) -> Result<()> { let snapshots_dir = undo::Snapshot::snapshots_dir()?; if !snapshots_dir.exists() { @@ -98,8 +101,10 @@ fn show_snapshot_details(max_count: usize, max_age_days: i64, verbosity: crate:: } // Collect all snapshots with metadata - let mut pull_snapshots: Vec<(std::path::PathBuf, chrono::DateTime, u64)> = Vec::new(); - let mut push_snapshots: Vec<(std::path::PathBuf, chrono::DateTime, u64)> = Vec::new(); + let mut pull_snapshots: Vec<(std::path::PathBuf, chrono::DateTime, u64)> = + Vec::new(); + let mut push_snapshots: Vec<(std::path::PathBuf, chrono::DateTime, u64)> = + Vec::new(); for entry in fs::read_dir(&snapshots_dir)? { let entry = entry?; @@ -116,8 +121,12 @@ fn show_snapshot_details(max_count: usize, max_age_days: i64, verbosity: crate:: if let Ok(content) = fs::read_to_string(&path) { if let Ok(snapshot) = serde_json::from_str::(&content) { match snapshot.operation_type { - OperationType::Pull => pull_snapshots.push((path, snapshot.timestamp, file_size)), - OperationType::Push => push_snapshots.push((path, snapshot.timestamp, file_size)), + OperationType::Pull => { + pull_snapshots.push((path, snapshot.timestamp, file_size)) + } + OperationType::Push => { + push_snapshots.push((path, snapshot.timestamp, file_size)) + } } } } @@ -135,7 +144,11 @@ fn show_snapshot_details(max_count: usize, max_age_days: i64, verbosity: crate:: println!("{}", "=".repeat(80).cyan()); // Show pull snapshots - println!("\n{} ({} total)", "Pull Snapshots:".bold().green(), pull_snapshots.len()); + println!( + "\n{} ({} total)", + "Pull Snapshots:".bold().green(), + pull_snapshots.len() + ); let mut pull_keep_count = 0; let mut pull_delete_count = 0; let mut pull_total_size = 0u64; @@ -173,7 +186,8 @@ fn show_snapshot_details(max_count: usize, max_age_days: i64, verbosity: crate:: } if verbosity != crate::VerbosityLevel::Verbose { - println!(" {} to keep, {} to delete ({:.1} KB total)", + println!( + " {} to keep, {} to delete ({:.1} KB total)", pull_keep_count.to_string().green(), pull_delete_count.to_string().red(), pull_total_size as f64 / 1024.0 @@ -181,7 +195,11 @@ fn show_snapshot_details(max_count: usize, max_age_days: i64, verbosity: crate:: } // Show push snapshots - println!("\n{} ({} total)", "Push Snapshots:".bold().blue(), push_snapshots.len()); + println!( + "\n{} ({} total)", + "Push Snapshots:".bold().blue(), + push_snapshots.len() + ); let mut push_keep_count = 0; let mut push_delete_count = 0; let mut push_total_size = 0u64; @@ -219,7 +237,8 @@ fn show_snapshot_details(max_count: usize, max_age_days: i64, verbosity: crate:: } if verbosity != crate::VerbosityLevel::Verbose { - println!(" {} to keep, {} to delete ({:.1} KB total)", + println!( + " {} to keep, {} to delete ({:.1} KB total)", push_keep_count.to_string().green(), push_delete_count.to_string().red(), push_total_size as f64 / 1024.0 @@ -232,15 +251,26 @@ fn show_snapshot_details(max_count: usize, max_age_days: i64, verbosity: crate:: let total_delete = pull_delete_count + push_delete_count; let total_size = (pull_total_size + push_total_size) as f64 / (1024.0 * 1024.0); - println!(" {} Total snapshots: {}", "•".cyan(), (pull_snapshots.len() + push_snapshots.len())); + println!( + " {} Total snapshots: {}", + "•".cyan(), + (pull_snapshots.len() + push_snapshots.len()) + ); println!(" {} Will keep: {}", "•".green(), total_keep); println!(" {} Will delete: {}", "•".red(), total_delete); println!(" {} Total disk space: {:.2} MB", "•".cyan(), total_size); if total_delete > 0 { - let freed_space = ((pull_delete_count as u64 * (pull_total_size / pull_snapshots.len().max(1) as u64)) + - (push_delete_count as u64 * (push_total_size / push_snapshots.len().max(1) as u64))) as f64 / (1024.0 * 1024.0); - println!(" {} Space to be freed: ~{:.2} MB", "•".yellow(), freed_space); + let freed_space = ((pull_delete_count as u64 + * (pull_total_size / pull_snapshots.len().max(1) as u64)) + + (push_delete_count as u64 * (push_total_size / push_snapshots.len().max(1) as u64))) + as f64 + / (1024.0 * 1024.0); + println!( + " {} Space to be freed: ~{:.2} MB", + "•".yellow(), + freed_space + ); } println!(); diff --git a/src/handlers/config.rs b/src/handlers/config.rs index 11389576..c99c9ab1 100644 --- a/src/handlers/config.rs +++ b/src/handlers/config.rs @@ -51,7 +51,10 @@ pub fn handle_config_interactive() -> Result<()> { .context("Failed to get user selections")?; if selections.is_empty() { - println!("{}", "No settings selected. Configuration unchanged.".yellow()); + println!( + "{}", + "No settings selected. Configuration unchanged.".yellow() + ); return Ok(()); } @@ -71,17 +74,26 @@ pub fn handle_config_interactive() -> Result<()> { .unwrap_or_else(|| "Not set".to_string()); let input = Text::new("Exclude older than (days):") - .with_help_message(&format!("Current: {}. Enter a number or leave empty to unset", current)) + .with_help_message(&format!( + "Current: {}. Enter a number or leave empty to unset", + current + )) .prompt()?; if input.trim().is_empty() { modified_config.exclude_older_than_days = None; println!(" {} Unset exclude_older_than_days", "✓".green()); } else { - let days: u32 = input.trim().parse() + let days: u32 = input + .trim() + .parse() .context("Invalid number. Must be a positive integer.")?; modified_config.exclude_older_than_days = Some(days); - println!(" {} Set exclude_older_than_days to {} days", "✓".green(), days); + println!( + " {} Set exclude_older_than_days to {} days", + "✓".green(), + days + ); } } @@ -93,7 +105,10 @@ pub fn handle_config_interactive() -> Result<()> { }; let input = Text::new("Include patterns (comma-separated):") - .with_help_message(&format!("Current: {}. Glob patterns like '*work*' or '/path/to/project'", current)) + .with_help_message(&format!( + "Current: {}. Glob patterns like '*work*' or '/path/to/project'", + current + )) .prompt()?; if input.trim().is_empty() { @@ -105,7 +120,11 @@ pub fn handle_config_interactive() -> Result<()> { .map(|s| s.trim().to_string()) .filter(|s| !s.is_empty()) .collect(); - println!(" {} Set include patterns: {:?}", "✓".green(), modified_config.include_patterns); + println!( + " {} Set include patterns: {:?}", + "✓".green(), + modified_config.include_patterns + ); } } @@ -117,7 +136,10 @@ pub fn handle_config_interactive() -> Result<()> { }; let input = Text::new("Exclude patterns (comma-separated):") - .with_help_message(&format!("Current: {}. Glob patterns like '*test*' or '/tmp/*'", current)) + .with_help_message(&format!( + "Current: {}. Glob patterns like '*test*' or '/tmp/*'", + current + )) .prompt()?; if input.trim().is_empty() { @@ -129,7 +151,11 @@ pub fn handle_config_interactive() -> Result<()> { .map(|s| s.trim().to_string()) .filter(|s| !s.is_empty()) .collect(); - println!(" {} Set exclude patterns: {:?}", "✓".green(), modified_config.exclude_patterns); + println!( + " {} Set exclude patterns: {:?}", + "✓".green(), + modified_config.exclude_patterns + ); } } @@ -138,7 +164,10 @@ pub fn handle_config_interactive() -> Result<()> { let exclude = Confirm::new("Exclude attachments (images, PDFs, etc.)?") .with_default(current) - .with_help_message(&format!("Current: {}. If yes, only .jsonl files will be synced", current)) + .with_help_message(&format!( + "Current: {}. If yes, only .jsonl files will be synced", + current + )) .prompt()?; modified_config.exclude_attachments = exclude; @@ -158,7 +187,9 @@ pub fn handle_config_interactive() -> Result<()> { .with_help_message("Maximum size for individual files (e.g., 10 for 10MB)") .prompt()?; - let size_mb: f64 = input.trim().parse() + let size_mb: f64 = input + .trim() + .parse() .context("Invalid number. Must be a positive number.")?; modified_config.max_file_size_bytes = (size_mb * 1024.0 * 1024.0) as u64; @@ -180,7 +211,9 @@ pub fn handle_config_interactive() -> Result<()> { .prompt()?; if confirm { - modified_config.save().context("Failed to save configuration")?; + modified_config + .save() + .context("Failed to save configuration")?; println!("\n{} Configuration saved successfully!", "✓".green().bold()); } else { println!("\n{}", "Configuration not saved.".yellow()); @@ -196,8 +229,14 @@ pub fn handle_config_wizard() -> Result<()> { println!("{}", "Configuration Wizard".cyan().bold()); println!("{}", "=".repeat(80).cyan()); println!(); - println!("{}", "This wizard will walk you through all configuration options.".dimmed()); - println!("{}", "Press Enter to keep current value or enter a new value.".dimmed()); + println!( + "{}", + "This wizard will walk you through all configuration options.".dimmed() + ); + println!( + "{}", + "Press Enter to keep current value or enter a new value.".dimmed() + ); println!(); // Load current configuration @@ -212,20 +251,30 @@ pub fn handle_config_wizard() -> Result<()> { .unwrap_or_else(|| "Not set".to_string()); println!(" Current: {}", current_age.yellow()); - let exclude_old = Confirm::new("Do you want to exclude projects older than a certain number of days?") - .with_default(modified_config.exclude_older_than_days.is_some()) - .prompt()?; + let exclude_old = + Confirm::new("Do you want to exclude projects older than a certain number of days?") + .with_default(modified_config.exclude_older_than_days.is_some()) + .prompt()?; if exclude_old { - let default_days = modified_config.exclude_older_than_days.unwrap_or(30).to_string(); + let default_days = modified_config + .exclude_older_than_days + .unwrap_or(30) + .to_string(); let input = Text::new("How many days?") .with_default(&default_days) .prompt()?; - let days: u32 = input.trim().parse() + let days: u32 = input + .trim() + .parse() .context("Invalid number. Must be a positive integer.")?; modified_config.exclude_older_than_days = Some(days); - println!(" {} Will exclude projects older than {} days\n", "✓".green(), days); + println!( + " {} Will exclude projects older than {} days\n", + "✓".green(), + days + ); } else { modified_config.exclude_older_than_days = None; println!(" {} Age filter disabled\n", "✓".green()); @@ -257,7 +306,11 @@ pub fn handle_config_wizard() -> Result<()> { .map(|s| s.trim().to_string()) .filter(|s| !s.is_empty()) .collect(); - println!(" {} Include patterns set: {:?}\n", "✓".green(), modified_config.include_patterns); + println!( + " {} Include patterns set: {:?}\n", + "✓".green(), + modified_config.include_patterns + ); } else { modified_config.include_patterns = Vec::new(); println!(" {} All projects will be included\n", "✓".green()); @@ -289,7 +342,11 @@ pub fn handle_config_wizard() -> Result<()> { .map(|s| s.trim().to_string()) .filter(|s| !s.is_empty()) .collect(); - println!(" {} Exclude patterns set: {:?}\n", "✓".green(), modified_config.exclude_patterns); + println!( + " {} Exclude patterns set: {:?}\n", + "✓".green(), + modified_config.exclude_patterns + ); } else { modified_config.exclude_patterns = Vec::new(); println!(" {} No exclusion patterns\n", "✓".green()); @@ -297,9 +354,13 @@ pub fn handle_config_wizard() -> Result<()> { // 4. Exclude attachments println!("{}", "4. File Type Filter".bold().cyan()); - println!(" Current: {}", - if modified_config.exclude_attachments { "Exclude attachments".yellow() } - else { "Include all files".yellow() } + println!( + " Current: {}", + if modified_config.exclude_attachments { + "Exclude attachments".yellow() + } else { + "Include all files".yellow() + } ); let exclude_attachments = Confirm::new("Exclude attachments (images, PDFs, etc.)?") @@ -308,9 +369,14 @@ pub fn handle_config_wizard() -> Result<()> { .prompt()?; modified_config.exclude_attachments = exclude_attachments; - println!(" {} Attachments will be {}\n", + println!( + " {} Attachments will be {}\n", "✓".green(), - if exclude_attachments { "excluded" } else { "included" } + if exclude_attachments { + "excluded" + } else { + "included" + } ); // 5. Max file size @@ -327,7 +393,9 @@ pub fn handle_config_wizard() -> Result<()> { .with_default(&format!("{:.1}", current_mb)) .prompt()?; - let size_mb: f64 = input.trim().parse() + let size_mb: f64 = input + .trim() + .parse() .context("Invalid number. Must be a positive number.")?; modified_config.max_file_size_bytes = (size_mb * 1024.0 * 1024.0) as u64; @@ -337,9 +405,10 @@ pub fn handle_config_wizard() -> Result<()> { } // Artifact sync categories - let change_artifacts = Confirm::new("Configure artifact sync categories (settings, skills, agents, ...)?") - .with_default(false) - .prompt()?; + let change_artifacts = + Confirm::new("Configure artifact sync categories (settings, skills, agents, ...)?") + .with_default(false) + .prompt()?; if change_artifacts { modified_config.sync_artifacts = prompt_artifact_toggle_selection(&modified_config.sync_artifacts)?; @@ -357,7 +426,9 @@ pub fn handle_config_wizard() -> Result<()> { .prompt()?; if confirm { - modified_config.save().context("Failed to save configuration")?; + modified_config + .save() + .context("Failed to save configuration")?; println!("\n{} Configuration saved successfully!", "✓".green().bold()); } else { println!("\n{}", "Configuration not saved.".yellow()); @@ -368,14 +439,17 @@ pub fn handle_config_wizard() -> Result<()> { /// Display a compact configuration summary fn display_config_summary(config: &FilterConfig) { - println!(" {} {}", + println!( + " {} {}", "Exclude older than:".cyan(), - config.exclude_older_than_days + config + .exclude_older_than_days .map(|d| format!("{} days", d)) .unwrap_or_else(|| "Not set".dimmed().to_string()) ); - println!(" {} {}", + println!( + " {} {}", "Include patterns:".cyan(), if config.include_patterns.is_empty() { "None (all included)".dimmed().to_string() @@ -384,7 +458,8 @@ fn display_config_summary(config: &FilterConfig) { } ); - println!(" {} {}", + println!( + " {} {}", "Exclude patterns:".cyan(), if config.exclude_patterns.is_empty() { "None".dimmed().to_string() @@ -399,7 +474,8 @@ fn display_config_summary(config: &FilterConfig) { config.max_file_size_bytes as f64 / (1024.0 * 1024.0) ); - println!(" {} {}", + println!( + " {} {}", "Exclude attachments:".cyan(), if config.exclude_attachments { "Yes (only .jsonl files)".green().to_string() @@ -412,7 +488,8 @@ fn display_config_summary(config: &FilterConfig) { .filter(|d| config.sync_artifacts.is_enabled(d.id)) .map(|d| d.name) .collect(); - println!(" {} {}", + println!( + " {} {}", "Artifact sync:".cyan(), if enabled.is_empty() { "All disabled".dimmed().to_string() @@ -501,16 +578,24 @@ pub fn handle_repo_selector() -> Result<()> { Ok(s) => s, Err(e) => { let err_msg = e.to_string(); - if err_msg.contains("not initialized") || err_msg.contains("Run 'claude-code-sync init'") { + if err_msg.contains("not initialized") + || err_msg.contains("Run 'claude-code-sync init'") + { // Check if there's an existing repo in the default location that we can recover if let Some(recovered) = try_recover_existing_repo()? { - println!("{}", "Found existing repository - recovered configuration!".green()); + println!( + "{}", + "Found existing repository - recovered configuration!".green() + ); println!(); recovered } else { println!("{}", "No repositories configured.".yellow()); println!(); - println!("Run '{}' to set up your first repository.", "claude-code-sync init".cyan()); + println!( + "Run '{}' to set up your first repository.", + "claude-code-sync init".cyan() + ); return Ok(()); } } else { @@ -522,7 +607,10 @@ pub fn handle_repo_selector() -> Result<()> { if state.repos.is_empty() { println!("{}", "No repositories configured.".yellow()); println!(); - println!("Run '{}' to set up your first repository.", "claude-code-sync init".cyan()); + println!( + "Run '{}' to set up your first repository.", + "claude-code-sync init".cyan() + ); return Ok(()); } @@ -556,7 +644,10 @@ pub fn handle_repo_selector() -> Result<()> { .map(|u| format!(" ({})", u.dimmed())) .unwrap_or_default(); - format!("{}{} - {}{}", repo.name, active_marker, path_str, remote_info) + format!( + "{}{} - {}{}", + repo.name, active_marker, path_str, remote_info + ) }) .collect(); @@ -647,19 +738,17 @@ pub fn handle_config_export() -> Result<()> { }; let remote_url = match MultiRepoState::load() { - Ok(ms) => { - match ms.repos.get(&ms.active_repo) { - Some(repo) => repo.remote_url.clone(), - None => { - eprintln!( - "{} Active repo '{}' not found in multi-repo state, remote_url will be unset", - "!".yellow(), - ms.active_repo - ); - None - } + Ok(ms) => match ms.repos.get(&ms.active_repo) { + Some(repo) => repo.remote_url.clone(), + None => { + eprintln!( + "{} Active repo '{}' not found in multi-repo state, remote_url will be unset", + "!".yellow(), + ms.active_repo + ); + None } - } + }, Err(e) => { eprintln!( "{} Could not load multi-repo state ({}), remote_url will be unset", @@ -697,8 +786,8 @@ pub fn handle_config_export() -> Result<()> { sync_artifacts: filter.sync_artifacts.clone(), }; - let content = toml::to_string_pretty(&init_config) - .context("Failed to serialize init config")?; + let content = + toml::to_string_pretty(&init_config).context("Failed to serialize init config")?; let output_path = PathBuf::from("claude-code-sync-init.toml"); std::fs::write(&output_path, content) @@ -796,10 +885,7 @@ mod tests { std::fs::write(claude_dir.join("config.toml"), content).unwrap(); } - fn make_multi_repo_state( - repo_path: &str, - remote_url: Option, - ) -> MultiRepoState { + fn make_multi_repo_state(repo_path: &str, remote_url: Option) -> MultiRepoState { let mut repos = HashMap::new(); repos.insert( "default".to_string(), @@ -860,7 +946,10 @@ mod tests { exported.remote_url.as_deref(), Some("https://github.com/user/repo.git") ); - assert!(exported.clone, "clone should be true when remote_url is set"); + assert!( + exported.clone, + "clone should be true when remote_url is set" + ); assert!(exported.exclude_attachments); assert_eq!(exported.exclude_older_than_days, Some(30)); assert!(exported.enable_lfs); diff --git a/src/handlers/history.rs b/src/handlers/history.rs index 4cb41c34..b156a7df 100644 --- a/src/handlers/history.rs +++ b/src/handlers/history.rs @@ -253,8 +253,14 @@ pub fn handle_history_clear() -> Result<()> { pub fn handle_history_review(limit: usize) -> Result<()> { // Check if we're in an interactive terminal if !interactive_conflict::is_interactive() { - println!("{}", "Review mode requires an interactive terminal.".yellow()); - println!("{}", "Use 'history list' for non-interactive viewing.".dimmed()); + println!( + "{}", + "Review mode requires an interactive terminal.".yellow() + ); + println!( + "{}", + "Use 'history list' for non-interactive viewing.".dimmed() + ); return Ok(()); } @@ -303,9 +309,12 @@ pub fn handle_history_review(limit: usize) -> Result<()> { options_with_exit.push("← Exit review".to_string()); loop { - let selection = Select::new("Select an operation to review (or Exit):", options_with_exit.clone()) - .with_help_message("Use arrow keys to navigate, Enter to select") - .prompt(); + let selection = Select::new( + "Select an operation to review (or Exit):", + options_with_exit.clone(), + ) + .with_help_message("Use arrow keys to navigate, Enter to select") + .prompt(); match selection { Ok(selected) => { diff --git a/src/handlers/mod.rs b/src/handlers/mod.rs index 0f6a2731..bd0ad37d 100644 --- a/src/handlers/mod.rs +++ b/src/handlers/mod.rs @@ -11,7 +11,13 @@ pub mod undo; // Re-export all public handler functions for convenient use pub use cleanup::handle_cleanup_snapshots; -pub use config::{handle_config_export, handle_config_interactive, handle_config_wizard, handle_repo_selector}; -pub use history::{handle_history_clear, handle_history_last, handle_history_list, handle_history_review}; -pub use onboarding::{is_initialized, run_init_from_config, run_onboarding_flow, try_init_from_config}; +pub use config::{ + handle_config_export, handle_config_interactive, handle_config_wizard, handle_repo_selector, +}; +pub use history::{ + handle_history_clear, handle_history_last, handle_history_list, handle_history_review, +}; +pub use onboarding::{ + is_initialized, run_init_from_config, run_onboarding_flow, try_init_from_config, +}; pub use undo::{handle_undo_pull, handle_undo_push}; diff --git a/src/handlers/onboarding.rs b/src/handlers/onboarding.rs index 1ebf6d3e..f76dbde2 100644 --- a/src/handlers/onboarding.rs +++ b/src/handlers/onboarding.rs @@ -75,14 +75,10 @@ pub fn run_init_from_config>(config_path: Option

) -> Result<() log::info!("Loading init config from: {}", path.as_ref().display()); InitConfig::load(path.as_ref())? } else { - InitConfig::load_default()? - .ok_or_else(|| anyhow::anyhow!("No init config file found"))? + InitConfig::load_default()?.ok_or_else(|| anyhow::anyhow!("No init config file found"))? }; - println!( - "{}", - "📄 Initializing from config file...".cyan().bold() - ); + println!("{}", "📄 Initializing from config file...".cyan().bold()); // Convert to onboarding config let onboarding_config = init_config.to_onboarding_config()?; @@ -122,7 +118,11 @@ pub fn run_init_from_config>(config_path: Option

) -> Result<() .context("Failed to save filter configuration")?; println!("{}", "✓ Initialization complete!".green().bold()); - println!(" {} {}", "Repo:".cyan(), onboarding_config.repo_path.display()); + println!( + " {} {}", + "Repo:".cyan(), + onboarding_config.repo_path.display() + ); if let Some(ref url) = onboarding_config.remote_url { println!(" {} {}", "Remote:".cyan(), url); } diff --git a/src/logger.rs b/src/logger.rs index b6898b16..b1e1b14b 100644 --- a/src/logger.rs +++ b/src/logger.rs @@ -61,9 +61,7 @@ pub fn init_logger() -> Result<()> { .ok(); // Ignore error if logger is already initialized // Also log initialization to file - log_to_file(&format!( - "Logger initialized with level: {default_level:?}" - ))?; + log_to_file(&format!("Logger initialized with level: {default_level:?}"))?; Ok(()) } diff --git a/src/main.rs b/src/main.rs index d4329cdc..87c9e050 100644 --- a/src/main.rs +++ b/src/main.rs @@ -403,7 +403,12 @@ fn main() -> Result<()> { } match command { - Commands::Init { local, remote, clone, config } => { + Commands::Init { + local, + remote, + clone, + config, + } => { // If config file is provided, use non-interactive init if config.is_some() { run_init_from_config(config)?; @@ -434,10 +439,7 @@ fn main() -> Result<()> { filter::FilterConfig::default().save()?; } - println!( - "{}", - "Clone and initialization complete!".green().bold() - ); + println!("{}", "Clone and initialization complete!".green().bold()); } else if let Some(local_path) = local { // Use CLI args for init (local path) sync::init_sync_repo(&local_path, remote.as_deref())?; @@ -447,7 +449,12 @@ fn main() -> Result<()> { println!( "{}", - format!("Cloning from {} to {}...", remote_url, default_path.display()).cyan() + format!( + "Cloning from {} to {}...", + remote_url, + default_path.display() + ) + .cyan() ); scm::clone(&remote_url, &default_path)?; @@ -459,10 +466,7 @@ fn main() -> Result<()> { filter::FilterConfig::default().save()?; } - println!( - "{}", - "Clone and initialization complete!".green().bold() - ); + println!("{}", "Clone and initialization complete!".green().bold()); } else { // No args provided, try config file first, then fall back to interactive onboarding if !try_init_from_config()? { @@ -622,7 +626,11 @@ fn main() -> Result<()> { sync::remove_remote(&name)?; } }, - Commands::Undo { operation, verbose, quiet } => { + Commands::Undo { + operation, + verbose, + quiet, + } => { // Determine verbosity level let verbosity = if verbose { VerbosityLevel::Verbose @@ -640,7 +648,7 @@ fn main() -> Result<()> { handle_undo_push(preview, verbosity)?; } } - }, + } Commands::History { action } => match action { HistoryAction::List { limit } => { handle_history_list(limit)?; diff --git a/src/merge.rs b/src/merge.rs index d9ae21f8..aa7eb01f 100644 --- a/src/merge.rs +++ b/src/merge.rs @@ -275,8 +275,7 @@ impl<'a> SmartMerger<'a> { ) -> MessageNode { // Phase 1: BFS to collect all UUIDs reachable from this root let mut processing_order: Vec = Vec::new(); - let mut queue: std::collections::VecDeque = - std::collections::VecDeque::new(); + let mut queue: std::collections::VecDeque = std::collections::VecDeque::new(); queue.push_back(root_uuid.to_string()); while let Some(uuid) = queue.pop_front() { diff --git a/src/onboarding.rs b/src/onboarding.rs index 8c3244b3..badd96a6 100644 --- a/src/onboarding.rs +++ b/src/onboarding.rs @@ -128,7 +128,10 @@ impl InitConfig { if let Ok(path) = std::env::var("CLAUDE_CODE_SYNC_INIT_CONFIG") { let path = PathBuf::from(&path); if path.exists() { - log::info!("Loading init config from CLAUDE_CODE_SYNC_INIT_CONFIG: {}", path.display()); + log::info!( + "Loading init config from CLAUDE_CODE_SYNC_INIT_CONFIG: {}", + path.display() + ); return Ok(Some(Self::load(&path)?)); } } @@ -553,7 +556,10 @@ mod tests { "#; let config: InitConfig = toml::from_str(toml).unwrap(); assert_eq!(config.repo_path, "~/claude-sync"); - assert_eq!(config.remote_url, Some("https://github.com/user/repo.git".to_string())); + assert_eq!( + config.remote_url, + Some("https://github.com/user/repo.git".to_string()) + ); assert!(config.clone); assert!(config.exclude_attachments); assert_eq!(config.exclude_older_than_days, Some(30)); @@ -629,7 +635,10 @@ mod tests { }; let onboarding = config.to_onboarding_config().unwrap(); assert_eq!(onboarding.repo_path, PathBuf::from("/tmp/test")); - assert_eq!(onboarding.remote_url, Some("https://github.com/user/repo.git".to_string())); + assert_eq!( + onboarding.remote_url, + Some("https://github.com/user/repo.git".to_string()) + ); assert!(onboarding.is_cloned); assert!(onboarding.exclude_attachments); assert_eq!(onboarding.exclude_older_than_days, Some(30)); diff --git a/src/scm/git.rs b/src/scm/git.rs index be90d8c9..e8510215 100644 --- a/src/scm/git.rs +++ b/src/scm/git.rs @@ -60,8 +60,9 @@ impl GitScm { /// Clone a remote repository. pub fn clone(url: &str, path: &Path) -> Result { if let Some(parent) = path.parent() { - std::fs::create_dir_all(parent) - .with_context(|| format!("Failed to create parent directory for '{}'", path.display()))?; + std::fs::create_dir_all(parent).with_context(|| { + format!("Failed to create parent directory for '{}'", path.display()) + })?; } let output = Command::new("git") @@ -184,7 +185,8 @@ impl Scm for GitScm { 4. Remote branch protection rules\n\n\ For HTTPS: Run 'git config --global credential.helper store' and try again\n\ For SSH: Ensure SSH keys are set up with 'ssh -T git@github.com'", - remote, stderr + remote, + stderr )); } @@ -202,7 +204,8 @@ impl Scm for GitScm { let stderr = String::from_utf8_lossy(&output.stderr); return Err(anyhow!( "Failed to pull from remote '{}': {}", - remote, stderr + remote, + stderr )); } @@ -275,7 +278,8 @@ mod tests { assert!(!scm.has_remote("origin")); - scm.add_remote("origin", "https://github.com/test/repo.git").unwrap(); + scm.add_remote("origin", "https://github.com/test/repo.git") + .unwrap(); assert!(scm.has_remote("origin")); assert!(!scm.has_remote("upstream")); } diff --git a/src/scm/hg.rs b/src/scm/hg.rs index b2100552..f38d0d92 100644 --- a/src/scm/hg.rs +++ b/src/scm/hg.rs @@ -86,7 +86,6 @@ impl HgScm { Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) } - /// Get path to .hg/hgrc config file. fn hgrc_path(&self) -> PathBuf { self.path.join(".hg").join("hgrc") @@ -348,13 +347,15 @@ mod tests { assert!(!scm.has_remote("origin")); - scm.add_remote("origin", "https://example.com/repo").unwrap(); + scm.add_remote("origin", "https://example.com/repo") + .unwrap(); assert!(scm.has_remote("origin")); let url = scm.get_remote_url("origin").unwrap(); assert_eq!(url, "https://example.com/repo"); - scm.set_remote_url("origin", "https://example.com/new").unwrap(); + scm.set_remote_url("origin", "https://example.com/new") + .unwrap(); let new_url = scm.get_remote_url("origin").unwrap(); assert_eq!(new_url, "https://example.com/new"); @@ -374,8 +375,10 @@ mod tests { assert!(scm.list_remotes().unwrap().is_empty()); - scm.add_remote("origin", "https://example.com/origin").unwrap(); - scm.add_remote("upstream", "https://example.com/upstream").unwrap(); + scm.add_remote("origin", "https://example.com/origin") + .unwrap(); + scm.add_remote("upstream", "https://example.com/upstream") + .unwrap(); let remotes = scm.list_remotes().unwrap(); assert_eq!(remotes.len(), 2); diff --git a/src/sync/discovery.rs b/src/sync/discovery.rs index eb435cf7..8a8e00f4 100644 --- a/src/sync/discovery.rs +++ b/src/sync/discovery.rs @@ -12,6 +12,14 @@ pub(crate) const LARGE_FILE_WARNING_THRESHOLD: u64 = 10 * 1024 * 1024; /// Get the Claude Code home directory (`~/.claude`) pub(crate) fn claude_home_dir() -> Result { + // Override for tests/automation, mirroring CLAUDE_CODE_SYNC_CONFIG_DIR: + // faking HOME cannot redirect dirs::home_dir() on Windows, where the + // profile comes from the known-folder API rather than the environment. + if let Ok(override_dir) = std::env::var("CLAUDE_CODE_SYNC_CLAUDE_DIR") { + if !override_dir.is_empty() { + return Ok(PathBuf::from(override_dir)); + } + } let home = dirs::home_dir().context("Failed to get home directory")?; Ok(home.join(".claude")) } @@ -118,7 +126,10 @@ pub fn extract_project_name(encoded_path: &str) -> &str { /// # Returns /// - `Some(PathBuf)` if exactly one matching project directory is found /// - `None` if no match found or multiple matches (ambiguous) -pub fn find_local_project_by_name(claude_projects_dir: &Path, project_name: &str) -> Option { +pub fn find_local_project_by_name( + claude_projects_dir: &Path, + project_name: &str, +) -> Option { let entries = std::fs::read_dir(claude_projects_dir).ok()?; let matches: Vec = entries diff --git a/src/sync/mod.rs b/src/sync/mod.rs index d1014eb4..268935a1 100644 --- a/src/sync/mod.rs +++ b/src/sync/mod.rs @@ -46,7 +46,14 @@ pub fn sync_bidirectional( } // Then, push local changes - push_history(commit_message, true, branch, exclude_attachments, interactive, verbosity)?; + push_history( + commit_message, + true, + branch, + exclude_attachments, + interactive, + verbosity, + )?; if verbosity == VerbosityLevel::Quiet { println!("Sync complete"); diff --git a/src/sync/pull.rs b/src/sync/pull.rs index 4375dc18..ac4bd48a 100644 --- a/src/sync/pull.rs +++ b/src/sync/pull.rs @@ -91,11 +91,8 @@ pub fn pull_history( // ============================================================================ // ARTIFACT PULL PLAN (read-only, so the snapshot below can cover it) // ============================================================================ - let artifact_plan = crate::artifacts::engine::plan_pull( - &claude_home_dir()?, - &state.sync_repo_path, - &filter, - )?; + let artifact_plan = + crate::artifacts::engine::plan_pull(&claude_home_dir()?, &state.sync_repo_path, &filter)?; // ============================================================================ // SNAPSHOT CREATION: Only backup files that will actually change @@ -162,7 +159,11 @@ pub fn pull_history( println!(); println!("{}", "Pull Summary:".bold().cyan()); println!(" {} Local sessions: {}", "•".cyan(), local_sessions.len()); - println!(" {} Remote sessions: {}", "•".cyan(), remote_sessions.len()); + println!( + " {} Remote sessions: {}", + "•".cyan(), + remote_sessions.len() + ); println!(); } @@ -174,7 +175,12 @@ pub fn pull_history( .strip_prefix(&remote_projects_dir) .unwrap_or(Path::new(&session.file_path)); - println!(" {}. {} ({} messages)", idx + 1, relative_path.display(), session.message_count()); + println!( + " {}. {} ({} messages)", + idx + 1, + relative_path.display(), + session.message_count() + ); } if remote_sessions.len() > 20 { println!(" ... and {} more", remote_sessions.len() - 20); @@ -184,11 +190,14 @@ pub fn pull_history( // Interactive confirmation if interactive && interactive_conflict::is_interactive() { - let confirm = Confirm::new("Do you want to proceed with pulling and merging these changes?") - .with_default(true) - .with_help_message("This will merge remote sessions into your local Claude Code history") - .prompt() - .context("Failed to get confirmation")?; + let confirm = + Confirm::new("Do you want to proceed with pulling and merging these changes?") + .with_default(true) + .with_help_message( + "This will merge remote sessions into your local Claude Code history", + ) + .prompt() + .context("Failed to get confirmation")?; if !confirm { println!("\n{}", "Pull cancelled.".yellow()); @@ -465,7 +474,10 @@ pub fn pull_history( .unwrap_or_else(|_| remote_relative.to_path_buf()); (dest, tracking_path) } else { - log::warn!("Could not extract filename from remote path: {:?}", remote_relative); + log::warn!( + "Could not extract filename from remote path: {:?}", + remote_relative + ); skipped_no_local_match += 1; continue; // Skip this session } @@ -534,10 +546,7 @@ pub fn pull_history( artifact_report.total_modified() ); if artifact_report.total_modified() > 0 { - println!( - " {}", - "Undo with: claude-code-sync undo pull".dimmed() - ); + println!(" {}", "Undo with: claude-code-sync undo pull".dimmed()); } } diff --git a/src/sync/push.rs b/src/sync/push.rs index 5f8ad051..cac6167b 100644 --- a/src/sync/push.rs +++ b/src/sync/push.rs @@ -186,12 +186,15 @@ pub fn push_history( println!(); println!( "{}", - "Warning: Multiple projects map to the same name:".yellow().bold() + "Warning: Multiple projects map to the same name:" + .yellow() + .bold() ); for (name, paths) in &collisions { println!(" {} -> {} locations:", name.cyan(), paths.len()); for path in paths.iter().take(3) { - let display_path = path.file_name() + let display_path = path + .file_name() .and_then(|n| n.to_str()) .unwrap_or("unknown"); println!(" - {}", display_path); @@ -243,11 +246,7 @@ pub fn push_history( entry.operation, ) { Ok(summary) => pushed_conversations.push(summary), - Err(e) => log::warn!( - "Failed to create summary for {}: {}", - relative_path_str, - e - ), + Err(e) => log::warn!("Failed to create summary for {}: {}", relative_path_str, e), } } @@ -342,11 +341,7 @@ pub fn push_history( if let Some(ref hash) = commit_before_push { if verbosity != VerbosityLevel::Quiet { - println!( - " {} Recorded commit {} for undo", - "✓".green(), - &hash[..8] - ); + println!(" {} Recorded commit {} for undo", "✓".green(), &hash[..8]); } } else if verbosity != VerbosityLevel::Quiet { println!( diff --git a/src/sync/remote.rs b/src/sync/remote.rs index 8bda7100..f9cfc138 100644 --- a/src/sync/remote.rs +++ b/src/sync/remote.rs @@ -63,7 +63,11 @@ pub fn set_remote(name: &str, url: &str) -> Result<()> { let repo = scm::open(&state.sync_repo_path)?; // Validate URL format - if !url.starts_with("http://") && !url.starts_with("https://") && !url.starts_with("git@") && !url.starts_with("ssh://") { + if !url.starts_with("http://") + && !url.starts_with("https://") + && !url.starts_with("git@") + && !url.starts_with("ssh://") + { return Err(anyhow!( "Invalid URL format: {url}\n\ \n\ diff --git a/src/undo/cleanup.rs b/src/undo/cleanup.rs index 1ab0fd59..ce04c5e8 100644 --- a/src/undo/cleanup.rs +++ b/src/undo/cleanup.rs @@ -3,8 +3,8 @@ use log::warn; use std::fs; use std::path::Path; -use crate::history::OperationType; use super::snapshot::Snapshot; +use crate::history::OperationType; /// Configuration for snapshot cleanup pub struct SnapshotCleanupConfig { diff --git a/src/undo/mod.rs b/src/undo/mod.rs index 7f9a2d86..8cdf5f94 100644 --- a/src/undo/mod.rs +++ b/src/undo/mod.rs @@ -4,28 +4,30 @@ //! Snapshots enable undoing pull operations (by restoring files) and push operations //! (by resetting Git commits). Includes validation and security checks for safe restoration. -mod snapshot; -mod restore; -mod preview; -mod operations; mod cleanup; +mod operations; +mod preview; +mod restore; +mod snapshot; // Re-export public types and functions to maintain API compatibility -pub use snapshot::Snapshot; -pub use preview::{VerbosityLevel, preview_undo_pull, preview_undo_push}; +pub use cleanup::{cleanup_old_snapshots, SnapshotCleanupConfig}; pub use operations::{undo_pull, undo_push}; -pub use cleanup::{SnapshotCleanupConfig, cleanup_old_snapshots}; +pub use preview::{preview_undo_pull, preview_undo_push, VerbosityLevel}; +pub use snapshot::Snapshot; // These are part of the public API but currently only used in tests #[allow(unused_imports)] -pub use preview::UndoPreview; -#[allow(unused_imports)] pub use cleanup::cleanup_old_snapshots_with_dir; +#[allow(unused_imports)] +pub use preview::UndoPreview; #[cfg(test)] mod tests { use super::*; - use crate::history::{ConversationSummary, OperationRecord, OperationType, SyncOperation, OperationHistory}; + use crate::history::{ + ConversationSummary, OperationHistory, OperationRecord, OperationType, SyncOperation, + }; use crate::scm::{self, Scm}; use std::collections::HashMap; use std::fs; @@ -707,7 +709,6 @@ mod tests { ); } - #[test] fn test_undo_pull_transaction_safety() { // This test verifies that history is updated FIRST, then files are restored. @@ -897,9 +898,19 @@ mod tests { snapshot.save_to_disk(Some(&snapshots_dir)).unwrap(); // Verify it's a full snapshot - assert!(snapshot.base_snapshot_id.is_none(), "First snapshot should not have a base"); - assert_eq!(snapshot.files.len(), 2, "First snapshot should contain all files"); - assert!(snapshot.deleted_files.is_empty(), "First snapshot should have no deleted files"); + assert!( + snapshot.base_snapshot_id.is_none(), + "First snapshot should not have a base" + ); + assert_eq!( + snapshot.files.len(), + 2, + "First snapshot should contain all files" + ); + assert!( + snapshot.deleted_files.is_empty(), + "First snapshot should have no deleted files" + ); } #[test] @@ -939,7 +950,10 @@ mod tests { snapshot2.save_to_disk(Some(&snapshots_dir)).unwrap(); // Verify it's a differential snapshot - assert!(snapshot2.base_snapshot_id.is_some(), "Second snapshot should have a base"); + assert!( + snapshot2.base_snapshot_id.is_some(), + "Second snapshot should have a base" + ); assert_eq!( snapshot2.base_snapshot_id.as_ref().unwrap(), &snapshot1.snapshot_id, @@ -947,10 +961,20 @@ mod tests { ); // Should only contain changed file (file1) and new file (file3), not file2 - assert_eq!(snapshot2.files.len(), 2, "Should only contain changed and new files"); - assert!(snapshot2.files.contains_key(&file1.to_string_lossy().to_string())); - assert!(snapshot2.files.contains_key(&file3.to_string_lossy().to_string())); - assert!(!snapshot2.files.contains_key(&file2.to_string_lossy().to_string())); + assert_eq!( + snapshot2.files.len(), + 2, + "Should only contain changed and new files" + ); + assert!(snapshot2 + .files + .contains_key(&file1.to_string_lossy().to_string())); + assert!(snapshot2 + .files + .contains_key(&file3.to_string_lossy().to_string())); + assert!(!snapshot2 + .files + .contains_key(&file2.to_string_lossy().to_string())); } #[test] @@ -988,9 +1012,15 @@ mod tests { snapshot2.save_to_disk(Some(&snapshots_dir)).unwrap(); // Verify deletion tracking - assert_eq!(snapshot2.deleted_files.len(), 1, "Should track one deleted file"); + assert_eq!( + snapshot2.deleted_files.len(), + 1, + "Should track one deleted file" + ); assert!( - snapshot2.deleted_files.contains(&file2.to_string_lossy().to_string()), + snapshot2 + .deleted_files + .contains(&file2.to_string_lossy().to_string()), "Should track file2 as deleted" ); } @@ -1030,13 +1060,34 @@ mod tests { snapshot2.save_to_disk(Some(&snapshots_dir)).unwrap(); // Reconstruct full state from differential snapshot - let full_state = snapshot2.reconstruct_full_state_with_dir(Some(&snapshots_dir)).unwrap(); + let full_state = snapshot2 + .reconstruct_full_state_with_dir(Some(&snapshots_dir)) + .unwrap(); // Should contain all three files with correct content - assert_eq!(full_state.len(), 3, "Should have 3 files after reconstruction"); - assert_eq!(full_state.get(&file1.to_string_lossy().to_string()).unwrap(), b"v2"); - assert_eq!(full_state.get(&file2.to_string_lossy().to_string()).unwrap(), b"v1"); // Unchanged from base - assert_eq!(full_state.get(&file3.to_string_lossy().to_string()).unwrap(), b"v2"); // New file + assert_eq!( + full_state.len(), + 3, + "Should have 3 files after reconstruction" + ); + assert_eq!( + full_state + .get(&file1.to_string_lossy().to_string()) + .unwrap(), + b"v2" + ); + assert_eq!( + full_state + .get(&file2.to_string_lossy().to_string()) + .unwrap(), + b"v1" + ); // Unchanged from base + assert_eq!( + full_state + .get(&file3.to_string_lossy().to_string()) + .unwrap(), + b"v2" + ); // New file } #[test] @@ -1075,7 +1126,9 @@ mod tests { fs::write(&file2, b"should_be_deleted").unwrap(); // Restore snapshot2 which should delete file2 - snapshot2.restore_with_base_and_snapshots(Some(temp_dir.path()), Some(&snapshots_dir)).unwrap(); + snapshot2 + .restore_with_base_and_snapshots(Some(temp_dir.path()), Some(&snapshots_dir)) + .unwrap(); // Verify file1 exists and file2 was deleted assert!(file1.exists(), "file1 should exist after restore"); @@ -1107,7 +1160,10 @@ mod tests { let result = snapshot.reconstruct_full_state(); assert!(result.is_err(), "Should fail when base snapshot is missing"); assert!( - result.unwrap_err().to_string().contains("Base snapshot not found"), + result + .unwrap_err() + .to_string() + .contains("Base snapshot not found"), "Error should mention missing base snapshot" ); } @@ -1136,19 +1192,28 @@ mod tests { } // Verify chain structure - assert!(snapshots[0].base_snapshot_id.is_none(), "First should have no base"); + assert!( + snapshots[0].base_snapshot_id.is_none(), + "First should have no base" + ); for i in 1..5 { assert_eq!( snapshots[i].base_snapshot_id.as_ref().unwrap(), &snapshots[i - 1].snapshot_id, - "Snapshot {} should reference snapshot {}", i, i - 1 + "Snapshot {} should reference snapshot {}", + i, + i - 1 ); } // Reconstruct from the last snapshot - let full_state = snapshots[4].reconstruct_full_state_with_dir(Some(&snapshots_dir)).unwrap(); + let full_state = snapshots[4] + .reconstruct_full_state_with_dir(Some(&snapshots_dir)) + .unwrap(); assert_eq!( - full_state.get(&file1.to_string_lossy().to_string()).unwrap(), + full_state + .get(&file1.to_string_lossy().to_string()) + .unwrap(), b"version_5", "Should reconstruct to the latest version" ); @@ -1185,7 +1250,8 @@ mod tests { max_age_days: 0, // Only count matters, not age }; - let deleted = cleanup_old_snapshots_with_dir(Some(config), false, Some(&snapshots_dir)).unwrap(); + let deleted = + cleanup_old_snapshots_with_dir(Some(config), false, Some(&snapshots_dir)).unwrap(); assert_eq!(deleted, 5, "Should delete 5 old snapshots"); // Count remaining snapshots @@ -1224,7 +1290,8 @@ mod tests { max_age_days: 50, }; - let deleted = cleanup_old_snapshots_with_dir(Some(config), false, Some(&snapshots_dir)).unwrap(); + let deleted = + cleanup_old_snapshots_with_dir(Some(config), false, Some(&snapshots_dir)).unwrap(); // Snapshots are at days: 5, 15, 25, 35, 45, 55, 65, 75, 85, 95 // Age threshold is now - 50 days @@ -1275,14 +1342,18 @@ mod tests { max_age_days: 0, // Don't keep by age }; - let deleted = cleanup_old_snapshots_with_dir(Some(config), false, Some(&snapshots_dir)).unwrap(); + let deleted = + cleanup_old_snapshots_with_dir(Some(config), false, Some(&snapshots_dir)).unwrap(); // Should delete 7 pull + 7 push = 14 total assert_eq!(deleted, 14, "Should delete 14 old snapshots"); // Should keep 3 pull + 3 push = 6 total let remaining = fs::read_dir(&snapshots_dir).unwrap().count(); - assert_eq!(remaining, 6, "Should have 6 snapshots remaining (3 per type)"); + assert_eq!( + remaining, 6, + "Should have 6 snapshots remaining (3 per type)" + ); } #[test] @@ -1312,11 +1383,15 @@ mod tests { }; // Dry run should report but not delete - let deleted = cleanup_old_snapshots_with_dir(Some(config), true, Some(&snapshots_dir)).unwrap(); + let deleted = + cleanup_old_snapshots_with_dir(Some(config), true, Some(&snapshots_dir)).unwrap(); assert_eq!(deleted, 7, "Should report 7 snapshots would be deleted"); // All snapshots should still exist let remaining = fs::read_dir(&snapshots_dir).unwrap().count(); - assert_eq!(remaining, 10, "All snapshots should still exist after dry run"); + assert_eq!( + remaining, 10, + "All snapshots should still exist after dry run" + ); } } diff --git a/src/undo/operations.rs b/src/undo/operations.rs index dea3af03..d3e75343 100644 --- a/src/undo/operations.rs +++ b/src/undo/operations.rs @@ -2,9 +2,9 @@ use anyhow::{anyhow, Context, Result}; use std::fs; use std::path::{Path, PathBuf}; +use super::snapshot::Snapshot; use crate::history::{OperationHistory, OperationType}; use crate::scm; -use super::snapshot::Snapshot; /// Undo the last pull operation /// @@ -140,9 +140,9 @@ pub fn undo_push(repo_path: &Path, history_path: Option) -> Result "Pull", OperationType::Push => "Push", }; - println!("Undo {}: {} conversations affected", op_type, self.conversation_count); + println!( + "Undo {}: {} conversations affected", + op_type, self.conversation_count + ); if !self.affected_files.is_empty() { println!(" {} files will be restored", self.affected_files.len()); } @@ -151,7 +154,12 @@ impl UndoPreview { } else { commit.as_str() }; - println!("{} {} (full: {})", "Will reset to:".bold(), short_hash.yellow(), commit.dimmed()); + println!( + "{} {} (full: {})", + "Will reset to:".bold(), + short_hash.yellow(), + commit.dimmed() + ); } println!( @@ -161,7 +169,11 @@ impl UndoPreview { ); if !self.affected_files.is_empty() { - println!("\n{} ({} total)", "Files to be restored:".bold(), self.affected_files.len()); + println!( + "\n{} ({} total)", + "Files to be restored:".bold(), + self.affected_files.len() + ); for (idx, file) in self.affected_files.iter().enumerate() { println!(" {}. {}", idx + 1, file); @@ -187,9 +199,12 @@ impl UndoPreview { let days = time_diff.num_days(); let hours = time_diff.num_hours() % 24; let mins = time_diff.num_minutes() % 60; - println!(" {} {} days, {} hours, {} minutes ago", + println!( + " {} {} days, {} hours, {} minutes ago", "Age:".dimmed(), - days, hours, mins + days, + hours, + mins ); } diff --git a/src/undo/restore.rs b/src/undo/restore.rs index dc12d12f..5508a766 100644 --- a/src/undo/restore.rs +++ b/src/undo/restore.rs @@ -51,9 +51,8 @@ impl Snapshot { // Validate the path is within allowed directory if let Ok(canonical) = path.canonicalize() { if canonical.starts_with(&allowed_base) && path.exists() { - fs::remove_file(&path).with_context(|| { - format!("Failed to delete file: {}", path.display()) - })?; + fs::remove_file(&path) + .with_context(|| format!("Failed to delete file: {}", path.display()))?; } } } diff --git a/src/undo/snapshot.rs b/src/undo/snapshot.rs index d372b6dd..e172d914 100644 --- a/src/undo/snapshot.rs +++ b/src/undo/snapshot.rs @@ -310,7 +310,10 @@ impl Snapshot { /// # Returns /// The most recent snapshot, or None if no snapshots exist #[allow(dead_code)] // used via the library target; the bin compiles this module separately - pub(crate) fn find_latest_snapshot(operation_type: OperationType, custom_dir: Option<&Path>) -> Result> { + pub(crate) fn find_latest_snapshot( + operation_type: OperationType, + custom_dir: Option<&Path>, + ) -> Result> { let snapshots_dir = if let Some(dir) = custom_dir { dir.to_path_buf() } else { @@ -483,7 +486,10 @@ impl Snapshot { /// /// # Returns /// A HashMap containing the full state of all files - pub fn reconstruct_full_state_with_dir(&self, snapshots_dir: Option<&Path>) -> Result>> { + pub fn reconstruct_full_state_with_dir( + &self, + snapshots_dir: Option<&Path>, + ) -> Result>> { let mut state = HashMap::new(); // If this is a differential snapshot, load the base chain @@ -533,5 +539,4 @@ impl Snapshot { pub(crate) fn snapshots_dir() -> Result { crate::config::ConfigManager::snapshots_dir() } - } diff --git a/tests/integration_sync_tests.rs b/tests/integration_sync_tests.rs index 15b025ee..212409b5 100644 --- a/tests/integration_sync_tests.rs +++ b/tests/integration_sync_tests.rs @@ -4,11 +4,11 @@ use tempfile::TempDir; use walkdir::WalkDir; // Import the necessary modules from claude_code_sync -use claude_code_sync::scm; use claude_code_sync::history::{ ConversationSummary, OperationHistory, OperationType, SyncOperation, }; use claude_code_sync::parser::ConversationSession; +use claude_code_sync::scm; use claude_code_sync::sync::SyncState; use claude_code_sync::undo::{undo_pull, undo_push, Snapshot}; @@ -514,7 +514,9 @@ fn test_conflict_handling() { fs::write(&m1_file, &m1_modified).unwrap(); let m1_session = ConversationSession::from_file(&m1_file).unwrap(); - let sync_file = sync_projects.join("test").join(format!("{session_id}.jsonl")); + let sync_file = sync_projects + .join("test") + .join(format!("{session_id}.jsonl")); fs::create_dir_all(sync_file.parent().unwrap()).unwrap(); m1_session.write_to_file(&sync_file).unwrap(); @@ -876,11 +878,8 @@ fn test_operation_record_with_no_commit_hash() { ) .unwrap(); - let mut record = OperationRecord::new( - OperationType::Push, - Some("main".to_string()), - vec![conv], - ); + let mut record = + OperationRecord::new(OperationType::Push, Some("main".to_string()), vec![conv]); // commit_hash should be None by default assert!(record.commit_hash.is_none()); diff --git a/tests/integration_tests.rs b/tests/integration_tests.rs index 65e98579..99ae7ab6 100644 --- a/tests/integration_tests.rs +++ b/tests/integration_tests.rs @@ -66,6 +66,7 @@ fn test_end_to_end_sync_workflow() { } #[test] +#[cfg(unix)] fn test_file_permissions() { use std::os::unix::fs::PermissionsExt; @@ -193,6 +194,7 @@ fn test_path_handling_with_spaces() { } #[test] +#[cfg(unix)] fn test_symlink_handling() { let temp_dir = TempDir::new().unwrap(); let real_file = temp_dir.path().join("real.jsonl"); @@ -200,11 +202,8 @@ fn test_symlink_handling() { fs::write(&real_file, r#"{"test":"data"}"#).unwrap(); - #[cfg(unix)] - { - std::os::unix::fs::symlink(&real_file, &link_file).unwrap(); - assert!(link_file.exists()); - } + std::os::unix::fs::symlink(&real_file, &link_file).unwrap(); + assert!(link_file.exists()); } #[test] diff --git a/tests/scm_backend_tests.rs b/tests/scm_backend_tests.rs index 2d81839b..bd073a0a 100644 --- a/tests/scm_backend_tests.rs +++ b/tests/scm_backend_tests.rs @@ -444,10 +444,7 @@ fn test_backend_selection_invalid() { scm_backend: "svn".to_string(), ..Default::default() }; - assert!( - config.backend().is_err(), - "Invalid backend should fail" - ); + assert!(config.backend().is_err(), "Invalid backend should fail"); } #[test] diff --git a/tests/test_artifact_e2e.rs b/tests/test_artifact_e2e.rs index 34e8ac5b..3eff9d45 100644 --- a/tests/test_artifact_e2e.rs +++ b/tests/test_artifact_e2e.rs @@ -1,6 +1,6 @@ //! Full-pipeline end-to-end tests for artifact sync: the real push_history / //! pull_history / sync_bidirectional / undo_pull entry points, real git -//! commits, and two simulated machines (distinct HOME + +//! commits, and two simulated machines (distinct CLAUDE_CODE_SYNC_CLAUDE_DIR + //! CLAUDE_CODE_SYNC_CONFIG_DIR sharing one sync repository). //! //! Serialized: HOME and the config-dir override are process-global. @@ -63,6 +63,9 @@ impl Machine { fn activate(&self) { std::env::set_var("HOME", &self.home); + // HOME alone is not enough on Windows (dirs::home_dir() ignores it + // there), so point the product at this machine's .claude explicitly. + std::env::set_var("CLAUDE_CODE_SYNC_CLAUDE_DIR", self.home.join(".claude")); std::env::set_var("CLAUDE_CODE_SYNC_CONFIG_DIR", &self.config); } @@ -73,6 +76,7 @@ impl Machine { struct EnvRestore { home: Option, + claude: Option, cfg: Option, } @@ -80,6 +84,7 @@ impl EnvRestore { fn capture() -> Self { Self { home: std::env::var("HOME").ok(), + claude: std::env::var("CLAUDE_CODE_SYNC_CLAUDE_DIR").ok(), cfg: std::env::var("CLAUDE_CODE_SYNC_CONFIG_DIR").ok(), } } @@ -91,6 +96,10 @@ impl Drop for EnvRestore { Some(v) => std::env::set_var("HOME", v), None => std::env::remove_var("HOME"), } + match &self.claude { + Some(v) => std::env::set_var("CLAUDE_CODE_SYNC_CLAUDE_DIR", v), + None => std::env::remove_var("CLAUDE_CODE_SYNC_CLAUDE_DIR"), + } match &self.cfg { Some(v) => std::env::set_var("CLAUDE_CODE_SYNC_CONFIG_DIR", v), None => std::env::remove_var("CLAUDE_CODE_SYNC_CONFIG_DIR"), @@ -176,9 +185,7 @@ fn test_full_pipeline_push_pull_undo_across_two_machines() { // The push record carries per-category artifact counts. let history = OperationHistory::load().unwrap(); - let last = history.get_last_operation_by_type( - claude_code_sync::history::OperationType::Push, - ); + let last = history.get_last_operation_by_type(claude_code_sync::history::OperationType::Push); assert!(!last.unwrap().artifact_counts.is_empty()); // ---- Machine B pulls the environment onto a fresh home ---- @@ -187,18 +194,26 @@ fn test_full_pipeline_push_pull_undo_across_two_machines() { pull_history(false, None, false, VerbosityLevel::Quiet).unwrap(); let b = machine_b.claude(); - assert_eq!(fs::read(b.join("settings.json")).unwrap(), b"{\"model\":\"opus\"}"); + assert_eq!( + fs::read(b.join("settings.json")).unwrap(), + b"{\"model\":\"opus\"}" + ); assert_eq!(fs::read(b.join("CLAUDE.md")).unwrap(), b"# global memory\n"); assert!(b.join("skills/deploy/SKILL.md").is_file()); assert!(b.join("projects/-home-user-webapp/diagram.png").is_file()); - assert!(b.join("projects/-home-user-webapp/memory/MEMORY.md").is_file()); + assert!(b + .join("projects/-home-user-webapp/memory/MEMORY.md") + .is_file()); assert!(b.join("history.jsonl").is_file()); assert!(!b.join(".credentials.json").exists()); // ---- Undo the pull: every artifact the pull created disappears ---- let summary = claude_code_sync::undo::undo_pull(None, Some(&machine_b.home)).unwrap(); assert!(summary.contains("undone"), "undo summary: {summary}"); - assert!(!b.join("settings.json").exists(), "created settings removed"); + assert!( + !b.join("settings.json").exists(), + "created settings removed" + ); assert!(!b.join("skills/deploy/SKILL.md").exists()); assert!(!b.join("CLAUDE.md").exists()); @@ -218,9 +233,24 @@ fn test_full_pipeline_second_push_creates_no_commit() { machine.activate(); seed_full_claude_home(&machine.claude()); - push_history(Some("first"), false, None, false, false, VerbosityLevel::Quiet).unwrap(); - let report = push_history(Some("second"), false, None, false, false, VerbosityLevel::Quiet) - .unwrap(); + push_history( + Some("first"), + false, + None, + false, + false, + VerbosityLevel::Quiet, + ) + .unwrap(); + let report = push_history( + Some("second"), + false, + None, + false, + false, + VerbosityLevel::Quiet, + ) + .unwrap(); // Issue #68 end-to-end: nothing changed, nothing is added/modified, // and git records no second commit. @@ -266,7 +296,11 @@ fn test_full_pipeline_sync_converges_prompt_history() { assert!(a_history.contains("from B")); let ts: Vec = a_history .lines() - .map(|l| serde_json::from_str::(l).unwrap()["timestamp"].as_u64().unwrap()) + .map(|l| { + serde_json::from_str::(l).unwrap()["timestamp"] + .as_u64() + .unwrap() + }) .collect(); assert_eq!(ts, vec![1000, 2000], "chronological order"); } diff --git a/tests/test_artifact_sync.rs b/tests/test_artifact_sync.rs index 182243fe..d3d85b1e 100644 --- a/tests/test_artifact_sync.rs +++ b/tests/test_artifact_sync.rs @@ -147,7 +147,11 @@ fn test_modified_detection_on_changed_settings() { let filter = all_on_filter(); push_artifacts(claude.path(), repo.path(), &filter).unwrap(); - fs::write(claude.path().join("settings.json"), b"{\"model\":\"sonnet\"}").unwrap(); + fs::write( + claude.path().join("settings.json"), + b"{\"model\":\"sonnet\"}", + ) + .unwrap(); let report = push_artifacts(claude.path(), repo.path(), &filter).unwrap(); let settings = report @@ -246,14 +250,21 @@ fn test_push_unions_prompt_history() { fs::create_dir_all(repo_history.parent().unwrap()).unwrap(); fs::write( &repo_history, - format!("{}{}", history_line(500, "other-machine"), history_line(1000, "one")), + format!( + "{}{}", + history_line(500, "other-machine"), + history_line(1000, "one") + ), ) .unwrap(); let report = push_artifacts(claude.path(), repo.path(), &filter).unwrap(); let merged = fs::read_to_string(&repo_history).unwrap(); - assert!(merged.contains("other-machine"), "repo-only line survives push"); + assert!( + merged.contains("other-machine"), + "repo-only line survives push" + ); assert!(merged.contains("one")); assert_eq!(merged.lines().count(), 2, "shared line dedups"); @@ -301,7 +312,10 @@ fn test_ignore_file_managed_block_is_idempotent_and_preserving() { assert!(changed_first, "first run writes the block"); assert!(!changed_second, "second run is a no-op"); assert_eq!(after_first, after_second); - assert!(after_first.starts_with("user-stuff/\n"), "user content preserved"); + assert!( + after_first.starts_with("user-stuff/\n"), + "user content preserved" + ); assert!(after_first.contains(".credentials.json")); assert!(after_first.contains("settings.local.json")); assert!(after_first.contains("*.pem")); @@ -339,7 +353,10 @@ fn test_pull_restores_artifacts_to_fresh_machine() { let plan = plan_pull(machine_b.path(), repo.path(), &filter).unwrap(); assert!(!plan.is_empty()); - assert!(plan.overwrites.is_empty(), "fresh machine has nothing to overwrite"); + assert!( + plan.overwrites.is_empty(), + "fresh machine has nothing to overwrite" + ); let report = apply_pull(&plan, false).unwrap(); assert_eq!( @@ -351,7 +368,10 @@ fn test_pull_restores_artifacts_to_fresh_machine() { b"# skill\n" ); assert!(machine_b.path().join("CLAUDE.md").is_file()); - assert!(machine_b.path().join("plugins/installed_plugins.json").is_file()); + assert!(machine_b + .path() + .join("plugins/installed_plugins.json") + .is_file()); assert!(machine_b.path().join("history.jsonl").is_file()); assert!(report.total_added() >= 12); assert_eq!(report.total_modified(), 0); @@ -367,7 +387,11 @@ fn test_pull_remote_wins_when_bytes_differ() { push_artifacts(machine_a.path(), repo.path(), &filter).unwrap(); // Machine B has its own, different settings. - fs::write(machine_b.path().join("settings.json"), b"{\"model\":\"local\"}").unwrap(); + fs::write( + machine_b.path().join("settings.json"), + b"{\"model\":\"local\"}", + ) + .unwrap(); let plan = plan_pull(machine_b.path(), repo.path(), &filter).unwrap(); let overwrite_targets: Vec<_> = plan @@ -395,7 +419,10 @@ fn test_pull_does_not_rewrite_identical_files() { // Pulling straight back into the same machine: everything identical. let plan = plan_pull(machine_a.path(), repo.path(), &filter).unwrap(); - assert!(plan.is_empty(), "no writes planned when bytes match: {plan:?}"); + assert!( + plan.is_empty(), + "no writes planned when bytes match: {plan:?}" + ); assert!(plan.unchanged >= 12); } @@ -407,8 +434,20 @@ fn test_pull_unions_prompt_history_with_local() { let repo_history = repo.path().join("artifacts/prompt-history/history.jsonl"); fs::create_dir_all(repo_history.parent().unwrap()).unwrap(); - fs::write(&repo_history, format!("{}{}", history_line(500, "remote-old"), history_line(2000, "remote-new"))).unwrap(); - fs::write(machine_b.path().join("history.jsonl"), history_line(1000, "local-only")).unwrap(); + fs::write( + &repo_history, + format!( + "{}{}", + history_line(500, "remote-old"), + history_line(2000, "remote-new") + ), + ) + .unwrap(); + fs::write( + machine_b.path().join("history.jsonl"), + history_line(1000, "local-only"), + ) + .unwrap(); let plan = plan_pull(machine_b.path(), repo.path(), &filter).unwrap(); let report = apply_pull(&plan, false).unwrap(); @@ -416,10 +455,23 @@ fn test_pull_unions_prompt_history_with_local() { let merged = fs::read_to_string(machine_b.path().join("history.jsonl")).unwrap(); let displays: Vec<_> = merged .lines() - .map(|l| serde_json::from_str::(l).unwrap()["display"].as_str().unwrap().to_string()) + .map(|l| { + serde_json::from_str::(l).unwrap()["display"] + .as_str() + .unwrap() + .to_string() + }) .collect(); - assert_eq!(displays, vec!["remote-old", "local-only", "remote-new"], "union, chronological"); - let ph = report.counts.iter().find(|c| c.category == CategoryId::PromptHistory).unwrap(); + assert_eq!( + displays, + vec!["remote-old", "local-only", "remote-new"], + "union, chronological" + ); + let ph = report + .counts + .iter() + .find(|c| c.category == CategoryId::PromptHistory) + .unwrap(); assert_eq!(ph.merged_entries, 2, "two remote lines were new locally"); } @@ -432,7 +484,11 @@ fn test_pull_refuses_denied_files_planted_in_repo() { // A poisoned sync repo tries to deliver credentials and key material. fs::create_dir_all(repo.path().join("artifacts/settings")).unwrap(); fs::write(repo.path().join("artifacts/settings/settings.json"), b"{}").unwrap(); - fs::write(repo.path().join("artifacts/settings/.credentials.json"), b"{\"t\":1}").unwrap(); + fs::write( + repo.path().join("artifacts/settings/.credentials.json"), + b"{\"t\":1}", + ) + .unwrap(); fs::create_dir_all(repo.path().join("artifacts/skills/s")).unwrap(); fs::write(repo.path().join("artifacts/skills/s/evil.pem"), b"PEM").unwrap(); fs::write(repo.path().join("artifacts/skills/s/ok.md"), b"fine").unwrap(); @@ -444,7 +500,10 @@ fn test_pull_refuses_denied_files_planted_in_repo() { assert!(machine_b.path().join("skills/s/ok.md").is_file()); assert!(!machine_b.path().join(".credentials.json").exists()); assert!(!machine_b.path().join("skills/s/evil.pem").exists()); - assert!(plan.skipped >= 2, "denied repo files are counted as skipped"); + assert!( + plan.skipped >= 2, + "denied repo files are counted as skipped" + ); } #[test] @@ -462,16 +521,16 @@ fn test_pull_plan_snapshot_paths_enable_exact_undo() { push_artifacts(machine_a.path(), repo.path(), &filter).unwrap(); // Machine B: one file that will be overwritten, everything else created. - fs::write(machine_b.path().join("settings.json"), b"{\"model\":\"mine\"}").unwrap(); + fs::write( + machine_b.path().join("settings.json"), + b"{\"model\":\"mine\"}", + ) + .unwrap(); let plan = plan_pull(machine_b.path(), repo.path(), &filter).unwrap(); - let mut snapshot = Snapshot::create( - OperationType::Pull, - plan.paths_to_snapshot().iter(), - None, - ) - .unwrap(); + let mut snapshot = + Snapshot::create(OperationType::Pull, plan.paths_to_snapshot().iter(), None).unwrap(); snapshot.deleted_files = plan.created_paths(); apply_pull(&plan, false).unwrap(); @@ -525,7 +584,10 @@ fn test_attachments_push_copies_non_jsonl_into_session_tree() { let report = push_artifacts(claude.path(), repo.path(), &filter).unwrap(); let proj = repo.path().join("projects/-home-user-myproj"); - assert!(proj.join("diagram.png").is_file(), "attachment lands in session tree"); + assert!( + proj.join("diagram.png").is_file(), + "attachment lands in session tree" + ); assert!( proj.join("memory/MEMORY.md").is_file(), "project memory syncs as attachment" @@ -534,7 +596,10 @@ fn test_attachments_push_copies_non_jsonl_into_session_tree() { !proj.join("abc-123.jsonl").exists(), "session transcripts belong to the session pipeline, not the engine" ); - assert!(!repo.path().join("artifacts").exists(), "no artifacts dir needed"); + assert!( + !repo.path().join("artifacts").exists(), + "no artifacts dir needed" + ); let att = report .counts @@ -655,7 +720,11 @@ fn test_pull_refuses_unlisted_files_in_allowlist_categories() { ) .unwrap(); fs::create_dir_all(repo.path().join("artifacts/plugins")).unwrap(); - fs::write(repo.path().join("artifacts/plugins/rogue-manifest.json"), b"{}").unwrap(); + fs::write( + repo.path().join("artifacts/plugins/rogue-manifest.json"), + b"{}", + ) + .unwrap(); let plan = plan_pull(machine_b.path(), repo.path(), &filter).unwrap(); apply_pull(&plan, false).unwrap(); @@ -663,8 +732,14 @@ fn test_pull_refuses_unlisted_files_in_allowlist_categories() { assert!(machine_b.path().join("settings.json").is_file()); for entry in walkdir::WalkDir::new(machine_b.path()) { let name = entry.unwrap().file_name().to_string_lossy().to_string(); - assert_ne!(name, "unexpected.json", "unlisted file must not be restored"); + assert_ne!( + name, "unexpected.json", + "unlisted file must not be restored" + ); assert_ne!(name, "rogue-manifest.json"); } - assert!(plan.skipped >= 2, "unlisted allowlist files count as skipped"); + assert!( + plan.skipped >= 2, + "unlisted allowlist files count as skipped" + ); } diff --git a/tests/test_differential_snapshots.rs b/tests/test_differential_snapshots.rs index 24e3a3a2..884df204 100644 --- a/tests/test_differential_snapshots.rs +++ b/tests/test_differential_snapshots.rs @@ -3,9 +3,9 @@ use std::fs; use std::path::{Path, PathBuf}; use tempfile::TempDir; -use claude_code_sync::scm; use claude_code_sync::history::OperationType; use claude_code_sync::parser::ConversationEntry; +use claude_code_sync::scm; use claude_code_sync::undo::Snapshot; // ============================================================================ @@ -106,7 +106,9 @@ fn modify_conversation(conv_path: &Path, additional_message: &str) -> Result<()> // Parse existing content to get session_id let first_line = content.lines().next().unwrap_or("{}"); let first_entry: ConversationEntry = serde_json::from_str(first_line)?; - let session_id = first_entry.session_id.unwrap_or_else(|| "session-unknown".to_string()); + let session_id = first_entry + .session_id + .unwrap_or_else(|| "session-unknown".to_string()); // Count existing entries to generate unique UUID let entry_count = content.lines().filter(|l| !l.trim().is_empty()).count(); @@ -187,11 +189,8 @@ fn test_differential_snapshots_minimize_disk_usage() { // First Push: Creates full snapshot // ======================================================================== println!("\nFirst push: Creating full snapshot..."); - let (snapshot1, snapshot1_path) = push_and_get_snapshot( - &snapshots_dir, - &all_files, - None, - ).unwrap(); + let (snapshot1, snapshot1_path) = + push_and_get_snapshot(&snapshots_dir, &all_files, None).unwrap(); assert!( snapshot1.base_snapshot_id.is_none(), @@ -199,7 +198,11 @@ fn test_differential_snapshots_minimize_disk_usage() { ); let size1 = calculate_snapshot_size(&snapshot1_path).unwrap(); - println!(" Snapshot 1 size: {} bytes ({:.2} MB)", size1, size1 as f64 / 1_000_000.0); + println!( + " Snapshot 1 size: {} bytes ({:.2} MB)", + size1, + size1 as f64 / 1_000_000.0 + ); println!(" Files in snapshot: {}", snapshot1.files.len()); // Full snapshot should contain all files @@ -220,11 +223,8 @@ fn test_differential_snapshots_minimize_disk_usage() { // Second Push: No changes, should create tiny differential // ======================================================================== println!("\nSecond push: No changes, creating differential snapshot..."); - let (snapshot2, snapshot2_path) = push_and_get_snapshot( - &snapshots_dir, - &all_files, - None, - ).unwrap(); + let (snapshot2, snapshot2_path) = + push_and_get_snapshot(&snapshots_dir, &all_files, None).unwrap(); assert!( snapshot2.base_snapshot_id.is_some(), @@ -237,7 +237,11 @@ fn test_differential_snapshots_minimize_disk_usage() { ); let size2 = calculate_snapshot_size(&snapshot2_path).unwrap(); - println!(" Snapshot 2 size: {} bytes ({:.2} KB)", size2, size2 as f64 / 1_000.0); + println!( + " Snapshot 2 size: {} bytes ({:.2} KB)", + size2, + size2 as f64 / 1_000.0 + ); println!(" Files in snapshot: {}", snapshot2.files.len()); println!(" Deleted files: {}", snapshot2.deleted_files.len()); @@ -257,8 +261,10 @@ fn test_differential_snapshots_minimize_disk_usage() { size2 ); - println!(" ✓ Space saved: {:.2}% compared to full snapshot", - (1.0 - size2 as f64 / size1 as f64) * 100.0); + println!( + " ✓ Space saved: {:.2}% compared to full snapshot", + (1.0 - size2 as f64 / size1 as f64) * 100.0 + ); // ======================================================================== // Third Push: Small change to one file @@ -266,11 +272,8 @@ fn test_differential_snapshots_minimize_disk_usage() { println!("\nThird push: Modifying one conversation..."); modify_conversation(&conv1, "This is a small additional message").unwrap(); - let (snapshot3, snapshot3_path) = push_and_get_snapshot( - &snapshots_dir, - &all_files, - None, - ).unwrap(); + let (snapshot3, snapshot3_path) = + push_and_get_snapshot(&snapshots_dir, &all_files, None).unwrap(); assert!( snapshot3.base_snapshot_id.is_some(), @@ -283,7 +286,11 @@ fn test_differential_snapshots_minimize_disk_usage() { ); let size3 = calculate_snapshot_size(&snapshot3_path).unwrap(); - println!(" Snapshot 3 size: {} bytes ({:.2} KB)", size3, size3 as f64 / 1_000.0); + println!( + " Snapshot 3 size: {} bytes ({:.2} KB)", + size3, + size3 as f64 / 1_000.0 + ); println!(" Files in snapshot: {}", snapshot3.files.len()); // KEY ASSERTION: Only one file should be in differential @@ -309,8 +316,10 @@ fn test_differential_snapshots_minimize_disk_usage() { size1 ); - println!(" ✓ Space saved: {:.2}% compared to full snapshot", - (1.0 - size3 as f64 / size1 as f64) * 100.0); + println!( + " ✓ Space saved: {:.2}% compared to full snapshot", + (1.0 - size3 as f64 / size1 as f64) * 100.0 + ); // ======================================================================== // Fourth Push: Add a new file @@ -319,14 +328,15 @@ fn test_differential_snapshots_minimize_disk_usage() { let conv4 = create_large_conversation(&conversations_dir, "conv4", 1_000_000).unwrap(); let all_files_with_new = vec![conv1.clone(), conv2.clone(), conv3.clone(), conv4.clone()]; - let (snapshot4, snapshot4_path) = push_and_get_snapshot( - &snapshots_dir, - &all_files_with_new, - None, - ).unwrap(); + let (snapshot4, snapshot4_path) = + push_and_get_snapshot(&snapshots_dir, &all_files_with_new, None).unwrap(); let size4 = calculate_snapshot_size(&snapshot4_path).unwrap(); - println!(" Snapshot 4 size: {} bytes ({:.2} KB)", size4, size4 as f64 / 1_000.0); + println!( + " Snapshot 4 size: {} bytes ({:.2} KB)", + size4, + size4 as f64 / 1_000.0 + ); println!(" Files in snapshot: {}", snapshot4.files.len()); // Should contain only the new file @@ -351,8 +361,14 @@ fn test_differential_snapshots_minimize_disk_usage() { println!("\n=== Summary ==="); println!("Full snapshot size: {:.2} MB", size1 as f64 / 1_000_000.0); - println!("Total differential size: {:.2} MB", total_differential_size as f64 / 1_000_000.0); - println!("Would-be full size: {:.2} MB", would_be_full_size as f64 / 1_000_000.0); + println!( + "Total differential size: {:.2} MB", + total_differential_size as f64 / 1_000_000.0 + ); + println!( + "Would-be full size: {:.2} MB", + would_be_full_size as f64 / 1_000_000.0 + ); println!("Space savings: {:.1}%", savings_ratio * 100.0); assert!( @@ -387,11 +403,8 @@ fn test_snapshot_chain_reconstruction() { println!("Creating snapshot chain..."); // Snapshot 1: Initial state (file1, file2) - let (snapshot1, _) = push_and_get_snapshot( - &snapshots_dir, - &[file1.clone(), file2.clone()], - None, - ).unwrap(); + let (snapshot1, _) = + push_and_get_snapshot(&snapshots_dir, &[file1.clone(), file2.clone()], None).unwrap(); println!(" Snapshot 1: 2 files"); // Snapshot 2: Modify file1, add file3 @@ -402,8 +415,12 @@ fn test_snapshot_chain_reconstruction() { &snapshots_dir, &[file1.clone(), file2.clone(), file3.clone()], None, - ).unwrap(); - println!(" Snapshot 2: {} files (differential)", snapshot2.files.len()); + ) + .unwrap(); + println!( + " Snapshot 2: {} files (differential)", + snapshot2.files.len() + ); // Snapshot 3: Modify file2 fs::write(&file2, b"version_3").unwrap(); @@ -412,8 +429,12 @@ fn test_snapshot_chain_reconstruction() { &snapshots_dir, &[file1.clone(), file2.clone(), file3.clone()], None, - ).unwrap(); - println!(" Snapshot 3: {} files (differential)", snapshot3.files.len()); + ) + .unwrap(); + println!( + " Snapshot 3: {} files (differential)", + snapshot3.files.len() + ); // Snapshot 4: Modify file3 fs::write(&file3, b"version_4").unwrap(); @@ -422,11 +443,18 @@ fn test_snapshot_chain_reconstruction() { &snapshots_dir, &[file1.clone(), file2.clone(), file3.clone()], None, - ).unwrap(); - println!(" Snapshot 4: {} files (differential)", snapshot4.files.len()); + ) + .unwrap(); + println!( + " Snapshot 4: {} files (differential)", + snapshot4.files.len() + ); // Verify chain structure - assert!(snapshot1.base_snapshot_id.is_none(), "First snapshot should have no base"); + assert!( + snapshot1.base_snapshot_id.is_none(), + "First snapshot should have no base" + ); assert_eq!( snapshot2.base_snapshot_id.as_ref().unwrap(), &snapshot1.snapshot_id, @@ -445,7 +473,9 @@ fn test_snapshot_chain_reconstruction() { // Reconstruct full state from snapshot 4 println!("\nReconstructing full state from snapshot 4..."); - let full_state = snapshot4.reconstruct_full_state_with_dir(Some(&snapshots_dir)).unwrap(); + let full_state = snapshot4 + .reconstruct_full_state_with_dir(Some(&snapshots_dir)) + .unwrap(); println!(" Reconstructed {} files", full_state.len()); @@ -500,7 +530,8 @@ fn test_deleted_files_tracking() { &snapshots_dir, &[file1.clone(), file2.clone(), file3.clone()], None, - ).unwrap(); + ) + .unwrap(); assert_eq!(snapshot1.files.len(), 3); assert!(snapshot1.deleted_files.is_empty()); @@ -509,11 +540,8 @@ fn test_deleted_files_tracking() { println!("Deleting file2..."); fs::remove_file(&file2).unwrap(); - let (snapshot2, _) = push_and_get_snapshot( - &snapshots_dir, - &[file1.clone(), file3.clone()], - None, - ).unwrap(); + let (snapshot2, _) = + push_and_get_snapshot(&snapshots_dir, &[file1.clone(), file3.clone()], None).unwrap(); println!(" Snapshot 2 deleted files: {:?}", snapshot2.deleted_files); @@ -531,19 +559,25 @@ fn test_deleted_files_tracking() { ); // Reconstruct and verify file2 is not in the state - let full_state = snapshot2.reconstruct_full_state_with_dir(Some(&snapshots_dir)).unwrap(); - assert_eq!(full_state.len(), 2, "Should only have 2 files after deletion"); - assert!(!full_state.contains_key(&file2_key), "file2 should not be in reconstructed state"); + let full_state = snapshot2 + .reconstruct_full_state_with_dir(Some(&snapshots_dir)) + .unwrap(); + assert_eq!( + full_state.len(), + 2, + "Should only have 2 files after deletion" + ); + assert!( + !full_state.contains_key(&file2_key), + "file2 should not be in reconstructed state" + ); // Delete another file println!("Deleting file3..."); fs::remove_file(&file3).unwrap(); - let (snapshot3, _) = push_and_get_snapshot( - &snapshots_dir, - std::slice::from_ref(&file1), - None, - ).unwrap(); + let (snapshot3, _) = + push_and_get_snapshot(&snapshots_dir, std::slice::from_ref(&file1), None).unwrap(); println!(" Snapshot 3 deleted files: {:?}", snapshot3.deleted_files); @@ -555,7 +589,9 @@ fn test_deleted_files_tracking() { ); // Reconstruct final state - let final_state = snapshot3.reconstruct_full_state_with_dir(Some(&snapshots_dir)).unwrap(); + let final_state = snapshot3 + .reconstruct_full_state_with_dir(Some(&snapshots_dir)) + .unwrap(); assert_eq!(final_state.len(), 1, "Should only have 1 file remaining"); let file1_key = file1.to_string_lossy().to_string(); @@ -579,27 +615,18 @@ fn test_broken_snapshot_chain() { fs::write(&file1, b"content1").unwrap(); // Create snapshot 1 - let (snapshot1, _snapshot1_path) = push_and_get_snapshot( - &snapshots_dir, - std::slice::from_ref(&file1), - None, - ).unwrap(); + let (snapshot1, _snapshot1_path) = + push_and_get_snapshot(&snapshots_dir, std::slice::from_ref(&file1), None).unwrap(); // Modify and create snapshot 2 fs::write(&file1, b"content2").unwrap(); - let (_snapshot2, snapshot2_path) = push_and_get_snapshot( - &snapshots_dir, - std::slice::from_ref(&file1), - None, - ).unwrap(); + let (_snapshot2, snapshot2_path) = + push_and_get_snapshot(&snapshots_dir, std::slice::from_ref(&file1), None).unwrap(); // Modify and create snapshot 3 fs::write(&file1, b"content3").unwrap(); - let (snapshot3, _snapshot3_path) = push_and_get_snapshot( - &snapshots_dir, - std::slice::from_ref(&file1), - None, - ).unwrap(); + let (snapshot3, _snapshot3_path) = + push_and_get_snapshot(&snapshots_dir, std::slice::from_ref(&file1), None).unwrap(); println!("Created chain: snapshot1 <- snapshot2 <- snapshot3"); @@ -615,13 +642,16 @@ fn test_broken_snapshot_chain() { let error_msg = result.unwrap_err().to_string(); println!(" Error: {}", error_msg); assert!( - error_msg.contains("Base snapshot not found") || error_msg.contains("snapshot chain is broken"), + error_msg.contains("Base snapshot not found") + || error_msg.contains("snapshot chain is broken"), "Error should mention missing base snapshot" ); // Verify snapshot1 can still be reconstructed (it's a full snapshot) println!("Verifying snapshot1 can still be reconstructed..."); - let state1 = snapshot1.reconstruct_full_state_with_dir(Some(&snapshots_dir)).unwrap(); + let state1 = snapshot1 + .reconstruct_full_state_with_dir(Some(&snapshots_dir)) + .unwrap(); assert_eq!(state1.len(), 1, "Should successfully reconstruct snapshot1"); println!("\n✓ Test passed! Broken chain detection works correctly.\n"); @@ -656,9 +686,13 @@ fn test_differential_snapshot_with_scm() { &snapshots_dir, std::slice::from_ref(&file1), Some(&initial_commit), - ).unwrap(); + ) + .unwrap(); - assert!(snapshot1.git_commit_hash.is_some(), "Snapshot should capture commit hash"); + assert!( + snapshot1.git_commit_hash.is_some(), + "Snapshot should capture commit hash" + ); assert_eq!( snapshot1.git_commit_hash.as_ref().unwrap(), &initial_commit, @@ -678,9 +712,13 @@ fn test_differential_snapshot_with_scm() { &snapshots_dir, std::slice::from_ref(&file1), Some(&second_commit), - ).unwrap(); + ) + .unwrap(); - assert!(snapshot2.base_snapshot_id.is_some(), "Should be differential"); + assert!( + snapshot2.base_snapshot_id.is_some(), + "Should be differential" + ); assert_eq!( snapshot2.git_commit_hash.as_ref().unwrap(), &second_commit, @@ -705,23 +743,22 @@ fn test_performance_differential_vs_full() { println!("Creating 5 large conversation files..."); let mut files = Vec::new(); for i in 0..5 { - let file = create_large_conversation(&conversations_dir, &format!("perf_test_{}", i), 500_000).unwrap(); + let file = + create_large_conversation(&conversations_dir, &format!("perf_test_{}", i), 500_000) + .unwrap(); files.push(file); } // Measure full snapshot creation time println!("\nMeasuring full snapshot creation..."); let start = std::time::Instant::now(); - let snapshot = Snapshot::create( - OperationType::Push, - files.iter(), - None, - ).unwrap(); + let snapshot = Snapshot::create(OperationType::Push, files.iter(), None).unwrap(); let full_duration = start.elapsed(); let snapshot_path = snapshot.save_to_disk(Some(&snapshots_dir)).unwrap(); let full_size = calculate_snapshot_size(&snapshot_path).unwrap(); - println!(" Full snapshot: {:.2}ms, {:.2} MB", + println!( + " Full snapshot: {:.2}ms, {:.2} MB", full_duration.as_secs_f64() * 1000.0, full_size as f64 / 1_000_000.0 ); @@ -737,12 +774,14 @@ fn test_performance_differential_vs_full() { files.iter(), None, Some(&snapshots_dir), - ).unwrap(); + ) + .unwrap(); let diff_duration = start.elapsed(); let diff_snapshot_path = diff_snapshot.save_to_disk(Some(&snapshots_dir)).unwrap(); let diff_size = calculate_snapshot_size(&diff_snapshot_path).unwrap(); - println!(" Differential snapshot: {:.2}ms, {:.2} KB", + println!( + " Differential snapshot: {:.2}ms, {:.2} KB", diff_duration.as_secs_f64() * 1000.0, diff_size as f64 / 1_000.0 ); @@ -782,31 +821,30 @@ fn test_empty_differential_snapshot() { // Create first snapshot println!("Creating initial snapshot..."); - let (_snapshot1, snapshot1_path) = push_and_get_snapshot( - &snapshots_dir, - std::slice::from_ref(&file1), - None, - ).unwrap(); + let (_snapshot1, snapshot1_path) = + push_and_get_snapshot(&snapshots_dir, std::slice::from_ref(&file1), None).unwrap(); let size1 = calculate_snapshot_size(&snapshot1_path).unwrap(); println!(" Initial snapshot: {} bytes", size1); // Create second snapshot with NO changes println!("Creating differential snapshot with no changes..."); - let (snapshot2, snapshot2_path) = push_and_get_snapshot( - &snapshots_dir, - std::slice::from_ref(&file1), - None, - ).unwrap(); + let (snapshot2, snapshot2_path) = + push_and_get_snapshot(&snapshots_dir, std::slice::from_ref(&file1), None).unwrap(); let size2 = calculate_snapshot_size(&snapshot2_path).unwrap(); println!(" Differential snapshot: {} bytes", size2); // Should be nearly empty assert!(snapshot2.files.is_empty(), "No files should have changed"); - assert!(snapshot2.deleted_files.is_empty(), "No files should be deleted"); + assert!( + snapshot2.deleted_files.is_empty(), + "No files should be deleted" + ); assert!(size2 < 1_000, "Should be < 1KB (got {} bytes)", size2); // Verify reconstruction still works - let reconstructed = snapshot2.reconstruct_full_state_with_dir(Some(&snapshots_dir)).unwrap(); + let reconstructed = snapshot2 + .reconstruct_full_state_with_dir(Some(&snapshots_dir)) + .unwrap(); assert_eq!(reconstructed.len(), 1, "Should reconstruct 1 file"); let file1_key = file1.to_string_lossy().to_string(); diff --git a/tests/test_interactive_handlers.rs b/tests/test_interactive_handlers.rs index 1e4645b5..571036b7 100644 --- a/tests/test_interactive_handlers.rs +++ b/tests/test_interactive_handlers.rs @@ -1,6 +1,6 @@ -use claude_code_sync::VerbosityLevel; -use claude_code_sync::undo::{UndoPreview, VerbosityLevel as UndoVerbosity}; use claude_code_sync::history::OperationType; +use claude_code_sync::undo::{UndoPreview, VerbosityLevel as UndoVerbosity}; +use claude_code_sync::VerbosityLevel; /// Test VerbosityLevel enum basic functionality #[test] @@ -172,7 +172,10 @@ fn test_filter_config_clone() { assert_eq!(config.max_file_size_bytes, cloned.max_file_size_bytes); assert_eq!(config.exclude_attachments, cloned.exclude_attachments); - assert_eq!(config.exclude_older_than_days, cloned.exclude_older_than_days); + assert_eq!( + config.exclude_older_than_days, + cloned.exclude_older_than_days + ); } /// Test that FilterConfig can be modified (needed for wizard) diff --git a/tests/test_onboarding.rs b/tests/test_onboarding.rs index 5ebc796a..f15a9490 100644 --- a/tests/test_onboarding.rs +++ b/tests/test_onboarding.rs @@ -313,10 +313,7 @@ fn test_init_sync_repo_with_remote_creates_filter_config() -> Result<()> { std::env::set_var("CLAUDE_CODE_SYNC_CONFIG_DIR", temp_dir.path()); // Initialize with remote URL using init_sync_repo (simulates --repo --remote flags) - claude_code_sync::sync::init_sync_repo( - &repo_path, - Some("https://github.com/user/repo.git"), - )?; + claude_code_sync::sync::init_sync_repo(&repo_path, Some("https://github.com/user/repo.git"))?; // Verify state was saved with remote let state = SyncState::load()?; @@ -394,10 +391,18 @@ fn test_clone_with_invalid_url_fails() -> Result<()> { #[test] fn test_clone_creates_parent_directories() -> Result<()> { let temp_dir = setup_test_config_env()?; - let nested_path = temp_dir.path().join("deeply").join("nested").join("path").join("repo"); + let nested_path = temp_dir + .path() + .join("deeply") + .join("nested") + .join("path") + .join("repo"); // Even though clone will fail (invalid URL), it should create parent directories - let result = scm::clone("https://invalid-url-that-wont-work.example.com/repo.git", &nested_path); + let result = scm::clone( + "https://invalid-url-that-wont-work.example.com/repo.git", + &nested_path, + ); // Clone fails but parent directory should be created assert!(result.is_err()); @@ -430,7 +435,10 @@ fn test_init_from_onboarding_sets_is_cloned_flag() -> Result<()> { let state = SyncState::load()?; assert_eq!(state.sync_repo_path, repo_path); assert!(state.has_remote); - assert!(state.is_cloned_repo, "is_cloned_repo should be true for cloned repos"); + assert!( + state.is_cloned_repo, + "is_cloned_repo should be true for cloned repos" + ); // Clean up env var std::env::remove_var("CLAUDE_CODE_SYNC_CONFIG_DIR"); @@ -452,16 +460,17 @@ fn test_init_from_onboarding_local_repo_not_cloned() -> Result<()> { // Test init_from_onboarding with is_cloned = false (local repo) claude_code_sync::sync::init_from_onboarding( - &repo_path, - None, - false, // not cloned + &repo_path, None, false, // not cloned )?; // Verify state was saved with is_cloned_repo = false let state = SyncState::load()?; assert_eq!(state.sync_repo_path, repo_path); assert!(!state.has_remote); - assert!(!state.is_cloned_repo, "is_cloned_repo should be false for local repos"); + assert!( + !state.is_cloned_repo, + "is_cloned_repo should be false for local repos" + ); // Clean up env var std::env::remove_var("CLAUDE_CODE_SYNC_CONFIG_DIR"); diff --git a/tests/test_push_plan.rs b/tests/test_push_plan.rs index b2c2d906..1c4c1278 100644 --- a/tests/test_push_plan.rs +++ b/tests/test_push_plan.rs @@ -78,7 +78,10 @@ fn test_second_push_plan_is_all_unchanged_despite_shared_session_id() { let sessions2 = discover_sessions(claude.path(), &filter).unwrap(); let plan2 = plan_push(&sessions2, claude.path(), repo_projects.path(), &filter).unwrap(); assert_eq!(plan2.added, 0, "second push must add nothing"); - assert_eq!(plan2.modified, 0, "second push must modify nothing (issue #68)"); + assert_eq!( + plan2.modified, 0, + "second push must modify nothing (issue #68)" + ); assert_eq!(plan2.unchanged, 3, "all three files are unchanged"); } @@ -116,6 +119,9 @@ fn test_push_plan_detects_real_modification() { let sessions2 = discover_sessions(claude.path(), &filter).unwrap(); let plan2 = plan_push(&sessions2, claude.path(), repo_projects.path(), &filter).unwrap(); assert_eq!(plan2.added, 0); - assert_eq!(plan2.modified, 1, "only the appended transcript is modified"); + assert_eq!( + plan2.modified, 1, + "only the appended transcript is modified" + ); assert_eq!(plan2.unchanged, 2); } diff --git a/tests/test_sync_verbosity.rs b/tests/test_sync_verbosity.rs index 9de35d61..6e0f0f2d 100644 --- a/tests/test_sync_verbosity.rs +++ b/tests/test_sync_verbosity.rs @@ -128,11 +128,9 @@ fn test_verbose_mode_extra_logic() { #[test] fn test_display_count_by_verbosity() { let files = vec![ - "file1", "file2", "file3", "file4", "file5", - "file6", "file7", "file8", "file9", "file10", - "file11", "file12", "file13", "file14", "file15", - "file16", "file17", "file18", "file19", "file20", - "file21", "file22", "file23", "file24", "file25", + "file1", "file2", "file3", "file4", "file5", "file6", "file7", "file8", "file9", "file10", + "file11", "file12", "file13", "file14", "file15", "file16", "file17", "file18", "file19", + "file20", "file21", "file22", "file23", "file24", "file25", ]; // In verbose mode, show more files (20 in implementation) @@ -173,7 +171,11 @@ fn test_three_way_verbosity_distinction() { seen.insert(format!("{:?}", normal)); seen.insert(format!("{:?}", verbose)); - assert_eq!(seen.len(), 3, "All three verbosity levels should be distinct"); + assert_eq!( + seen.len(), + 3, + "All three verbosity levels should be distinct" + ); } /// Test verbosity level can be passed as function parameter