From 107a958d6a166e0f3d251b7d4fa10e2233749239 Mon Sep 17 00:00:00 2001 From: Adam Poulemanos Date: Fri, 11 Sep 2026 10:22:10 -0400 Subject: [PATCH 01/27] Complete docs/IMPROVEMENT_PLAN.md implementation (Phases 1-8) Phases 1-6: regression harness, native Git mutation boundary, raw/effective config separation, shared reconciliation, checkout/removal/reset/recovery, command contracts and read-only inspection (R01-R27, R31 PASS). Phase 7: single native-Git mutation path; retired backend mutations, gitoxide-core/prodash deps and gix transport features; main.rs imports the library (no duplicate compilation); in-place config updates; real 1/10/100 benchmarks plus before/after measurement (add-one 4.9x/24x/61x linear to flat, all other paths at parity, identical Git invocation counts, no-op state identity held) (R28 PASS). Phase 8: required stable Linux/macOS/Windows CI plus informational beta/nightly, explicit MSRV gate, locked nextest/doctests, package job; gated release (tag verification, archive linkage/smoke checks, locked publish); immutable action SHAs; consolidated advisory policy with retired ignores removed (R29 PARTIAL - hosted execution unrun, R30 PASS). Review follow-ups: reopen preserves without_gix policy (with regression test), removed dead panicking conversion, fixed verify-tag prerequisites, corrected stale strategy docs and test names. --- .cargo/audit.toml | 4 +- .config/nextest.toml | 8 +- .github/workflows/ci.yml | 154 +- .github/workflows/docs.yml | 32 +- .github/workflows/release.yml | 271 +- CLAUDE.md | 31 +- CONTRIBUTING.md | 8 +- Cargo.lock | 451 +- Cargo.toml | 20 +- README.md | 94 +- audit.toml | 11 +- benches/benchmark.rs | 230 +- deny.toml | 27 +- docs/IMPLEMENTATION_REPORT.md | 811 ++++ docs/IMPROVEMENT_PLAN.md | 316 ++ docs/audit-results.json | 706 ++++ docs/audit-results.json.license | 3 + hk.pkl | 19 +- mise.toml | 2 +- sample_config/submod.toml | 133 +- schemas/current/submod_config.json | 248 ++ schemas/current/submod_config.json.license | 2 + schemas/latest/submod.json | 249 +- schemas/latest/submod.json.license | 2 + schemas/submod.json | 249 +- schemas/submod.json.license | 2 + schemas/v1.1.0/submod_config_v1.1.0.json | 243 +- scripts/measure-performance.py | 531 +++ scripts/run-tests.sh | 25 +- src/commands.rs | 334 +- src/config.rs | 822 +++- src/git_manager.rs | 4466 +++++++++++++++----- src/git_ops/git2_ops.rs | 519 +-- src/git_ops/gix_ops.rs | 678 +-- src/git_ops/mod.rs | 3112 ++++++++++++-- src/git_ops/simple_gix.rs | 137 - src/lib.rs | 4 +- src/long_abouts.rs | 7 +- src/main.rs | 437 +- src/options.rs | 216 +- src/shells.rs | 6 +- src/utilities.rs | 476 ++- tests/command_contract_tests.rs | 564 ++- tests/common/mod.rs | 211 +- tests/config_tests.rs | 607 ++- tests/error_handling_tests.rs | 1221 +++++- tests/fallback_tests.rs | 628 +-- tests/git_ops_tests.rs | 1342 ++++-- tests/integration_tests.rs | 963 ++++- tests/performance_tests.rs | 89 +- tests/phase5_checkout_recovery_tests.rs | 383 ++ tests/phase5_delete_move_tests.rs | 1312 ++++++ tests/phase5_reset_batch_tests.rs | 740 ++++ tests/phase6_cli_contract_tests.rs | 590 +++ tests/phase6_onboarding_tests.rs | 189 + tests/phase6_output_tests.rs | 617 +++ tests/reconciliation_lifecycle_tests.rs | 1183 ++++++ tests/reconciliation_metadata_tests.rs | 1034 +++++ tests/reconciliation_sparse_tests.rs | 287 ++ tests/security_tests.rs | 476 +++ tests/sparse_checkout_tests.rs | 213 +- 61 files changed, 23457 insertions(+), 5288 deletions(-) create mode 100644 docs/IMPLEMENTATION_REPORT.md create mode 100644 docs/IMPROVEMENT_PLAN.md create mode 100644 docs/audit-results.json create mode 100644 docs/audit-results.json.license create mode 100644 schemas/current/submod_config.json create mode 100644 schemas/current/submod_config.json.license mode change 120000 => 100644 schemas/latest/submod.json create mode 100644 schemas/latest/submod.json.license mode change 120000 => 100644 schemas/submod.json create mode 100644 schemas/submod.json.license create mode 100755 scripts/measure-performance.py delete mode 100644 src/git_ops/simple_gix.rs create mode 100644 tests/phase5_checkout_recovery_tests.rs create mode 100644 tests/phase5_delete_move_tests.rs create mode 100644 tests/phase5_reset_batch_tests.rs create mode 100644 tests/phase6_cli_contract_tests.rs create mode 100644 tests/phase6_onboarding_tests.rs create mode 100644 tests/phase6_output_tests.rs create mode 100644 tests/reconciliation_lifecycle_tests.rs create mode 100644 tests/reconciliation_metadata_tests.rs create mode 100644 tests/reconciliation_sparse_tests.rs diff --git a/.cargo/audit.toml b/.cargo/audit.toml index 1b67d425..516b349c 100644 --- a/.cargo/audit.toml +++ b/.cargo/audit.toml @@ -1,5 +1,7 @@ # SPDX-FileCopyrightText: 2026 Adam Poulemanos # SPDX-License-Identifier: LicenseRef-PlainMIT OR MIT +# Canonical advisory policy lives in deny.toml. This file mirrors its empty +# exception list so every audit entry point enforces the same policy. [advisories] -ignore = ["RUSTSEC-2024-0364"] +ignore = [] diff --git a/.config/nextest.toml b/.config/nextest.toml index a9bfcc8e..18f089ee 100644 --- a/.config/nextest.toml +++ b/.config/nextest.toml @@ -9,6 +9,10 @@ # and shell out to git are serialized among themselves to avoid any residual # contention on shared git state (e.g. lock files in /tmp). +# Test groups were introduced in cargo-nextest 0.9.48. Older runners silently +# ignore the isolation below and invalidate the retained performance ceilings. +nextest-version = { required = "0.9.55", recommended = "0.9.128" } + [test-groups] serial-integration = { max-threads = 1 } @@ -19,7 +23,7 @@ fail-fast = false # group. config_tests and command_contract_tests are safe to run in parallel # with everything (they use isolated temp dirs and per-test gitconfig). [[profile.default.overrides]] -filter = 'binary_id(submod::integration_tests) | binary_id(submod::sparse_checkout_tests) | binary_id(submod::error_handling_tests) | binary_id(submod::performance_tests)' +filter = 'binary(integration_tests) | binary(sparse_checkout_tests) | binary(error_handling_tests) | binary(performance_tests) | binary(reconciliation_lifecycle_tests) | binary(reconciliation_metadata_tests) | binary(reconciliation_sparse_tests) | binary(phase5_checkout_recovery_tests) | binary(phase5_delete_move_tests) | binary(phase5_reset_batch_tests) | binary(phase6_cli_contract_tests) | binary(phase6_output_tests) | binary(phase6_onboarding_tests)' test-group = 'serial-integration' # CI profile: fail fast but still serialize integration tests @@ -27,5 +31,5 @@ test-group = 'serial-integration' fail-fast = true [[profile.ci.overrides]] -filter = 'binary_id(submod::integration_tests) | binary_id(submod::sparse_checkout_tests) | binary_id(submod::error_handling_tests) | binary_id(submod::performance_tests)' +filter = 'binary(integration_tests) | binary(sparse_checkout_tests) | binary(error_handling_tests) | binary(performance_tests) | binary(reconciliation_lifecycle_tests) | binary(reconciliation_metadata_tests) | binary(reconciliation_sparse_tests) | binary(phase5_checkout_recovery_tests) | binary(phase5_delete_move_tests) | binary(phase5_reset_batch_tests) | binary(phase6_cli_contract_tests) | binary(phase6_output_tests) | binary(phase6_onboarding_tests)' test-group = 'serial-integration' diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c9a41abb..0e05b4f5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,6 +2,13 @@ # # SPDX-License-Identifier: LicenseRef-PlainMIT OR MIT +# Third-party actions are pinned to immutable commit SHAs (reviewed +# 2026-09-11; version comments record the moving ref each SHA was taken +# from). To update: resolve the new tag/branch SHA with +# `git ls-remote https://github.com//`, review the upstream +# diff, and update the SHA and comment together. There is no automation +# policy beyond this procedure. + name: CI on: @@ -15,49 +22,43 @@ env: jobs: test: - name: Test Suite - runs-on: ubuntu-latest - # beta/nightly are informational: a toolchain regression upstream should not - # gate merges, but we still want to see it. - continue-on-error: ${{ matrix.rust != 'stable' }} + name: Test Suite (stable, ${{ matrix.os }}) + runs-on: ${{ matrix.os }} strategy: fail-fast: false matrix: - rust: - - stable - - beta - - nightly + os: [ubuntu-latest, macos-latest, windows-latest] steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: submodules: recursive - - name: Install Rust - uses: dtolnay/rust-toolchain@master + - name: Install Rust (stable) + uses: dtolnay/rust-toolchain@6bed0761d98439e5a578e2877258200ad565ba87 # stable with: - toolchain: ${{ matrix.rust }} + toolchain: stable components: rustfmt, clippy - name: Cache cargo registry - uses: actions/cache@v6 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 with: path: ~/.cargo/registry - key: ${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }}-${{ matrix.rust }} + key: ${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }}-stable - name: Cache cargo index - uses: actions/cache@v6 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 with: path: ~/.cargo/git - key: ${{ runner.os }}-cargo-index-${{ hashFiles('**/Cargo.lock') }}-${{ matrix.rust }} + key: ${{ runner.os }}-cargo-index-${{ hashFiles('**/Cargo.lock') }}-stable - name: Cache cargo build - uses: actions/cache@v6 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 with: path: target - key: ${{ runner.os }}-cargo-build-target-${{ hashFiles('**/Cargo.lock') }}-${{ matrix.rust }} + key: ${{ runner.os }}-cargo-build-target-${{ hashFiles('**/Cargo.lock') }}-stable - name: Install cargo-nextest - uses: taiki-e/install-action@v2 + uses: taiki-e/install-action@fa23953489c080190314742a9b907f8e97c6767c # v2 with: tool: cargo-nextest @@ -68,7 +69,60 @@ jobs: git config --global user.name "CI" - name: Run tests - run: cargo nextest run --all-features --no-fail-fast + run: cargo nextest run --locked --all-features --profile ci --no-fail-fast + + - name: Run doctests + run: cargo test --locked --all-features --doc + + test-informational: + name: Test Suite (${{ matrix.rust }}, informational) + runs-on: ubuntu-latest + # beta/nightly are informational: a toolchain regression upstream should not + # gate merges, but we still want to see it. + continue-on-error: true + strategy: + fail-fast: false + matrix: + rust: + - beta + - nightly + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + submodules: recursive + + - name: Install Rust + uses: dtolnay/rust-toolchain@6bed0761d98439e5a578e2877258200ad565ba87 # stable + with: + toolchain: ${{ matrix.rust }} + components: rustfmt, clippy + + - name: Install cargo-nextest + uses: taiki-e/install-action@fa23953489c080190314742a9b907f8e97c6767c # v2 + with: + tool: cargo-nextest + + - name: Setup git + run: | + git config --global user.email "ci@example.com" + git config --global user.name "CI" + + - name: Run tests + run: cargo nextest run --locked --all-features --profile ci --no-fail-fast + + msrv: + name: MSRV check (Rust 1.89) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + + - name: Install Rust 1.89 + uses: dtolnay/rust-toolchain@6bed0761d98439e5a578e2877258200ad565ba87 # stable + with: + toolchain: "1.89" + + - name: Check MSRV build + run: cargo +1.89 check --locked --all-features lint: name: lint @@ -76,10 +130,10 @@ jobs: permissions: contents: read steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - name: Install hk with mise - uses: jdx/mise-action@v4 + uses: jdx/mise-action@c2a87611a18de5b3828c5652fe268e992400cb5c # v4 env: HK_MISE: 1 MISE_YES: 1 @@ -97,7 +151,7 @@ jobs: hk = { version = "1.48", depends = ["pkl"], postinstall = 'hk install --mise || true'} pkl = "latest" typos = { version = "latest", depends = ["rust"] } - + # mise installs the toolchain without optional components, so cargo_fmt and # cargo_clippy have no binary to call. - name: Add rustfmt and clippy components @@ -120,21 +174,55 @@ jobs: checks: write contents: read steps: - - uses: actions/checkout@v7 - - uses: rustsec/audit-check@v2 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + # Advisory exceptions live in deny.toml (canonical) and audit.toml; this + # step intentionally carries no inline ignore list so the two files + # cannot drift from what CI enforces. The v2 branch HEAD below was + # reviewed 2026-09-11. + - uses: rustsec/audit-check@69366f33c96575abad1ee0dba8212993eecbe998 # v2 with: token: ${{ secrets.GITHUB_TOKEN || github.token }} - # Keep in sync with the `ignore` list in deny.toml, which documents why - # each of these is accepted. - ignore: RUSTSEC-2024-0364 + + package: + name: Package check + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + + - name: Install Rust (stable) + uses: dtolnay/rust-toolchain@6bed0761d98439e5a578e2877258200ad565ba87 # stable + with: + toolchain: stable + + - name: Verify packaged source builds and tests + run: | + cargo package --locked + pkg=$(ls target/package/submod-*.crate | head -n 1) + rm -rf /tmp/submod-package-check + mkdir -p /tmp/submod-package-check + tar xzf "$pkg" -C /tmp/submod-package-check + cd /tmp/submod-package-check/submod-* + cargo build --locked --all-features + cargo test --locked --all-features --lib + + - name: Validate schemas and template generation + run: | + for schema in schemas/v1.0.0/*.json schemas/v1.1.0/*.json schemas/current/*.json schemas/submod.json schemas/latest/*.json; do + python3 -c "import json,sys; json.load(open(sys.argv[1]))" "$schema" \ + || { echo "::error::invalid schema JSON: $schema"; exit 1; } + done + cargo run --locked -- generate-config --template --force --output /tmp/submod-template-check.toml + test -s /tmp/submod-template-check.toml coverage: name: Code Coverage runs-on: ubuntu-latest permissions: - contents: write + contents: read steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: submodules: recursive @@ -148,7 +236,7 @@ jobs: git config --global user.email "ci@example.com" git config --global user.name "CI" - name: install other components - uses: taiki-e/install-action@v2 + uses: taiki-e/install-action@fa23953489c080190314742a9b907f8e97c6767c # v2 with: tool: cargo-nextest,cargo-llvm-cov @@ -161,7 +249,7 @@ jobs: --ignore-filename-regex '\.cargo[/\\]registry|\.cargo[/\\]git|rustup[/\\]toolchains|/rustc/|scripts[/\\]|tests[/\\]|examples[/\\]|benches[/\\]|target[/\\]|.*long_about.rs|.*lib.rs' \ --manifest-path Cargo.toml - name: Upload coverage to Codecov - uses: codecov/codecov-action@v7 + uses: codecov/codecov-action@a99c28d3f0da835de33ff2feb2e15691c7b9641f # v7 with: files: lcov.info token: ${{ secrets.CODECOV_TOKEN }} diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 570768a0..a9dafa35 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -2,6 +2,14 @@ # # SPDX-License-Identifier: LicenseRef-PlainMIT OR MIT +# Third-party actions are pinned to immutable commit SHAs (reviewed +# 2026-09-11; version comments record the moving ref each SHA was taken +# from). See .github/workflows/ci.yml for the update procedure. +# +# These jobs validate documentation builds only. API-doc success does not +# prove schema delivery or CLI example behavior; those are covered by the +# package job in ci.yml and the R27 integration tests. + name: Documentation on: @@ -17,45 +25,53 @@ jobs: docs: name: Build and Deploy Documentation runs-on: ubuntu-latest + permissions: + contents: read steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: submodules: recursive - name: Install Rust - uses: dtolnay/rust-toolchain@stable + uses: dtolnay/rust-toolchain@6bed0761d98439e5a578e2877258200ad565ba87 # stable + with: + toolchain: stable - name: Cache cargo registry - uses: actions/cache@v6 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 with: path: ~/.cargo/registry key: ${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }} - name: Cache cargo index - uses: actions/cache@v6 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 with: path: ~/.cargo/git key: ${{ runner.os }}-cargo-index-${{ hashFiles('**/Cargo.lock') }} - name: Cache cargo build - uses: actions/cache@v6 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 with: path: target key: ${{ runner.os }}-cargo-build-target-${{ hashFiles('**/Cargo.lock') }} - name: Build documentation - run: cargo doc --all-features --no-deps + run: cargo doc --locked --all-features --no-deps docs_rs_check: name: Check docs.rs compatibility runs-on: ubuntu-latest + permissions: + contents: read steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: submodules: recursive - name: Install Rust nightly - uses: dtolnay/rust-toolchain@nightly + uses: dtolnay/rust-toolchain@be39649afda95dbf70f87cce95f68b8d5797b296 # nightly + with: + toolchain: nightly - name: Install cargo-docs-rs run: cargo install cargo-docs-rs diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9c134c4e..f6a8ff86 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -2,6 +2,16 @@ # # SPDX-License-Identifier: LicenseRef-PlainMIT OR MIT +# Third-party actions are pinned to immutable commit SHAs (reviewed +# 2026-09-11; version comments record the moving ref each SHA was taken +# from). See .github/workflows/ci.yml for the update procedure. +# +# Release order is: verify-tag (exact tag commit) -> build archives -> +# linkage/smoke on the exact archives -> upload -> publish. No release +# mutation (draft, upload, publish) happens before verification passes. +# workflow_dispatch is dry-run only: it builds and checks but publishes +# nothing. Never tag or publish merely to test this workflow. + name: Release on: @@ -14,24 +24,86 @@ env: CARGO_TERM_COLOR: always jobs: - create-release: + verify-tag: + name: Verify tag commit runs-on: ubuntu-latest permissions: - contents: write + contents: read steps: - - uses: actions/checkout@v7 - - uses: taiki-e/create-gh-release-action@v1 - if: github.event_name != 'workflow_dispatch' + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + fetch-depth: 0 + fetch-tags: true + lfs: false + + - name: Resolve tag to commit + run: | + set -euo pipefail + if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then + echo "dry run: verifying checked-out HEAD only" + git rev-parse HEAD + exit 0 + fi + tag="${GITHUB_REF#refs/tags/}" + expected=$(git rev-list -n 1 "$tag") + actual=$(git rev-parse HEAD) + echo "tag $tag resolves to $expected; HEAD is $actual" + test "$expected" = "$actual" || { echo "::error::tag/commit mismatch"; exit 1; } + test "$actual" = "${{ github.sha }}" || { echo "::error::HEAD is not the push SHA"; exit 1; } + + - name: Install Rust (stable) + uses: dtolnay/rust-toolchain@6bed0761d98439e5a578e2877258200ad565ba87 # stable + with: + toolchain: stable + components: rustfmt, clippy + + - name: Install Rust 1.89 (MSRV gate below needs it) + uses: dtolnay/rust-toolchain@6bed0761d98439e5a578e2877258200ad565ba87 # stable with: - changelog: CHANGELOG.md - token: ${{ secrets.GITHUB_TOKEN }} - draft: true + toolchain: "1.89" + + - name: Install cargo-nextest + uses: taiki-e/install-action@fa23953489c080190314742a9b907f8e97c6767c # v2 + with: + tool: cargo-nextest + + - name: Install cargo-deny and cargo-audit + uses: taiki-e/install-action@fa23953489c080190314742a9b907f8e97c6767c # v2 + with: + tool: cargo-deny,cargo-audit + + - name: Setup git + run: | + git config --global user.email "ci@example.com" + git config --global user.name "CI" + + - name: Required functional tests on the exact tag commit + run: cargo nextest run --locked --all-features --profile ci --no-fail-fast + + - name: Doctests on the exact tag commit + run: cargo test --locked --all-features --doc + + - name: MSRV build gate + run: cargo +1.89 check --locked --all-features + + - name: Lint gate + run: | + cargo fmt --all -- --check + cargo clippy --locked --all-targets --all-features + + - name: Security gates + run: | + cargo deny check + cargo audit + + - name: Package gate + run: cargo package --locked build: runs-on: ${{ matrix.os }} - needs: [create-release] + needs: [verify-tag] permissions: - contents: write + contents: read env: # Force curl-sys to build libcurl from source instead of probing pkg-config # for whatever the build image happens to expose. A release binary that @@ -50,94 +122,143 @@ jobs: - target: aarch64-apple-darwin os: macos-latest build-tool: cargo + native-exec: true - target: x86_64-unknown-linux-gnu os: ubuntu-latest build-tool: cross + native-exec: true - target: aarch64-unknown-linux-gnu os: ubuntu-latest build-tool: cross - # musl targets link every native dependency statically — OpenSSL (shared - # by curl and libgit2 since submod dropped rustls), libgit2 and libssh2 - # come from the vendored features below, and musl itself replaces the - # glibc the -gnu targets link dynamically. The result runs on any Linux. + native-exec: false + # musl targets link every native dependency statically — OpenSSL, + # libgit2 and libssh2 come from the vendored features below, and musl + # itself replaces the glibc the -gnu targets link dynamically. The + # result runs on any Linux. - target: x86_64-unknown-linux-musl os: ubuntu-latest build-tool: cross + native-exec: true - target: aarch64-unknown-linux-musl os: ubuntu-latest build-tool: cross + native-exec: false - target: x86_64-pc-windows-msvc os: windows-latest build-tool: cargo + native-exec: true - target: aarch64-pc-windows-msvc os: windows-latest build-tool: cargo + native-exec: false steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 with: fetch-depth: 0 fetch-tags: true lfs: false - - uses: Swatinem/rust-cache@v2 + - uses: Swatinem/rust-cache@49a0bdc70d2e1b713ca9e2869b211fcce03d3c1c # v2 with: shared-key: rust-${{ matrix.target }} - - uses: taiki-e/upload-rust-binary-action@v1 + + - name: Install Rust target + uses: dtolnay/rust-toolchain@6bed0761d98439e5a578e2877258200ad565ba87 # stable + with: + toolchain: stable + targets: ${{ matrix.target }} + + - name: Install cross + if: matrix.build-tool == 'cross' + uses: taiki-e/install-action@fa23953489c080190314742a9b907f8e97c6767c # v2 with: - bin: submod - checksum: sha256 - target: ${{ matrix.target }} - build-tool: ${{ matrix.build-tool }} - token: ${{ secrets.GITHUB_TOKEN }} - features: git2/vendored-libgit2,git2/vendored-openssl - dry-run: ${{ github.event_name == 'workflow_dispatch' }} - - # A dry run builds the archive and then throws it away, which left the whole - # point of the musl targets — that the binary is actually static — argued - # from build-script logic rather than measured. These two steps only run on - # workflow_dispatch, so a real tag release is unaffected. - - name: Verify Linux binary linkage - if: github.event_name == 'workflow_dispatch' && contains(matrix.target, 'linux') + tool: cross + + - name: Build release archive + shell: bash + run: | + set -euo pipefail + ${{ matrix.build-tool }} build --locked --release \ + --target ${{ matrix.target }} \ + --features git2/vendored-libgit2,git2/vendored-openssl + bin="target/${{ matrix.target }}/release/submod" + if [[ "${{ matrix.target }}" == *windows* ]]; then bin="$bin.exe"; fi + staging="archive-staging/submod-${{ matrix.target }}" + mkdir -p "$staging" + cp "$bin" "$staging/" + cp README.md CHANGELOG.md "$staging/" 2>/dev/null || true + tar czf "submod-${{ matrix.target }}.tar.gz" -C archive-staging "submod-${{ matrix.target }}" + (cd archive-staging && sha256sum "../submod-${{ matrix.target }}.tar.gz" > "../submod-${{ matrix.target }}.tar.gz.sha256" || shasum -a 256 "../submod-${{ matrix.target }}.tar.gz" > "../submod-${{ matrix.target }}.tar.gz.sha256") + + - name: Verify archive linkage and smoke the exact bytes + shell: bash env: TARGET: ${{ matrix.target }} run: | set -euo pipefail - mkdir -p /tmp/linkcheck + rm -rf /tmp/linkcheck && mkdir -p /tmp/linkcheck tar xzf "submod-${TARGET}.tar.gz" -C /tmp/linkcheck - desc=$(file /tmp/linkcheck/submod) - echo "$desc" - case "$TARGET" in - *musl*) - # Rust links musl targets as static-pie by default, which `file` - # reports as "static-pie linked" rather than "statically linked". - # Accept either; reject anything dynamically linked. - case "$desc" in - *"statically linked"*|*"static-pie linked"*) - echo "OK: ${TARGET} is statically linked" ;; - *) - echo "::error::${TARGET} is not statically linked: ${desc}" - exit 1 ;; - esac - ;; - *) - echo "note: ${TARGET} is a glibc target; dynamic linking is expected" ;; - esac - - - name: Upload dry-run archive for inspection - if: github.event_name == 'workflow_dispatch' - uses: actions/upload-artifact@v7 + if [[ "$TARGET" == *windows* ]]; then + cli="/tmp/linkcheck/submod-${TARGET}/submod.exe" + else + cli="/tmp/linkcheck/submod-${TARGET}/submod" + fi + if [[ "$TARGET" == *linux* ]]; then + desc=$(file "$cli") + echo "$desc" + case "$TARGET" in + *musl*) + # Rust links musl targets as static-pie by default, which `file` + # reports as "static-pie linked" rather than "statically linked". + # Accept either; reject anything dynamically linked. + case "$desc" in + *"statically linked"*|*"static-pie linked"*) + echo "OK: ${TARGET} is statically linked" ;; + *) + echo "::error::${TARGET} is not statically linked: ${desc}" + exit 1 ;; + esac + ;; + *gnu*) + # GNU targets link glibc dynamically; reject unexpected + # vendored-native linkage hiding as a system library. + case "$desc" in + *"dynamically linked"*) echo "OK: ${TARGET} is dynamically linked (glibc expected)" ;; + *) echo "::error::unexpected linkage for ${TARGET}: ${desc}"; exit 1 ;; + esac + ;; + esac + fi + if [[ "${{ matrix.native-exec }}" == "true" ]]; then + "$cli" --version + "$cli" --help >/dev/null + # Local disposable-repository smoke on the exact archive bytes. + smoke=$(mktemp -d) + git config --global user.email "release-smoke@example.com" 2>/dev/null || true + git config --global user.name "ReleaseSmoke" 2>/dev/null || true + git init -b main "$smoke/origin" && (cd "$smoke/origin" && echo hi > f && git add f && git commit -qm init) + git init -b main "$smoke/parent" && (cd "$smoke/parent" && git commit -qm init --allow-empty) + (cd "$smoke/parent" && printf '[s]\npath = "lib"\nurl = "%s"\n' "$smoke/origin" > submod.toml && "$cli" init && "$cli" check) + rm -rf "$smoke" + echo "OK: ${TARGET} smoke passed" + else + echo "note: ${TARGET} cannot execute on ${{ matrix.os }}; archive checksum recorded, execution unverified on this runner" + fi + sha256sum -c "submod-${TARGET}.tar.gz.sha256" 2>/dev/null || shasum -a 256 -c "submod-${TARGET}.tar.gz.sha256" + + - name: Upload archive for inspection + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: - name: dry-run-${{ matrix.target }} - path: submod-${{ matrix.target }}.* - retention-days: 7 + name: submod-${{ matrix.target }} + path: | + submod-${{ matrix.target }}.tar.gz + submod-${{ matrix.target }}.tar.gz.sha256 + retention-days: 30 if-no-files-found: error publish: name: Publish to crates.io needs: [build] - # workflow_dispatch is the dry run: `build` already honors it via its - # dry-run input, but this job and `github_release` did not, so a manual - # dispatch still ran `cargo publish` against crates.io and cut a real - # GitHub release. Only a v* tag push publishes anything. + # workflow_dispatch is the dry run: only a v* tag push publishes anything. if: github.event_name != 'workflow_dispatch' runs-on: ubuntu-latest environment: cratesio @@ -145,13 +266,21 @@ jobs: contents: read id-token: write steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 with: + fetch-depth: 0 + fetch-tags: true submodules: recursive - - uses: dtolnay/rust-toolchain@stable - - uses: rust-lang/crates-io-auth-action@v1 + - name: Confirm verified tag commit + run: | + set -euo pipefail + tag="${GITHUB_REF#refs/tags/}" + test "$(git rev-list -n 1 "$tag")" = "$(git rev-parse HEAD)" || { echo "::error::tag/commit mismatch"; exit 1; } + - uses: dtolnay/rust-toolchain@6bed0761d98439e5a578e2877258200ad565ba87 # stable + - uses: rust-lang/crates-io-auth-action@c6f97d42243bad5fab37ca0427f495c86d5b1a18 # v1 id: auth - - run: cargo publish --allow-dirty + # Locked, clean-source publication of the verified commit: no --allow-dirty. + - run: cargo publish --locked env: CARGO_REGISTRY_TOKEN: ${{ steps.auth.outputs.token }} @@ -163,12 +292,18 @@ jobs: permissions: contents: write steps: - - uses: actions/checkout@v6 - - name: Publish Release - uses: softprops/action-gh-release@v3 + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + fetch-depth: 1 + - name: Download checked archives + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + path: release-archives + - uses: softprops/action-gh-release@5113cdc90fd4d541c801c55356214017bf5ae34b # v3 with: body_path: CHANGELOG.md draft: false prerelease: false + files: release-archives/**/* env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/CLAUDE.md b/CLAUDE.md index 59883f7c..938c6a8a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -53,22 +53,22 @@ Managed by `hk` (configured in `hk.pkl`). Pre-commit runs: cargo fmt, clippy, ch ### Layer Stack ``` -CLI (commands.rs + main.rs) +CLI (commands.rs + main.rs, imports the library — no duplicated modules) ↓ clap parsing -GitManager (git_manager.rs) ← high-level submodule operations +GitManager (git_manager.rs) ← desired-state planning and reconciliation ↓ delegates to -GitOpsManager (git_ops/mod.rs) ← unified backend with automatic fallback - ├── GixOperations (git_ops/gix_ops.rs) ← gitoxide (preferred) - ├── Git2Operations (git_ops/git2_ops.rs) ← libgit2 (fallback) - └── Git CLI ← last resort (spawned via std::process) -Config (config.rs) ← figment-based TOML config loading/saving +GitOpsManager (git_ops/mod.rs) ← native Git mutation boundary + ├── Native Git CLI (spawned via std::process) ← all mutations + ├── GixOperations (git_ops/gix_ops.rs) ← gitoxide reads + └── Git2Operations (git_ops/git2_ops.rs) ← libgit2 reads +Config (config.rs) ← raw TOML declarations plus an effective-entry resolver ``` -### Fallback Architecture +### Git operation design -The core design is a **gix-first, git2-fallback, CLI-last-resort** strategy, driven by the immaturity of gitoxide's submodule support. `GitOpsManager` wraps both backends behind the `GitOperations` trait and calls `try_with_fallback()` / `try_with_fallback_mut()` for every operation. When gix fails, it logs a warning and transparently retries with git2; `add_submodule` has an additional CLI fallback that also cleans up any partial state from the prior attempt. +All lifecycle mutations (add/init/update/move/deinit/delete/reset/stash/clean/sparse) run through **one native Git path** inside `GitOpsManager` (`std::process::Command` with argument arrays, rooted cwd, checked statuses). There are no competing backend mutation implementations and no cross-backend retry after a mutation failure; real errors are terminal with recoverable partial state left in place. Inspection reads use `try_with_fallback()` (gix first, git2 fallback); `GitOpsManager::without_gix` is the injection seam for exercising the git2 read path. `Config::add_submodule` and `remove_submodule` mutate in place (no whole-map clones); sparse patterns are borrowed (`&[String]`) through the read APIs. -After destructive operations (delete, nuke), `GitOpsManager::reopen()` must be called to refresh the in-memory repository state. git2 reopen errors are fatal; gix reopen errors are warnings only. +`GitOpsManager::reopen()` refreshes the in-memory repository handles after external changes; it is used by tests and explicit refresh flows, not by every lifecycle call. ### Configuration @@ -85,7 +85,7 @@ After destructive operations (delete, nuke), `GitOpsManager::reopen()` must be c - `missing_docs` is warn — public items need doc comments - `module_name_repetitions` and `too_many_lines` are allowed - All error handling uses `anyhow` for propagation and `thiserror` for defining error types -- `simple_gix.rs` contains lightweight gix helpers used in `gix_ops.rs` +- Raw TOML declarations stay separate from effective settings: never persist inherited defaults into entries; `active`/`sparse` app-only state stays out of portable `.gitmodules` fields ### Testing Approach @@ -95,9 +95,10 @@ Integration tests in `tests/` use a `TestHarness` (in `tests/common/mod.rs`) tha - `config_tests.rs` — configuration parsing/serialization - `sparse_checkout_tests.rs` — sparse checkout behavior - `error_handling_tests.rs` — error conditions and messages -- `fallback_tests.rs` — the gix→git2→CLI fallback chain, via the `GitOpsManager::without_gix` and `::forcing_cli_add` injection seams -- `git_ops_tests.rs` — the `GitOperations` backends directly +- `fallback_tests.rs` — the retained gix→git2 read fallback and native CLI mutation state, via the `GitOpsManager::without_gix` injection seam +- `git_ops_tests.rs` — `GitOpsManager` (native mutations) and the retained backend read APIs directly - `security_tests.rs` — path traversal, symlink escape, and command/flag injection containment -- `performance_tests.rs` — timing and peak-allocation ceilings (plain `#[test]`s, not criterion) +- `performance_tests.rs` — timing ceilings and Rust-allocator growth bounds (plain `#[test]`s, not criterion; the allocator measures test-process Rust allocations only, not process RSS) +- `reconciliation_*_tests.rs`, `phase5_*_tests.rs`, `phase6_*_tests.rs` — the R01–R31 regression families from `docs/IMPROVEMENT_PLAN.md` -Criterion benchmarks live separately in `benches/benchmark.rs` (`cargo bench`). +Criterion benchmarks live separately in `benches/benchmark.rs` (`cargo bench`): real config parse/load/edit workloads at 1/10/100 modules, plus a `SUBMOD_MEASURE_CONFIG` mode driven by `scripts/measure-performance.py` for before/after comparisons with wall time, Git invocation counts, and no-op state identity. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 45019635..764f9fe7 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -39,7 +39,7 @@ This project adheres to a Code of Conduct that we expect all contributors to fol Before contributing, ensure you have: -- **Rust 1.87+** - Latest stable version recommended +- **Rust 1.89+** - MSRV is 1.89 (see `Cargo.toml`); latest stable recommended - **Git** - For version control - **Basic Git knowledge** - Understanding of branches, commits, and pull requests - **GitHub account** - For submitting contributions @@ -78,7 +78,7 @@ curl https://mise.run | sh mise install # This automatically installs: -# - Rust 1.87+ +# - Rust 1.89+ (MSRV) # - hk (git hooks) # - cargo tools (nextest, audit, deny, watch) # - prettier, typos, and other linters @@ -230,10 +230,10 @@ My philosophy on testing is "test what matters." Tests focus on integration and ### Test Categories -1. **Unit Tests** - We currently don't have unit tests, but they can be added in the future for critical functionality. +1. **Unit Tests** - Unit tests are minimal by design; most coverage comes from integration tests below. Run the library unit tests with: ```bash - cargo test --test unit_tests + cargo test --lib ``` 2. **Integration Tests** - Test complete workflows diff --git a/Cargo.lock b/Cargo.lock index 82490636..13907074 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -118,12 +118,6 @@ version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" -[[package]] -name = "base64" -version = "0.22.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" - [[package]] name = "bisync" version = "0.3.0" @@ -195,12 +189,6 @@ version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" -[[package]] -name = "bytesize" -version = "2.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d7c8918969267b2932ffd5655509bbbea0833823058c378876953217f5fc50e" - [[package]] name = "cast" version = "0.3.0" @@ -416,42 +404,6 @@ version = "0.8.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" -[[package]] -name = "crossterm" -version = "0.29.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" -dependencies = [ - "bitflags 2.13.1", - "crossterm_winapi", - "document-features", - "mio", - "parking_lot", - "rustix", - "signal-hook 0.3.18", - "signal-hook-mio", - "winapi", -] - -[[package]] -name = "crossterm_winapi" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" -dependencies = [ - "winapi", -] - -[[package]] -name = "crosstermion" -version = "0.16.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6ae462f0b868614980d59df41e217a77648de7ad7cf8b2a407155659896d889" -dependencies = [ - "crossterm", - "nu-ansi-term", -] - [[package]] name = "crunchy" version = "0.2.4" @@ -468,36 +420,6 @@ dependencies = [ "typenum", ] -[[package]] -name = "curl" -version = "0.4.50" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a45ee8994e5307cb4c60cfc1c20bf7263ffb771ddc135c9f768a14bcbc15b09" -dependencies = [ - "curl-sys", - "libc", - "openssl-probe", - "openssl-sys", - "schannel", - "socket2", - "windows-sys", -] - -[[package]] -name = "curl-sys" -version = "0.4.90+curl-8.21.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97799a0d220bfb3361e0fe4936966ff8c4b24d65c3f06dfc70d7b680b44e7897" -dependencies = [ - "cc", - "libc", - "libz-sys", - "openssl-sys", - "pkg-config", - "vcpkg", - "windows-sys", -] - [[package]] name = "dashmap" version = "6.2.1" @@ -553,15 +475,6 @@ dependencies = [ "crypto-common", ] -[[package]] -name = "document-features" -version = "0.2.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" -dependencies = [ - "litrs", -] - [[package]] name = "dunce" version = "1.0.5" @@ -715,27 +628,6 @@ dependencies = [ "log", ] -[[package]] -name = "gitoxide-core" -version = "0.60.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40ca7abeac7fb1cf3246b272f46f6afb61864bd7e88f40b05f9e4597cca4e9e2" -dependencies = [ - "anyhow", - "bytesize", - "gix", - "gix-error", - "gix-fsck", - "gix-pack", - "gix-status", - "gix-transport", - "layout-rs", - "open", - "tempfile", - "thiserror", - "unicode-width", -] - [[package]] name = "gix" version = "0.86.0" @@ -744,11 +636,9 @@ checksum = "bb3790fd8981cba7949f1ba924ef865d902df731627bc5998d14164063892fce" dependencies = [ "gix-actor", "gix-attributes", - "gix-blame", "gix-command", "gix-commitgraph", "gix-config", - "gix-credentials", "gix-date", "gix-diff", "gix-dir", @@ -763,15 +653,11 @@ dependencies = [ "gix-ignore", "gix-index", "gix-lock", - "gix-mailmap", - "gix-merge", - "gix-negotiate", "gix-object", "gix-odb", "gix-pack", "gix-path", "gix-pathspec", - "gix-prompt", "gix-protocol", "gix-ref", "gix-refspec", @@ -783,18 +669,14 @@ dependencies = [ "gix-submodule", "gix-tempfile", "gix-trace", - "gix-transport", "gix-traverse", "gix-url", "gix-utils", "gix-validate", "gix-worktree", - "gix-worktree-state", "gix-worktree-stream", "gix-zlib", "nonempty", - "parking_lot", - "signal-hook 0.4.4", "smallvec", "thiserror", ] @@ -836,26 +718,6 @@ dependencies = [ "gix-error", ] -[[package]] -name = "gix-blame" -version = "0.16.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b06d20ff0e88ada2dd852b3852727b664ec9baefdf653d1d4e722e858c52cd17" -dependencies = [ - "gix-commitgraph", - "gix-date", - "gix-diff", - "gix-error", - "gix-hash", - "gix-object", - "gix-revwalk", - "gix-trace", - "gix-traverse", - "gix-worktree", - "smallvec", - "thiserror", -] - [[package]] name = "gix-chunk" version = "0.7.3" @@ -924,24 +786,6 @@ dependencies = [ "thiserror", ] -[[package]] -name = "gix-credentials" -version = "0.39.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "73b637f873d5f9aa67e672c1b23741a18e046d33028017adede32f4b4087a2e7" -dependencies = [ - "bstr", - "gix-command", - "gix-config-value", - "gix-date", - "gix-path", - "gix-prompt", - "gix-sec", - "gix-trace", - "gix-url", - "thiserror", -] - [[package]] name = "gix-date" version = "0.15.6" @@ -1019,7 +863,6 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4a9292309fd944e71b2a3c96d3c03a6feb8852db646febdde7cbb9f79cb5f329" dependencies = [ - "anyhow", "bstr", ] @@ -1076,17 +919,6 @@ dependencies = [ "thiserror", ] -[[package]] -name = "gix-fsck" -version = "0.24.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0e077bd038bc79219eb5f1a15a2e7b11e930119ddce4e682f33058347e42f73" -dependencies = [ - "gix-hash", - "gix-hashtable", - "gix-object", -] - [[package]] name = "gix-glob" version = "0.27.0" @@ -1184,58 +1016,6 @@ dependencies = [ "thiserror", ] -[[package]] -name = "gix-mailmap" -version = "0.33.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a824767d38b81475059cb01f5020a13fb96e7ed6bbf9851c7112b46ada78db48" -dependencies = [ - "bstr", - "gix-actor", - "gix-date", - "gix-error", -] - -[[package]] -name = "gix-merge" -version = "0.19.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7b2e202fa474a3dd0bbd551be35adf199be0bf5c925fd7707fff2fdb520f651" -dependencies = [ - "bstr", - "gix-command", - "gix-diff", - "gix-filter", - "gix-fs", - "gix-hash", - "gix-imara-diff", - "gix-index", - "gix-object", - "gix-path", - "gix-quote", - "gix-revision", - "gix-revwalk", - "gix-tempfile", - "gix-trace", - "gix-worktree", - "nonempty", - "thiserror", -] - -[[package]] -name = "gix-negotiate" -version = "0.34.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2fa5aa789990f124e4f559b142cecfb60662cb4ae17006684ea9d668f4f07689" -dependencies = [ - "bitflags 2.13.1", - "gix-commitgraph", - "gix-date", - "gix-hash", - "gix-object", - "gix-revwalk", -] - [[package]] name = "gix-object" version = "0.63.0" @@ -1285,18 +1065,14 @@ checksum = "6d5446127b269706e85998065267ddd2ccc3550179da6780b22fe496175ccb20" dependencies = [ "clru", "gix-chunk", - "gix-diff", "gix-error", "gix-features", "gix-hash", "gix-hashtable", "gix-object", "gix-path", - "gix-tempfile", - "gix-traverse", "gix-zlib", "memmap2", - "parking_lot", "smallvec", "thiserror", "uluru", @@ -1341,19 +1117,6 @@ dependencies = [ "thiserror", ] -[[package]] -name = "gix-prompt" -version = "0.16.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5cb1f1eb92d6f4c9d4c105a6ca912cff637fbd5cacbcbafa5deccd88bfaa3565" -dependencies = [ - "gix-command", - "gix-config-value", - "parking_lot", - "rustix", - "thiserror", -] - [[package]] name = "gix-protocol" version = "0.64.0" @@ -1362,18 +1125,11 @@ checksum = "dede40e89c1e90f548415f50636bb051f6d9c60f68b8b710bc07825722d19588" dependencies = [ "bisync", "bstr", - "gix-credentials", "gix-date", "gix-features", "gix-hash", - "gix-lock", - "gix-negotiate", - "gix-object", "gix-ref", - "gix-refspec", - "gix-revwalk", "gix-shallow", - "gix-trace", "gix-transport", "gix-utils", "nonempty", @@ -1433,16 +1189,13 @@ version = "0.48.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e55e09d4a1ecf2beecc8c09cafcad37979e805b31f588b0e957e191df5783681" dependencies = [ - "bitflags 2.13.1", "bstr", "gix-commitgraph", "gix-date", "gix-error", "gix-hash", - "gix-hashtable", "gix-object", "gix-revwalk", - "gix-trace", "nonempty", ] @@ -1537,8 +1290,6 @@ dependencies = [ "gix-fs", "libc", "parking_lot", - "signal-hook 0.4.4", - "signal-hook-registry", "tempfile", ] @@ -1554,18 +1305,14 @@ version = "0.58.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7c1bcf30081eb8ab04540a5795c67a2fcb22cb2e976e8b1a4ea657b1ed61469" dependencies = [ - "base64", "bstr", - "curl", "gix-command", - "gix-credentials", "gix-features", "gix-packetline", "gix-path", "gix-quote", "gix-sec", "gix-url", - "parking_lot", "thiserror", ] @@ -1639,24 +1386,6 @@ dependencies = [ "gix-validate", ] -[[package]] -name = "gix-worktree-state" -version = "0.33.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc47372fc12b9fbea51b257bcc8d4970326cf5c7e42135bbfb5d72871bb30bd4" -dependencies = [ - "bstr", - "gix-features", - "gix-filter", - "gix-fs", - "gix-index", - "gix-object", - "gix-path", - "gix-worktree", - "io-close", - "thiserror", -] - [[package]] name = "gix-worktree-stream" version = "0.35.0" @@ -1749,12 +1478,6 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" -[[package]] -name = "hermit-abi" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" - [[package]] name = "indexmap" version = "2.14.0" @@ -1765,46 +1488,6 @@ dependencies = [ "hashbrown 0.17.1", ] -[[package]] -name = "io-close" -version = "0.3.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9cadcf447f06744f8ce713d2d6239bb5bde2c357a452397a9ed90c625da390bc" -dependencies = [ - "libc", - "winapi", -] - -[[package]] -name = "is-docker" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3" -dependencies = [ - "once_cell", -] - -[[package]] -name = "is-terminal" -version = "0.4.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" -dependencies = [ - "hermit-abi", - "libc", - "windows-sys", -] - -[[package]] -name = "is-wsl" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5" -dependencies = [ - "is-docker", - "once_cell", -] - [[package]] name = "is_terminal_polyfill" version = "1.70.2" @@ -1900,12 +1583,6 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "layout-rs" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b8b38bc67665e362eb770c6b6ae88b48d040d94a0a10c4904c37bc79d263b95" - [[package]] name = "libc" version = "0.2.189" @@ -1942,12 +1619,6 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" -[[package]] -name = "litrs" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" - [[package]] name = "lock_api" version = "0.4.14" @@ -1978,33 +1649,12 @@ dependencies = [ "libc", ] -[[package]] -name = "mio" -version = "1.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" -dependencies = [ - "libc", - "log", - "wasi", - "windows-sys", -] - [[package]] name = "nonempty" version = "0.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9737e026353e5cd0736f98eddae28665118eb6f6600902a7f50db585621fecb6" -[[package]] -name = "nu-ansi-term" -version = "0.50.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" -dependencies = [ - "windows-sys", -] - [[package]] name = "num-traits" version = "0.2.19" @@ -2032,34 +1682,6 @@ version = "11.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" -[[package]] -name = "open" -version = "5.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a0b3d059e795d52b8a72fef45658620edd4d9c359b338564aa14391ffa511ed5" -dependencies = [ - "is-wsl", - "libc", -] - -[[package]] -name = "openssl-probe" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" - -[[package]] -name = "openssl-sys" -version = "0.9.117" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695" -dependencies = [ - "cc", - "libc", - "pkg-config", - "vcpkg", -] - [[package]] name = "page_size" version = "0.6.0" @@ -2169,11 +1791,7 @@ version = "31.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "962200e2d7d551451297d9fdce85138374019ada198e30ea9ede38034e27604c" dependencies = [ - "crosstermion", - "is-terminal", - "jiff", "parking_lot", - "unicode-width", ] [[package]] @@ -2277,15 +1895,6 @@ dependencies = [ "winapi-util", ] -[[package]] -name = "schannel" -version = "0.1.29" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" -dependencies = [ - "windows-sys", -] - [[package]] name = "scopeguard" version = "1.2.0" @@ -2386,47 +1995,6 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" -[[package]] -name = "signal-hook" -version = "0.3.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" -dependencies = [ - "libc", - "signal-hook-registry", -] - -[[package]] -name = "signal-hook" -version = "0.4.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2a0c28ca5908dbdbcd52e6fdaa00358ab88637f8ab33e1f188dd510eb44b53d" -dependencies = [ - "libc", - "signal-hook-registry", -] - -[[package]] -name = "signal-hook-mio" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" -dependencies = [ - "libc", - "mio", - "signal-hook 0.3.18", -] - -[[package]] -name = "signal-hook-registry" -version = "1.4.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" -dependencies = [ - "errno", - "libc", -] - [[package]] name = "slab" version = "0.4.12" @@ -2439,16 +2007,6 @@ version = "1.15.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" -[[package]] -name = "socket2" -version = "0.6.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" -dependencies = [ - "libc", - "windows-sys", -] - [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -2473,14 +2031,13 @@ dependencies = [ "criterion", "figment", "git2", - "gitoxide-core", "gix", "gix-submodule", - "prodash", "serde", "tempfile", "thiserror", "toml 1.1.3+spec-1.1.0", + "toml_edit", ] [[package]] @@ -2738,12 +2295,6 @@ dependencies = [ "winapi-util", ] -[[package]] -name = "wasi" -version = "0.11.1+wasi-snapshot-preview1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" - [[package]] name = "wasm-bindgen" version = "0.2.126" diff --git a/Cargo.toml b/Cargo.toml index b05c70b2..4ef4c313 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,7 +8,7 @@ name = "submod" version = "0.4.0" edition = "2024" rust-version = "1.89" -description = "A headache-free submodule management tool, built on top of gitoxide. Manage sparse checkouts, submodule updates, and adding/removing submodules with ease." +description = "A headache-free git submodule management tool. Manage sparse checkouts, submodule updates, and adding/removing submodules with ease." license = "LicenseRef-PlainMIT OR MIT" repository = "https://github.com/bashandbone/submod" homepage = "https://github.com/bashandbone/submod" @@ -31,8 +31,7 @@ include = [ "sample_config/submod.toml", "REUSE.toml", "LICENSES/*", - "LICENSE.md", - "submod-sbom.spdx" + "LICENSE-*.md", ] [package.metadata.docs.rs] @@ -43,21 +42,10 @@ rustdoc-args = ["--cfg", "docsrs"] [dependencies] # Gitoxide ops gix = { version = "^0.86.0", default-features = false, features = [ - # OpenSSL, not rustls: git2/libgit2-sys/libssh2-sys already link openssl-sys, so - # this makes curl share it rather than adding a second TLS stack. The rustls - # variant pulled curl-sys -> rustls-ffi -> {rustls -> aws-lc-rs -> aws-lc-sys, - # paste}, which was the sole source of three ignored RUSTSEC advisories. - # - # Note this is the *-openssl* variant deliberately: bare - # `blocking-http-transport-curl` enables no TLS crate at all and leaves HTTPS to - # whatever system libcurl the host provides. - "blocking-http-transport-curl-openssl", "max-performance-safe", "status", - "worktree-mutation", "sha1" ] } -gitoxide-core = { version = "^0.60.0", default-features = false, features = ["blocking-client"] } gix-submodule = { version = "^0.33.0", features = ["sha1"]} # CLI @@ -69,7 +57,6 @@ clap = { version = "4.6.4", features = [ ] } clap_complete = "4.6.7" clap_complete_nushell = "4.6.1" -prodash = { version = "31.0.0", features = ["render-line-crossterm", "render-line-autoconfigure", "render-line"] } serde = { version = "1.0.229", features = ["derive"] } # TOML config @@ -86,6 +73,8 @@ bitflags = "2.13.1" # gix_submodule just isn't mature enough to realistically provide our functionality without falling back to git2 git2 = { version = "0.21.0" } toml = "1.1.3" +toml_edit = "0.22.27" +tempfile = "3.27.0" [lib] name = "submod" @@ -100,7 +89,6 @@ figment = { version = "0.10.19", default-features = false, features = [ "test", "toml", ] } -tempfile = "3.27.0" criterion = "0.8.2" toml = "1.1.3" diff --git a/README.md b/README.md index 2b80c396..cc98e77c 100644 --- a/README.md +++ b/README.md @@ -11,23 +11,22 @@ SPDX-License-Identifier: LicenseRef-PlainMIT OR MIT [![Crates.io](https://img.shields.io/crates/v/submod.svg)](https://crates.io/crates/submod) [![Documentation](https://docs.rs/submod/badge.svg)](https://docs.rs/submod) [![Static Badge](https://img.shields.io/badge/Plain-MIT-15db95?style=flat-square&labelColor=0d19a3&cacheSeconds=86400&link=https%3A%2F%2Fplainlicense.org%2Flicenses%2Fpermissive%2Fmit%2Fmit%2F)](https://plainlicense.org/licenses/permissive/mit/) -[![Rust](https://img.shields.io/badge/rust-1.87%2B-blue.svg)](https://www.rust-lang.org) +[![Rust](https://img.shields.io/badge/rust-1.89%2B-blue.svg)](https://www.rust-lang.org) [![codecov](https://codecov.io/gh/bashandbone/submod/branch/main/graph/badge.svg?token=MOW92KKK0G)](https://codecov.io/gh/bashandbone/submod) ![Crates.io Downloads (latest version)](https://img.shields.io/crates/dv/submod) Git submodules solve a real problem. **Managing submodules is a pain.** You use them infrequently enough that you always forget which command does what — and when something breaks, the recovery steps are a small nightmare. New contributors hit this especially hard: onboarding onto a project that uses submodules is its own obstacle course. -`submod` wraps the whole lifecycle in one consistent CLI. Sixteen commands, including nuke-it-from-orbit for when you're done being reasonable. Built on gitoxide and git2, with automatic fallback so operations don't fail silently.[^1] It's actively used across @knitli and @plainlicense, where submodules handle shared functionality between repos. +`submod` manages Git submodules from a TOML configuration. Lifecycle mutations use native Git, with repository and path validation before changes. Read operations also use gitoxide and git2. Git must be installed and available on `PATH`. ## :rocket: Features - **TOML config** — define submodules, sparse-checkout paths, and defaults in one file -- **Sparse checkout** — clone only the parts of a submodule you actually need +- **Sparse checkout** — check out only the files and directories you need - **Global defaults with per-submodule overrides** — set it once, customize where it matters -- **Fallback chain** — tries gitoxide first, falls back to git2, then CLI +- **Native Git lifecycle** — preserve Git registration, parent pins, and recoverable module history - **Clear status and errors** — you'll know what broke and why -[^1]: The fallback architecture is more a reflection of the status of `gitoxide` and `git2` submodule support than a stability concern. Their features do not consistently provide the full lifecycle of submodule operations. Together they cover >90%, but sometimes immaturely. The fallbacks architecture handles that gracefully and lets native operations grow with those libraries. ## 📋 Table of Contents @@ -70,6 +69,10 @@ cargo install --path . ## 🚀 Quick Start +The `example` and `company` repository URLs below are placeholders. Replace them with repositories you can access; valid TOML does not guarantee that a remote exists. Git credential helpers, SSH agents, and transport restrictions still apply. + +For an existing Git submodule setup, start with `submod generate-config --from-setup`, inspect the imported TOML, then run `submod check` and `submod sync`. Import reads the discovered repository; `--from-setup` is a boolean flag, not a path argument. Use `--output` to select the generated file and `--force` only to replace an existing output. + 1. **Initialize a config file** in your git repository: ```bash @@ -99,7 +102,11 @@ cargo install --path . ## ⚙️ Configuration -Create a `submod.toml` file in your repository root: +Create a `submod.toml` file in your repository root. Commands discover the enclosing worktree, so invoking them from a nested directory uses that same root and default config. A relative explicit `--config` path is resolved from the invocation directory; a missing explicit config is an error. Checkout paths are relative to the worktree root, must remain inside it, and cannot overlap Git administrative storage or other managed paths. + +The TOML table name is the logical module name; `path` is its checkout location and defaults to that name. Submodules registered in Git but absent from TOML are unmanaged: submod reports and preserves them rather than adopting or deleting them automatically. + +Example: ```toml # Global defaults applied to all submodules @@ -128,23 +135,31 @@ branch = "develop" # track specific branch #### Global Defaults - `ignore`: How to handle dirty submodules (`all`, `dirty`, `untracked`, `none`) -- `update`: Update strategy (`checkout`, `rebase`, `merge`, `none`, `!command`) +- `update`: Update strategy (`checkout`, `rebase`, `merge`, `none`); custom executable update commands are rejected - `branch`: Default branch to track (`.` for current superproject branch) - `fetchRecurse`: Fetch recursion (`always`, `on-demand`, `never`) +- `use_git_default_sparse_checkout`: Use Git's unprefixed sparse patterns (`false` by default) + +An explicit per-module value overrides `[defaults]`; omitted fields inherit without being copied into the raw entry. Built-in defaults are `ignore = "none"`, `update = "checkout"`, `fetchRecurse = "on-demand"`, and no explicit branch. Use `submod change NAME --unset FIELD` to restore inheritance, or `submod change-global --unset FIELD` to remove a global default. `fetch` and `fetch_recurse` are accepted legacy aliases for canonical `fetchRecurse`; do not supply multiple spellings in one table. #### Per-Submodule Settings - `path`: Local path where submodule should be placed -- `url`: Git repository URL -- `sparse_paths`: Array of paths to include in sparse checkout -- `active`: Whether the submodule is active (default: `true`) +- `url`: Required nonempty Git repository URL or local remote path +- `sparse_paths`: Ordered non-cone sparse patterns; absent or empty disables sparse checkout +- `active`: Whether automatic lifecycle work is enabled (default: `true`) +- `shallow`: Request shallow history (default: `false`) - All global defaults can be overridden per submodule +Sparse checkout controls files in the working tree; it is not partial clone and does not by itself reduce downloaded objects or history. By default submod prepends `!/*` to the ordered patterns. Set `use_git_default_sparse_checkout = true` globally or per module to use the patterns without that prefix. `active` and sparse patterns stay in TOML/local configuration rather than portable `.gitmodules` fields. + +The [sample configuration](sample_config/submod.toml) and [current JSON schema](schemas/current/submod_config.json) describe the current format. Historical versioned schemas remain available for their original contracts. + ## 📖 Commands ### `submod add` -Add a new submodule to your configuration and repository: +Add a new submodule to your configuration and repository. Existing declarations and occupied destinations are refused without replacing their contents; use init/sync for an existing managed module: ```bash # Basic add @@ -172,7 +187,7 @@ submod add https://github.com/example/my-lib.git \ | `--sparse-paths` | `-x` | Comma-separated sparse checkout paths or globs | | `--fetch` | `-f` | Recursive fetch behavior (`always`, `on-demand`, `never`) | | `--update` | `-u` | Update strategy (`checkout`, `rebase`, `merge`, `none`) | -| `--shallow` | `-s` | Shallow clone (last commit only) | +| `--shallow` | `-s` | Request shallow clone history | | `--no-init` | | Add to config only; do not clone/initialize | ### `submod check` @@ -193,20 +208,26 @@ Initialize all missing submodules: submod init ``` -*alias*: `submid i` +*alias*: `submod i` ### `submod update` -Update all submodules to their latest commits: +Materialize the commit recorded by the parent gitlink. Remote advancement is explicit: ```bash +# Parent-pin update (default) submod update + +# Fetch and apply the configured remote branch/default +submod update --remote ``` + +`--branch` selects the tracking branch; it does not make ordinary updates remote-tracking updates. Checkout, merge, and rebase follow the configured strategy; `update = "none"` skips automatic checkout. Review and stage changed parent gitlinks when adopting a remote update. Use `--recursive` on init/update/sync for nested submodules. *alias*: `submod u` ### `submod reset` -Hard reset submodules (stash changes, reset --hard, clean): +Stash tracked and untracked changes, then reset to the parent gitlink commit. If stash preservation fails, reset refuses before discarding work. Ignored files and nested repositories are preserved; collisions with files required by the target commit cause refusal. A successful stash reports its identity and a recovery command; apply that stash in the child repository to recover the saved work: ```bash # Reset all submodules @@ -219,7 +240,7 @@ submod reset my-lib,vendor-utils ### `submod sync` -Run a complete sync (check + init + update): +Reconcile managed declarations, registration, missing checkouts, settings, and parent-pin checkout state. Disabled and update-none entries skip automatic materialization. Repeated sync with no drift avoids unnecessary cloning/fetching. Explicit sync makes the managed TOML URL authoritative: it can overwrite local parent/child URL overrides for that module. Keep machine-specific authentication in Git credential helpers instead of relying on an overridden managed URL: ```bash submod sync @@ -228,7 +249,7 @@ submod sync ### `submod change` -Change the configuration of an existing submodule: +Change selected fields while preserving omitted settings. Metadata-only changes retain HEAD; a path change moves the verified checkout and preserves its repository identity/history. `--shallow false` clears shallow preference, and `--unset branch` restores branch inheritance: ```bash submod change my-lib --branch main --sparse-paths "src/,include/" --fetch always @@ -239,7 +260,7 @@ submod change my-lib --branch main --sparse-paths "src/,include/" --fetch always Change global defaults for all submodules: ```bash -submod change-global --ignore dirty --update checkout +submod change-global --ignore dirty --update checkout --branch main ``` *aliases*: `submod cg`, `submod chgl`, `submod global` @@ -256,7 +277,7 @@ submod list --recursive ### `submod delete` -Delete a submodule from configuration and filesystem: +Remove the exact managed registration and checkout while retaining the module repository for recovery. Dirty, untracked, or ignored checkout contents require `--force` to discard; force is limited to that verified checkout and never authorizes deleting unrelated storage: ```bash submod delete my-lib @@ -266,28 +287,28 @@ submod delete my-lib ### `submod disable` -Disable a submodule without deleting files (sets `active = false`): +Disable automatic lifecycle work without deleting files or history (sets TOML and managed local activation to false): ```bash submod disable my-lib ``` -*alias*: `submod del` +*alias*: `submod d` ### `submod nuke-it-from-orbit` -Delete all or specific submodules from config and filesystem, with optional reinit: +Rebuild selected verified checkouts, retaining recoverable repositories and local refs. `--kill` removes their declarations/checkouts without reinitializing; it does not purge retained history: ```bash # Nuke all submodules (re-initializes by default) submod nuke-it-from-orbit --all -# Nuke specific submodules permanently +# Remove specific checkouts without reinitializing submod nuke-it-from-orbit --kill my-lib,old-dep ``` *aliases*: `submod nuke-em`, `submod nuke-it`, `submod nuke-them` -Use `nuke-it-from-orbit` was created because sometimes submodule just... don't cooperate. You're done being nice and just want to get back to work. Nuke it. +A failed rebuild leaves the intended declaration available for recovery. Inspect the reported failure before retrying; `--force` only authorizes discarding local content inside the verified checkout. ### `submod generate-config` @@ -295,7 +316,7 @@ Generate a new configuration file: ```bash # From current git submodule setup -submod generate-config --from-setup . +submod generate-config --from-setup # As a template with defaults submod generate-config --template --output my-config.toml @@ -323,7 +344,7 @@ submod completeme bash > ~/.bash_completion.d/submod # zsh has an fpath array with possible function directories. You can # put your completions in any of these; we use the first one here: ZSH_DEFAULT="${XDG_DATA_HOME:-~/.local/share}/zsh/site-functions" -ZFUNCDIR=""${fpath[1]:-$ZSH_DEFAULT}" +ZFUNCDIR="${fpath[1]:-$ZSH_DEFAULT}" mkdir -p "$ZFUNCDIR" submod completeme zsh > "${ZFUNCDIR}/_submod" ``` @@ -331,8 +352,8 @@ submod completeme zsh > "${ZFUNCDIR}/_submod" #### fish ```fish -mkdir -p "${XDG_CONFIG_HOME:-~/.config}/fish/completions" -submod completeme fish > "{XDG_CONFIG_HOME:-~/.config}/fish/completions/submod.fish +mkdir -p ~/.config/fish/completions +submod completeme fish > ~/.config/fish/completions/submod.fish ``` #### powershell @@ -352,10 +373,11 @@ submod completeme elvish > ~/.config/elvish/completions/submod.elv #### nushell ```nushell -submod completeme nu > "$NUSHELL_CONFIG_DIR/scripts/completions/submod.nu" -echo 'use completions/submod.nu' >> "$NU_CONFIG_PATH" +submod completeme nu ``` +Save the Nushell output as `submod.nu` and load it from your Nushell configuration. Completion scripts are generated from the installed binary's command model; regenerate them after upgrading. + ## 💻 Usage Examples @@ -369,7 +391,7 @@ submod check # Initialize any missing submodules submod init -# Update everything to latest +# Materialize the recorded parent commits submod update # Or do it all at once @@ -409,13 +431,13 @@ submod check submod sync ``` -If that doesn't work, try [nuke-it-from-orbit](https://github.com/bashandbone/submod/edit/main/README.md#submod-nuke-it-from-orbit) +If recovery is still needed, inspect the error and retained repository before choosing a [rebuild](#submod-nuke-it-from-orbit). Rebuilds retain Git history but can discard local checkout contents when explicitly forced. ## 🛠️ Development ### Prerequisites -- Rust 1.87 or later +- Rust 1.89 or later - Git - [Mise](https://mise.jdx.dev/) (recommended) - for tool management and task running @@ -548,8 +570,8 @@ submod/ │ ├── config.rs # TOML configuration handling │ ├── git_manager.rs # High-level submodule operations │ └── git_ops/ # Git backend abstraction -│ ├── mod.rs # GitOpsManager (gix→git2→CLI fallback) -│ ├── gix_ops.rs # gitoxide backend +│ ├── mod.rs # GitOpsManager (native Git mutation boundary) +│ ├── gix_ops.rs # gitoxide read backend │ └── git2_ops.rs # libgit2 backend ├── tests/ # Integration tests ├── sample_config/ # Example configurations @@ -615,7 +637,7 @@ This tool was created to: - **Reduce barriers to contribution** - Make it easier for new developers to work with projects using submodules - **Simplify complex workflows** - Handle initialization, updates, and sparse checkout configuration automatically - **Provide better tooling** - Clear status reporting and error messages -- **Leverage modern Git libraries** - Use `gitoxide` for better performance and reliability +- **Use Git semantics** - Delegate lifecycle mutations to native Git and preserve recoverable history The tool is actively used in multiple projects at [@knitli](https://github.com/knitli) and [@plainlicense](https://github.com/plainlicense), where submodules are essential for sharing core functionality across repositories. diff --git a/audit.toml b/audit.toml index c4eade9b..cc4e2595 100644 --- a/audit.toml +++ b/audit.toml @@ -2,8 +2,15 @@ # # SPDX-License-Identifier: LicenseRef-PlainMIT OR MIT +# Canonical advisory policy lives in deny.toml. This file mirrors its empty +# exception list so `cargo audit` (which reads audit.toml) and +# `cargo deny check advisories` enforce the same policy. [advisories] ignore = [ - "RUSTSEC-2024-0364", # gitoxide-core does not neutralize special characters for terminals. No patched version available. - "RUSTSEC-2024-0436" # No patched version available. + # Retired, kept as documentation so a re-entry fails loudly: + # - RUSTSEC-2024-0364 (gitoxide-core): dependency removed in Phase 7, + # no longer in the graph. + # - RUSTSEC-2024-0436 (paste, unmaintained): arrived via the old rustls + # TLS stack; that stack is gone, paste is not in the graph. The ignore + # was stale (it named a crate absent from Cargo.lock). ] diff --git a/benches/benchmark.rs b/benches/benchmark.rs index c5c26351..0f3f34ce 100644 --- a/benches/benchmark.rs +++ b/benches/benchmark.rs @@ -1,113 +1,149 @@ // SPDX-FileCopyrightText: 2026 Adam Poulemanos // SPDX-License-Identifier: LicenseRef-PlainMIT OR MIT -//! Benchmarks comparing two implementations of `.gitmodules` line key parsing. -//! -//! The benchmarks measure the performance difference between: -//! -//! - **`line_key_old`**: Allocates a formatted string for each key comparison. -//! - **`line_key_new`**: Performs a zero-allocation prefix check followed by a boundary -//! character test. -//! -//! Run with: -//! ```sh -//! cargo bench -//! ``` - -use criterion::{Criterion, criterion_group, criterion_main}; +//! Production config microbenchmarks. CLI inspection/sync are measured separately +//! by scripts/measure-performance.py, using immutable bench-profile executables. +use criterion::{BatchSize, BenchmarkId, Criterion}; use std::hint::black_box; +use std::time::Instant; +use submod::config::{Config, SubmoduleEntry}; -/// Returns the first key from `known_keys` that matches the start of `line`, or `None`. -/// -/// Matching uses `format!("{key} =")` and `format!("{key}=")` to build comparison strings, -/// which allocates once per key per line. This is the baseline ("old") implementation -/// used to establish a performance reference point. -/// -/// Empty lines and lines beginning with `#` are skipped immediately. -fn line_key_old<'a>(line: &str, known_keys: &[&'a str]) -> Option<&'a str> { - let trimmed = line.trim(); - if trimmed.is_empty() || trimmed.starts_with('#') { - return None; - } - for key in known_keys { - if trimmed.starts_with(&format!("{key} =")) || trimmed.starts_with(&format!("{key}=")) { - return Some(key); - } +fn fixture(count: usize) -> String { + use std::fmt::Write as _; + let mut text = String::from("[defaults]\nignore = \"dirty\"\nupdate = \"checkout\"\n\n"); + for i in 0..count { + let _ = write!( + text, + "[module-{i}]\npath = \"lib/module-{i}\"\nurl = \"file:///local/origin-{i}\"\nactive = true\nsparse_paths = [\"src\", \"docs\"]\n\n" + ); } - None + text } -/// Returns the first key from `known_keys` that matches the start of `line`, or `None`. -/// -/// Matching first checks that `line` starts with the key as a prefix, then verifies that -/// the very next character is `=` or ` =` — avoiding any heap allocation. This is the -/// optimized ("new") implementation being benchmarked against [`line_key_old`]. -/// -/// Empty lines and lines beginning with `#` are skipped immediately. -fn line_key_new<'a>(line: &str, known_keys: &[&'a str]) -> Option<&'a str> { - let trimmed = line.trim(); - if trimmed.is_empty() || trimmed.starts_with('#') { - return None; - } - for key in known_keys { - if let Some(rest) = trimmed.strip_prefix(key) - && (rest.starts_with('=') || rest.starts_with(" =")) - { - return Some(key); - } +fn insertion() -> SubmoduleEntry { + SubmoduleEntry { + path: Some("lib/inserted".into()), + url: Some("file:///local/inserted".into()), + active: Some(true), + branch: None, + ignore: None, + update: None, + fetch_recurse: None, + shallow: None, + no_init: None, + sparse_paths: None, + use_git_default_sparse_checkout: None, } - None } -/// Registers the `line_key_old` and `line_key_new` benchmarks with Criterion. -/// -/// Both functions are exercised over an identical set of representative input lines, -/// covering all supported key forms (`key = value`, `key=value`, leading whitespace, -/// unknown keys, comments, and blank lines) so the measurements are directly comparable. -pub fn criterion_benchmark(c: &mut Criterion) { - let keys = vec![ - "path", - "url", - "branch", - "ignore", - "fetch", - "update", - "active", - "shallow", - "sparse_paths", - ]; - let lines = vec![ - "path = foo", - "url=bar", - "branch = baz", - "ignore=qux", - "fetch = quux", - "update=corge", - "active = grault", - "shallow=garply", - "sparse_paths = waldo", - "unknown = fred", - "# comment", - "", - " path = spaced ", - ]; +fn validate(config: &Config, count: usize) { + assert_eq!(config.get_submodules().count(), count); + let entry = config.effective_entry("module-0").unwrap(); + assert_eq!(entry.path.as_deref(), Some("lib/module-0")); + assert_eq!(entry.active, Some(true)); + assert!(entry.ignore.is_some()); + assert!(entry.update.is_some()); +} - c.bench_function("line_key_old", |b| { - b.iter(|| { - for line in &lines { - black_box(line_key_old(black_box(line), black_box(&keys))); +fn main() { + // One sample per process lets the comparison driver alternate immutable + // baseline/candidate artifacts. Preparation and validation are not timed. + if let Ok(workload) = std::env::var("SUBMOD_MEASURE_CONFIG") { + let count: usize = std::env::var("SUBMOD_MEASURE_COUNT") + .unwrap() + .parse() + .unwrap(); + let path = std::env::var("SUBMOD_MEASURE_FILE").unwrap(); + let text = std::fs::read_to_string(&path).unwrap(); + let config = Config::parse(&text).unwrap(); + validate(&config, count); + let iterations = 1000; + let mut results = Vec::with_capacity(iterations); + let mut prepared: Vec<_> = if workload == "add" { + (0..iterations) + .map(|_| (config.clone(), "inserted".to_string(), insertion())) + .collect() + } else { + Vec::new() + }; + let start = Instant::now(); + match workload.as_str() { + "parse" => { + for _ in 0..iterations { + results.push(Config::parse(black_box(&text)).unwrap()); + } } - }); - }); - - c.bench_function("line_key_new", |b| { - b.iter(|| { - for line in &lines { - black_box(line_key_new(black_box(line), black_box(&keys))); + "load" => { + for _ in 0..iterations { + results.push( + Config::default() + .load_from_file(Some(black_box(&path))) + .unwrap(), + ); + } } + "add" => { + for (config, name, entry) in &mut prepared { + let name = std::mem::take(name); + let entry = std::mem::replace(entry, insertion()); + config.add_submodule(name, entry); + } + } + _ => panic!("unknown config workload"), + } + let nanos = start.elapsed().as_nanos(); + if workload == "add" { + for (config, _, _) in &prepared { + assert_eq!(config.get_submodules().count(), count + 1); + assert_eq!( + config.get_submodule("inserted").unwrap().path.as_deref(), + Some("lib/inserted") + ); + } + } else { + for config in &results { + validate(config, count); + } + } + println!("{{\"iterations\":{iterations},\"elapsed_ns\":{nanos},\"verdict\":\"pass\"}}"); + return; + } + let mut criterion = Criterion::default().configure_from_args(); + let temp = tempfile::tempdir().unwrap(); + for count in [1, 10, 100] { + let text = fixture(count); + let path = temp.path().join(format!("config-{count}.toml")); + std::fs::write(&path, &text).unwrap(); + let config = Config::parse(&text).unwrap(); + validate(&config, count); + criterion.bench_with_input(BenchmarkId::new("config_parse", count), &text, |b, text| { + b.iter(|| Config::parse(black_box(text)).unwrap()); }); - }); + criterion.bench_with_input( + BenchmarkId::new("config_load_file", count), + &path, + |b, path| { + b.iter(|| { + Config::default() + .load_from_file(Some(black_box(path))) + .unwrap() + }); + }, + ); + criterion.bench_with_input( + BenchmarkId::new("config_add_one", count), + &config, + |b, config| { + b.iter_batched( + || (config.clone(), "inserted".to_string(), insertion()), + |(mut config, name, entry)| { + config.add_submodule(name, entry); + black_box(config) + }, + BatchSize::SmallInput, + ); + }, + ); + } + criterion.final_summary(); } - -criterion_group!(benches, criterion_benchmark); -criterion_main!(benches); diff --git a/deny.toml b/deny.toml index 9e1575a0..a70f25af 100644 --- a/deny.toml +++ b/deny.toml @@ -77,21 +77,36 @@ feature-depth = 1 # The url(s) of the advisory databases to use # db-urls = ["https://github.com/rustsec/advisory-db"] # A list of advisory IDs to ignore. Note that ignored advisories will still -# output a note when they are encountered. +# output a note when they are encountered. This list is intentionally empty: +# every retired exception below documents why it left, so a re-entry fails +# instead of silently passing. ignore = [ - "RUSTSEC-2024-0364", # gitoxide-core does not neutralize special characters for terminals. No patched version available. + # RUSTSEC-2024-0364 (gitoxide-core terminal-output manipulation) was ignored + # while submod depended on gitoxide-core directly. Phase 7 removed that + # dependency and gitoxide-core is no longer in the graph at all, so the + # ignore was deleted rather than left unused — its return means the + # dependency regressed and must fail loudly. # RUSTSEC-2024-0436 (paste, unmaintained) and RUSTSEC-2026-0044 / RUSTSEC-2026-0048 # (aws-lc-sys X.509) used to be ignored here. All three arrived through one root: # gix[blocking-http-transport-curl-rustls] -> curl-sys -> rustls-ffi -> {rustls -> aws-lc-rs, paste}. - # submod now builds gix with blocking-http-transport-curl-openssl, so curl shares the - # single openssl-sys that git2/libgit2-sys/libssh2-sys already link. rustls, aws-lc-sys - # and paste are no longer in the graph at all, so the ignores are gone rather than merely - # unused — a re-entry of any of them means the TLS backend regressed and should fail. + # submod first moved gix to blocking-http-transport-curl-openssl and Phase 7 + # then removed gix transport features entirely. rustls, aws-lc-sys and paste + # are no longer in the graph at all, so those ignores are gone rather than + # merely unused — a re-entry of any of them means the TLS backend regressed + # and should fail. # "RUSTSEC-0000-0000", # { id = "RUSTSEC-0000-0000", reason = "you can specify a reason the advisory is ignored" }, # "a-crate-that-is-yanked@0.1.1", # you can also ignore yanked crate versions if you wish # { crate = "a-crate-that-is-yanked@0.1.1", reason = "you can specify why you are ignoring the yanked crate" }, ] +# Yanked-crate note (not an exception): bisync 0.3.0 is yanked and remains +# reachable via submod -> gix(status) -> gix-protocol -> bisync. Upstream +# (GitoxideLabs/gitoxide#2939) the author withdrew bisync as a supply-chain +# maintenance risk and the macro port into gix-macros (#2940) is unreleased, +# so there is no owning-dependency update that removes it yet. bisync provides +# only compile-time barrier macros to gix-protocol; it contributes no runtime +# transport surface to submod's read-only gix use. No blanket yanked-package +# allowance is added; revisit when gix releases without it. # If this is true, then cargo deny will use the git executable to fetch advisory database. # If this is false, then it uses a built-in git library. # Setting this to true can be helpful if you have special authentication requirements that cargo-deny does not support. diff --git a/docs/IMPLEMENTATION_REPORT.md b/docs/IMPLEMENTATION_REPORT.md new file mode 100644 index 00000000..6cf867a0 --- /dev/null +++ b/docs/IMPLEMENTATION_REPORT.md @@ -0,0 +1,811 @@ + + +# Improvement plan implementation report + +Implementation base: `31c5e373bfd29162f86675fa60ed4af0adf8df97` on +`codex/improvement-plan`. Early delegated harness RED/retained runs used the +pre-existing `/private/tmp/submod-audit-target`, and early config and R11–R15 +RED runs resolved to the worktree default `target/` despite their requested +target environment. Phase 2 gate checks and all later checks use the explicit +Cargo option +`--target-dir /private/tmp/submod-improvement-target`. This is required because +login-shell activation reads `mise.toml`, whose `CARGO_TARGET_DIR = "target"` +overrides a requested environment value. Test fixtures set +their own Git identity, disable signing and prompts, and allow local file +transport only in the fixture environment. + +## Baseline + +Before application edits: + +```text +cargo test --locked --offline --all-features -- --test-threads=1 \ + --skip test_invalid_git_url --skip test_network_timeout_simulation +exit 0: 567 executions passed; two external-network tests filtered +rustc 1.98.1; cargo 1.98.1 +``` + +The baseline reproduced the audit. It also reproduced duplicate compilation +of 168 unit tests in both the library and binary targets. + +## Phase reports + +### Phase 1 — regression harness + +Gate ready for independent review. The RED runs established these audited discrepancies before +production changes: + +- R11: omitted path is rejected instead of defaulting to the TOML nickname. +- R12: a successful fresh-clone init leaves no checkout. +- R13: explicit add with `--branch develop` checks out the default branch and + omits the branch from `.gitmodules`; explicit add with `update=none` already + creates the requested initial checkout. +- R14: sync does not propagate an edited managed URL to `.gitmodules`. +- R15: removing sparse paths leaves sparse checkout enabled and excluded files + absent. + +The preservation slice additionally reproduced R01–R05 and R16 with checked +Git commands and actual file, ref, index mode/OID, config, and gitdir +snapshots. The configuration slice reproduced R06–R10 and R18. Three specified +behaviors already worked: absent schema metadata loads, append without sparse +patterns is rejected, and an occupied output ancestor is preserved. These are +smoke reproductions for each family; untested variants remain outstanding in +the table. No production file was changed before these runs. + +Harness/tooling validation: + +```text +87 retained tests in security/error/git-ops: PASS +cargo nextest ... --profile ci -E test(regression_r04_delete_preserves): + command/profile accepted and test RED for the intended sibling-index loss +cargo fmt --all -- --check: PASS after formatting +git diff --check: PASS +``` + +Exact logs: `/private/tmp/submod-phase1-harness-report.md` links the harness +logs; config RED output is `/private/tmp/submod-phase1-config-final.log`; +lead lifecycle output is `/private/tmp/submod-r11-r14-red.log` and +`/private/tmp/submod-r15-red.log`. The first complete retained nextest run found +one old test that relied on ignored Git failure status; it was corrected to +assert the expected missing-object failure explicitly. Three old config tests +whose names promised inheritance/roundtrip/required-field behavior without +asserting it were removed because the new RED fixtures cover those contracts. + +### Phase 2 — repository context and native Git mutation boundary + +The implementation now discovers the invocation directory, worktree root, +per-worktree Git directory, shared Git common directory, and resolved config +path once. Git-reported paths are retained as `PathBuf` values; on Unix their +raw bytes are preserved. Lifecycle calls use the worktree root even when the +CLI starts in a nested directory or a linked worktree. + +All lifecycle mutations now pass through one native Git command boundary in +the operations module. Paths and logical registration names are validated +before mutation; destination ancestors cannot be symlinks; root, parent, +absolute, administrative, overlapping, and case-normalized overlapping paths +are rejected. Existing files, directories, symlinks, independent repositories, +and dirty registered checkouts are refused without cleanup. Exact +`.gitmodules` lookup supports ordinary path-named sections such as +`submodule.vendor/lib` and alternate formatting while rejecting duplicate path +registrations and custom update commands. + +Native add, move, delete, update, reset, stash, and sparse calls use argument +arrays, rooted working directories, checked statuses, and option terminators. +There is no mutation retry through gix or git2 after an ordinary failure. +Before metadata changes the implementation refuses native Git lock files, +symlink metadata targets, and mixed staged/unstaged `.gitmodules` layers. It +verifies the resulting registration, gitlink mode, checkout identity, and +deletion postconditions instead of trusting exit status alone. + +Deletion has separate verified paths for committed and newly staged gitlinks. +Both remove only the exact registration and parent local section, leave the +stored module repository and refs available, and leave no checkout placeholder. +A later add can reactivate that repository only when the requested URL exactly +matches its retained origin; occupied checkout paths still refuse. Disable and +activation changes write local `submodule..active` while keeping the +app-only `active` field out of portable `.gitmodules`, and never deinitialize a +checkout. Reset refuses held stash locks and target-tree collisions with +ignored content or nested repositories; it no longer runs `clean`. + +Mutating manager calls acquire the canonical shared-common-dir app lock and +the canonical sibling config lock in lexical order, reload and revalidate +configuration under the locks, and hold both through postcondition checks. +Config generation acquires only its output sibling lock. Locks are never +stolen; timeout errors explain stale-lock recovery. Full simultaneous-writer, +external-edit comparison, and no-op-write acceptance remains in R10 for Phase +3, where the direct config writer is replaced atomically. + +Phase 2 focused gate evidence: + +```text +cargo +1.98.1 nextest run \ + --target-dir /private/tmp/submod-improvement-target \ + --locked --offline --all-features --profile ci \ + --no-fail-fast \ + -E 'test(/^(regression_r0[1-5]|regression_r16|phase2_|test_delete_committed_gitlink_retains_history_and_readds)/)' +exit 0: 44/44 passed; 608 skipped + +cargo +1.98.1 nextest run \ + --target-dir /private/tmp/submod-improvement-target \ + --locked --offline --all-features --profile ci \ + --no-fail-fast -E 'not test(/phase1_config|phase1_commands|r1[1-5]_/)' +exit 0: 614/614 retained tests passed; 38 later RED tests skipped + +reviewed debug artifact SHA-256: +73e399b7bd56946d631816c6783a2e50c4f8f751193d878df314665e1f10af78 +``` + +The first retained run after routing mutations through native Git produced 35 +failures. Independent triage classified 14 as obsolete retry/purge/path-name +contracts, 13 as fake or unisolated fixtures, seven as real integration +blockers, and one as a later sparse import contract. The real blockers were +fixed: unspecified Git policy values are no longer emitted, both delete paths +support safe retained reattachment, alias-aware git2 status resolves the +portable name, and initialized sparse imports propagate real inspection +errors while never-initialized registrations import without fabricating sparse +state. Obsolete tests were retargeted to preservation and actual Git state; +fake sparse fixtures now use contained repositories. + +Independent Phase 2 review found three additional containment defects. A +configured checkout named `lib*` was reaching `lib-extra` because `--` does +not disable Git pathspec expansion; an existing child gitfile could redirect +update/init into an unrelated worktree; and a symlink within +`/modules/` could redirect retained-repository reuse +outside the parent Git directory. The fixes pass explicit `:(literal)` +pathspecs only to Git interfaces that consume selectors, keep filesystem +operands such as `git mv` raw, verify an existing materialization target's +top-level identity, and validate every derived module-storage ancestor beneath +the canonical per-worktree gitdir. Positive coverage retains a legitimate +external child gitdir whose worktree identity is the intended checkout. +Dangling Git lock symlinks are detected with `symlink_metadata`, and sparse Git +paths use the same lossless native decoder as repository context. + +Logs: `/private/tmp/submod-phase2-gate-final-family-explicit-target.log`, +`/private/tmp/submod-phase2-retained-explicit-target.log`, +`/private/tmp/submod-phase2-delete-context-final.log`, and +`/private/tmp/submod-phase2-contract-retarget-report.md`. Focused R03/R05, +storage/external-gitdir, and literal-interface evidence is in +`/private/tmp/submod-phase2-r03-r05-acceptance.md`, +`/private/tmp/submod-phase2-storage-boundary.log`, and +`/private/tmp/submod-phase2-literal-move-magic.log`. The linked-worktree, +native lock, staged-layer, reset-collision, and gitfile-containment probes used +as implementation inputs are recorded under `/private/tmp/submod-supervision/`; +they are review evidence, not counted as Submod acceptance tests. + +### Phase 3 — raw configuration, effective defaults, and atomic persistence + +Configuration now parses and retains raw declarations. A single +`effective_entry` resolver supplies inherited and built-in behavior to planning +without adding those values to the module's TOML table. This includes global +branch inheritance while preserving an explicit legacy `branch = "HEAD"` +override. TOML alias escaping is distinct from native Git spelling, so a literal +branch named `current` round-trips through TOML as `refs/heads/current` while Git +receives `current`. Sparse settings are derived from module entries rather than +kept in a second mutable map. + +The parser validates schema versions, aliases, unknown keys, types, required +URLs, effective paths, option enums, and sparse boundaries before actions are +constructed. Semantic errors carry the original TOML line. The checked-in +sample, generated template, absent schema, and supported 1.0/1.1 schemas use the +same parser. CLI booleans are tri-state, supported optional values can be +removed with `--unset`, sparse patterns can be cleared explicitly, and dynamic +argument conflicts return command-line usage status 2 before mutation. + +The config editor uses `toml_edit` on the exact bytes loaded while mutation +locks are held. It patches only touched managed fields, preserves comments, +ordering, table spelling, unrelated sections, explicit false values, and raw +inheritance, then reparses rendered bytes before replacement. A byte-identical +edit skips the write. Otherwise it writes and syncs a temporary sibling and +atomically replaces the destination while preserving existing Unix mode bits. +The final comparison distinguishes an absent file from an empty file and +detects changed, created, or deleted destinations. Symlink outputs and +non-files are refused. Deterministic injected failure tests preserve the exact +old parseable bytes and remove the temporary file. + +All cooperating writers acquire canonical common-repository and output locks +in lexical order, reload the same bytes used for document parsing and the final +snapshot comparison, and write at most once per command. This includes nuke +rebuilds and `generate-config --from-setup`; ordinary relative, relative +subdirectory, and absolute output paths share the same lock-path normalization. +Nuke invokes Git-only lifecycle primitives while the command lock is held and +keeps declarations through rebuild. Simultaneous same/different output files, +linked worktrees, repositories sharing one config, and stale preloaded managers +are covered with deterministic lock-arrival barriers rather than timing sleeps. + +Effective policy reconciliation validates the exact child checkout and rejects +repository-supplied custom update commands before any metadata write. It +changes only explicitly declared or inherited settings, compares the exact +managed worktree key with its index value before editing, preserves unrelated +staged/unstaged content, and leaves existing-module policy edits unstaged for +review. Automatic init/update no longer manufactures built-in policy keys. + +Phase 3 gate evidence: + +```text +cargo +1.98.1 nextest run \ + --target-dir /private/tmp/submod-improvement-target \ + --locked --offline --all-features --profile ci --no-fail-fast \ + -E 'test(phase3_acceptance)' +exit 0: 51/51 passed; 645 skipped + +cargo +1.98.1 nextest run \ + --target-dir /private/tmp/submod-improvement-target \ + --locked --offline --all-features --profile ci --no-fail-fast \ + -E 'not test(/phase1_config|phase1_commands|r1[1-5]_/)' +exit 0: 690/690 retained tests passed; 6 later RED tests skipped + +reviewed debug artifact SHA-256: +0a4ed386090f6c3a53c6e2f40b475012fe3fd3739f7e043d16bbb8cae781ac94 +``` + +Exact final logs are `/private/tmp/submod-phase3-acceptance-gate-fix1.log` +and `/private/tmp/submod-phase3-retained-fix1.log`. Focused atomic/stale-manager, +nuke, concurrency, relative-output, model, and CLI evidence is linked from +`/private/tmp/submod-phase3-acceptance-report.md`, +`/private/tmp/submod-phase3-config-model-report.md`, and +`/private/tmp/submod-phase3-cli-report.md`. The first retained gate run exposed +eight integration regressions and three obsolete assertions; all were corrected +before the final 690-test run. Independent model review passed. Independent +persistence review accepted nuke and concurrency, then identified the relative +default output regression that the final tests now cover. + +Atomic replacement protects one config file, not a transaction spanning TOML, +Git config, and the parent index. An external editor that ignores the app lock +can still race after the final byte comparison and before replacement. Windows +replacement uses `tempfile`'s platform operation; this report does not claim +preservation of Windows ACLs or read-only attributes until hosted Windows +acceptance runs. + +### Phase 4 — shared reconciliation + +Init, update, and sync now use one reconciliation flow. It observes each +selected module's raw and effective TOML declaration, exact portable +registration, stage-0 gitlink, retained repository, materialized checkout, +managed parent/child config, selected update strategy, and sparse state before +choosing actions. Whole-batch preflight rejects unsafe identities, unmerged +gitlinks, known native locks, mixed `.gitmodules` layers, dirty checkout +transitions, and incomplete registrations before the first mutation. Structural +registration actions run before metadata-only actions, so changes created by +the command are not mistaken for pre-existing user edits and retries converge. + +The same flow covers TOML-only setup, fresh-clone materialization, incomplete +registration repair, retained-gitdir reattachment, and initialized checkout +drift. A manual TOML path edit cannot silently reuse a logical registration at +its old path. Missing `.gitmodules` can be reconstructed only from an +unambiguous managed gitlink; a declaration without a gitlink completes only at +an empty destination. Unmerged index stages and ambiguous occupied content are +reported without changing files, refs, config, or the parent index. Successful +reconciliation verifies the stage-0 pin, registration, checkout identity, and +strategy-specific ancestry before reporting success. + +Managed metadata is patched key by key in portable, local, worktree, and child +config. Removed declarations unset stale managed overrides while unrelated +module keys remain. Native Git resolves relative URLs against the parent remote +and selects the child's configured default remote, including cases where the +parent and child URL spellings legitimately differ. The no-op predicate checks +those native results before invoking `git submodule sync`; a second unchanged +sync preserves config bytes, inode, and modification time and performs no +fetch. Known parent worktree and selected child config locks are preflighted +before TOML or Git metadata changes. + +Checkout policy now distinguishes the recorded parent pin from explicit +`--remote`. Default init/update/sync materializes the recorded stage-0 pin +without fetching when it is already available; `--remote` selects the +configured/default branch without staging a new parent gitlink. Checkout, +merge, and rebase verify their respective target or ancestry postconditions. +`update = "none"` and `active = false` skip automatic fetch/materialization, +while explicit add with `update = "none"` still creates its initial checkout. +Branch `.` uses the parent's symbolic branch and refuses a detached parent +before batch mutation. Metadata-only edits never move a divergent child HEAD. + +Sparse reconciliation compares the exact ordered effective pattern sequence, +enablement, and cone mode. It restores a full checkout when patterns are +removed, protects dirty included and excluded paths, and skips sparse commands +when state already matches. Recursive init, sync, and update traverse real +nested module registrations independently of `fetchRecurseSubmodules`. +Unmanaged Git modules are preserved and reported. `--no-init` remains a +one-command selection; a later init materializes normally. + +The installed mise runner was cargo-nextest 0.9.29, which predates test groups +and silently ran the integration and performance binaries concurrently. Those +failed performance logs are diagnostic only. The repository now pins 0.9.128, +declares 0.9.55 as the enforceable minimum, and makes `scripts/run-tests.sh` +reject older versions before running tests. `show-config test-groups` with +cargo-nextest 0.9.128 confirmed the integration, sparse, reconciliation, and +performance binaries in `serial-integration` with one thread. + +Phase 4 combined gate evidence: + +```text +/Users/adampoulemanos/.cargo/bin/cargo-nextest nextest run \ + --config-file /private/tmp/submod-improvement-plan/.config/nextest.toml \ + --manifest-path /private/tmp/submod-improvement-plan/Cargo.toml \ + --locked --offline --all-features --profile ci \ + --target-dir /private/tmp/submod-improvement-target --no-fail-fast +exit 0: 757/757 passed; 0 skipped; 399.247s + +cargo-nextest 0.9.128 (96bc6d4f4 2026-02-19) +debug artifact SHA-256 before and after the gate: +6b06a819931a4167e34e03b38dc8da7e80f03d461c3f5a70d48c3dbc871e5e3f + +cargo +1.98.1 check --locked --all-features \ + --target-dir /private/tmp/submod-improvement-target: PASS +cargo +1.98.1 fmt --all -- --check: PASS +bash -n scripts/run-tests.sh: PASS +git diff --check: PASS +``` + +The combined log is +`/private/tmp/submod-phase4-retained-supported-nextest.log`; effective group +membership is `/private/tmp/submod-phase4-nextest-groups.log`. The unchanged +performance ceilings passed in the serialized run. Separate uncontended runs +also passed, supporting contention as the cause of the old-runner failures; +they are not a before/after optimization measurement. + +Independent metadata review verified relative URLs, selected non-origin child +remotes, parent-relative URL contexts, known lock refusal, exact key removal, +and second-sync file identity. Independent planner review reproduced and then +closed dirty later-module partial mutation, initialized init drift, unmerged +gitlink acceptance, manual path identity reuse, and mixed metadata/registration +layer hazards. Reports are +`/private/tmp/submod-supervision/phase4-metadata-review.md` and +`/private/tmp/submod-supervision/phase4-planner-review.md`. The supervisor's +original lifecycle replay is +`/private/tmp/submod-supervision/phase4-original-repro.json`. These independent +probes supplement the retained regression suite and are not counted as its +757 tests. + +### Phase 5 — checkout, removal, reset, and recovery completion + +Reset now plans a sorted, duplicate-free selection under one command lock and +preflights every stage-0 parent gitlink, child identity, stash lock, and target +collision before creating the first stash. It records the exact stash object, +resets to the parent gitlink rather than the current child HEAD, and verifies +the resulting OID and content. The recovery guidance is an in-child +`git stash branch` command with an unused generated branch name; executing only +that printed command from the post-reset pinned checkout restores the stash +base, index, staged and unstaged bytes, and untracked files. A stash failure or +unmerged parent gitlink aborts before reset. Ignored files, directories, and +nested repositories that collide with the target tree refuse without mutation. + +Fresh materialization follows native relative-URL resolution and preserves an +older recorded pin even after the remote advances. Shallow initialization +checks the exact requested object: an available older pin succeeds, while a +remote that cannot supply it reports that OID and never substitutes the remote +tip. Malformed and nonexistent child gitfile targets refuse before touching the +referenced or intended worktree. The Phase 4 branch, update-strategy, recursive, +and sparse tests remain part of this gate and continue to verify exact target or +ancestry and ordered sparse content. + +Delete has an explicit `--force` contract. The default protects tracked, +untracked, ignored, nested-repository, and deleted-worktree content. Force may +discard content only inside the selected validated checkout; it still retains +the module object database, refs, stash, registration identity needed for later +reuse, and unrelated parent/sibling state. Native Git's supported removal +migrates a legacy embedded `.git` directory into retained module storage before +the checkout is removed. A config-only delete changes only the TOML +declaration and leaves an occupied unrelated destination untouched. + +Default nuke rebuild now retains the Git registration, parent gitlink, logical +section name, and per-worktree retained storage, then uses native deinit/update +rather than delete/re-add. All selected modules are preflighted before the first +rebuild. Native deinit's internal force flag is used only after the app proves +the selected checkout has no tracked changes, untracked or ignored files, or +nested gitlinks; an explicit user `--force` is required to authorize discarding +such data. +Because outer status can hide ignored content inside a nested checkout, default +rebuild conservatively refuses any nested mode-160000 index entry, including an +incomplete nested registration with no `.gitmodules` declaration. + +Rebuild snapshots ordered duplicate local and worktree Git configuration, +reconciles a TOML-only URL change to the selected child remote before a needed +fetch, and restores all unrelated values afterward. Managed active, update, +ignore, branch, recurse, shallow, URL, and worktree override state reaches the +declaration after materialization; `active=false` remains durable and an +explicit nuke still creates the requested initial checkout when update is +`none`. New structural registrations deliberately stage only their requested +`.gitmodules` settings so multiple config-only rebuilds do not reject deltas +created by an earlier module. Existing-registration metadata edits remain +unstaged and preserve the prior staged blob and parent index. A later runtime +failure reports completed, failed, and pending modules, keeps declarations, +gitlinks, storage, refs, and stashes repairable, and converges on retry or an +explicit force retry when native deinit left a deletion-only checkout. + +Path changes use native move for initialized modules and metadata-only movement +for inactive declarations. Dirty sources, occupied destinations, and identity +conflicts refuse with exact parent, child, config, ref, and file snapshots +unchanged. An actual interrupted child process leaves owned partial state and +locks diagnosable; after the injected cause is repaired, retry converges without +erasing prior work. + +Phase 5 combined gate evidence: + +```text +/Users/adampoulemanos/.cargo/bin/cargo-nextest nextest run \ + --config-file /private/tmp/submod-improvement-plan/.config/nextest.toml \ + --manifest-path /private/tmp/submod-improvement-plan/Cargo.toml \ + --locked --offline --all-features --profile ci \ + --target-dir /private/tmp/submod-improvement-target --no-fail-fast +full retained run: 784 run; 778 passed; 6 test assertions failed; 0 skipped; +670.867s + +same command and artifact, filtered to the six corrected assertions: +6/6 passed; 778 skipped; 31.533s + +final executable reset-recovery assertion on the same artifact: +1/1 passed; 3.029s + +cargo-nextest 0.9.128 (96bc6d4f4 2026-02-19) +debug artifact SHA-256 before and after both runs: +68be84a59b877a7cb5526145008b27711be2b0800677c757ec0d1eed1334ec64 + +RUSTUP_TOOLCHAIN=1.98.1 cargo check --locked --offline --all-features \ + --target-dir /private/tmp/submod-improvement-target: PASS +cargo +1.98.1 fmt --all -- --check: PASS +git diff --check: PASS +``` + +The six full-run failures were stale test expectations on the same successful +production artifact. Three expected old progress wording. Their replacements +require verified parent pins, child HEAD/content, parent index, exact stash OID, +stash contents, and executable recovery output. Three fixtures assumed the old +add path omitted `shallow=false`: one redundantly tried to commit an already +staged value, and two compared a pre-declaration portable value that correct +reconciliation removed. The corrected tests require the exact intended +working-tree removal while preserving the raw TOML, staged `.gitmodules` blob, +parent index, history, and unrelated settings. No production source or binary +changed between the 778-test result and the 6-test correction run. + +Logs are `/private/tmp/submod-phase5-retained-final.log`, +`/private/tmp/submod-phase5-retained-corrections.log`, +`/private/tmp/submod-phase5-reset-recovery-final.log`, +`/private/tmp/submod-phase5-nextest-groups-final2.log`, and +`/private/tmp/submod-phase5-cargo-check.log`. Focused recovery evidence is in +`/private/tmp/submod-phase5-checkout-recovery-final.log` and the Phase 5 review +and native-oracle files under `/private/tmp/submod-supervision/`. Independent +review replayed unmerged-reset preservation, the printed stash recovery command +with paths containing spaces, logical-name nuke identity, TOML-only changed-URL +fetch, ordered local/worktree config preservation, missing nested declarations, +and two `shallow=false` config-only rebuilds on frozen artifacts. Those probes +supplement the repository suite and are not included in its 784-test count. + +### Phase 6 — command contract, read-only inspection, and operator output + +Every mutating command now has a read-only planning path. `--dry-run` uses the +same selection, validation, observed-state, and preflight logic as execution, +then prints the concrete per-module action without acquiring application or Git +locks, changing config/index/worktree metadata, or starting clone/fetch +transport. Plans distinguish unchanged modules, metadata-only reconciliation, +structural registration and staging, checkout materialization, known parent +pins, unresolved remote targets, branch/remote selection, skipped checkout +policy, and completed/failed/pending batch outcomes. Git inspection commands +disable `diff.autoRefreshIndex`; this is necessary because Git 2.50.1 refreshes +the index stat cache even with optional locks disabled. Stale-stat-cache tests +fingerprint file contents, inode/mode/mtime, index bytes, directory metadata, +and transfer traces before and after every structural preview. + +Read-only `check` and `list` use the same repository context and validated +child identity boundary as lifecycle execution. They report portable/local/ +worktree/child metadata drift, recorded-pin or strategy ancestry drift, dirty +managed checkouts including `update=none`, unmanaged registrations, inactive +and uninitialized states, and inspection failures. Recursive inspection walks +actual nested registrations, marks uninitialized descendants as not inspected, +sorts output deterministically, and rejects redirected child worktrees before +descent. Implicit config discovery is distinct from an explicit path even when +the spelling is `submod.toml`; default, relative, absolute, nested-cwd, linked +worktree, missing-config, and outside-repository template/completion contexts +have command-specific behavior. + +Errors retain their origin. Argument and configuration validation exits 2; +native Git, repository discovery, drift, filesystem permission, and other +operational failures exit 1. Filesystem validation preserves the affected path, +the underlying `io::ErrorKind`, and its cause instead of converting every path +failure to invalid input. Native Git output is parsed from its original bytes; +only human display passes through the output sanitizer. + +Human results are written to stdout, progress and warnings to stderr, and +machine completion output remains unmodified. Per-module summaries are +finalized from observed postconditions, so a remote advance reports the actual +new OID, a no-op remote or recursive request reports unchanged, and a metadata +repair under disabled/none policy reports changed plus the checkout skip. +Partial failures name completed, failed, and pending modules and do not print a +success summary. Human text escapes control characters and terminal sequences, +redacts apparent URL userinfo before escaping, preserves ordinary Unicode and +credential-free host:port prose, and covers native, configuration, fallback, +captured gix, generated-path, sparse-diagnostic, and final error paths. + +The README, long help, sample, built-in template, current schema aliases, +versioned schema, shell completions including nushell, and test runner now +describe the canonical command and configuration contract. `generate-config +--from-setup` is a boolean repository import, handles registered but +uninitialized children, validates the destination equally in preview and +execution, and conflicts with template mode. Global branch changes use the +same raw/effective inheritance rules as module settings. + +The first full Phase 6 diagnostic run exposed 42 retained assertions tied to +old wording or fixtures, one real permission-error category defect, and a +repeatable 10-module inspection cost. The retained assertions were rewritten +around exact named outcomes, targets, streams, Git state, bytes, OIDs, index, +and recovery behavior. The identity path was then reduced from repeated full +repository discovery to lossless gitfile inspection plus one native +`--show-toplevel` containment check. A controlled replay on the preserved test +artifact changed check from 6.510s to 3.637s and no-op update from 25.275s to +8.566s; the original 5s and 20s limits were retained. This is bounded evidence +for the corrected inspection path, not the Phase 7 production benchmark or a +general before/after speed claim. + +Phase 6 final gate evidence: + +```text +cwd: /private/tmp/submod-improvement-plan +RUSTUP_TOOLCHAIN=1.98.1 \ +/Users/adampoulemanos/.cargo/bin/cargo-nextest nextest run \ + --locked --offline --all-features --profile ci --no-fail-fast \ + --target-dir /private/tmp/submod-improvement-target +exit 0: 834/834 passed; 0 skipped; 756.762s + +cargo-nextest 0.9.128 (96bc6d4f4 2026-02-19) +test-selected debug CLI SHA-256 before and after the gate: +387eb3f79be8cb514ea22dd66acdb868bd2d776c4a50b1bf19bf5de8e4de474c + +ordinary cargo-build CLI independently reviewed by the supervisor: +37529dd21de7aa18325b884d927060f588fbbfcc8804c1b4053104860bfa63b5 + +cargo +1.98.1 check --locked --offline --all-features \ + --target-dir /private/tmp/submod-improvement-target: PASS +cargo +1.98.1 fmt --all -- --check: PASS +git diff --check: PASS +``` + +The final log is `/private/tmp/submod-phase6-retained-fix3.log`. Supported +Nextest group inspection confirmed that all integration, reconciliation, +performance, Phase 5, and Phase 6 binaries use the serial group with one +thread. Cargo supplies a distinct integration-test executable artifact through +`CARGO_BIN_EXE_submod`; its hash and the ordinary debug-build hash are recorded +separately and evidence is not transferred between them. + +Independent read-only and summary replays passed 9/9 and 4/4 on the frozen +Phase 6 candidate. Independent identity replay passed relative gitfiles, +embedded gitdirs, a valid external gitdir reached through a symlink, explicit +foreign `core.worktree` refusal, and owned-storage ancestor escape refusal with +exact state snapshots. Evidence is in +`/private/tmp/submod-supervision/phase6-readonly-fix2.json`, +`phase6-summary-fix2.json`, and `phase6-identity-optimization-fix3.json`. +Sanitizer, schema, execution-summary, retained-correction, and identity reviews +are recorded in the corresponding `phase6-*-review.md` files under +`/private/tmp/submod-supervision/`. Those probes supplement the 834 retained +tests and are not included in that count. + +### Phase 7 — backend retirement, duplicate compilation, and measurement + +`src/main.rs` imports the library instead of redeclaring +config/manager/options/Git/shell/utility modules, removing the duplicate +compilation (and duplicate unit-test execution) of the library in the binary +target. The unreachable backend mutation bodies, their `GitOperations` +implementations, `try_with_fallback_mut`, the `forcing_cli_add` seam, and +`simple_gix.rs` (with the direct `gitoxide-core` and `prodash` dependencies +and the gix `blocking-http-transport-curl-openssl` / `worktree-mutation` +features) were deleted. `gitoxide-core` is no longer in the dependency graph +at all. gix remains for supported reads (`status`, plus `sha1` and +`max-performance-safe` tuning); git2 remains for reads, repository handles, +and option conversions. The yanked `bisync 0.3.0` stays reachable via +gix `status` → `gix-protocol`; upstream withdrew it without a released +replacement, so it is documented in `deny.toml`, not blanket-allowed. + +`Config::add_submodule` and `remove_submodule` mutate in place (no whole-map +clone); sparse patterns are borrowed (`&[String]`) through the read APIs. +`fallback_tests.rs` and `git_ops_tests.rs` were retargeted to the surviving +contract: native manager mutations checked against real Git state plus the +retained backend readers; obsolete stub-error assertions were removed, and +the `.gitmodules` `active` handling now asserts the app-only contract +(`active` never leaks into portable fields). `benches/benchmark.rs` measures +real config parse/load/edit at 1/10/100 modules, and +`scripts/measure-performance.py` drives before/after comparisons (alternating +samples, warm-up, Trace2 child-launch counts, direct-Git invocation counts, +no-op byte/mtime/index identity) against immutable bench-profile artifacts. +The performance memory test labels the tracking allocator as Rust allocations +only and restores the process working directory via a drop guard. + +Compatibility impact: the `GitOperations` implementations for +`GixOperations`/`Git2Operations` are removed along with +`Config::sync_with_git_config`/`load_with_git_sync` and the backend mutation +methods. The library API is explicitly unstable (see `src/lib.rs`); in-tree +callers were migrated to `GitOpsManager`. No TOML, CLI, or Git-state contract +changes in this phase. + +Phase 7 gate evidence (`scripts/measure-performance.py`, `--profile bench`, +10 alternating samples after validation + warm-up, same macOS host, +Git 2.50.1, raw records in `/private/tmp/submod-perf-work/final.jsonl`): + +- Baseline: accepted Phase 6 snapshot production code with only the + measurement harness file overlaid (verified: the build copy differs from + the snapshot in `benches/benchmark.rs` alone); CLI + `c12429684c60f5961e95c133ab92c559a08f1302a71bb4272c1300f1ca09d431` + (3,924,672 bytes), bench + `8c44e40b5c5f26a277fb9adf5428c2bdf0ca4bfe1d975ee0999b7ec8754f5d1a`. +- Candidate: current tree; CLI + `12b0281a197ef0a4db75803ae409420c14c7522091a1316c3715d0c6d264010e` + (3,888,016 bytes, ~36 KB smaller; doc-comment-only source edits after the + freeze rebuild to this identical hash), bench + `756abab96fdd5868da69899f92df5fb154b7b81e63bd4818041f11ea12f89348`. + +| Workload (median of 10) | Baseline | Candidate | +| --- | --- | --- | +| config parse 1/10/100 (ns/iter) | 3846 / 25549 / 240781 | 3985 / 26123 / 242557 (parity) | +| config load 1/10/100 (ns/iter) | 12785 / 33861 / 248290 | 12502 / 34475 / 249881 (parity) | +| config add-one 1/10/100 (ns/iter) | 185 / 1135 / 9729 (linear) | 38 / 47 / 158 (flat; 4.9x/24x/61x) | +| check absent 1/10/100 (ms wall) | 53.3 / 46.3 / 48.0 | 53.3 / 43.4 / 45.1 (parity) | +| check materialized 1/10 (ms wall) | 441 / 4062 | 437 / 4023 (parity) | +| sync no-op 1/10 (ms wall) | 923 / 9263, state held | 916 / 9182, state held (parity) | + +The add-one speedup is the in-place `update_entry` (no whole-map clone): +baseline cost grows with map size, candidate cost is flat. Parse/load and all +CLI paths are within overlapping sample ranges — no measured regression, and +no source-level simplification is described as a speedup beyond the add-one +case above. Instrumented samples show identical direct Git invocation counts +(8/50/101/428/938 across workloads) and identical Trace2 child-launch counts +on both artifacts, byte/mtime/index/porcelain identity held on every +inspection and no-op run, and lower direct-executable max RSS on the +candidate (~10.0–10.5 MB vs ~11.7–12.5 MB, `/usr/bin/time -l` boundary: +direct executable only, not a parent+descendant aggregate). This is +warm-cache local evidence only; no cold-cache or network claim is made. + +### Phase 8 — shipping gates + +CI runs the required stable suite on Linux, macOS, and Windows plus a +separate informational beta/nightly job, an explicit Rust 1.89 MSRV gate, +locked nextest with the `ci` profile, separate locked doctests, and a new +package job (`cargo package --locked`, build/test of the extracted packaged +source, schema JSON validation, template generation smoke). Coverage +permissions are read-only. Release resolves the tag to its commit and fails +mismatches; a `verify-tag` job (tests, doctests, MSRV, fmt/clippy, deny, +audit, package) gates the build; archives are built, then their exact bytes +undergo linkage inspection and native execution smoke (`--version`, `--help`, +disposable local submodule add/init/check) before upload; `cargo publish` +uses locked clean-source publication; the GitHub release attaches the checked +archives. All third-party actions are pinned to reviewed immutable SHAs (see +workflow headers for the update procedure). Advisory policy is consolidated: +the stale `RUSTSEC-2024-0436` ignore and the now-unreachable +`RUSTSEC-2024-0364` ignore were removed from `audit.toml`, +`.cargo/audit.toml`, `deny.toml`, and CI, so a re-entry fails loudly. +`cargo package` no longer names absent `LICENSE.md`/`submod-sbom.spdx` +assets; the real `LICENSE-*.md` files ship. Stale "TODO: implement" markers +on completed commands and the delete/reclone `change --path` help text were +corrected to the implemented safe-move contract. + +Unrun and explicitly unverified: hosted Linux/macOS/Windows job results, +cross-architecture archive execution (aarch64 on x64 runners), live private +authentication, and HTTP schema delivery. No release was published or tagged +as part of this work. + +## Regression coverage + +Status values are `RED` (proved failing before its fix), `PASS`, `PARTIAL`, or +`PENDING`. Each row represents the family in `IMPROVEMENT_PLAN.md`, not a claim +that every platform variant has run locally. + +| ID | Status | Evidence / remaining condition | +| --- | --- | --- | +| R01 | PASS | Empty/root/admin/parent/absolute/escaped/symlinked paths and administrative names refuse before mutation; sentinels and index/config snapshots remain unchanged. | +| R02 | PASS | Occupied file/directory/symlink/unrelated repo, dirty registration, and unavailable remote preserve files, refs, index, config, and metadata. | +| R03 | PASS | Disable and re-enable preserve the exact divergent checkout, local branch/ref, local-only commit, stash, child index/config, object database, and parent state. A later init intentionally converges to the parent pin; the saved commit/ref and stash remain accessible and recover the original files/index. | +| R04 | PASS | Delete removes only the exact gitlink/registration; prefix siblings retain mode/OIDs and held index lock refuses without mutation. | +| R05 | PASS | Held stash-ref lock and ignored file/directory/nested-repo target collisions refuse before reset with all bytes/state preserved; successful stash/reset reaches the parent pin and retained stash recovers staged, unstaged, and untracked work. | +| R06 | PASS | Raw inheriting fields remain absent while explicit module overrides remain; changed global ignore is applied to portable Git metadata and survives a fresh process. | +| R07 | PASS | Omitted/true/false booleans and sparse mode at both scopes round-trip without resetting unrelated fields. | +| R08 | PASS | Sample/template, absent/1.0/1.1/future schema, aliases, legacy values, unknown fields, types, and source-line diagnostics use one validated model. | +| R09 | PASS | Multiline arrays/strings, comments, quoted/literal/dotted/Unicode names, escapes, dotted keys, and unrelated sections survive managed edits without duplicate tables. | +| R10 | PASS | No-op bytes/inode/mtime, atomic failure, absence/empty identity, symlink refusal, deterministic simultaneous writers, linked worktrees, shared configs, stale managers, and relative/absolute generated outputs pass. Arbitrary external-editor race remains documented. | +| R11 | PASS | TOML-only init/sync with omitted path creates the exact registration, mode-160000 gitlink, checkout files, and effective root-relative path; a repeat is byte/state identical. | +| R12 | PASS | Fresh-clone init/sync materializes the recorded old parent pin rather than the newer remote tip, and initialized checkout drift converges with verified content. | +| R13 | PASS | Named/default/`.` branches, detached-parent refusal, checkout/merge/rebase/none strategies, recorded-pin and explicit remote updates, divergent metadata-only HEAD preservation, ancestry postconditions, and repeat no-ops pass with exact OIDs/content/index evidence. | +| R14 | PASS | URL/branch/ignore/update/fetch and inherited defaults reconcile across portable, local, worktree, and selected child config; removed managed keys are unset, relative URLs follow native Git semantics, unrelated keys remain, and a repeat preserves file identity. | +| R15 | PASS | Ordered positive/negative patterns, extra/reordered patterns, mode/enable drift, removal/empty restoration, dirty included/excluded protection, exact content, and unchanged no-op behavior pass. | +| R16 | PASS | Alias/path splits, native nested logical names, alternate formatting, prefix paths, duplicate registrations, exact/case-normalized overlaps all use exact unique matching or refuse before mutation. | +| R17 | PASS | Root, nested cwd, linked-worktree, implicit/default-explicit/relative/absolute custom config, and missing-config policy select the intended repository/config; conflicting selectors fail before action. Check distinguishes config validation from Git/inspection failure and reports portable, local, child, pin, and strategy drift. | +| R18 | PASS | Every supported optional override can be unset, sparse paths can be cleared, boolean tri-state survives fresh processes, and no-settings/set+unset/clear+replace/append/all+names conflicts refuse with usage status before mutation. | +| R19 | PASS | Disabled/update-none entries with unreachable URLs are untouched while inspectable metadata/dirty drift is reported; no-init is transient; unmanaged modules are preserved/reported. Recursive init/sync/update/list traverses actual registrations, marks uninitialized descendants, sorts output, and reports identity/inspection failures. | +| R20 | PASS | Fresh relative-URL materialization resolves against the parent remote and preserves the parent pin; non-main branches and recursive selection pass; shallow available older pins materialize exactly and unavailable pins fail with the requested OID without substituting the tip. | +| R21 | PASS | Missing registration/gitlink and retained-gitdir states recover only when unambiguous, preserving exact pins/history; malformed, nonexistent, redirected, and conflicting child pointers refuse before mutation. | +| R22 | PASS | Config-only, initialized, forced, and legacy embedded-layout deletion preserve their exact boundaries and retained history. Rebuild preserves logical/storage identity, refs/stash, ordered unmanaged config, inactive/none policy, changed selected URLs, declarations/gitlinks, staged layers, and exact pins; failed/partial rebuilds retain usable retry state. Default destructive work conservatively refuses nested gitlinks because nested ignored data cannot be proven safe. | +| R23 | PASS | Clean initialized moves preserve registration, gitlink, checkout/storage identity, history, and unrelated state; dirty, occupied, and conflicting moves refuse exactly; inactive path changes do not clone or fetch. | +| R24 | PASS | Invalid/dirty later targets preflight before earlier reset/nuke mutation; runtime failures report completed/failed/pending and retry converges. A real killed child process, owned partial state, blocked retry, repair, and final convergence retain exact user work. | +| R25 | PASS | Every mutating command previews through shared validation/planning with concrete actions, staging/target/remote context, exact full-tree/index/metadata fingerprints unchanged, stale stat-cache coverage, and zero transfer attempts. Read-only check/list retain the same boundary. | +| R26 | PASS | Results/progress/warnings use the documented streams; named outcomes and actual targets/counts reflect postconditions; control and terminal text is escaped and apparent URL userinfo redacted across native, config, fallback, captured backend, generated path, sparse, and final-error output while useful Unicode/prose remains. | +| R27 | PASS | README, long help, template, sample, schema aliases/version, shell completions including nushell, test runner, and generate/import workflows agree with the canonical options and runtime behavior. | +| R28 | PASS | Real config parse/load/edit at 1/10/100 plus CLI inspection/no-op sync measured before/after on immutable bench-profile artifacts: add-one 4.9x/24x/61x (linear→flat), all other paths at parity, identical Git invocation counts, no-op byte/mtime/index identity held, allocator scope honestly labeled. Raw records `/private/tmp/submod-perf-work/final.jsonl`. | +| R29 | PARTIAL | `cargo +1.89 check --locked --all-features` passes locally (Rust 1.89.0). CI defines required stable Linux/macOS/Windows jobs, an informational beta/nightly job, an explicit MSRV gate, tag→commit resolution with mismatch failure, a verify-tag gate (tests, doctests, MSRV, fmt/clippy, deny, audit, package), build→linkage/smoke→upload ordering with native-execution smoke, locked clean-source `cargo publish`, and immutable action SHAs. Hosted job results, cross-arch archive execution, and any actual publication remain explicitly unrun/unverified; nothing was tagged or published. | +| R30 | PASS | All five schema JSON files parse; sample/template/completion agreement covered by passing R27 tests; fresh `cargo audit` (1243 advisories) reports zero vulnerabilities with one allowed yanked warning (bisync 0.3.0, documented in `deny.toml`); `cargo deny check` passes advisories/bans/licenses/sources with the retired ignores removed; `cargo package --locked --allow-dirty` builds `submod-0.4.0.crate` (23 files, real `LICENSE-*.md` shipped, absent-asset globs removed); the clean-tree `cargo package --locked` plus packaged-source build/test run in CI's package job. | +| R31 | PASS | Add, move, delete, metadata reconciliation, incomplete registration, and mixed structural/metadata batches preserve staged and unstaged `.gitmodules` layers independently or refuse before mutation; unrelated edits are never swept into the index. | + +## Final local verification (closing gates) + +```text +cargo fmt --all -- --check: PASS +git diff --check: PASS +cargo clippy --locked --offline --all-targets --all-features: PASS (zero errors; + warn-level pedantic/nursery only; touched files warning-free) +/private/tmp/submod-nextest-bin/cargo-nextest (0.9.128, 96bc6d4f4) nextest run + --locked --offline --all-features --profile ci --no-fail-fast + exit 0: 631/631 passed, 0 skipped, 874.418s + log: /private/tmp/submod-final-retained.log +cargo test --locked --offline --all-features --doc: PASS (0 tests by design) +cargo test --locked --offline --all-features --lib: PASS (185/185) +cargo +1.89 check --locked --offline --all-features: PASS (MSRV) +cargo audit (fresh DB, 1243 advisories): 0 vulnerabilities, 1 allowed yanked + warning (bisync 0.3.0) +cargo deny --all-features check: advisories/bans/licenses/sources ok +cargo package --locked --allow-dirty: builds submod-0.4.0.crate (local tree is + uncommitted by design; clean-tree packaging runs in CI) +``` + +Test-selected debug CLI SHA-256 before and after the gate (no concurrent +builds during the run): +`a911ca4d9c5c626bfcf3a3487273bd0b2e2889b122498f0261bd3b078037acde` +(identical — no mixed-artifact replacement). Post-gate edits are limited to +this report, one comment word (`behaviour` → `behavior`), and the +`unparsable` spelling in the measurement script; no production code changed +after the gate. + +## Review follow-up (adversarial pass) + +Delegated agent review was requested but the runner failed every spawn +(4/4 infrastructure failures, no reviewer ever started), so a structured +self-review was performed instead — weaker independence, disclosed here. +Findings, all fixed and re-verified (fmt/clippy clean; fallback 30/30, +git_ops 73/73, lib 185/185 after the fixes): + +- Stale strategy docs (minor): module/trait/struct docs still described the + retired gix-first/git2-fallback mutation design and a "CLI fallback" update + path. Rewritten to the native-mutation boundary. +- `reopen` silently upgraded `without_gix` managers back to gix (minor, + real): `reopen` now preserves the backend policy and documents it; new test + `reopen_preserves_without_gix_policy` was proven to FAIL on the old logic + and pass on the new. +- Dead panicking API (minor): `From for GixOperations` had + zero callers and panicked on gix-less managers. Removed; the infallible + git2 conversion stays. +- `verify-tag` release job could never pass (major, static): it invoked + `cargo +1.89`, `cargo deny`, and `cargo audit` with only the stable + toolchain and nextest installed. Added the 1.89 toolchain and the + deny/audit install step (binstall fallback covers resolution). +- Misleading test names (minor): six fallback test names and one git_ops + name still described the retired mutation-fallback design. Renamed to the + native contract they actually assert. +- Checked and cleared: validation/preflight coverage on all native mutations + (`child_git` validates internally), no whole-map clones in hot paths (the + one `add`-time prospective clone is once per command, confirmed flat by + measurement), no stub remnants, `without_gix`/`gix_enabled` docs accurate, + install-action binstall fallback covers the deny/audit install step. + +Count reconciliation against the accepted Phase 6 gate (834): the exact +test-set diff shows 207 removals and 4 additions (631 = 834 − 207 + 4). +Removals: 186 duplicated lib unit tests in the binary target (Phase 7 item 1; +every one retains its lib counterpart — spot-checked +`config::tests::test_config_toml_roundtrip`, +`options::tests::test_branch_set_branch_none_is_not_defaulted`, and +`utilities::tests::repository_context_nested_and_linked_worktrees` passing +under `--lib`), 1 `simple_gix` unit test with its deleted module, and 20 +obsolete backend-mutation/stub assertions (3 fallback, 17 git_ops) replaced +by 4 retargeted native-contract tests. No unique coverage was lost. + +Completion checklist: safe preservation tests pass; root/nested/fresh-clone +onboarding works; TOML edits persist with inheritance intact; Git +configuration and checkout state converge; disabled and sparse removal +behavior is correct; repeated sync is a no-op; output/exit codes describe +reality; regression tests check real Git state; package/security gates pass +locally; docs describe the implemented contracts. Hosted platform/release +execution is defined but unrun (R29 PARTIAL); everything runnable locally is +green. + +## Known environment limits + +- Hosted Linux, Windows, and release authorization jobs cannot be asserted from + this macOS worktree; workflow definitions and all locally available checks + were validated, and unrun hosted results remain explicitly unverified. +- Live private-remote authentication is outside the deterministic regression + suite. HTTP schema delivery was not verified. +- No release was published or tagged as part of this work. diff --git a/docs/IMPROVEMENT_PLAN.md b/docs/IMPROVEMENT_PLAN.md new file mode 100644 index 00000000..70c3a2c8 --- /dev/null +++ b/docs/IMPROVEMENT_PLAN.md @@ -0,0 +1,316 @@ + + +# Submod reliability audit and implementation plan + +Audited 2026-09-10 at commit `31c5e373bfd29162f86675fa60ed4af0adf8df97`, version 0.4.0. This document is an implementation handoff, not a claim that the fixes have been made. Application source was unchanged during the audit. Pre-existing `.serena/` work was left alone. + +## Assessment + +The reported problems are real and arise from shared lifecycle and configuration defects. There are several implementations of what a submodule is, when it is initialized, and which configuration wins. Commands update different pieces of state, sometimes return success after incomplete work, and sometimes treat failure as permission to delete existing files. Repairing individual command symptoms will leave sibling paths broken. + +The highest priority is data preservation. In disposable local repositories, a failed add erased a pre-existing file; disable removed the submodule object database containing local history and a stash; deleting `lib` removed unrelated prefix siblings from the index. The next priority is reliable reconciliation between declared configuration and observed Git state. Performance improvements should primarily remove repeated work and duplicate implementations after those contracts are established. + +Keep the Rust CLI, current commands, flat TOML configuration, sparse pattern support, and integration-test approach. Use native Git for repository lifecycle mutations, retain useful native read operations only where they remain simpler, and replace the handwritten TOML editor with a real document editor. Do not complete three different mutation backends, build a plugin system, add a daemon/database, or introduce a general transaction engine. + +### Evidence and limits + +Four independent code audits covered configuration/lifecycle, Git safety, option/schema consistency, and tests/CI/performance. Runtime reproductions used only disposable local repositories. The environment was macOS aarch64, Rust 1.98.1, and Apple Git 2.50.1. Linux/Windows behavior and live private-remote authentication were not exercised. + +| Verification | Observed result | +| --- | --- | +| `cargo test --locked --offline --all-features -- --test-threads=1 --skip test_invalid_git_url --skip test_network_timeout_simulation` | Passed: 567 executions, zero failures, two network tests excluded. Includes all ten performance tests. | +| Independent test count | 168 unit tests run in both library and binary, so 567 executions represent 399 distinct tests passing; two further tests were excluded. | +| `cargo fmt --all -- --check` | Passed. | +| `cargo clippy --locked --offline --all-targets --all-features` | Passed with six distinct warnings; this was not a warning-free run. | +| `cargo +1.89 check --locked --offline --all-features` | Passed; the declared MSRV builds locally. | +| Existing hk CI command: `cargo nextest run --offline --all-features --fail-fast -p ci` | Failed with exit 101: `ci` is interpreted as a package. | +| `cargo audit --db /private/tmp/submod-audit/advisory-db --json` with freshly fetched database | Exit 0, 274 dependencies, zero non-ignored vulnerabilities; `RUSTSEC-2024-0364` is ignored and `bisync 0.3.0` is reported yanked. Database commit `b50980aad8b8f14f77e25a97b32dd94bf008b0af`, updated 2026-09-09. | + +Build/test artifacts were isolated using `CARGO_TARGET_DIR=/private/tmp/submod-audit-target`; MSRV used a separate temporary target. The dependency audit result does not establish application safety or excuse the ignored advisory. The current RustSec entry describes terminal-output manipulation in `gitoxide-core` and lists no patched version. Review the actual remaining use of that dependency after mutation consolidation. [RustSec advisory](https://rustsec.org/advisories/RUSTSEC-2024-0364.html) + +Machine-readable observations are preserved in [audit-results.json](audit-results.json). Original scripts/logs for this session are under `/private/tmp/submod-audit/`: `repro_config.py`, `repro_preservation.py`, `state_lifecycle.py`, `cargo-test.log`, `cargo-clippy.log`, `cargo-msrv.log`, and `cargo-audit-live.json`. These temporary files are supporting evidence; implementation must not depend on their continued existence. The regression matrix below specifies the fixtures to retain in the repository's test suite. + +### Prioritized findings + +Severity is repair priority for this application, not a CVSS score. **P0** means prevent data loss or writes outside the intended module before further feature work; **P1** means core lifecycle/configuration correctness; **P2** means usability, assurance, or avoidable cost. “Reproduced” means observed with the compiled CLI; “source” means traced without a dedicated runtime experiment. + +| ID | Priority / evidence | Finding, impact, and source | +| --- | --- | --- | +| F01 | P0 / source | Mutation validation is incomplete and inconsistently called. Empty/root-equivalent paths and `.git` pass the existing validator; config-driven init/delete bypass it; unchecked names enter module-directory joins. `src/utilities.rs:230-292`, `src/git_manager.rs:413,845,1519-1522,1548-1557`, `src/git_ops/mod.rs:451-464`. | +| F02 | P0 / reproduced | Add performs replacement cleanup before establishing whether it can succeed, and backend fallback removes the destination after an error. Adding a nonexistent local remote over an ordinary directory deleted its sentinel file, then exited 1. `src/git_manager.rs:443,505-514`, `src/git_ops/mod.rs:364-508`. | +| F03 | P0 / reproduced | Disable can erase repository history. With a clean checkout containing a local-only commit and stash, `disable` exited 0 and removed the object database. A remaining worktree directory is not preservation of the repository. `src/git_manager.rs:1466-1493`, `src/git_ops/gix_ops.rs:609-684`. | +| F04 | P0 / reproduced | Gix deletion removes index entries by string prefix. Deleting `lib` also removed `library.txt` and `lib-extra/file` from the index; their files survived, so this is unintended staged deletion. Index writes also bypass normal lock handling. `src/git_ops/gix_ops.rs:537-552`. | +| F05 | P0 / source | Reset warns on every stash error and continues into hard reset and clean. A failed preservation step is treated as optional. `src/git_manager.rs:733-752`, `src/git_ops/git2_ops.rs:658-662`. | +| F06 | P1 / reproduced | `sync` never calls the existing configuration synchronization API. Changing a URL then running sync left both `.gitmodules` and child `origin` unchanged while announcing success. Existing init short-circuits on `.git` presence. `src/main.rs:191-205`, `src/config.rs:1054-1077`, `src/git_manager.rs:805-814,1659-1724`. | +| F07 | P1 / reproduced | A fresh clone with `.gitmodules` and an old pinned gitlink reported successful init but produced no checkout. Native Git initialized the same fixture correctly. Gix drops a prepared checkout instead of completing it. `src/git_ops/gix_ops.rs:378-393,415-462`. This differs from TOML-only creation, which succeeded in the audit. | +| F08 | P1 / reproduced + source | Repository roots and module identity are inconsistent. Invoking even list/check from a nested directory failed repository discovery; omitted `path` failed despite the documented name-based default. Init searches `.gitmodules` by substring, allowing prefix/comment false matches and formatting false misses. Nicknames, Git section names, and paths are interchanged. `src/git_manager.rs:243-250,766-827`, `src/git_ops/git2_ops.rs:22-33,408-421`. | +| F09 | P1 / reproduced | Durable disabled state is ignored. `active=false` still cloned during init. Conversely, `add --no-init` persists `active=false`, conflating a one-invocation option with persistent disabling. `src/main.rs:106-135,173-205`, `src/git_manager.rs:418-435,688-705,761-800`. | +| F10 | P1 / reproduced | Loading materializes inherited defaults into stored entries. Changing global ignore from dirty to all wrote `ignore="dirty"` into the formerly inheriting module. `src/config.rs:1000-1017,1125`, `src/git_manager.rs:1053-1069,1439-1451`. | +| F11 | P1 / reproduced | The line-based TOML editor corrupts valid multiline arrays and duplicates tables with trailing comments. Commands return success and the next load fails. Sparse mode is omitted from serialization; unrelated change clears `shallow=true` because the CLI always passes false. Writes truncate the live file. `src/git_manager.rs:1030-1370`, `src/main.rs:238`, `src/commands.rs:208-215`. | +| F12 | P1 / reproduced + source | Branch/update behavior differs by backend. Add with feature branch checked out main and omitted branch from `.gitmodules`. Manager update always uses default options. Gix updates fetch without the required checkout transition; git2 normally follows the parent gitlink. Remaining at a parent pin is valid under the chosen contract below, but contradicts the README's unconditional “latest” promise. `src/git_ops/git2_ops.rs:344-403`, `src/git_ops/gix_ops.rs:398-479`, `src/git_manager.rs:701-705,854-857`. | +| F13 | P1 / reproduced + source | Removing sparse paths does not disable sparse checkout or restore excluded files. Status uses a subset comparison, accepts extra patterns and ignores order, so it cannot establish equivalent sparse behavior. `src/git_manager.rs:328-378,805-814`; low-level application also uses a cwd-relative path at `src/git_ops/mod.rs:600-609`. | +| F14 | P1 / reproduced + source | Generated template cannot load: `schema_version` is interpreted as a submodule. Version 1.1 schema has invalid JSON, schema symlinks are broken, schema `fetch` differs from runtime `fetchRecurse`, and current fields are missing. Unknown runtime settings can be silently ignored. `sample_config/submod.toml:4-5`, `src/config.rs:961-968`, `schemas/v1.1.0/submod_config_v1.1.0.json:19-21,67,76`. | +| F15 | P1 / source | Wiring the existing sync function directly would be unsafe/incomplete: gix replaces all `.gitmodules`, git2 only upserts present values, and only local branch receives explicit synchronization. Removed fields and unmanaged Git modules have no consistent contract. Every backend error triggers fallback, including errors after partial mutation. `src/config.rs:1054-1077`, `src/git_ops/gix_ops.rs:140-145`, `src/git_ops/git2_ops.rs:213-294`, `src/git_ops/mod.rs:288-326`. | +| F16 | P2 / reproduced + source | `check` reports missing checkout with exit 0; an explicitly misspelled config path behaves like an empty config; recursive list is only a top-level supplement. README `generate-config --from-setup .` and `completeme nu` both fail. Normal sync emitted terminal control output even when captured. `src/git_manager.rs:885-1000,1369-1420`, `src/commands.rs:344-351`, `src/shells.rs:43-56`. | +| F17 | P1 assurance / source + reproduced | Green tests include heading-only “inheritance”/roundtrip checks, a fake disk-exhaustion test, a sequential “concurrency” test, and helpers that ignore Git exit status. The hk CI entry point fails; test-only edits do not trigger its test hook. `tests/config_tests.rs:18-100,298-318`, `tests/error_handling_tests.rs:325-405,587-622`, `tests/common/mod.rs:352-359`, `hk.pkl:37,67`. | +| F18 | P2 / source | Performance benchmarks copy production algorithms; allocation ceilings omit native and child-process memory; test code changes global cwd. Main compiles library modules again. Linux-only functional CI does not establish macOS/Windows release correctness; tag builds skip the manual linkage checks and have no direct test prerequisite. `benches/benchmark.rs:24-58`, `tests/performance_tests.rs:18-55,430-474`, `src/main.rs:25-32`, `.github/workflows/ci.yml:19`, `.github/workflows/release.yml:99,131-169`. | + +## Required behavior: settle these contracts before editing + +The following are recommended product decisions for this implementation. They intentionally remove ambiguity so the implementing agent can proceed. They are not claims about current behavior. Record their compatibility impact in the changelog. + +### Configuration and state ownership + +1. `submod.toml` declares desired settings for its managed modules. `.gitmodules` remains compatible with ordinary Git and is an import source, not a second source that silently overwrites declared TOML. Import is explicit through `generate-config --from-setup`. +2. An entry absent from TOML but present in Git is **unmanaged**, not scheduled for deletion. Preserve and report it. Only explicit delete/nuke removes a module. Do not add implicit pruning. +3. Retain raw declarations, including whether a field was absent. Compute effective settings separately: explicit command override, then per-module declaration, then supported global default, then documented built-in/Git behavior. Never save inherited values as declarations. +4. Discover the repository from invocation cwd once. Use its worktree root for all module paths, its discovered Git directories for metadata, and Git's actual gitdir/common-dir resolution for linked worktrees. Do not infer these from `/.git`. +5. The default config is `/submod.toml`. An explicit relative `--config` is relative to invocation cwd; module paths inside any config are still relative to repo root. A missing explicit config is an error. Without a default file, `add` may create one; configuration-dependent commands explain how to import/generate it. Help, completions, and template generation work outside repositories. +6. Omitted module path means its validated name relative to root. URL is required for managed entries. Reject duplicate/overlapping managed paths before mutation. Normalize harmless `./` spelling; reject `..` components rather than interpreting them during writes. +7. Keep TOML nickname, Git logical section name, and checkout path distinct. Match existing Git registrations by exact normalized path and preserve their section names. New registrations use the TOML nickname as Git logical name. A nickname rename at an unchanged path must not create another module. Never assume old path-named registrations must be renamed. Ambiguous matches are errors. Manual path edits with no unambiguous old identity are reported for explicit `change --path`, not guessed as permission to delete old state. +8. `active=false` means no clone/fetch/checkout during init/update/sync. Disable changes activation only and preserves checkout, object database, refs, and stashes. `--no-init` skips materialization for this add only; it does not disable future init. + +### Managed fields + +| Field | TOML behavior | Git/application effect | +| --- | --- | --- | +| `path`, `url` | Required effective path; required declared URL | Exact `.gitmodules` entry; synchronize managed URL into parent local config and initialized child remote using Git's relative-URL semantics. An explicit sync of a managed URL replaces a divergent local URL; say so in preview. | +| `branch` | Optional globally and per entry; absence retains Git remote-default semantics | `.gitmodules` tracking branch and any corresponding managed local override. Named branch is selected for initial add; existing pinned checkout moves to tracking tip only with `update --remote`. Preserve `.` semantics and fail clearly when resolution needs a branch but the parent is detached. | +| `ignore` | Canonical all/dirty/untracked/none | `.gitmodules` plus appropriate local value; safety checks must still detect local work even when display/status ignore settings hide it. | +| `update` | checkout/merge/rebase/none | `.gitmodules` and local policy; effective strategy actually controls update. `none` skips update and fetching. Reject `!command` from TOML/CLI; do not promote executable configuration from a repository file into trusted local config. | +| `fetchRecurse` | always/on-demand/never | Git key is `fetchRecurseSubmodules`, encoded true/on-demand/false. This is fetch behavior, not an implicit instruction to initialize all nested checkouts. | +| `active` | Default true | Managed local activation and app selection; keep it in TOML, not as a portable `.gitmodules` feature. | +| `shallow` | Default false; cloning policy | Advertise supported Git shallow recommendation and pass depth when creating a clone. It is not a promise to retroactively truncate/unshallow an existing repository. Report an existing-history mismatch; document that limitation explicitly. | +| `sparse_paths` | Ordered patterns; absent/empty means full checkout | Per-child sparse configuration and real checked-out contents. Removing patterns disables sparse checkout safely. | +| `use_git_default_sparse_checkout` | Per-entry > global > false | Preserve the existing opt-in/default pattern semantics, including explicit false; do not write this app-only setting to `.gitmodules`. | +| `schema_version` | Optional; accept absent legacy form and supported 1.0.0/1.1.0 metadata | Explicit metadata, never interpreted as a module. Unsupported future version fails before writes. | + +Clear obsolete managed Git keys when their declaration/default disappears; otherwise an old local override continues winning. Preserve unrelated keys and sections. Validate aliases explicitly: accept historical `fetch` and `fetch_recurse` with a migration warning, write canonical `fetchRecurse`, and reject conflicting duplicate spellings. Unknown keys must produce a contextual error rather than silently changing behavior. Preserve the original document on validation failure. + +Compatibility also includes values produced by older submod commands: treat serialized `branch="HEAD"` as the legacy remote-default sentinel, and legacy fetch strings `"true"`/`"false"` as always/never, with contextual migration warnings. Do not reject every older generated file when introducing stricter validation. Do not rewrite these files during read-only commands; canonicalize the touched fields on an explicit edit/import. + +Native Git distinguishes the parent-recorded commit from tracking a remote tip, and its URL sync is narrower than full configuration reconciliation. The implementation must handle both distinctions explicitly. [Git submodule documentation](https://git-scm.com/docs/git-submodule) + +### Command contract + +| Command | Required behavior | +| --- | --- | +| `check` | Read-only, no fetch. Report missing, uninitialized, disabled, unmanaged, dirty, conflicted, configuration drift, gitlink difference, and sparse drift distinctly. Exit 0 if managed active modules match their strategy-specific required state, 1 for drift/operation failure, 2 for argument/config validation errors. Unmanaged/disabled modules alone do not fail. Dirty managed checkouts are reported and yield 1. | +| `list [--recursive]` | Read-only deterministic listing. Recursive means actual descendants, including registered but uninitialized entries where discoverable; distinguish “not inspected” from empty. Exit nonzero on inspection failure. | +| `add` | Validate then register/materialize a new module and save its explicit settings. Refuse conflicting existing content. Never silently replace a module. Report that Git registration stages `.gitmodules`/gitlink; never commit. | +| `add --no-init` | Save a valid enabled declaration only; later init/sync can materialize it. | +| `init` | Reconcile managed settings, create missing registration/checkout as appropriate, honor existing gitlinks, and apply sparse policy. Already-correct state is a no-op. | +| `sync` | Inspect/preflight, reconcile settings, initialize eligible active missing modules, apply the selected strategy against parent-recorded commits, reconcile sparse checkout, then verify. No preliminary status error may prevent expected missing-state repair. No automatic advancement to remote tips. | +| `update [--remote]` | Default updates to parent gitlink using the effective strategy; `--remote` explicitly advances to the configured remote branch/default and leaves its changed gitlink for user review. Initialize missing active checkouts safely. Report skipped `update=none`. Do not silently stage remote advancement. | +| `change` / `change-global` | Patch only supplied fields, then run metadata reconciliation for existing affected modules and safe sparse reconciliation only when sparse policy changed. These commands do not fetch, move HEAD, or materialize a missing checkout. Missing/config-only modules remain declared until init. Unrelated omitted flags do not reset values. `--unset FIELD` removes an override; `--clear-sparse-paths` requests a full checkout. | +| `change --path` | Validate the destination and use a safe Git-aware move for an initialized, unambiguous module; do not delete/reclone. Decline dirty/conflicted or unsupported layout moves without mutation. For never-materialized declarations, change only desired path. | +| `disable` / `change --active false` | Persist inactive state/local activation; preserve files and history. `change --active true` makes the module eligible for later init/sync, without an implicit destructive reset. | +| `reset` | Preserve tracked/untracked changes first, identify the resulting stash, abort on preservation error, reset to the parent gitlink, and verify. No `clean -x`; ignored data is preserved. | +| `delete` | Explicit removal of selected registration/checkout/config; refuse local changes without explicit force. Retain Git's stored module repository so local history remains recoverable. Config-only deletion never deletes an unrelated occupied directory. | +| `nuke-it-from-orbit` | Explicit repair/remove workflow with selected targets, preview, and protection for local data. Default rebuild retains declarations until successful reinit. `--kill` removes declarations after successful removal. Neither mode silently purges stored local history; do not add an implicit `.git/modules` garbage collector. | +| `generate-config` | `--from-setup` is a boolean flag; reads registration even without checkout. `--template` conflicts with it. Existing output needs `--force`. Output is atomically written and must load through the ordinary parser. | + +Provide `--dry-run` for mutating commands using the same computed actions as execution. It must not write files, lock metadata, stage the index, or access remotes. Make `--all` conflict with explicit names for reset/nuke, reject duplicate targets, and validate all selected targets before changing any. Keep existing aliases and add documented `nu`; do not add more aliases or a TUI. + +Verification is strategy-specific. Checkout requires child HEAD equal to the target gitlink. Merge/rebase require the native operation to succeed without conflicts and the target commit to be an ancestor of resulting HEAD; preserved local commits can make HEAD differ legitimately. If that relationship already holds, a repeated sync skips the operation. Check reports that expected difference as informational. `update=none` skips automatic fetch/materialization/checkout, including missing checkout, and reports it as intentionally skipped; metadata still reconciles where possible. Explicit `add` is a request to create the initial checkout even if later update policy is none. For ordinary init/update/sync and sparse reconciliation, dirty work that would be touched by a planned action causes refusal; untouched dirty work is preserved and reported as unresolved, so sync returns 1 rather than claiming every check is clean. Reset/delete/nuke follow their explicit preservation/force contracts instead. After `update --remote`, check can correctly report a checkout-policy difference until the user records the new parent gitlink. + +Deletion/deinitialization must preserve repository history according to the chosen command contract; Git distinguishes submodule worktrees from their stored repositories. Linked worktrees can also use a `.git` file and separate administrative directories. [Git repository layout](https://git-scm.com/docs/gitrepository-layout) + +## Implementation sequence + +Follow these phases in order. Each phase has a completion gate. Keep changes reviewable, but finish the whole plan rather than stopping after the first passing subset. Tests should be added beside the affected existing integration tests, not as a second test framework. + +### Phase 1 — Make the regression harness trustworthy + +**Own:** `tests/common/mod.rs`, relevant existing test files, `hk.pkl`, `.config/nextest.toml`, `scripts/run-tests.sh`. + +1. Fix Git helpers to return/check exit status, stderr, and output. A missing config key is a specifically handled Git exit status; arbitrary Git failure must not become “absent.” Capture index mode/OID, worktree HEAD/content, parent/child config, and retained module gitdir through successful Git queries. +2. Use local remotes with at least two commits and two distinct branches. Clone the parent without submodules to test the onboarding state; do not use only `submod add` to build every fixture. +3. Keep Git identity/signing/transport settings isolated to each fixture or child process. Allow local file transport only in tests. Avoid process-global cwd/environment changes; use child `current_dir` and explicit repository paths. +4. Add failing reproductions for R01–R16 below before fixes. Snapshot unrelated files, refs, TOML, `.gitmodules`, and index state where preservation matters. A test that expects rejection must also prove nothing unintended changed. +5. Fix hk `-p ci` to `--profile ci`; include tests, Cargo files, schemas, and test tooling in relevant hook inputs. Distinguish nextest configuration profile from Cargo build profile. For performance execution, pass the actual intended Cargo profile to nextest. +6. Replace fake failure tests with deterministic failure conditions: missing local remote, held Git lock, unwritable fixture where supported, or a narrowly scoped test seam at the actual file-write/stash boundary. Delete tests whose name promises behavior they never exercise. + +**Gate:** Original baseline remains reproducible, new tests fail for the intended state discrepancies, and the advertised hk/nextest command runs the real suite. No fixture operation reaches the user's repository or global Git configuration. + +### Phase 2 — Centralize repository context and stop destructive fallback + +**Own:** `src/utilities.rs`, `src/git_ops/mod.rs`, `src/git_ops/{gix_ops,git2_ops}.rs`, shared `src/git_manager.rs` call sites. + +1. Discover once from cwd; store invocation directory, worktree root, gitdir/common-dir, and resolved config path in a small repository context. Reuse an existing repository object where possible. Bare repositories produce a specific unsupported-worktree error. Pass rooted paths everywhere; remove literal `Path::new(".")` and manual `.git/modules` assumptions from lifecycle code. +2. At the common mutation boundary validate every effective path and any administrative identity before touching disk. Require a strict descendant, forbid root-equivalent/empty paths, `..`, absolute paths, Git administrative locations and their aliases. Check existing ancestors without requiring a nonexistent destination to canonicalize. Reject symlink components in mutation destinations rather than trying to safely mutate through them. Reject overlapping managed paths and relevant case-insensitive collisions. Retain `PathBuf`/`OsStr` through filesystem and process operations. +3. Inspect occupied destinations. Treat an unrelated directory, file, symlink, or independent repository as a conflict. An existing registered submodule can be adopted only by the reconciliation rules; add does not replace it. Do not erase partial-looking directories simply because a command failed. +4. Route add/init/update/move/deinit/delete/reset/stash/clean/sparse work to **one native Git mutation path** inside the existing operations module. Use `std::process::Command` with argument arrays, rooted cwd, option terminators where supported, literal pathspec handling, checked statuses, and contextual errors. Read-only/preview Git calls use `--no-optional-locks` where applicable so status cannot silently refresh the index. Keep credential helpers/SSH agent behavior available. Do not run shell command strings assembled from config. +5. Remove unconditional cleanup in `cleanup_existing_submodule` and CLI fallback. Select the supported implementation before mutation; real permission/authentication/lock/conflict errors are terminal. If temporary cleanup is necessary, record the exact artifacts created by this invocation and clean only those; otherwise leave recoverable partial state with a useful error. +6. Remove manual index rewriting and prefix deletion. Use exact validated Git paths and ordinary Git lock handling. For mutation, lock both `/submod.lock` and a sibling `.submod.lock` for each output config. For template generation outside a repo, only the output-file lock applies. Acquire all lock paths in canonical lexical order using exclusive creation, then load/revalidate mutable state; hold them through postcondition verification and release on normal error/success. This serializes linked worktrees, different configs targeting one repo, and one config shared by different repos. Dry-run takes no locks. Stale locks produce recovery guidance, not automatic stealing. External Git commands do not honor these app locks, so retain native Git lock handling and revalidation around each mutation; do not promise a transaction across repositories. +7. Before native Git updates, reject repository-supplied custom update commands and avoid importing arbitrary Git config. Apply the allowed effective update strategy explicitly so a latent local custom command cannot become a surprise execution path. Preserve user-controlled Git protocol restrictions; do not set `protocol.file.allow=always` in production. + +**Gate:** R01–R05 and R16 preservation assertions pass, plus nested cwd and worktree fixtures. A failed add over existing data cannot delete it. No lifecycle mutation retries through another backend after an ordinary failure. Read-only native backends can remain while useful; no effort goes into filling their mutation stubs. + +### Phase 3 — Fix parsing, defaults, and atomic persistence + +**Own:** `src/config.rs`, `src/options.rs`, config editing portions of `src/git_manager.rs`, `src/commands.rs`, `src/main.rs`, `Cargo.toml`/`Cargo.lock`. + +1. Parse raw declarations without `apply_defaults()` mutating them. Add a single effective-entry resolver, used by command planning and status. Reduce `SubmoduleEntries`' duplicated sparse map to one authoritative representation, or make derived accessors read entries directly. +2. Explicitly parse/validate schema metadata, required URL, effective path, booleans, option enums, sparse pattern boundaries, and unknown fields before constructing actions. Because top-level modules are flattened, do not blindly add serde `deny_unknown_fields` where flattening makes it unsuitable; validate the parsed TOML table and preserve field/line context. +3. Unify branch parsing for serde and CLI. Keep supported historical aliases consistent, distinguish absence from `.` and a named branch, reject blank/invalid refs, and accept full `refs/heads/...` spelling for a literal branch whose name otherwise matches an alias. Do not manufacture explicit `branch="HEAD"` on an omitted flag. +4. Promote the already-transitive `toml_edit` crate to a direct dependency compatible with the project's MSRV. Use its document model to patch explicit fields; remove `section_name_from_header`, `entry_to_kv_lines`, `line_key`, `merge_section_body`, and the handwritten comment scanner once replaced. Preserve untouched comments/unknown text on rejected input; for valid edits preserve unrelated sections, comments, and ordering to the editor's documented capabilities. [toml_edit documentation](https://docs.rs/toml_edit/latest/toml_edit/) +5. Serialize every supported setting, including explicit false and sparse mode at both scopes. CLI change booleans use `Option` so omitted/true/false are distinct. Add `--unset` for supported optional overrides and `--clear-sparse-paths` with clear conflicts against replacement/append. Changing defaults never pins inheriting entries. +6. Validate the rendered document by parsing it before replacing the original. Write to a temporary sibling, preserve appropriate permissions, flush, and atomically replace; use platform-correct replacement behavior. Refuse symlink config destinations. Compare current file bytes with the locked load snapshot immediately before committing a write. This detects most external edits, but an editor ignoring the lock can still race between comparison and replacement: document that remaining limitation rather than promising race-free protection against arbitrary writers. Cooperating submod processes must serialize without lost updates. Atomic replacement protects one file; it is not a transaction across TOML, Git config and index. Write once per command and skip byte-identical output. +7. Fix generated templates and all serialization paths to use the same representation. No separate sample-only or generation-only parser behavior. + +**Gate:** R06–R10 and R18 pass. Every supported edit can be loaded in a fresh CLI process. Failed writes leave the original file valid and recoverable. A no-op command leaves configuration bytes unchanged. + +### Phase 4 — Implement one reconciliation flow + +**Own:** `src/git_manager.rs`, `src/config.rs`, `src/git_ops/mod.rs`, thin CLI dispatch in `src/main.rs`. + +Represent observed state with the smallest useful records: declared/effective entry, matched Git registration, index gitlink, checkout/gitdir presence, activation, and drift. A small concrete action enum/list is sufficient to share preview and application; do not build a generic workflow engine. Move init/update/sync loops out of `main.rs` so selection, ordering, failure policy, and summaries are shared. + +| Observed state | Required action | +| --- | --- | +| TOML entry only; absent destination | For an eligible module, add registration/checkout using effective options; verify a mode-160000 gitlink before reporting successful creation. Disabled/update-none automatic materialization remains intentionally skipped. | +| `.gitmodules` + gitlink, checkout missing or empty | Initialize/update to the recorded commit using Git; do not add again or select remote HEAD. | +| `.gitmodules` present, gitlink missing | Treat as incomplete registration. If there is no conflicting content, complete through the controlled add path and report the new gitlink. If existing content/history makes intent ambiguous, fail with exact repair guidance. | +| Gitlink present, `.gitmodules` absent | Reconstruct only the uniquely matched managed registration from validated TOML, retaining the gitlink OID; then initialize. No matching declaration means report unsupported/unmanaged incomplete state. | +| Initialized registered module, correct metadata | Skip clone/init work; apply only detected configuration/checkout/sparse drift. | +| Initialized checkout with divergent URL/options | Update owned settings before any fetch; synchronize URL resolution and then apply the relevant operation. | +| Existing `.git` but no registered identity/gitlink | Do not equate `.git` presence with success. Report conflict; leave the independent repository intact. | +| Retained module gitdir, missing checkout | Let Git reattach/reinitialize it; preserve local refs/stashes. Do not remove it to make clone succeed. | +| Broken gitdir pointer, unmerged index, conflicting path, corrupt metadata | Fail before mutation with the location and a targeted recovery step. Do not swallow parse/open errors as absence. | +| Inactive or update-none declared entry | Synchronize safe declaration/activation metadata where applicable; skip automatic network/materialization/checkout work, including unreachable URLs. Explicit add with update-none still creates the initial checkout. Do not require an intentionally skipped automatic checkout to exist. | +| Git module absent from TOML | Preserve and report unmanaged. | + +1. Parse `.gitmodules` through Git config APIs/commands, not substring matching. Build an exact identity map once; detect duplicate names/paths and missing required fields. Keep app-only sparse/active data out of this representation so equality measures only relevant fields. +2. Build a managed-key diff. Patch existing registrations without rebuilding unrelated sections. Implement setting and unsetting, including removal of stale local overrides. For URL changes, first update portable registration, then synchronize initialized parent/child URL state through native Git. Do not mistake `git submodule sync` for handling every managed option. +3. Validate the entire batch before its first mutation: selections, names, paths, local conflicts, executable update policies, existing metadata, output writability where knowable. Network availability is not knowable without doing work; handle later failure explicitly. Dry-run names the intended remote/branch action but labels unknown remote commit IDs unresolved until execution; it must not invent a target or fetch to fill the preview. +4. Apply modules in deterministic order. For newly created modules, preserve the intended TOML declaration on partial failure so a later init/sync can resume. For removal/rebuild, keep declarations until the corresponding operation has succeeded. On failure, stop later mutations and report completed/failed/pending modules; never print “sync complete.” Do not roll back completed unrelated modules by deleting their data. +5. Separate structural inspection from command status policy. Check may return drift, but sync must treat missing eligible registered checkouts as normal repair input. Pass an explicit command scope into the shared planner: metadata-only for ordinary change/defaults, metadata plus selected sparse edits when requested, and lifecycle/checkout for init/update/sync. After applying actions, query actual Git state again and require the intended strategy-specific postconditions before reporting success. +6. Reconcile removals of managed values as first-class changes. Do not initialize a disabled module just to set local child configuration. Apply those settings when it is explicitly re-enabled and initialized. +7. Record Git staging behavior in action output. Registration/add/move/delete can stage `.gitmodules` and exact gitlinks through Git; an existing-module policy sync should leave config edits reviewable without staging unrelated files. Refuse commands that would clobber conflicting staged/unstaged `.gitmodules` changes. Never commit, stash the entire superproject, or reset its index as cleanup. + +**Gate:** R11–R17, R19, and R31 pass. TOML-only setup, ordinary fresh clone, retained-gitdir recovery, and an existing correctly initialized repo all converge according to selected policy. Running sync a second time with unchanged local/remote inputs produces no metadata/index/content changes, no unnecessary fetch, and no misleading errors. + +### Phase 5 — Complete checkout, sparse, and recovery semantics + +**Own:** the same manager/Git operation boundaries, plus `src/commands.rs`; focused integration tests. + +1. For default update/init/sync, use the parent gitlink as the target. For `--remote`, obtain the configured tracking branch/default. Apply checkout/merge/rebase as selected; `none` does no update. Explicit `--branch` at add affects checkout before the gitlink is finalized. Do not turn a fetch into an “updated” message without checking the resulting target. +2. Test relative URLs with a parent remote, non-main default branches, branch `.`, detached parent HEAD, shallow initialization of an older recorded commit, and nested submodules. If a shallow remote cannot provide the required pin, report the exact missing commit; do not silently choose its tip. Add explicit `--recursive` selection for init/update/sync if recursive materialization is exposed; keep it separate from `fetchRecurse`. +3. Apply sparse patterns with native non-cone behavior for arbitrary globs, passing patterns through stdin rather than command-line options. Keep order and negation semantics; reject embedded NUL/newline boundaries in individual input patterns. Preserve the existing automatic deny-all prefix only when that mode is selected. +4. Compare the full normalized ordered effective pattern sequence and actual enabled/mode settings. Equal sequences skip writes and checkout reapplication. Absent/empty patterns use Git's sparse disable operation and restore the full clean checkout. Handle dirty paths safely: preserve edits or report refusal rather than overwriting them. [Git sparse-checkout documentation](https://git-scm.com/docs/git-sparse-checkout) +5. Reset must distinguish “nothing to stash” from an actual stash failure. On preservation success, retain/report stash identity and a recovery command; on failure, do not reset/clean. Reset target is the parent pin, not whatever child HEAD happened to be. Before stashing, detect ignored files/directories and nested repositories that obstruct tracked paths at the target commit; refuse those collisions without mutation. Avoiding `clean -x` alone does not protect them from hard reset. Preserve ignored files and nested repository content. Test recovery by applying the saved stash and comparing original bytes. +6. Disable must not call the current gix deinit path. It updates the raw active flag and managed local activation without removing checkout/history. Re-enabling then init/sync must not lose stashes or local refs. +7. Delete uses Git-aware removal and retains the module object database. For legacy embedded `.git` layouts, move/retain the repository safely before removing the checkout, using Git's supported layout migration. Protect dirty/untracked/ignored data; require an explicit `--force` if the user intends to discard worktree data and show that scope in dry-run. A config-only deletion removes only the declaration. +8. Nuke rebuilds one preflighted module at a time, preserves recoverable history, and leaves intended declarations present if reinit fails. Force does not authorize deletion of unrelated files or an arbitrary gitdir. Do not purge retained repositories automatically. Path change uses Git-aware move, updating registration/local metadata and preserving retained repository identity. + +**Gate:** R03–R05, R12–R15, R20–R24 pass. Verify content, OIDs, refs/stashes, parent index, and sparse state—not just command output. Offline reruns and failed remotes leave a repairable repository. + +### Phase 6 — Make the CLI explain what happened + +**Own:** `src/main.rs`, `src/commands.rs`, `src/git_manager.rs` output, `src/shells.rs`, `src/long_abouts.rs`, `README.md`, `sample_config/`, `schemas/`. + +1. Return structured per-module outcomes internally and format them at the CLI boundary. Normal output should identify changed, unchanged, skipped-disabled, and failed modules with a concise summary; verbose output adds effective settings, target commits, and operation context. +2. Separate human status on stdout from progress/errors on stderr. Use `std::io::IsTerminal` to suppress progress renderer control sequences and decorative output when redirected. Sanitize control characters in repository-derived names/paths/messages. Redact URL credentials in app-generated errors and captured child diagnostics; do not print raw full command arguments containing credential-bearing URLs. +3. Return documented exit codes, including check drift and incomplete batches. Preserve underlying Git causes instead of reducing everything to “Repository not found” or calling git2/CLI errors “GitoxideError.” Error context should name the module, phase, affected path, and actionable next step. +4. Make command help match the contract table. Reject conflicting flags at clap parsing. Fix `--shallow` change semantics, `nu`, the from-setup example, disable alias, reset target description, `--all` selection conflicts, and global-default precedence descriptions. Implement actual recursive listing or do not claim it works. +5. Add global `--branch` editing support to `change-global` if documenting `[defaults].branch`; keep schema, types, CLI, and effective resolution aligned. Keep migration/resetting an override discoverable through `--unset`. +6. Repair versioned schema JSON and field names. Make advertised current/latest paths actual JSON assets or verified publication outputs, not broken repository symlinks. Check local alias content and test HTTP-delivered JSON during release verification. Preserve old schemas as historical contracts while documenting canonical current fields/aliases. +7. Make the exact sample and generated template parse. Clearly mark template placeholder URLs; successful parsing does not mean example remotes exist. Execute copy-paste command examples with local fixture URLs. Explain import, default root discovery, pinned versus remote update, unmanaged modules, local URL overwrite during explicit sync, sparse versus partial clone, and recoverable removal. + +**Gate:** R08, R17–R19, R25–R27 pass. A new contributor can import an existing repository or start from TOML, initialize from a nested directory, see configuration drift, repair it, and recover from a failed operation without reading implementation details. + +### Phase 7 — Remove avoidable cost and misleading measurements + +**Own:** `src/main.rs`, `src/lib.rs`, config storage, surviving Git backends, `Cargo.toml`, `benches/benchmark.rs`, `tests/performance_tests.rs`, test script. + +1. Import the library from `main.rs` instead of redeclaring config/manager/options/Git modules. Keep CLI-only modules where appropriate. This removes duplicated compilation and test execution without changing functionality. +2. After lifecycle parity passes, delete unreachable mutation implementations and helpers. Remove `gitoxide-core`/`simple_gix`/progress dependencies if the live call graph no longer needs them. Retain only Git libraries used for supported reads; reduce their unused transport/features. Do not upgrade every dependency or rewrite enums solely to erase a crate in this phase. +3. Eliminate whole-map cloning in `Config::add_submodule` (`src/config.rs:1026-1028`) and duplicated sparse storage. Sort module names once per command. Load relevant metadata once, then refresh only after mutation. Skip byte-identical TOML/Git config and unchanged sparse application. +4. Replace benchmarks of private copies with real config load/edit, inspection, and no-op sync paths. Use release/bench artifacts intentionally. Include 1/10/100-module metadata fixtures and small materialized Git fixtures. Keep fixture creation outside timed loops. +5. Record wall time, child process counts, and parent/child memory where available. Label the existing tracking allocator as Rust allocations only if retained; do not call it process peak memory. Use process isolation for allocator tests and host-appropriate RSS reporting. Compare before/after on the same machine and dataset. +6. Performance acceptance is initially structural: zero duplicate module compilation, no-op sync avoids clone/fetch/sparse reapply/config writes, metadata is not reparsed inside every module loop, and existing meaningful ceilings do not regress. Do not invent a universal millisecond/RSS limit from this audit's green synthetic tests. +7. Defer custom threading, caching, and async runtime. If realistic clone/fetch measurement still shows a material bottleneck, use Git's native bounded `--jobs` for compatible batches before writing a scheduler. Do not parallelize shared index/config mutations. + +**Gate:** R28 passes, measurements name their actual scope, and the retained dependency/backend surface is smaller. Explain any measured regression; do not describe a source-level simplification as a measured speedup. + +### Phase 8 — Gate the shipped behavior + +**Own:** `.github/workflows/{ci,release,docs}.yml`, `hk.pkl`, `mise.toml`, `audit.toml`, `.cargo/audit.toml`, `deny.toml`, contributor documentation. + +1. Run required stable functional tests on Linux, macOS, and Windows; platform-guard only genuinely unavailable fixture features such as symlink creation. Keep a Rust 1.89 build gate while that is the declared MSRV. Beta/nightly can remain informational; prioritize platform behavior over repeating identical stable coverage jobs. +2. Use locked dependency resolution in CI and release. Ensure the actual tagged commit passes required tests/lint/audit before publication, using an explicit workflow dependency/reusable verification job. Check the artifact intended for release, including native linkage and a CLI smoke test, for tag builds as well as manual dry runs. Do not infer release correctness from an earlier branch run. +3. Consolidate advisory exception policy: the root `audit.toml` still lists a stale extra ignore, while `.cargo/audit.toml`, deny, and workflow settings differ. Every retained exception needs a current reachable-dependency rationale. Determine why `bisync 0.3.0` is yanked and update/remove through its owning dependency; do not add a blanket yanked-package allowance. Rerun audit after dependency pruning. +4. Remove unnecessary workflow write permissions (for example coverage `contents: write`) and replace mutable third-party action refs with reviewed immutable refs under the project's update policy. No runtime or workflow should print credentials. Preserve existing release authorization/environment gates. +5. Validate schema JSON, sample/parser agreement, help/examples, and package contents in CI. Run `cargo package --locked` and test the packaged source so development-only paths are not required. Run docs checks without implying that generated Rust API docs prove end-user command behavior. +6. Update README/CLAUDE/CONTRIBUTING descriptions of backends, defaults, test counts, Git requirement, supported platforms/MSRV, sync semantics, and recovery. Remove stale “TODO: implement” comments on completed commands and unsupported claims that sparse checkout alone reduces downloaded history. + +**Gate:** R29–R30 pass and the full acceptance checklist is satisfied. Prepare a reviewable code/documentation change; do not publish or tag a release as part of implementing this plan unless separately authorized. + +## Regression matrix and completion checklist + +Each row is a fixture family, not a request for one test per helper function. Reuse the existing harness and put closely related cases into table-driven integration tests. All setup Git commands must succeed. Use local remotes and unique temporary directories. For data-preservation rows assert the before/after repository state in addition to exit status. + +| Test ID | Fixture and action | Required assertions / suggested test file | +| --- | --- | --- | +| R01 | Config and CLI paths: empty, `.`, `.git`, `..`, absolute path, escaped/symlinked ancestors; names reaching administrative paths | Every mutation rejects unsafe targets before writes; root/outside sentinels and index/config snapshots unchanged. `security_tests.rs`. | +| R02 | Occupied ordinary directory, unrelated repo, dirty module; add with unavailable local remote | Failure preserves files, refs, index and metadata; no unconditional cleanup. `error_handling_tests.rs`. | +| R03 | Clean module with local-only commit and stash; disable, enable, init | Files and object DB retained; original commit/stash remain accessible and recoverable. `integration_tests.rs`. | +| R04 | `lib` module alongside tracked `library.txt` and `lib-extra/file`; delete | Only exact gitlink/registration removed; sibling mode/OIDs unchanged. Held `index.lock` causes refusal with preserved index. `git_ops_tests.rs`. | +| R05 | Dirty tracked/untracked/ignored files; force real stash write/lock failure, then reset; ignored file/directory becomes tracked at target pin | Failed stash or target collision refuses without reset/clean and preserves all bytes. Successful stash+reset can restore original work and resets to parent pin. `error_handling_tests.rs`. | +| R06 | Default ignore dirty, one inheriting module, one explicit none; change default to all and unrelated entry field | Inheriting raw entry stays absent; effective all applied, explicit none preserved; second process sees same values. `config_tests.rs`. | +| R07 | Sparse mode true/false at both scopes; shallow true followed by unrelated change | All booleans round-trip; absent CLI options preserve existing settings; false is not treated as absent. `command_contract_tests.rs`. | +| R08 | Exact checked-in sample, generated template, absent/supported/future schema versions, typo/legacy aliases, older generated `branch="HEAD"` and fetch true/false strings | Canonical examples load; future version/unknown field/conflicting aliases fail contextually without writes; legacy generated values retain documented intent and warn. `config_tests.rs`. | +| R09 | Multiline arrays/strings, commented table headers, quoted/dotted/literal names, escaped strings, non-ASCII names, inline comments | Edit then parse succeeds; requested value changed; unrelated document content preserved within editor contract. `config_tests.rs`. | +| R10 | Write failure, external edit before final comparison, simultaneous submod writers using same/different configs and linked worktrees, shared config across repos, symlink output, no-op edit | Original stays valid; external edit detected at comparison; cooperating writers serialize without lost updates/deadlocks; identical edit avoids rewrite; locks released on normal errors. The external check/replace race is an explicitly documented limit. `error_handling_tests.rs`. | +| R11 | TOML-only parent with no `.gitmodules`, no module directories and optional omitted path | Init creates correct registration, mode-160000 gitlink and files at effective root-relative path. `integration_tests.rs`. | +| R12 | Parent records old child commit; child remote advances; clone parent without recursion, init/sync | Correct old HEAD/content materialized, never empty successful checkout or remote tip. Repeat is no-op. `integration_tests.rs`. | +| R13 | Distinct main/feature/default branches; add branch feature; default update and update --remote; update=none/merge/rebase; clean divergent local commits | HEAD/content, gitlink and branch configuration satisfy strategy-specific contract; explicit add with none creates the initial checkout, while later automatic init/update/sync do not fetch/materialize. Successful merge/rebase followed by check accepts preserved descendants; repeated sync is no-op. Metadata-only change must not move a divergent HEAD. Include `.` and detached parent. `integration_tests.rs`. | +| R14 | Existing module: edit URL/branch/ignore/update/fetch, change defaults, remove previously managed keys | TOML, `.gitmodules`, parent local config and child URL/checkout effects agree; stale local overrides removed; unrelated keys retained. `integration_tests.rs`. | +| R15 | Ordered positive/negative sparse patterns, extra/reordered existing patterns, changed mode, removed/empty patterns | Exact effective sequence and enabled/mode drift detected; full files restored on disable; unchanged sync no reapply; dirty excluded paths protected. `sparse_checkout_tests.rs`. | +| R16 | Alias != path; Git name != path; quoted/alternate-formatted `.gitmodules`; prefix paths; duplicate/overlapping paths | Exact unique matching, no duplicate registration, no substring match; ambiguity rejected before mutation. `git_ops_tests.rs`. | +| R17 | Same command from repo root and nested cwd; custom config; linked worktree; missing explicit/default config | Same managed repository/paths; intended file selected; helpful missing-file errors; no accidental discovery of sibling repo. `command_contract_tests.rs`. | +| R18 | Change omits fields, explicit false, unset override, clear sparse, append conflicts, all+names, no settings | CLI tri-state/clear/conflict semantics enforced; no silent defaults; conflict before manager mutation. `command_contract_tests.rs`. | +| R19 | Disabled entry with unreachable URL; no-init add; unmanaged Git module; actual nested module hierarchy | Disabled not touched; no-init later initializes; unmanaged preserved; recursive list reports real descendants. `integration_tests.rs`. | +| R20 | Relative child URL with parent origin; non-main default branch; shallow older pin; requested recursive init | Git-consistent URL and commit resolution; shallow failure explicit; recursive selection distinct from fetch settings. `integration_tests.rs`. | +| R21 | Missing `.gitmodules` with valid managed gitlink; missing gitlink with valid declaration; retained gitdir; broken pointer | Recover only unambiguous states; preserve pin/history; conflicts fail with targeted diagnosis. `error_handling_tests.rs`. | +| R22 | Never-initialized/config-only delete, initialized delete, legacy embedded gitdir, failed nuke reinit | Removal never deletes unrelated directory; recoverable object DB retained; failed rebuild keeps declaration and clear partial outcome. `integration_tests.rs`. | +| R23 | Initialized path move with clean tree, dirty tree, occupied destination, and inactive declaration | Safe move preserves identity/history and registration; rejected moves preserve all state; inactive not cloned. `integration_tests.rs`. | +| R24 | Three-module batch with invalid target, later network failure, and interruption/retry | Preflight error changes none; runtime failure reports completed/failed/pending accurately; retry converges without erasing work. `error_handling_tests.rs`. | +| R25 | Read-only check/list and every dry-run; fingerprint files/index/refs before and after | No writes/network for inspection; correct exit codes; planned actions match later mutation; no dry-run lock file. `command_contract_tests.rs`. | +| R26 | Redirected output; fake credential-bearing URL; control characters in repository metadata; real Git error | No unintended terminal control sequences or credential leakage; module/phase/cause retained; no false success. `security_tests.rs`. | +| R27 | Copy-paste README workflow with local URLs, from-setup, template, help and all supported completion shells | Examples parse and accomplish stated operations; templates reload; `nu` works; generation works outside repo where appropriate. `command_contract_tests.rs`. | +| R28 | Production config/edit/status/no-op sync at 1/10/100 modules with bench profile | Real production code measured; no duplicate test compilation or unnecessary writes/fetch; allocator scope accurately labeled. `performance_tests.rs`, benchmark. | +| R29 | Linux/macOS/Windows stable jobs, MSRV build, required release verification and linkage/smoke tests | Failures gate the exact artifact/tag; no release path skips functional prerequisites; locked package builds. CI workflows. | +| R30 | All schemas/aliases, sample agreement, fresh advisory/deny check, packaged source test | Published assets are real valid JSON; current dependency exceptions deliberate; no stale/broad suppression; package is self-contained. CI/tooling. | +| R31 | Unrelated staged `.gitmodules` edit plus a separate unstaged edit before add/move/delete and metadata sync | Refuse before mutation if both layers cannot be preserved; otherwise assert index bytes/content and worktree diff independently. No accidental staging of unrelated edits. `integration_tests.rs`. | + +After native mutation consolidation, retarget `fallback_tests.rs` to the surviving backend's operation/error contract or remove obsolete fallback-only tests. Do not keep 100% coverage of removed code as a goal. Any backend still selectable for mutations must pass the same behavior matrix; the preferred end state has only one. + +Final local verification should include: + +```sh +cargo fmt --all -- --check +cargo clippy --locked --all-targets --all-features +cargo nextest run --locked --all-features --profile ci --no-fail-fast +cargo test --locked --all-features --doc +cargo +1.89 check --locked --all-features +cargo audit +cargo deny check +cargo package --locked +``` + +Run profile-specific performance checks through the corrected script and record the command/profile/dataset. Remove the two external-network dependencies from ordinary test execution by replacing them with deterministic local failures; retain any live-authentication smoke test as explicit, optional evidence. Verify platform/release jobs from their actual results when available, and name any environment limitation rather than declaring unrun checks green. + +Completion requires all of the following: safe preservation tests pass; root/nested/fresh-clone onboarding works; TOML edits persist with inheritance intact; Git configuration and checkout state converge; disabled and sparse removal behavior is correct; repeated sync is a no-op; output/exit codes describe reality; regression tests check real Git state; package/platform/security gates pass; and docs describe the implemented contracts. Do not stop at passing the old suite. + +## Agent execution guidance + +The implementing agent should first read this plan, inspect current changes since the audited commit, and preserve unrelated work. Reproduce the baseline before editing. Use independent agents for bounded work as requested by repository instructions, with explicit file ownership; do not let config and lifecycle workers edit `git_manager.rs` concurrently without dividing its responsibilities. + +A useful sequence is: one harness/CI worker during the root-context and safety work; then config/options/schema work independently of the Git operation layer; integrate both before reconciliation; then parallel documentation/CI and performance measurement while the main agent verifies lifecycle acceptance. The primary agent owns contract consistency and final integration. Each worker must report which regression IDs pass and any changed assumptions. + +The plan deliberately defers custom parallel schedulers, persistent caches, new UI modes, automatic pruning of undeclared modules, purging retained history, and general transactions across repositories. Add those only for a demonstrated requirement. This repair is complete when the existing product's promised workflows work safely and predictably. diff --git a/docs/audit-results.json b/docs/audit-results.json new file mode 100644 index 00000000..989d8044 --- /dev/null +++ b/docs/audit-results.json @@ -0,0 +1,706 @@ +{ + "audit_date": "2026-09-10", + "commit": "31c5e373bfd29162f86675fa60ed4af0adf8df97", + "scope": "Disposable local fixtures; original application unchanged", + "config_reproductions": { + "fixture_root": "/private/tmp/submod-config-audit-mi7l0orx", + "results": [ + { + "case": "missing config list", + "result": { + "code": 0, + "stdout": "No submodules configured.\n", + "stderr": "" + } + }, + { + "case": "missing explicit config list", + "result": { + "code": 0, + "stdout": "No submodules configured.\n", + "stderr": "" + } + }, + { + "case": "README from-setup with dot", + "result": { + "code": 2, + "stdout": "", + "stderr": "error: unexpected argument '.' found\n\nUsage: submod generate-config [OPTIONS]\n\nFor more information, try '--help'.\n" + } + }, + { + "case": "documented nu completion alias", + "result": { + "code": 2, + "stdout": "", + "stderr": "error: invalid value 'nu' for ''\n [possible values: bash, elvish, fish, powershell, zsh, nushell]\n\n tip: a similar value exists: 'nushell'\n\nFor more information, try '--help'.\n" + } + }, + { + "case": "generate template", + "result": { + "code": 0, + "stdout": "Generated template config at 'submod.toml'.\n", + "stderr": "" + } + }, + { + "case": "generated template load", + "result": { + "code": 1, + "stdout": "", + "stderr": "Error: Failed to create manager: Configuration error: Failed to load config: invalid type: found string \"1.1.0\", expected struct SubmoduleEntry\n" + } + }, + { + "case": "change inherited default", + "result": { + "code": 0, + "stdout": "", + "stderr": "" + }, + "config": "[defaults]\nignore = \"all\"\n[m]\npath = \"vendor/m\"\nurl = \"/absent\"\nignore = \"dirty\"\n" + }, + { + "case": "change sparse mode and omitted shallow", + "result": { + "code": 0, + "stdout": "Updated submodule 'm'.\n", + "stderr": "" + }, + "config": "[defaults]\n[m]\npath = \"vendor/m\"\nurl = \"/absent\"\n" + }, + { + "case": "multiline edit", + "result": { + "code": 0, + "stdout": "Updated submodule 'm'.\n", + "stderr": "" + }, + "config": "[defaults]\n[m]\npath = \"vendor/m\"\nurl = \"/absent\"\nsparse_paths = [\"src/\", \"docs/\"]\n \"src/\",\n \"docs/\",\n]\nignore = \"all\"\n", + "valid_toml": "Expected '=' after a key in a key/value pair (at line 6, column 9)" + }, + { + "case": "commented_header edit", + "result": { + "code": 0, + "stdout": "Updated submodule 'm'.\n", + "stderr": "" + }, + "config": "[defaults]\n[m] # hello\npath = \"vendor/m\"\nurl = \"/absent\"\n[m]\npath = \"vendor/m\"\nurl = \"/absent\"\nignore = \"all\"\n\n", + "valid_toml": "Cannot declare ('m',) twice (at line 5, column 3)" + }, + { + "case": "literal_header edit", + "result": { + "code": 0, + "stdout": "Updated submodule 'm'.\n", + "stderr": "" + }, + "config": "[defaults]\n[m]\npath = \"vendor/m\"\nurl = \"/absent\"\nignore = \"all\"\n\n", + "valid_toml": true + }, + { + "case": "documented path default omitted", + "result": { + "code": 1, + "stdout": "", + "stderr": "Error: Failed to init submodule m: Configuration error: No path configured for submodule\n" + } + }, + { + "case": "fresh TOML-only init", + "result": { + "code": 0, + "stdout": "", + "stderr": "" + }, + "modules": "[submodule \"vendor/m\"]\n\tpath = vendor/m\n\turl = /private/tmp/submod-config-audit-mi7l0orx/remote\n" + }, + { + "case": "initial .gitmodules", + "result": { + "code": 0, + "stdout": "submodule.vendor/m.path=vendor/m\nsubmodule.vendor/m.url=/private/tmp/submod-config-audit-mi7l0orx/remote\n", + "stderr": "" + } + }, + { + "case": "change URL", + "result": { + "code": 0, + "stdout": "Updated submodule 'm'.\n", + "stderr": "" + } + }, + { + "case": "sync URL drift", + "result": { + "code": 0, + "stdout": "Syncing submodules: m\n\u2705 Updated m\n\u2705 Sync complete (0.0s)\n", + "stderr": "\u001b[2K\n" + }, + "config": "[m]\npath = \"vendor/m\"\nurl = \"/private/tmp/submod-config-audit-mi7l0orx/other_remote\"\n", + "modules": "[submodule \"vendor/m\"]\n\tpath = vendor/m\n\turl = /private/tmp/submod-config-audit-mi7l0orx/remote\n", + "origin": { + "code": 0, + "stdout": "/private/tmp/submod-config-audit-mi7l0orx/remote\n", + "stderr": "" + } + }, + { + "case": "nested cwd default config", + "result": { + "code": 1, + "stdout": "", + "stderr": "Error: Failed to create manager: Repository not found or invalid\n" + } + }, + { + "case": "nested cwd explicit root config check", + "result": { + "code": 1, + "stdout": "", + "stderr": "Error: Failed to create manager: Repository not found or invalid\n" + } + }, + { + "case": "inactive init", + "result": { + "code": 0, + "stdout": "", + "stderr": "" + }, + "exists": true + }, + { + "case": "check absent checkout exit", + "result": { + "code": 0, + "stdout": " \u274c m: Folder missing (vendor/m)\n", + "stderr": "" + } + }, + { + "case": "no-init active state", + "result": { + "code": 0, + "stdout": "", + "stderr": "" + }, + "config": "[defaults]\n[m]\npath = \"vendor/m\"\nurl = \"/private/tmp/submod-config-audit-mi7l0orx/remote\"\nbranch = \"HEAD\"\nactive = false\n\n" + } + ] + }, + "preservation_reproductions": { + "fixture_root": "/private/tmp/submod-preservation-audit-lsp8gn5_", + "results": [ + { + "case": "failed add over ordinary existing directory", + "result": { + "code": 1, + "stdout": "", + "stderr": "Warning: failed to deinit submodule at 'occupied': Submodule not found: occupied\n\nCaused by:\n no submodule named 'occupied'; class=Submodule (17); code=NotFound (-3)\nWarning: failed to delete submodule at 'occupied': Submodule not found: occupied\n\nCaused by:\n no submodule named 'occupied'; class=Submodule (17); code=NotFound (-3)\nError: Failed to add submodule: Configuration error: Git operation failed: Failed to add submodule (git2 failed with: Failed to clone submodule 'm' from '/private/tmp/submod-preservation-audit-lsp8gn5_/missing-remote'). CLI output: fatal: repository '/private/tmp/submod-preservation-audit-lsp8gn5_/missing-remote' does not exist\nfatal: clone of '/private/tmp/submod-preservation-audit-lsp8gn5_/missing-remote' into submodule path '/private/tmp/submod-preservation-audit-lsp8gn5_/occupied_add/occupied' failed\n" + }, + "sentinel_survives": false + }, + { + "case": "disable clean module with local history and stash", + "result": { + "code": 0, + "stdout": "Disabled submodule 'm'.\n", + "stderr": "" + }, + "worktree_survives": true, + "object_database_survives": false, + "config": "[defaults]\n[m]\npath = \"module\"\nurl = \"/private/tmp/submod-preservation-audit-lsp8gn5_/remote\"\nbranch = \"HEAD\"\nactive = false\n\n" + }, + { + "case": "delete lib preserves prefix siblings", + "result": { + "code": 0, + "stdout": "Deleted submodule 'm'.\n", + "stderr": "" + }, + "index_before": "100644 39bc1480ad2edf9e2ee0c4c3bb6adf82bc26532b 0\t.gitmodules\n100644 0d4e77fbe77b5a30eb9c50ebe677f6a7f664bd5b 0\tREADME.md\n160000 e9e8e1a85308905269dcf3ef106140d061c5b68a 0\tlib\n100644 e620d96ddbb46fde6b49052f50ab204df88d2b1c 0\tlib-extra/file\n100644 e620d96ddbb46fde6b49052f50ab204df88d2b1c 0\tlibrary.txt\n", + "index_after": "100644 39bc1480ad2edf9e2ee0c4c3bb6adf82bc26532b 0\t.gitmodules\n100644 0d4e77fbe77b5a30eb9c50ebe677f6a7f664bd5b 0\tREADME.md\n", + "sibling_file_survives": true + } + ] + }, + "lifecycle_reproductions": { + "root": "/private/tmp/submod-audit/state-lifecycle-lxfadanf", + "old": "429228da34552e887c1d607f3069c91d60868631", + "new": "5be521c46899a8853323db11a4207a33ca528fd4", + "feature": "a5f52a6165112f2079e649a3fcb2523cf816f34f", + "fresh_init_head": "MISSING CHECKOUT", + "fresh_init_content": "MISSING", + "native_init_head": "429228da34552e887c1d607f3069c91d60868631", + "native_init_content": "old", + "update_head": "429228da34552e887c1d607f3069c91d60868631", + "update_content": "old", + "add_branch_head": "5be521c46899a8853323db11a4207a33ca528fd4", + "add_branch_feature_file": false, + "add_branch_gitmodules": "[submodule \"mod\"]\n\tpath = mod\n\turl = /private/tmp/submod-audit/state-lifecycle-lxfadanf/remote\n", + "sparse_before_drop_exists": false, + "sparse_after_drop_exists": false, + "sparse_after_enabled": "true" + }, + "lifecycle_commands": [ + { + "cwd": "/private/tmp/submod-audit/state-lifecycle-lxfadanf/remote", + "args": [ + "git", + "init", + "-b", + "main" + ], + "exit": 0, + "stdout": "Initialized empty Git repository in /private/tmp/submod-audit/state-lifecycle-lxfadanf/remote/.git/\n", + "stderr": "" + }, + { + "cwd": "/private/tmp/submod-audit/state-lifecycle-lxfadanf/remote", + "args": [ + "git", + "add", + "." + ], + "exit": 0, + "stdout": "", + "stderr": "" + }, + { + "cwd": "/private/tmp/submod-audit/state-lifecycle-lxfadanf/remote", + "args": [ + "git", + "-c", + "commit.gpgsign=false", + "commit", + "-m", + "old" + ], + "exit": 0, + "stdout": "[main (root-commit) 429228d] old\n 2 files changed, 2 insertions(+)\n create mode 100644 drop/hidden\n create mode 100644 keep/version\n", + "stderr": "" + }, + { + "cwd": "/private/tmp/submod-audit/state-lifecycle-lxfadanf/remote", + "args": [ + "git", + "rev-parse", + "HEAD" + ], + "exit": 0, + "stdout": "429228da34552e887c1d607f3069c91d60868631\n", + "stderr": "" + }, + { + "cwd": "/private/tmp/submod-audit/state-lifecycle-lxfadanf/parent", + "args": [ + "git", + "init", + "-b", + "main" + ], + "exit": 0, + "stdout": "Initialized empty Git repository in /private/tmp/submod-audit/state-lifecycle-lxfadanf/parent/.git/\n", + "stderr": "" + }, + { + "cwd": "/private/tmp/submod-audit/state-lifecycle-lxfadanf/parent", + "args": [ + "git", + "submodule", + "add", + "/private/tmp/submod-audit/state-lifecycle-lxfadanf/remote", + "mod" + ], + "exit": 0, + "stdout": "", + "stderr": "Cloning into '/private/tmp/submod-audit/state-lifecycle-lxfadanf/parent/mod'...\ndone.\n" + }, + { + "cwd": "/private/tmp/submod-audit/state-lifecycle-lxfadanf/parent", + "args": [ + "git", + "add", + "." + ], + "exit": 0, + "stdout": "", + "stderr": "" + }, + { + "cwd": "/private/tmp/submod-audit/state-lifecycle-lxfadanf/parent", + "args": [ + "git", + "-c", + "commit.gpgsign=false", + "commit", + "-m", + "pin old" + ], + "exit": 0, + "stdout": "[main (root-commit) 7378f8d] pin old\n 3 files changed, 7 insertions(+)\n create mode 100644 .gitmodules\n create mode 160000 mod\n create mode 100644 submod.toml\n", + "stderr": "" + }, + { + "cwd": "/private/tmp/submod-audit/state-lifecycle-lxfadanf/parent", + "args": [ + "git", + "rev-parse", + "HEAD" + ], + "exit": 0, + "stdout": "7378f8dc58424728051286188e7cae4791d8ebd3\n", + "stderr": "" + }, + { + "cwd": "/private/tmp/submod-audit/state-lifecycle-lxfadanf/remote", + "args": [ + "git", + "add", + "." + ], + "exit": 0, + "stdout": "", + "stderr": "" + }, + { + "cwd": "/private/tmp/submod-audit/state-lifecycle-lxfadanf/remote", + "args": [ + "git", + "-c", + "commit.gpgsign=false", + "commit", + "-m", + "new" + ], + "exit": 0, + "stdout": "[main 5be521c] new\n 1 file changed, 1 insertion(+), 1 deletion(-)\n", + "stderr": "" + }, + { + "cwd": "/private/tmp/submod-audit/state-lifecycle-lxfadanf/remote", + "args": [ + "git", + "rev-parse", + "HEAD" + ], + "exit": 0, + "stdout": "5be521c46899a8853323db11a4207a33ca528fd4\n", + "stderr": "" + }, + { + "cwd": "/private/tmp/submod-audit/state-lifecycle-lxfadanf/remote", + "args": [ + "git", + "switch", + "-c", + "feature" + ], + "exit": 0, + "stdout": "", + "stderr": "Switched to a new branch 'feature'\n" + }, + { + "cwd": "/private/tmp/submod-audit/state-lifecycle-lxfadanf/remote", + "args": [ + "git", + "add", + "." + ], + "exit": 0, + "stdout": "", + "stderr": "" + }, + { + "cwd": "/private/tmp/submod-audit/state-lifecycle-lxfadanf/remote", + "args": [ + "git", + "-c", + "commit.gpgsign=false", + "commit", + "-m", + "feature" + ], + "exit": 0, + "stdout": "[feature a5f52a6] feature\n 1 file changed, 1 insertion(+)\n create mode 100644 feature-only\n", + "stderr": "" + }, + { + "cwd": "/private/tmp/submod-audit/state-lifecycle-lxfadanf/remote", + "args": [ + "git", + "rev-parse", + "HEAD" + ], + "exit": 0, + "stdout": "a5f52a6165112f2079e649a3fcb2523cf816f34f\n", + "stderr": "" + }, + { + "cwd": "/private/tmp/submod-audit/state-lifecycle-lxfadanf/remote", + "args": [ + "git", + "switch", + "main" + ], + "exit": 0, + "stdout": "", + "stderr": "Switched to branch 'main'\n" + }, + { + "cwd": "/private/tmp/submod-audit/state-lifecycle-lxfadanf", + "args": [ + "git", + "clone", + "/private/tmp/submod-audit/state-lifecycle-lxfadanf/parent", + "/private/tmp/submod-audit/state-lifecycle-lxfadanf/fresh" + ], + "exit": 0, + "stdout": "", + "stderr": "Cloning into '/private/tmp/submod-audit/state-lifecycle-lxfadanf/fresh'...\ndone.\n" + }, + { + "cwd": "/private/tmp/submod-audit/state-lifecycle-lxfadanf/fresh", + "args": [ + "/private/tmp/submod-audit-target/debug/submod", + "init" + ], + "exit": 0, + "stdout": "", + "stderr": "" + }, + { + "cwd": "/private/tmp/submod-audit/state-lifecycle-lxfadanf/fresh", + "args": [ + "git", + "submodule", + "update", + "--init" + ], + "exit": 0, + "stdout": "Submodule path 'mod': checked out '429228da34552e887c1d607f3069c91d60868631'\n", + "stderr": "Submodule 'mod' (/private/tmp/submod-audit/state-lifecycle-lxfadanf/remote) registered for path 'mod'\nCloning into '/private/tmp/submod-audit/state-lifecycle-lxfadanf/fresh/mod'...\ndone.\n" + }, + { + "cwd": "/private/tmp/submod-audit/state-lifecycle-lxfadanf/fresh/mod", + "args": [ + "git", + "rev-parse", + "HEAD" + ], + "exit": 0, + "stdout": "429228da34552e887c1d607f3069c91d60868631\n", + "stderr": "" + }, + { + "cwd": "/private/tmp/submod-audit/state-lifecycle-lxfadanf/fresh", + "args": [ + "/private/tmp/submod-audit-target/debug/submod", + "update" + ], + "exit": 0, + "stdout": "\u2705 Updated mod\nUpdated 1 submodule(s)\n", + "stderr": "\u001b[2K\n" + }, + { + "cwd": "/private/tmp/submod-audit/state-lifecycle-lxfadanf/fresh/mod", + "args": [ + "git", + "rev-parse", + "HEAD" + ], + "exit": 0, + "stdout": "429228da34552e887c1d607f3069c91d60868631\n", + "stderr": "" + }, + { + "cwd": "/private/tmp/submod-audit/state-lifecycle-lxfadanf/branchrepo", + "args": [ + "git", + "init", + "-b", + "main" + ], + "exit": 0, + "stdout": "Initialized empty Git repository in /private/tmp/submod-audit/state-lifecycle-lxfadanf/branchrepo/.git/\n", + "stderr": "" + }, + { + "cwd": "/private/tmp/submod-audit/state-lifecycle-lxfadanf/branchrepo", + "args": [ + "git", + "add", + "." + ], + "exit": 0, + "stdout": "", + "stderr": "" + }, + { + "cwd": "/private/tmp/submod-audit/state-lifecycle-lxfadanf/branchrepo", + "args": [ + "git", + "-c", + "commit.gpgsign=false", + "commit", + "-m", + "seed" + ], + "exit": 0, + "stdout": "[main (root-commit) ebe30e2] seed\n 2 files changed, 2 insertions(+)\n create mode 100644 seed\n create mode 100644 submod.toml\n", + "stderr": "" + }, + { + "cwd": "/private/tmp/submod-audit/state-lifecycle-lxfadanf/branchrepo", + "args": [ + "git", + "rev-parse", + "HEAD" + ], + "exit": 0, + "stdout": "ebe30e23ad88c6dc31a4d6cdab4f91bd60058860\n", + "stderr": "" + }, + { + "cwd": "/private/tmp/submod-audit/state-lifecycle-lxfadanf/branchrepo", + "args": [ + "/private/tmp/submod-audit-target/debug/submod", + "add", + "/private/tmp/submod-audit/state-lifecycle-lxfadanf/remote", + "--name", + "mod", + "--path", + "mod", + "--branch", + "feature" + ], + "exit": 0, + "stdout": "Added submodule mod\n", + "stderr": "Warning: failed to deinit submodule at 'mod': Submodule not found: mod\n\nCaused by:\n no submodule named 'mod'; class=Submodule (17); code=NotFound (-3)\nWarning: failed to delete submodule at 'mod': Submodule not found: mod\n\nCaused by:\n no submodule named 'mod'; class=Submodule (17); code=NotFound (-3)\n" + }, + { + "cwd": "/private/tmp/submod-audit/state-lifecycle-lxfadanf/branchrepo/mod", + "args": [ + "git", + "rev-parse", + "HEAD" + ], + "exit": 0, + "stdout": "5be521c46899a8853323db11a4207a33ca528fd4\n", + "stderr": "" + }, + { + "cwd": "/private/tmp/submod-audit/state-lifecycle-lxfadanf", + "args": [ + "git", + "clone", + "/private/tmp/submod-audit/state-lifecycle-lxfadanf/parent", + "/private/tmp/submod-audit/state-lifecycle-lxfadanf/sparse" + ], + "exit": 0, + "stdout": "", + "stderr": "Cloning into '/private/tmp/submod-audit/state-lifecycle-lxfadanf/sparse'...\ndone.\n" + }, + { + "cwd": "/private/tmp/submod-audit/state-lifecycle-lxfadanf/sparse", + "args": [ + "git", + "submodule", + "update", + "--init" + ], + "exit": 0, + "stdout": "Submodule path 'mod': checked out '429228da34552e887c1d607f3069c91d60868631'\n", + "stderr": "Submodule 'mod' (/private/tmp/submod-audit/state-lifecycle-lxfadanf/remote) registered for path 'mod'\nCloning into '/private/tmp/submod-audit/state-lifecycle-lxfadanf/sparse/mod'...\ndone.\n" + }, + { + "cwd": "/private/tmp/submod-audit/state-lifecycle-lxfadanf/sparse", + "args": [ + "/private/tmp/submod-audit-target/debug/submod", + "init" + ], + "exit": 0, + "stdout": "Configured sparse checkout\n", + "stderr": "\u2139\ufe0f submod uses a deny-all-by-default sparse-checkout model: `!/*` is automatically prepended so only the paths you list are checked out.\nTo use git's default behavior instead, set `use_git_default_sparse_checkout = true` in your submod.toml (globally under `[defaults]` or per submodule) or pass `--use-git-default-sparse-checkout`.\n" + }, + { + "cwd": "/private/tmp/submod-audit/state-lifecycle-lxfadanf/sparse", + "args": [ + "/private/tmp/submod-audit-target/debug/submod", + "sync" + ], + "exit": 0, + "stdout": "Syncing submodules: mod\n\u2705 Updated mod\n\u2705 Sync complete (0.0s)\n", + "stderr": "\u001b[2K\n" + }, + { + "cwd": "/private/tmp/submod-audit/state-lifecycle-lxfadanf/sparse/mod", + "args": [ + "git", + "config", + "--get", + "core.sparseCheckout" + ], + "exit": 0, + "stdout": "true\n", + "stderr": "" + } + ], + "dependency_audit": { + "database": { + "advisory-count": 1243, + "last-commit": "b50980aad8b8f14f77e25a97b32dd94bf008b0af", + "last-updated": "2026-09-09T12:49:52+02:00" + }, + "lockfile": { + "dependency-count": 274 + }, + "settings": { + "target_arch": [], + "target_os": [], + "severity": null, + "ignore": [ + "RUSTSEC-2024-0364" + ], + "informational_warnings": [ + "unmaintained", + "unsound", + "notice" + ] + }, + "vulnerabilities": { + "found": false, + "count": 0, + "list": [] + }, + "warnings": { + "yanked": [ + { + "kind": "yanked", + "package": { + "name": "bisync", + "version": "0.3.0", + "source": "registry+https://github.com/rust-lang/crates.io-index", + "checksum": "5020822f6d6f23196ccaf55e228db36f9de1cf788052b37992e17cbc96ec41a7", + "dependencies": [ + { + "name": "bisync_macros", + "version": "0.2.3", + "source": "registry+https://github.com/rust-lang/crates.io-index" + } + ], + "replace": null + }, + "advisory": null, + "affected": null, + "versions": null + } + ] + } + } +} diff --git a/docs/audit-results.json.license b/docs/audit-results.json.license new file mode 100644 index 00000000..035055fa --- /dev/null +++ b/docs/audit-results.json.license @@ -0,0 +1,3 @@ +SPDX-FileCopyrightText: 2026 Adam Poulemanos and contributors + +SPDX-License-Identifier: LicenseRef-PlainMIT OR MIT diff --git a/hk.pkl b/hk.pkl index b1cee16b..c79058af 100644 --- a/hk.pkl +++ b/hk.pkl @@ -19,23 +19,24 @@ local linters = new Mapping { // sources — letting cargo-deny's own exit code decide. This was // `check advisories` with `-A unsound -A unmaintained -A yanked -A notice`, // which meant `deny.toml`'s licenses/bans/sources policy was configured but - // never enforced by any hook or CI job. The four allowances are gone too: - // they suppress nothing in the current graph, so keeping them would only - // hide a *future* unmaintained or yanked dependency. If one lands, add it to - // `ignore` in deny.toml with a reason rather than reinstating a blanket -A. + // never enforced by any hook or CI job. The four allowances are gone: + // keeping them would only hide a *future* unmaintained or yanked + // dependency. The one known yanked crate (bisync 0.3.0, via gix) is + // reported as a warning and documented in deny.toml, not suppressed. // (The previous `-f json ... | jq -e '.[].vulnerabilities | length == 0'` // pipeline no longer parses: with `-f json` cargo-deny streams one object // per diagnostic, not an array, so jq failed with exit 5 on every run.) - // --exclude-dev is a top-level flag; it moved off the `check` subcommand. - check = "cargo deny --all-features --manifest-path {{ workspace_indicator }} --exclude-dev -L warn check --hide-inclusion-graph" + // No --exclude-dev: release assurance covers the graph used to + // build/test/package, dev dependencies included. + check = "cargo deny --all-features --manifest-path {{ workspace_indicator }} -L warn check --hide-inclusion-graph" } ["cargo_fmt"] = Builtins.cargo_fmt ["cargo_clippy"] = Builtins.cargo_clippy ["cargo_check"] = Builtins.cargo_check ["cargo_test"] = new Step { workspace_indicator = "Cargo.toml" - glob = "src/**/*.rs" - check = "cargo nextest --manifest-path {{ workspace_indicator }} run --all-features --no-fail-fast" + glob = List("src/**/*.rs", "tests/**/*.rs", "Cargo.toml", "Cargo.lock", "schemas/**", "sample_config/**", ".config/nextest.toml", "scripts/run-tests.sh", "hk.pkl") + check = "cargo nextest --manifest-path {{ workspace_indicator }} run --locked --all-features --no-fail-fast" env = new Mapping { ["RUST_BACKTRACE"] = "1" } @@ -64,7 +65,7 @@ local linters = new Mapping { local ci = (linters) { ["cargo_test"] { - check = "cargo nextest --manifest-path {{ workspace_indicator }} run --all-features --fail-fast -p ci" + check = "cargo nextest --manifest-path {{ workspace_indicator }} run --locked --all-features --fail-fast --profile ci" } } diff --git a/mise.toml b/mise.toml index d62d910a..6ec94fa4 100644 --- a/mise.toml +++ b/mise.toml @@ -13,7 +13,7 @@ cargo-binstall = "latest" "cargo:cargo-deny" = "latest" # For checking licenses and other policies. "cargo:git-cliff" = "latest" "cargo:cargo-edit" = "latest" -"cargo:cargo-nextest" = "latest" # For running tests in parallel. +"cargo:cargo-nextest" = "0.9.128" # Test-group isolation; keep in sync with .config/nextest.toml. "cargo:cargo-smart-release" = "latest" # For automating releases. "cargo:cargo-watch" = "latest" # For watching files and rerunning commands. hk = "1.48" # Handles git hooks, like pre-commit. Pinned to match CI; hk.pkl targets the 1.48 schema. diff --git a/sample_config/submod.toml b/sample_config/submod.toml index 78a6d802..525f44c6 100644 --- a/sample_config/submod.toml +++ b/sample_config/submod.toml @@ -3,95 +3,70 @@ # SPDX-License-Identifier: LicenseRef-PlainMIT OR MIT #:schema = "https://raw.githubusercontent.com/bashandbone/submod/main/schemas/latest/submod.json" schema_version = "1.1.0" -# =========== Example Submodule Configuration ========== +# Optional metadata: omit it for legacy files, or use exactly "1.0.0" or "1.1.0". +# EXAMPLE ONLY: replace every example.invalid URL with a real repository before +# running init/sync. Parsing this document does not verify that remotes exist. # ========================= GLOBAL DEFAULTS ========================= -# Set global (repo-level) defaults for all submodules here. -# Git does not have global submodule configuration; we resolve the difference and apply the settings to all submodules. -# Defaults (without defining them) are: -# ```toml -# [defaults] -# ignore = "none" -# update = "checkout" -# fetchRecurse = "on-demand" -# ``` -# Submodule paths can't be set globally, but their default assignment is the submodule name in the repository root. -# -# ## Available options: -# See [docs](https://docs.rs/submod/latest/submod/options/) for details. -# - `ignore`: "all", "dirty", "untracked", "none" [default] -# - `update`: "checkout" [default], "rebase", "merge", "none" -# - `fetchRecurse`: "on-demand" [default], "always", "never" - +# Explicit per-module values override [defaults], then built-in values apply: +# ignore = "none", update = "checkout", fetchRecurse = "on-demand", +# use_git_default_sparse_checkout = false. +# branch has no built-in named branch: Git resolves the remote default. +# Only these five fields are supported globally. Remove a module override with +# `submod change NAME --unset FIELD` to inherit the global value again. [defaults] -ignore = "dirty" # Override default ignore setting for all submodules +ignore = "dirty" +fetchRecurse = "on-demand" +# branch = "main" # Optional inherited tracking branch; does not advance pins. -# =========================== SUBMODULE CONFIGURATION =========================== -# -# ## Submodule Name -# -# Each submodule uses its name as the section header. -# If the submodule exists in the repository, use the name from `.gitmodules` or `.git/config`. -# -# ## Available Options -# -# **Submodules support all global options (ignore, fetchRecurse, update)**, which will override the defaults. If you want a submodule to maintain default behavior, but set a different global behavior, you *must* explicitly add the options here. Other submodule options are: -# -# ## `url` -# **Required.** The submodule repository URL. Use the same value as in `.gitmodules` or `.git/config`. Accepts remote URLs or local paths (absolute or relative). -# -# ## `path` -# The path where the submodule is checked out. Defaults to the submodule name in the repository root. If not in a repository root, finds the superproject root. Specify a path to override. -# -# ## `branch` -# The submodule branch to check out. Defaults to the submodule's default branch (usually `main` or `master`). You may use `"."` (or aliases: `current`, `current-in-superproject`, `superproject`, `super`) to match the superproject branch. Do not use these as branch names in the submodule repository. -# Submit an issue if you encounter a branch name conflict at https://github.com/bashandbone/submod/issues. +# =========================== SUBMODULES =========================== +# Section names are logical identities; new Git registrations use these names. +# A checkout path can differ from its name. Existing Git names are preserved +# when unambiguously resolved. Do not rename an existing entry by adding it again. +# url is required and accepts remote URLs or local repository paths. +# path is relative to the discovered superproject root; omission uses the name. +# Absolute checkout paths, parent traversal and .git components are rejected. +# Commands discover the superproject root even when run from a nested directory. # -# ## `sparse_paths` -# A list of relative paths or glob patterns to include in the sparse checkout. If omitted, includes all files. -# **Deny-all by default:** `submod` automatically prepends `!/*` as the first pattern, -# so _only_ the paths you list are checked out. You declare what you want; everything -# else is excluded. No manual negation rules are needed for the common case. -# Example — check out only `src/`, `include/`, and markdown files at any depth: -# ```toml -# sparse_paths = ["src/", "include/", "*.md"] -# ``` -# This writes the following patterns to git's sparse-checkout file: -# ``` -# !/* ← deny all by default (added automatically) -# src/ ← include src/ -# include/ ← include include/ -# *.md ← include markdown files at any depth -# ``` -# To match markdown files only at the repository root use `/*.md` instead. -# Any `!/*` entries you add yourself are de-duplicated automatically. +# Per-module fields also include active (default true), shallow (default false), +# and sparse_paths (absent/empty means full checkout). Explicit false overrides +# an inherited sparse-mode true. Unknown fields or conflicting aliases fail. # -# ## `use_git_default_sparse_checkout` -# Set to `true` to opt out of submod's deny-all-by-default model for this submodule -# and use git's standard sparse-checkout semantics instead (no automatic `!/*` prefix). -# Can also be set globally under `[defaults]`. +# ignore: all | dirty | untracked | none. Safety checks still protect local work. +# update: checkout | merge | rebase | none. Default update follows the parent +# gitlink; `submod update --remote` explicitly advances to the tracking tip. +# update = "none" skips updating and fetching. Custom !command is rejected. +# fetchRecurse: on-demand | always | never controls Git's recursive fetch policy, +# not whether every command fetches or initializes nested submodules. +# Legacy fetch/fetch_recurse keys and string values "true"/"false" are accepted +# with warnings. Use one spelling per table; write canonical fetchRecurse values. # -# ## `shallow` +# branch selects an initial/tracking branch. "." follows the superproject branch; +# accepted aliases: current, current-in-super-project, current-in-superproject, +# superproject, super. Use "refs/heads/current" for a literal branch named current. +# Legacy "HEAD" explicitly selects remote-default tracking over a global branch. # -# If `true`, performs a shallow clone of the submodule, which means it only fetches the most recent commit. Defaults to `false`. This is useful for large repositories where you only need the latest commit. +# Sparse patterns are ordered. With nonempty sparse_paths, submod prepends !/* +# once so only included paths are checked out. use_git_default_sparse_checkout +# = true passes your patterns to Git without that prefix; it does not imply all +# files are included. Sparse checkout limits files, not downloaded objects. +# shallow requests a depth-one new clone; it does not truncate existing history. # +# active is stored in TOML and local Git config, not .gitmodules. Disabling keeps +# files/history. Delete removes registration/checkout but retains Git history; +# local work may require recovery before removal can proceed. +# Explicit sync reconciles managed URLs, overwriting divergent local URLs. +# Modules not declared here are unmanaged and remain untouched by normal sync. -# NAMES (the part between "[" and "]" below). -# You can name the submodule "bob" or "vendor-utils" if you want in your `submod.toml` -# This name is only used for the configuration, and for your reference when using `submod` commands. You can make the names easy to remember for calling `submod` commands. -# Git expects the submodule name to be the full relative path from the repository root, so that is what we'll use on the back end with `.gitmodules`/`.git/config`. [vendor-utils] -path = "vendor/utils" # <-- will be the name in `.gitmodules` and `.git/config` -url = "https://github.com/example/utils.git" -sparse_paths = [ - "src/", # All files in src directory - "include/", # All files in include directory - "*.md" # All markdown files at any depth -] -ignore = "all" # Override default ignore setting +path = "vendor/utils" +url = "https://example.invalid/utils.git" # Replace before use. +sparse_paths = ["src/", "include/", "*.md"] +ignore = "none" +use_git_default_sparse_checkout = false [my-submodule] -path = "my-submodule" -url = "https://github.com/example/my-submodule.git" -sparse_paths = ["src/", "include/"] -ignore = "all" +# path omitted: checkout at my-submodule beneath the superproject root. +url = "https://example.invalid/my-submodule.git" # Replace before use. +active = false # Ineligible for init/sync until explicitly enabled. +shallow = false diff --git a/schemas/current/submod_config.json b/schemas/current/submod_config.json new file mode 100644 index 00000000..464f3e48 --- /dev/null +++ b/schemas/current/submod_config.json @@ -0,0 +1,248 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://raw.githubusercontent.com/bashandbone/submod/main/schemas/v1.1.0/submod_config_v1.1.0.json", + "title": "submod configuration", + "description": "Current configuration contract. Omitted per-module settings inherit defaults, then built-in defaults. Git validates repository paths and branch names at runtime.", + "type": "object", + "properties": { + "schema_version": { + "type": "string", + "enum": [ + "1.0.0", + "1.1.0" + ], + "description": "Optional metadata. These exact legacy/current versions are accepted; omission is supported." + }, + "defaults": { + "type": "object", + "description": "Inherited settings. Per-module declarations, including explicit false, take precedence.", + "properties": { + "branch": { + "$ref": "#/definitions/branch" + }, + "ignore": { + "$ref": "#/definitions/ignore" + }, + "update": { + "$ref": "#/definitions/update" + }, + "fetchRecurse": { + "$ref": "#/definitions/fetchRecurse" + }, + "use_git_default_sparse_checkout": { + "$ref": "#/definitions/sparseMode" + }, + "fetch": { + "allOf": [ + { + "$ref": "#/definitions/fetchRecurse" + } + ], + "description": "Legacy alias for fetchRecurse; accepted with a migration warning. Use only one spelling per table." + }, + "fetch_recurse": { + "allOf": [ + { + "$ref": "#/definitions/fetchRecurse" + } + ], + "description": "Legacy alias for fetchRecurse; accepted with a migration warning. Use only one spelling per table." + } + }, + "additionalProperties": false, + "allOf": [ + { + "not": { + "required": [ + "fetchRecurse", + "fetch" + ] + } + }, + { + "not": { + "required": [ + "fetchRecurse", + "fetch_recurse" + ] + } + }, + { + "not": { + "required": [ + "fetch", + "fetch_recurse" + ] + } + } + ] + } + }, + "additionalProperties": { + "$ref": "#/definitions/submodule" + }, + "definitions": { + "ignore": { + "type": "string", + "enum": [ + "all", + "dirty", + "untracked", + "none" + ], + "default": "none", + "description": "Git status ignore policy. It does not disable safety checks for local work." + }, + "update": { + "type": "string", + "enum": [ + "checkout", + "merge", + "rebase", + "none" + ], + "default": "checkout", + "description": "Strategy against the parent-recorded gitlink. none skips updating and fetching. Custom !command values are rejected. Only update --remote requests tracking-tip advancement." + }, + "fetchRecurse": { + "type": "string", + "enum": [ + "on-demand", + "always", + "never", + "true", + "false" + ], + "default": "on-demand", + "description": "Git fetchRecurseSubmodules policy: on-demand/true/false respectively for on-demand/always/never. Legacy strings \"true\" and \"false\" are accepted with a warning; TOML booleans are invalid. This does not promise a fetch on every operation or initialize nested checkouts." + }, + "branch": { + "type": "string", + "minLength": 1, + "pattern": "\\S", + "description": "Named tracking branch, or . for the parent branch. Omission inherits defaults; with no default Git selects its remote default. Aliases current, current-in-super-project, current-in-superproject, superproject, super are accepted. Use refs/heads/current for a literal alias name. Legacy HEAD explicitly selects remote-default tracking, overriding a global branch. Git branch validation also applies. Default update remains pinned." + }, + "sparseMode": { + "type": "boolean", + "default": false, + "description": "true passes sparse patterns to Git without adding !/*. false prepends !/* to nonempty patterns. This does not imply all files are included." + }, + "submodule": { + "type": "object", + "description": "Section name is the logical identity, distinct from checkout path. Existing Git names are preserved when unambiguously resolved.", + "required": [ + "url" + ], + "properties": { + "branch": { + "$ref": "#/definitions/branch" + }, + "ignore": { + "$ref": "#/definitions/ignore" + }, + "update": { + "$ref": "#/definitions/update" + }, + "fetchRecurse": { + "$ref": "#/definitions/fetchRecurse" + }, + "use_git_default_sparse_checkout": { + "$ref": "#/definitions/sparseMode" + }, + "fetch": { + "allOf": [ + { + "$ref": "#/definitions/fetchRecurse" + } + ], + "description": "Legacy alias for fetchRecurse; accepted with a migration warning. Use only one spelling per table." + }, + "fetch_recurse": { + "allOf": [ + { + "$ref": "#/definitions/fetchRecurse" + } + ], + "description": "Legacy alias for fetchRecurse; accepted with a migration warning. Use only one spelling per table." + }, + "url": { + "type": "string", + "minLength": 1, + "allOf": [ + { + "pattern": "\\S" + }, + { + "not": { + "pattern": "[\\u0000\\r\\n]" + } + } + ], + "description": "Required nonempty remote URL or local repository path. Relative URLs follow Git semantics." + }, + "path": { + "type": "string", + "minLength": 1, + "allOf": [ + { + "pattern": "\\S" + }, + { + "not": { + "pattern": "[\\u0000\\r\\n]" + } + } + ], + "description": "Checkout path relative to the superproject root; defaults to the section name. Absolute paths, parent traversal and .git components are rejected; runtime containment checks apply." + }, + "active": { + "type": "boolean", + "default": true, + "description": "App selection and local Git activation only; not portable .gitmodules metadata. Disabling preserves checkout and history." + }, + "shallow": { + "type": "boolean", + "default": false, + "description": "Request depth-one cloning for new repositories. Does not truncate or unshallow existing history." + }, + "sparse_paths": { + "type": "array", + "items": { + "type": "string", + "not": { + "pattern": "[\\u0000\\r\\n]" + } + }, + "description": "Ordered Git sparse-checkout patterns. Absent or empty means full checkout. By default !/* is prepended once; sparse mode can opt out. Sparse checkout limits working-tree files, not downloaded objects (partial clone)." + } + }, + "additionalProperties": false, + "allOf": [ + { + "not": { + "required": [ + "fetchRecurse", + "fetch" + ] + } + }, + { + "not": { + "required": [ + "fetchRecurse", + "fetch_recurse" + ] + } + }, + { + "not": { + "required": [ + "fetch", + "fetch_recurse" + ] + } + } + ] + } + } +} diff --git a/schemas/current/submod_config.json.license b/schemas/current/submod_config.json.license new file mode 100644 index 00000000..c1fd1064 --- /dev/null +++ b/schemas/current/submod_config.json.license @@ -0,0 +1,2 @@ +SPDX-FileCopyrightText: 2026 Adam Poulemanos +SPDX-License-Identifier: LicenseRef-PlainMIT OR MIT diff --git a/schemas/latest/submod.json b/schemas/latest/submod.json deleted file mode 120000 index 58cdf8f1..00000000 --- a/schemas/latest/submod.json +++ /dev/null @@ -1 +0,0 @@ -submod_config_v1.0.0.json \ No newline at end of file diff --git a/schemas/latest/submod.json b/schemas/latest/submod.json new file mode 100644 index 00000000..464f3e48 --- /dev/null +++ b/schemas/latest/submod.json @@ -0,0 +1,248 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://raw.githubusercontent.com/bashandbone/submod/main/schemas/v1.1.0/submod_config_v1.1.0.json", + "title": "submod configuration", + "description": "Current configuration contract. Omitted per-module settings inherit defaults, then built-in defaults. Git validates repository paths and branch names at runtime.", + "type": "object", + "properties": { + "schema_version": { + "type": "string", + "enum": [ + "1.0.0", + "1.1.0" + ], + "description": "Optional metadata. These exact legacy/current versions are accepted; omission is supported." + }, + "defaults": { + "type": "object", + "description": "Inherited settings. Per-module declarations, including explicit false, take precedence.", + "properties": { + "branch": { + "$ref": "#/definitions/branch" + }, + "ignore": { + "$ref": "#/definitions/ignore" + }, + "update": { + "$ref": "#/definitions/update" + }, + "fetchRecurse": { + "$ref": "#/definitions/fetchRecurse" + }, + "use_git_default_sparse_checkout": { + "$ref": "#/definitions/sparseMode" + }, + "fetch": { + "allOf": [ + { + "$ref": "#/definitions/fetchRecurse" + } + ], + "description": "Legacy alias for fetchRecurse; accepted with a migration warning. Use only one spelling per table." + }, + "fetch_recurse": { + "allOf": [ + { + "$ref": "#/definitions/fetchRecurse" + } + ], + "description": "Legacy alias for fetchRecurse; accepted with a migration warning. Use only one spelling per table." + } + }, + "additionalProperties": false, + "allOf": [ + { + "not": { + "required": [ + "fetchRecurse", + "fetch" + ] + } + }, + { + "not": { + "required": [ + "fetchRecurse", + "fetch_recurse" + ] + } + }, + { + "not": { + "required": [ + "fetch", + "fetch_recurse" + ] + } + } + ] + } + }, + "additionalProperties": { + "$ref": "#/definitions/submodule" + }, + "definitions": { + "ignore": { + "type": "string", + "enum": [ + "all", + "dirty", + "untracked", + "none" + ], + "default": "none", + "description": "Git status ignore policy. It does not disable safety checks for local work." + }, + "update": { + "type": "string", + "enum": [ + "checkout", + "merge", + "rebase", + "none" + ], + "default": "checkout", + "description": "Strategy against the parent-recorded gitlink. none skips updating and fetching. Custom !command values are rejected. Only update --remote requests tracking-tip advancement." + }, + "fetchRecurse": { + "type": "string", + "enum": [ + "on-demand", + "always", + "never", + "true", + "false" + ], + "default": "on-demand", + "description": "Git fetchRecurseSubmodules policy: on-demand/true/false respectively for on-demand/always/never. Legacy strings \"true\" and \"false\" are accepted with a warning; TOML booleans are invalid. This does not promise a fetch on every operation or initialize nested checkouts." + }, + "branch": { + "type": "string", + "minLength": 1, + "pattern": "\\S", + "description": "Named tracking branch, or . for the parent branch. Omission inherits defaults; with no default Git selects its remote default. Aliases current, current-in-super-project, current-in-superproject, superproject, super are accepted. Use refs/heads/current for a literal alias name. Legacy HEAD explicitly selects remote-default tracking, overriding a global branch. Git branch validation also applies. Default update remains pinned." + }, + "sparseMode": { + "type": "boolean", + "default": false, + "description": "true passes sparse patterns to Git without adding !/*. false prepends !/* to nonempty patterns. This does not imply all files are included." + }, + "submodule": { + "type": "object", + "description": "Section name is the logical identity, distinct from checkout path. Existing Git names are preserved when unambiguously resolved.", + "required": [ + "url" + ], + "properties": { + "branch": { + "$ref": "#/definitions/branch" + }, + "ignore": { + "$ref": "#/definitions/ignore" + }, + "update": { + "$ref": "#/definitions/update" + }, + "fetchRecurse": { + "$ref": "#/definitions/fetchRecurse" + }, + "use_git_default_sparse_checkout": { + "$ref": "#/definitions/sparseMode" + }, + "fetch": { + "allOf": [ + { + "$ref": "#/definitions/fetchRecurse" + } + ], + "description": "Legacy alias for fetchRecurse; accepted with a migration warning. Use only one spelling per table." + }, + "fetch_recurse": { + "allOf": [ + { + "$ref": "#/definitions/fetchRecurse" + } + ], + "description": "Legacy alias for fetchRecurse; accepted with a migration warning. Use only one spelling per table." + }, + "url": { + "type": "string", + "minLength": 1, + "allOf": [ + { + "pattern": "\\S" + }, + { + "not": { + "pattern": "[\\u0000\\r\\n]" + } + } + ], + "description": "Required nonempty remote URL or local repository path. Relative URLs follow Git semantics." + }, + "path": { + "type": "string", + "minLength": 1, + "allOf": [ + { + "pattern": "\\S" + }, + { + "not": { + "pattern": "[\\u0000\\r\\n]" + } + } + ], + "description": "Checkout path relative to the superproject root; defaults to the section name. Absolute paths, parent traversal and .git components are rejected; runtime containment checks apply." + }, + "active": { + "type": "boolean", + "default": true, + "description": "App selection and local Git activation only; not portable .gitmodules metadata. Disabling preserves checkout and history." + }, + "shallow": { + "type": "boolean", + "default": false, + "description": "Request depth-one cloning for new repositories. Does not truncate or unshallow existing history." + }, + "sparse_paths": { + "type": "array", + "items": { + "type": "string", + "not": { + "pattern": "[\\u0000\\r\\n]" + } + }, + "description": "Ordered Git sparse-checkout patterns. Absent or empty means full checkout. By default !/* is prepended once; sparse mode can opt out. Sparse checkout limits working-tree files, not downloaded objects (partial clone)." + } + }, + "additionalProperties": false, + "allOf": [ + { + "not": { + "required": [ + "fetchRecurse", + "fetch" + ] + } + }, + { + "not": { + "required": [ + "fetchRecurse", + "fetch_recurse" + ] + } + }, + { + "not": { + "required": [ + "fetch", + "fetch_recurse" + ] + } + } + ] + } + } +} diff --git a/schemas/latest/submod.json.license b/schemas/latest/submod.json.license new file mode 100644 index 00000000..c1fd1064 --- /dev/null +++ b/schemas/latest/submod.json.license @@ -0,0 +1,2 @@ +SPDX-FileCopyrightText: 2026 Adam Poulemanos +SPDX-License-Identifier: LicenseRef-PlainMIT OR MIT diff --git a/schemas/submod.json b/schemas/submod.json deleted file mode 120000 index b462fd45..00000000 --- a/schemas/submod.json +++ /dev/null @@ -1 +0,0 @@ -schemas/v1.1.0/submod_config_v1.1.0.json \ No newline at end of file diff --git a/schemas/submod.json b/schemas/submod.json new file mode 100644 index 00000000..464f3e48 --- /dev/null +++ b/schemas/submod.json @@ -0,0 +1,248 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://raw.githubusercontent.com/bashandbone/submod/main/schemas/v1.1.0/submod_config_v1.1.0.json", + "title": "submod configuration", + "description": "Current configuration contract. Omitted per-module settings inherit defaults, then built-in defaults. Git validates repository paths and branch names at runtime.", + "type": "object", + "properties": { + "schema_version": { + "type": "string", + "enum": [ + "1.0.0", + "1.1.0" + ], + "description": "Optional metadata. These exact legacy/current versions are accepted; omission is supported." + }, + "defaults": { + "type": "object", + "description": "Inherited settings. Per-module declarations, including explicit false, take precedence.", + "properties": { + "branch": { + "$ref": "#/definitions/branch" + }, + "ignore": { + "$ref": "#/definitions/ignore" + }, + "update": { + "$ref": "#/definitions/update" + }, + "fetchRecurse": { + "$ref": "#/definitions/fetchRecurse" + }, + "use_git_default_sparse_checkout": { + "$ref": "#/definitions/sparseMode" + }, + "fetch": { + "allOf": [ + { + "$ref": "#/definitions/fetchRecurse" + } + ], + "description": "Legacy alias for fetchRecurse; accepted with a migration warning. Use only one spelling per table." + }, + "fetch_recurse": { + "allOf": [ + { + "$ref": "#/definitions/fetchRecurse" + } + ], + "description": "Legacy alias for fetchRecurse; accepted with a migration warning. Use only one spelling per table." + } + }, + "additionalProperties": false, + "allOf": [ + { + "not": { + "required": [ + "fetchRecurse", + "fetch" + ] + } + }, + { + "not": { + "required": [ + "fetchRecurse", + "fetch_recurse" + ] + } + }, + { + "not": { + "required": [ + "fetch", + "fetch_recurse" + ] + } + } + ] + } + }, + "additionalProperties": { + "$ref": "#/definitions/submodule" + }, + "definitions": { + "ignore": { + "type": "string", + "enum": [ + "all", + "dirty", + "untracked", + "none" + ], + "default": "none", + "description": "Git status ignore policy. It does not disable safety checks for local work." + }, + "update": { + "type": "string", + "enum": [ + "checkout", + "merge", + "rebase", + "none" + ], + "default": "checkout", + "description": "Strategy against the parent-recorded gitlink. none skips updating and fetching. Custom !command values are rejected. Only update --remote requests tracking-tip advancement." + }, + "fetchRecurse": { + "type": "string", + "enum": [ + "on-demand", + "always", + "never", + "true", + "false" + ], + "default": "on-demand", + "description": "Git fetchRecurseSubmodules policy: on-demand/true/false respectively for on-demand/always/never. Legacy strings \"true\" and \"false\" are accepted with a warning; TOML booleans are invalid. This does not promise a fetch on every operation or initialize nested checkouts." + }, + "branch": { + "type": "string", + "minLength": 1, + "pattern": "\\S", + "description": "Named tracking branch, or . for the parent branch. Omission inherits defaults; with no default Git selects its remote default. Aliases current, current-in-super-project, current-in-superproject, superproject, super are accepted. Use refs/heads/current for a literal alias name. Legacy HEAD explicitly selects remote-default tracking, overriding a global branch. Git branch validation also applies. Default update remains pinned." + }, + "sparseMode": { + "type": "boolean", + "default": false, + "description": "true passes sparse patterns to Git without adding !/*. false prepends !/* to nonempty patterns. This does not imply all files are included." + }, + "submodule": { + "type": "object", + "description": "Section name is the logical identity, distinct from checkout path. Existing Git names are preserved when unambiguously resolved.", + "required": [ + "url" + ], + "properties": { + "branch": { + "$ref": "#/definitions/branch" + }, + "ignore": { + "$ref": "#/definitions/ignore" + }, + "update": { + "$ref": "#/definitions/update" + }, + "fetchRecurse": { + "$ref": "#/definitions/fetchRecurse" + }, + "use_git_default_sparse_checkout": { + "$ref": "#/definitions/sparseMode" + }, + "fetch": { + "allOf": [ + { + "$ref": "#/definitions/fetchRecurse" + } + ], + "description": "Legacy alias for fetchRecurse; accepted with a migration warning. Use only one spelling per table." + }, + "fetch_recurse": { + "allOf": [ + { + "$ref": "#/definitions/fetchRecurse" + } + ], + "description": "Legacy alias for fetchRecurse; accepted with a migration warning. Use only one spelling per table." + }, + "url": { + "type": "string", + "minLength": 1, + "allOf": [ + { + "pattern": "\\S" + }, + { + "not": { + "pattern": "[\\u0000\\r\\n]" + } + } + ], + "description": "Required nonempty remote URL or local repository path. Relative URLs follow Git semantics." + }, + "path": { + "type": "string", + "minLength": 1, + "allOf": [ + { + "pattern": "\\S" + }, + { + "not": { + "pattern": "[\\u0000\\r\\n]" + } + } + ], + "description": "Checkout path relative to the superproject root; defaults to the section name. Absolute paths, parent traversal and .git components are rejected; runtime containment checks apply." + }, + "active": { + "type": "boolean", + "default": true, + "description": "App selection and local Git activation only; not portable .gitmodules metadata. Disabling preserves checkout and history." + }, + "shallow": { + "type": "boolean", + "default": false, + "description": "Request depth-one cloning for new repositories. Does not truncate or unshallow existing history." + }, + "sparse_paths": { + "type": "array", + "items": { + "type": "string", + "not": { + "pattern": "[\\u0000\\r\\n]" + } + }, + "description": "Ordered Git sparse-checkout patterns. Absent or empty means full checkout. By default !/* is prepended once; sparse mode can opt out. Sparse checkout limits working-tree files, not downloaded objects (partial clone)." + } + }, + "additionalProperties": false, + "allOf": [ + { + "not": { + "required": [ + "fetchRecurse", + "fetch" + ] + } + }, + { + "not": { + "required": [ + "fetchRecurse", + "fetch_recurse" + ] + } + }, + { + "not": { + "required": [ + "fetch", + "fetch_recurse" + ] + } + } + ] + } + } +} diff --git a/schemas/submod.json.license b/schemas/submod.json.license new file mode 100644 index 00000000..c1fd1064 --- /dev/null +++ b/schemas/submod.json.license @@ -0,0 +1,2 @@ +SPDX-FileCopyrightText: 2026 Adam Poulemanos +SPDX-License-Identifier: LicenseRef-PlainMIT OR MIT diff --git a/schemas/v1.1.0/submod_config_v1.1.0.json b/schemas/v1.1.0/submod_config_v1.1.0.json index 95ae2f69..464f3e48 100644 --- a/schemas/v1.1.0/submod_config_v1.1.0.json +++ b/schemas/v1.1.0/submod_config_v1.1.0.json @@ -1,81 +1,248 @@ { "$schema": "http://json-schema.org/draft-07/schema#", - "$id": "https://raw.githubusercontent.com/bashandbone/submod/main/schemas/submod.json", + "$id": "https://raw.githubusercontent.com/bashandbone/submod/main/schemas/v1.1.0/submod_config_v1.1.0.json", "title": "submod configuration", - "description": "Configuration file for submod, a git submodule management CLI.\nhttps://docs.rs/", + "description": "Current configuration contract. Omitted per-module settings inherit defaults, then built-in defaults. Git validates repository paths and branch names at runtime.", "type": "object", "properties": { "schema_version": { "type": "string", - "pattern": "^v?\\d+(\\.\\d+){0,2}$", - "description": "The version of the submod configuration schema in use. Accepts \"1\", \"1.0\", or \"1.0.0\" format.\nhttps://docs.rs/submod" + "enum": [ + "1.0.0", + "1.1.0" + ], + "description": "Optional metadata. These exact legacy/current versions are accepted; omission is supported." }, "defaults": { "type": "object", - "description": "Global defaults applied to all submodules. Git has no native global submodule configuration; submod resolves this by applying these settings across all submodule entries. Any settings you set for an individual submodule will override these defaults.\nhttps://docs.rs/submod/latest/submod/options/", + "description": "Inherited settings. Per-module declarations, including explicit false, take precedence.", "properties": { - "ignore": { "$ref": "#/$defs/ignoreValue" }, - "update": { "$ref": "#/$defs/updateValue" }, - "fetch": { "$ref": "#/$defs/fetchValue" }, - "" + "branch": { + "$ref": "#/definitions/branch" + }, + "ignore": { + "$ref": "#/definitions/ignore" + }, + "update": { + "$ref": "#/definitions/update" + }, + "fetchRecurse": { + "$ref": "#/definitions/fetchRecurse" + }, + "use_git_default_sparse_checkout": { + "$ref": "#/definitions/sparseMode" + }, + "fetch": { + "allOf": [ + { + "$ref": "#/definitions/fetchRecurse" + } + ], + "description": "Legacy alias for fetchRecurse; accepted with a migration warning. Use only one spelling per table." + }, + "fetch_recurse": { + "allOf": [ + { + "$ref": "#/definitions/fetchRecurse" + } + ], + "description": "Legacy alias for fetchRecurse; accepted with a migration warning. Use only one spelling per table." + } }, - "additionalProperties": false + "additionalProperties": false, + "allOf": [ + { + "not": { + "required": [ + "fetchRecurse", + "fetch" + ] + } + }, + { + "not": { + "required": [ + "fetchRecurse", + "fetch_recurse" + ] + } + }, + { + "not": { + "required": [ + "fetch", + "fetch_recurse" + ] + } + } + ] } }, "additionalProperties": { - "$ref": "#/$defs/submodule" + "$ref": "#/definitions/submodule" }, - "$defs": { - "ignoreValue": { + "definitions": { + "ignore": { "type": "string", - "enum": ["all", "dirty", "untracked", "none"], + "enum": [ + "all", + "dirty", + "untracked", + "none" + ], "default": "none", - "description": "Controls which changes cause a submodule to appear as modified in git status.\n- \"none\": report all changes (default)\n- \"untracked\": ignore untracked files\n- \"dirty\": ignore all working tree changes, only track commits\n- \"all\": always ignore the submodule\nhttps://docs.rs/submod/latest/submod/options/" + "description": "Git status ignore policy. It does not disable safety checks for local work." }, - "updateValue": { + "update": { "type": "string", - "enum": ["checkout", "rebase", "merge", "none"], + "enum": [ + "checkout", + "merge", + "rebase", + "none" + ], "default": "checkout", - "description": "How to update the submodule when the superproject moves to a new commit.\n- \"checkout\": detach HEAD at the recorded commit (default)\n- \"rebase\": rebase current branch onto the recorded commit\n- \"merge\": merge the recorded commit into the current branch\n- \"none\": do not update\nhttps://docs.rs/submod/latest/submod/options/" + "description": "Strategy against the parent-recorded gitlink. none skips updating and fetching. Custom !command values are rejected. Only update --remote requests tracking-tip advancement." }, - "fetchValue": { + "fetchRecurse": { "type": "string", - "enum": ["on-demand", "always", "never"], + "enum": [ + "on-demand", + "always", + "never", + "true", + "false" + ], "default": "on-demand", - "description": "When to fetch submodule updates from the remote.\n- \"on-demand\": fetch only when needed (default)\n- \"always\": fetch on every operation\n- \"never\": never fetch automatically\nhttps://docs.rs/submod/latest/submod/options/" + "description": "Git fetchRecurseSubmodules policy: on-demand/true/false respectively for on-demand/always/never. Legacy strings \"true\" and \"false\" are accepted with a warning; TOML booleans are invalid. This does not promise a fetch on every operation or initialize nested checkouts." + }, + "branch": { + "type": "string", + "minLength": 1, + "pattern": "\\S", + "description": "Named tracking branch, or . for the parent branch. Omission inherits defaults; with no default Git selects its remote default. Aliases current, current-in-super-project, current-in-superproject, superproject, super are accepted. Use refs/heads/current for a literal alias name. Legacy HEAD explicitly selects remote-default tracking, overriding a global branch. Git branch validation also applies. Default update remains pinned." + }, + "sparseMode": { + "type": "boolean", + "default": false, + "description": "true passes sparse patterns to Git without adding !/*. false prepends !/* to nonempty patterns. This does not imply all files are included." }, "submodule": { "type": "object", - "description": "Configuration for a single submodule. The section name is your local alias for the submodule; submod maps it to the full relative path in .gitmodules and .git/config.", - "required": ["url"], + "description": "Section name is the logical identity, distinct from checkout path. Existing Git names are preserved when unambiguously resolved.", + "required": [ + "url" + ], "properties": { + "branch": { + "$ref": "#/definitions/branch" + }, + "ignore": { + "$ref": "#/definitions/ignore" + }, + "update": { + "$ref": "#/definitions/update" + }, + "fetchRecurse": { + "$ref": "#/definitions/fetchRecurse" + }, + "use_git_default_sparse_checkout": { + "$ref": "#/definitions/sparseMode" + }, + "fetch": { + "allOf": [ + { + "$ref": "#/definitions/fetchRecurse" + } + ], + "description": "Legacy alias for fetchRecurse; accepted with a migration warning. Use only one spelling per table." + }, + "fetch_recurse": { + "allOf": [ + { + "$ref": "#/definitions/fetchRecurse" + } + ], + "description": "Legacy alias for fetchRecurse; accepted with a migration warning. Use only one spelling per table." + }, "url": { "type": "string", - "description": "Required. The submodule repository URL. Accepts remote URLs (https, ssh) or local paths (absolute or relative). Use the same value as in .gitmodules or .git/config." + "minLength": 1, + "allOf": [ + { + "pattern": "\\S" + }, + { + "not": { + "pattern": "[\\u0000\\r\\n]" + } + } + ], + "description": "Required nonempty remote URL or local repository path. Relative URLs follow Git semantics." }, "path": { "type": "string", - "description": "Path where the submodule is checked out, relative to the superproject root. Defaults to the submodule name in the repository root." - }, - "branch": { - "type": "string", - "description": "Branch to track in the submodule. Defaults to the submodule's default branch (usually main or master).\nUse \".\" or the aliases \"current\", \"current-in-superproject\", \"superproject\", or \"super\" to track the superproject's current branch. **Do not use these strings as actual branch names in the submodule repository.** If you need to track a branch with one of these names, use the full branch name (e.g., \"refs/heads/current\")." + "minLength": 1, + "allOf": [ + { + "pattern": "\\S" + }, + { + "not": { + "pattern": "[\\u0000\\r\\n]" + } + } + ], + "description": "Checkout path relative to the superproject root; defaults to the section name. Absolute paths, parent traversal and .git components are rejected; runtime containment checks apply." }, - "sparse_paths": { - "type": "array", - "items": { "type": "string" }, - "description": "Relative paths or glob patterns to include in a sparse checkout. If omitted, all files are checked out.\nWhen set, git prepends:\n /*\n !/*/\nThis includes only root-level files by default. To include subdirectories, add paths like \"src/\". To exclude root files, prefix with \"!\" (e.g., \"!/README.md\")." + "active": { + "type": "boolean", + "default": true, + "description": "App selection and local Git activation only; not portable .gitmodules metadata. Disabling preserves checkout and history." }, "shallow": { "type": "boolean", "default": false, - "description": "If true, performs a shallow clone (only the most recent commit). Useful for large repositories where full history is not needed." + "description": "Request depth-one cloning for new repositories. Does not truncate or unshallow existing history." }, - "ignore": { "$ref": "#/$defs/ignoreValue" }, - "update": { "$ref": "#/$defs/updateValue" }, - "fetch": { "$ref": "#/$defs/fetchValue" } + "sparse_paths": { + "type": "array", + "items": { + "type": "string", + "not": { + "pattern": "[\\u0000\\r\\n]" + } + }, + "description": "Ordered Git sparse-checkout patterns. Absent or empty means full checkout. By default !/* is prepended once; sparse mode can opt out. Sparse checkout limits working-tree files, not downloaded objects (partial clone)." + } }, - "additionalProperties": false + "additionalProperties": false, + "allOf": [ + { + "not": { + "required": [ + "fetchRecurse", + "fetch" + ] + } + }, + { + "not": { + "required": [ + "fetchRecurse", + "fetch_recurse" + ] + } + }, + { + "not": { + "required": [ + "fetch", + "fetch_recurse" + ] + } + } + ] } } } diff --git a/scripts/measure-performance.py b/scripts/measure-performance.py new file mode 100755 index 00000000..cd33c914 --- /dev/null +++ b/scripts/measure-performance.py @@ -0,0 +1,531 @@ +#!/usr/bin/env python3 +# SPDX-FileCopyrightText: 2026 Adam Poulemanos +# SPDX-License-Identifier: LicenseRef-PlainMIT OR MIT +"""Before/after performance comparison for Phase 7 (R28). + +Compares two immutable CLI executables (baseline vs candidate, same +``--profile``) plus two criterion benchmark executables on real production +workloads: + +* config parse / load-from-file / add-one at 1/10/100 modules, driven through + the ``SUBMOD_MEASURE_CONFIG`` mode of ``benches/benchmark.rs`` (1000 timed + iterations per sample, preparation and validation outside the clock); +* CLI ``check --verbose`` on declared-but-absent modules (inspection only); +* CLI ``check --verbose`` then ``sync`` on small materialized fixtures, with + byte/mtime/index snapshots proving the no-op path performs no clone, fetch, + sparse reapply, or config rewrite. + +Method (see docs/IMPROVEMENT_PLAN.md Phase 7 and the measurement protocol): +one untimed validation, one warm-up, then alternating baseline/candidate wall +clock samples (default 10) on the same host/filesystem with quiesced builds. +A separate instrumented sample per artifact/workload counts direct Git +invocations (PATH wrapper log) and Git-spawned launches (Trace2 +``child_start`` events); those counts are reported separately, never summed. +Memory is direct-executable accounting only (``/usr/bin/time -l`` on macOS, +``/usr/bin/time -v`` on Linux); unavailable child RSS is reported as missing, +never zero. + +Usage: + python3 scripts/measure-performance.py \\ + --baseline /path/to/phase6-submod --candidate /path/to/phase7-submod \\ + --bench-baseline /path/to/phase6-benchmark \\ + --bench-candidate /path/to/phase7-benchmark \\ + --profile bench --samples 10 --workdir /tmp/submod-perf + +Build the executables first with the identical profile, e.g. +``cargo build --locked --offline --profile bench`` (CLI) and +``cargo bench --locked --offline --no-run --profile bench`` (criterion), then +copy each to an immutable path before building the other revision. +""" + +import argparse +import hashlib +import json +import os +import shutil +import statistics +import subprocess +import sys +import tempfile +import time + +ITERATIONS = 1000 + + +def run(cmd, **kwargs): + kwargs.setdefault("check", True) + kwargs.setdefault("text", True) + kwargs.setdefault("capture_output", True) + return subprocess.run(cmd, **kwargs) + + +def sha256_file(path): + digest = hashlib.sha256() + with open(path, "rb") as handle: + for chunk in iter(lambda: handle.read(65536), b""): + digest.update(chunk) + return digest.hexdigest() + + +def isolate_git_env(workdir, extra=None): + """Per-run Git isolation mirroring tests/common/mod.rs conventions.""" + gitconfig = os.path.join(workdir, "fixture-gitconfig") + if not os.path.exists(gitconfig): + with open(gitconfig, "w") as handle: + handle.write( + '[protocol "file"]\n\tallow = always\n' + "[core]\n\tautocrlf = false\n\tfilemode = false\n" + "[commit]\n\tgpgsign = false\n" + "[user]\n\tname = Perf Measure\n\temail = perf@example.com\n" + ) + env = dict(os.environ) + env["GIT_CONFIG_GLOBAL"] = gitconfig + env["GIT_CONFIG_NOSYSTEM"] = "1" + env["GIT_TERMINAL_PROMPT"] = "0" + if extra: + env.update(extra) + return env + + +def git(env, cwd, *args): + return run(["git", *args], cwd=cwd, env=env) + + +def make_origin(root, name, branch="main"): + """Bare origin with two commits; returns (path, pinned_oid, tree_oid).""" + origin = os.path.join(root, f"{name}.git") + work = os.path.join(root, f"{name}-work") + os.makedirs(work) + env = isolate_git_env(root) + git(env, work, "init", "-b", branch, ".") + with open(os.path.join(work, "file.txt"), "w") as handle: + handle.write(f"{name} one\n") + git(env, work, "add", "file.txt") + git(env, work, "commit", "-m", "one") + with open(os.path.join(work, "file.txt"), "a") as handle: + handle.write(f"{name} two\n") + git(env, work, "commit", "-am", "two") + pinned = git(env, work, "rev-parse", "HEAD").stdout.strip() + tree = git(env, work, "rev-parse", "HEAD^{tree}").stdout.strip() + git(env, root, "clone", "--bare", work, origin) + return origin, pinned, tree + + +def write_config_toml(path, count, origins, sparse=True, defaults=True): + # CLI fixtures omit [defaults]: `git submodule add` writes only path+url, + # so inherited defaults would read as metadata drift. Config-bench + # datasets keep defaults for parse realism. + lines = ( + ['[defaults]\nignore = "dirty"\nupdate = "checkout"\n'] if defaults else [] + ) + for i in range(count): + entry = ( + f'[module-{i}]\npath = "lib/module-{i}"\nurl = "{origins[i]}"\n' + "active = true\n" + ) + if sparse: + entry += 'sparse_paths = ["src", "docs"]\n' + lines.append(entry) + with open(path, "w") as handle: + handle.write("\n".join(lines) + "\n") + return sha256_file(path) + + +def materialize_parent(root, name, count): + """Parent repo with `count` submodules added via real Git; committed.""" + env = isolate_git_env(root) + parent = os.path.join(root, name) + os.makedirs(parent) + git(env, parent, "init", "-b", "main", ".") + origins = [] + pins = [] + for i in range(count): + origin, pinned, _tree = make_origin(root, f"{name}-origin-{i}") + origins.append(origin) + pins.append(pinned) + git( + env, + parent, + "-c", + "protocol.file.allow=always", + "submodule", + "add", + "--name", + f"module-{i}", + origin, + f"lib/module-{i}", + ) + # No sparse declaration and no defaults: fixtures are materialized as full + # checkouts via plain `git submodule add` (path+url only), so the no-op + # workloads isolate pin convergence, not metadata/sparse drift. + with open(os.path.join(parent, "submod.toml"), "w") as handle: + for i, origin in enumerate(origins): + handle.write( + f'[module-{i}]\npath = "lib/module-{i}"\nurl = "{origin}"\nactive = true\n' + ) + git(env, parent, "add", "-A") + git(env, parent, "commit", "-m", "fixture") + manifest = { + "modules": count, + "pins": pins, + "toml_sha256": sha256_file(os.path.join(parent, "submod.toml")), + } + return parent, manifest + + +def snapshot_state(cli_env, repo): + """File bytes + mtimes, index listing, and porcelain status.""" + files = {} + for dirpath, dirnames, filenames in os.walk(repo): + dirnames[:] = [d for d in dirnames if d != ".git"] + for filename in filenames: + full = os.path.join(dirpath, filename) + rel = os.path.relpath(full, repo) + with open(full, "rb") as handle: + digest = hashlib.sha256(handle.read()).hexdigest() + files[rel] = (digest, os.path.getmtime(full)) + index = run( + ["git", "ls-files", "--stage"], cwd=repo, env=cli_env, + check=True, text=True, capture_output=True, + ).stdout + status = run( + ["git", "status", "--porcelain=v1", "--untracked-files=all"], + cwd=repo, env=cli_env, check=True, text=True, capture_output=True, + ).stdout + return {"files": files, "index": index, "status": status} + + +def time_cli(cli, cli_env, repo, args): + start = time.monotonic_ns() + proc = run([cli, *args], cwd=repo, env=cli_env, check=False) + elapsed_ns = time.monotonic_ns() - start + return proc.returncode, elapsed_ns, proc.stdout, proc.stderr + + +def make_git_wrapper(bin_dir, log_path): + """PATH shim logging every direct `git` spawn (argv, tab-separated).""" + real_git = shutil.which("git") + shim = os.path.join(bin_dir, "git") + with open(shim, "w") as handle: + handle.write( + "#!/bin/sh\n" + f'printf "%s\\n" "$*" >> {log_path}\n' + f'exec {real_git} "$@"\n' + ) + os.chmod(shim, 0o755) + return real_git + + +def count_trace2_children(event_path): + """Count Trace2 child_start events: Git-spawned launches, not an OS-wide + process census. Reported separately from direct invocations.""" + count = 0 + try: + with open(event_path) as handle: + for line in handle: + try: + event = json.loads(line) + except json.JSONDecodeError: + continue + if event.get("event") == "child_start": + count += 1 + except FileNotFoundError: + return None + return count + + +def instrumented_cli_sample(cli, repo, args, workdir): + """One instrumented run: direct git count, Trace2 child launches, + no-op state identity, and direct-executable resource accounting.""" + run_id = f"instr-{os.getpid()}-{time.monotonic_ns()}" + bin_dir = os.path.join(workdir, f"shim-{run_id}") + os.makedirs(bin_dir) + git_log = os.path.join(workdir, f"gitcalls-{run_id}.log") + open(git_log, "w").close() + make_git_wrapper(bin_dir, git_log) + trace_path = os.path.join(workdir, f"trace2-{run_id}.json") + env = isolate_git_env(workdir) + env["PATH"] = bin_dir + os.pathsep + env.get("PATH", "") + env["GIT_TRACE2_EVENT"] = trace_path + env["GIT_TRACE2_EVENT_NESTING"] = "5" + + before = snapshot_state(env, repo) + start = time.monotonic_ns() + proc = run([cli, *args], cwd=repo, env=env, check=False) + elapsed_ns = time.monotonic_ns() - start + after = snapshot_state(env, repo) + + with open(git_log) as handle: + direct_git_calls = sum(1 for line in handle if line.strip()) + traced_launches = count_trace2_children(trace_path) + state_unchanged = before == after + + memory = {"method": "unavailable", "detail": "no supported accounting tool"} + if sys.platform == "darwin" and shutil.which("/usr/bin/time"): + timed = run( + ["/usr/bin/time", "-l", cli, *args], cwd=repo, env=env, check=False, + ) + memory = { + "method": "/usr/bin/time -l (direct executable only; " + "excludes concurrently running Git children)", + "raw_stderr_tail": timed.stderr[-2000:], + } + elif sys.platform.startswith("linux") and shutil.which("/usr/bin/time"): + timed = run( + ["/usr/bin/time", "-v", cli, *args], cwd=repo, env=env, check=False, + ) + memory = { + "method": "/usr/bin/time -v (direct executable only)", + "raw_stderr_tail": timed.stderr[-2000:], + } + return { + "exit": proc.returncode, + "elapsed_ns": elapsed_ns, + "direct_git_calls": direct_git_calls, + "traced_git_child_launches": traced_launches, + "state_unchanged": state_unchanged, + "memory": memory, + } + + +def bench_config_sample(bench_bin, workload, count, toml_path): + """One SUBMOD_MEASURE_CONFIG sample: 1000 timed iterations in-process.""" + env = dict(os.environ) + env["SUBMOD_MEASURE_CONFIG"] = workload + env["SUBMOD_MEASURE_COUNT"] = str(count) + env["SUBMOD_MEASURE_FILE"] = toml_path + start = time.monotonic_ns() + proc = run([bench_bin], env=env, check=False) + wall_ns = time.monotonic_ns() - start + try: + payload = json.loads(proc.stdout.strip().splitlines()[-1]) + except (IndexError, json.JSONDecodeError): + payload = {"verdict": "unparsable", "raw": proc.stdout[-500:]} + payload["process_exit"] = proc.returncode + payload["wall_ns"] = wall_ns + return payload + + +def alternating(samples, baseline_fn, candidate_fn): + """Alternate baseline/candidate samples; returns (base_list, cand_list).""" + base, cand = [], [] + for _ in range(samples): + base.append(baseline_fn()) + cand.append(candidate_fn()) + return base, cand + + +def summarize(ns_list): + return { + "samples": len(ns_list), + "median_ns": int(statistics.median(ns_list)), + "min_ns": min(ns_list), + "max_ns": max(ns_list), + } + + +def main(): + parser = argparse.ArgumentParser(description="Phase 7 before/after measurement") + parser.add_argument("--baseline", required=True, help="immutable baseline CLI") + parser.add_argument("--candidate", required=True, help="immutable candidate CLI") + parser.add_argument("--bench-baseline", required=True, help="immutable baseline criterion executable") + parser.add_argument("--bench-candidate", required=True, help="immutable candidate criterion executable") + parser.add_argument("--profile", default="bench") + parser.add_argument("--samples", type=int, default=10) + parser.add_argument("--workdir", required=True) + parser.add_argument("--out", default="-", help="JSONL output path, - for stdout") + args = parser.parse_args() + + for path in (args.baseline, args.candidate, args.bench_baseline, args.bench_candidate): + if not (os.path.isfile(path) and os.access(path, os.X_OK)): + parser.error(f"not an executable file: {path}") + os.makedirs(args.workdir, exist_ok=True) + artifacts = { + "baseline": {"cli": args.baseline, "bench": args.bench_baseline}, + "candidate": {"cli": args.candidate, "bench": args.bench_candidate}, + } + identity = {} + for name, paths in artifacts.items(): + identity[name] = { + "cli_sha256": sha256_file(paths["cli"]), + "cli_bytes": os.path.getsize(paths["cli"]), + "bench_sha256": sha256_file(paths["bench"]), + } + git_version = run(["git", "--version"], check=True, text=True, capture_output=True).stdout.strip() + header = { + "record": "header", + "profile": args.profile, + "samples_per_workload": args.samples, + "host": { + "platform": sys.platform, + "git": git_version, + "python": sys.version.split()[0], + }, + "artifacts": identity, + } + + records = [header] + fixture_root = tempfile.mkdtemp(prefix="submod-perf-", dir=args.workdir) + + # --- Config datasets (1/10/100) shared by both bench executables --- + datasets = {} + for count in (1, 10, 100): + origins = [f"file:///local/origin-{count}-{i}" for i in range(count)] + toml_path = os.path.join(fixture_root, f"config-{count}.toml") + toml_sha = write_config_toml(toml_path, count, origins) + datasets[count] = {"toml": toml_path, "sha256": toml_sha} + + for workload in ("parse", "load", "add"): + for count, dataset in datasets.items(): + # Untimed validation on both artifacts first. + for name in ("baseline", "candidate"): + check = bench_config_sample( + artifacts[name]["bench"], workload, count, dataset["toml"] + ) + if check.get("verdict") != "pass" or check["process_exit"] != 0: + raise SystemExit( + f"validation failed: {name} {workload}@{count}: {check}" + ) + # Warm-up (labeled, discarded), then alternating timed samples. + bench_config_sample( + artifacts["candidate"]["bench"], workload, count, dataset["toml"] + ) + base, cand = alternating( + args.samples, + lambda: bench_config_sample( + artifacts["baseline"]["bench"], workload, count, dataset["toml"] + ), + lambda: bench_config_sample( + artifacts["candidate"]["bench"], workload, count, dataset["toml"] + ), + ) + for name, samples in (("baseline", base), ("candidate", cand)): + inner = [s["elapsed_ns"] for s in samples] + records.append({ + "record": "config", + "artifact": name, + "artifact_sha256": identity[name]["bench_sha256"], + "workload": workload, + "modules": count, + "dataset_sha256": dataset["sha256"], + "iterations_per_sample": ITERATIONS, + "verdict": "pass" + if all(s.get("verdict") == "pass" for s in samples) + else "FAIL", + "inner_timing_ns": summarize(inner), + "outer_wall_ns": summarize([s["wall_ns"] for s in samples]), + }) + + # --- CLI fixtures: absent-module inspection + materialized no-op sync --- + cli_env = isolate_git_env(fixture_root) + absent_repos = {} + for count in (1, 10, 100): + repo = os.path.join(fixture_root, f"absent-{count}") + os.makedirs(repo) + git(cli_env, repo, "init", "-b", "main", ".") + git(cli_env, repo, "commit", "--allow-empty", "-m", "init") + origins = [f"/nonexistent/origin-{count}-{i}" for i in range(count)] + toml = os.path.join(repo, "submod.toml") + # No sparse declaration and no defaults: this workload isolates + # missing-checkout inspection, not metadata/sparse drift. + toml_sha = write_config_toml(toml, count, origins, sparse=False, defaults=False) + absent_repos[count] = {"repo": repo, "toml_sha256": toml_sha} + + materialized = {} + for count in (1, 10): + repo, manifest = materialize_parent(fixture_root, f"mat-{count}", count) + materialized[count] = {"repo": repo, "manifest": manifest} + + cli_workloads = [] + for count, fix in absent_repos.items(): + cli_workloads.append({ + "name": "check-absent", "modules": count, "repo": fix["repo"], + "args": ["check", "--verbose"], "expect_exit": 1, + "dataset": fix["toml_sha256"], "must_state_hold": False, + }) + for count, fix in materialized.items(): + cli_workloads.append({ + "name": "check-materialized", "modules": count, "repo": fix["repo"], + "args": ["check", "--verbose"], "expect_exit": 0, + "dataset": fix["manifest"], "must_state_hold": False, + }) + cli_workloads.append({ + "name": "sync-noop", "modules": count, "repo": fix["repo"], + "args": ["sync"], "expect_exit": 0, + "dataset": fix["manifest"], "must_state_hold": True, + }) + + for workload in cli_workloads: + # Untimed validation on both artifacts. + for name in ("baseline", "candidate"): + code, _ns, _out, _err = time_cli( + artifacts[name]["cli"], cli_env, workload["repo"], workload["args"] + ) + if code != workload["expect_exit"]: + raise SystemExit( + f"validation failed: {name} {workload['name']}@{workload['modules']}: " + f"exit {code}, expected {workload['expect_exit']}" + ) + if workload["must_state_hold"]: + before = snapshot_state(cli_env, workload["repo"]) + # Warm-up, then alternating samples on pristine state. CLI check/sync + # are read-only/no-op here, so no restore is needed between samples; + # any unexpected mutation fails the state assertion below. + time_cli(artifacts["candidate"]["cli"], cli_env, workload["repo"], workload["args"]) + base, cand = alternating( + args.samples, + lambda: time_cli(artifacts["baseline"]["cli"], cli_env, workload["repo"], workload["args"]), + lambda: time_cli(artifacts["candidate"]["cli"], cli_env, workload["repo"], workload["args"]), + ) + verdict = "pass" + for samples in (base, cand): + for code, _ns, _out, _err in samples: + if code != workload["expect_exit"]: + verdict = "FAIL" + state_held = None + if workload["must_state_hold"]: + after = snapshot_state(cli_env, workload["repo"]) + state_held = before == after + if not state_held: + verdict = "FAIL" + for name, samples in (("baseline", base), ("candidate", cand)): + records.append({ + "record": "cli", + "artifact": name, + "artifact_sha256": identity[name]["cli_sha256"], + "workload": workload["name"], + "modules": workload["modules"], + "dataset": workload["dataset"], + "expected_exit": workload["expect_exit"], + "verdict": verdict, + "no_op_state_held": state_held, + "wall_ns": summarize([ns for _c, ns, _o, _e in samples]), + }) + # One instrumented sample per artifact (diagnostic, not timed). + for name in ("baseline", "candidate"): + sample = instrumented_cli_sample( + artifacts[name]["cli"], workload["repo"], workload["args"], fixture_root + ) + records.append({ + "record": "instrumented", + "artifact": name, + "artifact_sha256": identity[name]["cli_sha256"], + "workload": workload["name"], + "modules": workload["modules"], + **sample, + }) + + out = sys.stdout if args.out == "-" else open(args.out, "w") + with out: + for record in records: + out.write(json.dumps(record, sort_keys=True) + "\n") + print( + f"wrote {len(records)} records for " + f"{len(datasets) * 3} config + {len(cli_workloads)} CLI workloads", + file=sys.stderr, + ) + + +if __name__ == "__main__": + main() + diff --git a/scripts/run-tests.sh b/scripts/run-tests.sh index 7e92e0cd..605b9a52 100755 --- a/scripts/run-tests.sh +++ b/scripts/run-tests.sh @@ -49,6 +49,22 @@ if ! command -v git &>/dev/null; then exit 1 fi +# Versions before 0.9.55 cannot enforce nextest-version and versions before +# 0.9.48 ignore test groups. Refuse them explicitly so integration tests and +# performance ceilings are not run concurrently by accident. +if ! NEXTEST_VERSION_TEXT=$(cargo nextest --version 2>/dev/null); then + print_error "cargo-nextest 0.9.55 or newer is required" + exit 1 +fi +NEXTEST_VERSION=${NEXTEST_VERSION_TEXT#cargo-nextest } +NEXTEST_VERSION=${NEXTEST_VERSION%% *} +IFS=. read -r NEXTEST_MAJOR NEXTEST_MINOR NEXTEST_PATCH <<<"$NEXTEST_VERSION" +if [[ ! "$NEXTEST_MAJOR" =~ ^[0-9]+$ ]] || [[ ! "$NEXTEST_MINOR" =~ ^[0-9]+$ ]] || [[ ! "$NEXTEST_PATCH" =~ ^[0-9]+$ ]] || + ((NEXTEST_MAJOR == 0 && (NEXTEST_MINOR < 9 || (NEXTEST_MINOR == 9 && NEXTEST_PATCH < 55)))); then + print_error "cargo-nextest 0.9.55 or newer is required; found $NEXTEST_VERSION" + exit 1 +fi + # Parse command line arguments VERBOSE=false PERFORMANCE=false @@ -105,15 +121,20 @@ print_success "Build completed successfully" NEXTEST_ARGS=( nextest --manifest-path ./Cargo.toml run --all-features + --cargo-profile "$PROFILE" --no-fail-fast ) # Build the filterset expression FILTERSET="" -# Exclude performance tests unless explicitly requested +# Exclude performance tests unless explicitly requested. With --performance and +# no filter, run only the performance binary: ceilings are controlled +# single-workload measurements, not whole-suite runs. if [[ "$PERFORMANCE" != true ]]; then - FILTERSET="not binary_id(submod::performance_tests)" + FILTERSET="not binary(performance_tests)" +elif [[ -z "$FILTER" ]]; then + FILTERSET="binary(performance_tests)" fi # Apply filter if provided diff --git a/src/commands.rs b/src/commands.rs index 6660afb6..c6700347 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -50,15 +50,15 @@ Use the `--config` option to specify a custom config file location. See the [README.md](../README.md) for full usage and configuration details. "#] -use crate::shells::Shell; -use clap::{Parser, Subcommand}; +use clap::{CommandFactory, Parser, Subcommand, ValueEnum, error::ErrorKind}; use crate::long_abouts::COMPLETE_ME; -use crate::options::{ +use std::{ffi::OsString, path::PathBuf}; +use submod::options::{ SerializableFetchRecurse as FetchRecurse, SerializableIgnore as Ignore, SerializableUpdate as Update, }; -use std::{ffi::OsString, path::PathBuf}; +use submod::shells::Shell; /// Top-level CLI parser for the `submod` tool. /// @@ -71,14 +71,44 @@ pub struct Cli { pub command: Commands, /// Path to the configuration file (default: submod.toml). - #[arg(long = "config", global = true, default_value = "submod.toml", value_parser = clap::value_parser!(PathBuf), value_hint = clap::ValueHint::FilePath, help = "Optionally provide a different configuration file path. Defaults to submod.toml in the current directory.")] + #[arg(long = "config", global = true, default_value = "submod.toml", value_parser = clap::value_parser!(PathBuf), value_hint = clap::ValueHint::FilePath, help = "Use this configuration file. Without --config, submod discovers the repository root and uses its submod.toml.")] pub config: PathBuf, + /// Preview a mutating command after full local validation, without locks, writes, staging, or remote access. + #[arg(long, global = true, action = clap::ArgAction::SetTrue)] + pub dry_run: bool, + /// Enable verbose output with detailed status information. #[arg(long, short, global = true)] pub verbose: bool, } +/// Optional settings that can be removed to restore inherited behavior. +#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)] +pub enum UnsetSetting { + Branch, + Ignore, + Fetch, + Update, + Shallow, + Active, + UseGitDefaultSparseCheckout, +} + +impl UnsetSetting { + pub const fn as_str(self) -> &'static str { + match self { + Self::Branch => "branch", + Self::Ignore => "ignore", + Self::Fetch => "fetch", + Self::Update => "update", + Self::Shallow => "shallow", + Self::Active => "active", + Self::UseGitDefaultSparseCheckout => "use-git-default-sparse-checkout", + } + } +} + /// Supported commands for the `submod` tool. #[derive(Subcommand, Debug)] pub enum Commands { @@ -149,11 +179,11 @@ pub enum Commands { #[arg(long = "no-init", default_value = "false", action = clap::ArgAction::SetTrue, default_missing_value = "true", help = "If given, we'll add the submodule to your submod.toml but not initialize it.")] no_init: bool, }, - // TODO: Implement this command #[command( name = "change", + group(clap::ArgGroup::new("settings").required(true).multiple(true).args(["path", "branch", "sparse_paths", "clear_sparse_paths", "use_git_default_sparse_checkout", "ignore", "fetch", "update", "shallow", "url", "active", "unset"])), next_help_heading = "Change a Submodule's Settings", - about = "Change the configuration of an existing submodule. Any field you provide will overwrite an existing value (unless both are defaults). If you change the path, it will nuke-it-from-orbit (delete it and re-clone it)." + about = "Change the configuration of an existing submodule. Only provided fields are changed. A path change uses a safe Git-aware move for clean initialized modules; dirty, conflicted, or unsupported moves are refused without mutation." )] Change { #[arg(required = true, value_parser = clap::value_parser!(String), value_hint = clap::ValueHint::CommandName, help = "The name of the submodule to change. Must match an existing submodule.", long_help = "The name of the submodule to change. Must match an existing submodule in your submod.toml. Because we use this value to lookup your config, you cannot change the name from the CLI. You must manually change it in your submod.toml. All other options can be changed here.")] @@ -169,12 +199,23 @@ pub enum Commands { )] branch: Option, - #[arg(short = 'x', long = "sparse-paths", value_delimiter = ',', value_parser = clap::value_parser!(OsString), help = "Replace the sparse checkout paths (comma-separated), or add if not set. Use `--append` to append to existing sparse paths.", default_missing_value = "none")] + #[arg(short = 'x', long = "sparse-paths", value_delimiter = ',', value_parser = clap::value_parser!(OsString), help = "Replace the sparse checkout paths (comma-separated), or add if not set. Use `--append` to append to existing sparse paths.")] sparse_paths: Option>, #[arg(requires("sparse_paths"), short = 'a', long = "append", value_parser = clap::value_parser!(bool), default_value = "false", default_missing_value = "true", help = "If given, appends the new sparse paths to the existing ones.")] append: bool, + #[arg(long, conflicts_with_all = ["sparse_paths", "append"], help = "Clear all sparse checkout paths.")] + clear_sparse_paths: bool, + + #[arg( + long, + value_enum, + value_delimiter = ',', + help = "Remove optional overrides to restore inherited behavior. May be repeated." + )] + unset: Vec, + #[arg( long = "use-git-default-sparse-checkout", num_args = 0..=1, @@ -208,11 +249,11 @@ pub enum Commands { #[arg( short = 's', long = "shallow", - default_value = "false", + num_args = 0..=1, default_missing_value = "true", help = "If true, sets the submodule as a shallow clone. Set false to disable shallow cloning." )] - shallow: bool, + shallow: Option, #[arg(short = 'U', long = "url", value_parser = clap::value_parser!(String), help = "Change the URL of the submodule. The submodule name from the url must match an existing submodule.")] url: Option, @@ -220,26 +261,40 @@ pub enum Commands { #[arg(long = "active", num_args = 0..=1, value_parser = clap::value_parser!(bool), default_missing_value = "true", help = "Set to true/false to enable or disable the submodule. Omit to leave unchanged. For a quick disable, use `submod disable ` instead.")] active: Option, }, - #[command(name = "change-global", visible_aliases = ["cg", "chgl", "global"], next_help_heading = "Change Global Settings", about = "Add or change the global settings for submodules, affecting all submodules in the current repository. Any individual submodule settings will override these global settings.")] + #[command(name = "change-global", visible_aliases = ["cg", "chgl", "global"], next_help_heading = "Change Global Settings", about = "Patch inherited defaults. Explicit per-submodule settings take precedence.")] + #[command(group(clap::ArgGroup::new("settings").required(true).multiple(true).args(["branch", "ignore", "fetch", "update", "use_git_default_sparse_checkout", "unset"])))] ChangeGlobal { + #[arg( + long, + value_enum, + value_delimiter = ',', + help = "Remove a global default to restore the built-in behavior." + )] + unset: Vec, + #[arg( + short = 'b', + long = "branch", + help = "Set the inherited tracking branch. Use --unset branch to restore each remote's default branch." + )] + branch: Option, #[arg( short = 'i', long = "ignore", - help = "Sets the default ignore behavior for all submodules in this repository. This will override any individual submodule settings." + help = "Set the inherited ignore behavior. An explicit per-submodule value takes precedence." )] ignore: Option, #[arg( short = 'f', long = "fetch", - help = "Sets the default fetch behavior for all submodules in this repository. This will override any individual submodule settings." + help = "Set the inherited fetch behavior. An explicit per-submodule value takes precedence." )] fetch: Option, #[arg( short = 'u', long = "update", - help = "Sets the default update behavior for all submodules in this repository. This will override any individual submodule settings." + help = "Set the inherited update behavior. An explicit per-submodule value takes precedence." )] update: Option, @@ -274,9 +329,12 @@ pub enum Commands { next_help_heading = "Initialize Submodules", about = "Initializes missing submodules based on the configuration file." )] - Init, + Init { + /// Also initialize nested submodules selected by each managed submodule. + #[arg(short = 'r', long = "recursive", default_value = "false", action = clap::ArgAction::SetTrue, default_missing_value = "true")] + recursive: bool, + }, - // TODO: Implement this command (use git2 + fs to delete files) #[command( name = "delete", visible_alias = "del", @@ -287,6 +345,10 @@ pub enum Commands { /// Name of the submodule to delete. #[arg(help = "Name of the submodule to delete.")] name: String, + + /// Discard tracked, untracked, and ignored content inside the verified checkout. + #[arg(long, action = clap::ArgAction::SetTrue, help = "Discard local content inside the verified submodule checkout. Never removes an unrelated path or repository.")] + force: bool, }, #[command( @@ -307,13 +369,21 @@ pub enum Commands { next_help_heading = "Update Submodules", about = "Updates all submodules to their configured state." )] - Update, + Update { + /// Advance each submodule to its configured remote-tracking branch. + #[arg(long = "remote", default_value = "false", action = clap::ArgAction::SetTrue, default_missing_value = "true")] + remote: bool, + + /// Also update nested submodules selected by each managed submodule. + #[arg(short = 'r', long = "recursive", default_value = "false", action = clap::ArgAction::SetTrue, default_missing_value = "true")] + recursive: bool, + }, #[command( name = "reset", visible_alias = "r", next_help_heading = "Reset Submodules", - about = "Hard resets submodules, stashing changes, resetting to the configured state, and cleaning untracked files." + about = "Preserve local changes in a named stash, then reset selected submodules to their parent gitlinks." )] Reset { #[arg(short = 'a', long = "all", default_value = "false", action = clap::ArgAction::SetTrue, default_missing_value = "true", help = "If given, resets all submodules. If not given, you must specify specific submodules to reset.")] @@ -321,6 +391,7 @@ pub enum Commands { #[arg( required_unless_present = "all", + conflicts_with = "all", value_delimiter = ',', help = "Names of specific submodules to reset. If `--all` is not given, you must specify at least one submodule name." )] @@ -333,7 +404,11 @@ pub enum Commands { next_help_heading = "Sync Submodules", about = "Runs a full sync: check, init, update. Ensures all submodules are in sync with the configuration." )] - Sync, + Sync { + /// Also initialize and update nested submodules selected by each managed submodule. + #[arg(short = 'r', long = "recursive", default_value = "false", action = clap::ArgAction::SetTrue, default_missing_value = "true")] + recursive: bool, + }, #[command(name = "generate-config", visible_aliases = ["gc", "genconf"], next_help_heading = "Generate a Config File", about = "Generates a new configuration file.")] GenerateConfig { @@ -344,25 +419,26 @@ pub enum Commands { #[arg( short = 's', long = "from-setup", - num_args = 0, - default_missing_value = "true", + action = clap::ArgAction::SetTrue, + conflicts_with = "template", help = "Generates the config from your current repository's submodule settings." )] - from_setup: Option, + from_setup: bool, #[arg(short = 'f', long = "force", default_value = "false", action = clap::ArgAction::SetTrue, default_missing_value = "true", help = "If given, overwrites the existing configuration file without prompting.")] force: bool, - #[arg(short = 't', long = "template", help = "Generates a template configuration file with default values.", default_value = "false", action = clap::ArgAction::SetTrue, default_missing_value = "true")] + #[arg(short = 't', long = "template", conflicts_with = "from_setup", help = "Generates a template configuration file with placeholder URLs.", default_value = "false", action = clap::ArgAction::SetTrue, default_missing_value = "true")] template: bool, }, - #[command(name = "nuke-it-from-orbit", visible_aliases = ["nuke-em", "nuke-it", "nuke-them"], next_help_heading = "Nuke It From Orbit", about = "Deletes all submodules or specific ones, removing them from the configuration and the filesystem. Optionally leaves them dead. 🚀💥👾💥💀.")] + #[command(name = "nuke-it-from-orbit", visible_aliases = ["nu", "nuke-em", "nuke-it", "nuke-them"], next_help_heading = "Nuke It From Orbit", about = "Repair selected submodules by rebuilding their checkouts, or remove them with --kill.")] NukeItFromOrbit { #[arg(long = "all", default_value = "false", action = clap::ArgAction::SetTrue, default_missing_value = "true", help = "Nuke 'em all? 🤓")] all: bool, #[arg( required_unless_present = "all", + conflicts_with = "all", value_delimiter = ',', help = "... or only specific ones? 😔 (comma-separated list of names" )] @@ -370,6 +446,9 @@ pub enum Commands { #[arg(short = 'k', long = "kill", default_value = "false", action = clap::ArgAction::SetTrue, default_missing_value = "true", help = "If given, DOES NOT reinitialize the submodules and DOES NOT add them back to the config. They will be truly dead. 💀")] kill: bool, + + #[arg(long, action = clap::ArgAction::SetTrue, help = "Discard local content inside each verified submodule checkout. Never removes unrelated paths or repositories.")] + force: bool, }, // Shell completions are implemented using clap_complete/clap_complete_nushell @@ -379,3 +458,210 @@ pub enum Commands { shell: Shell, }, } + +impl Cli { + /// Validate combinations whose legality depends on argument values. + pub fn validate(&self) -> Result<(), clap::Error> { + if self.dry_run + && matches!( + self.command, + Commands::Check | Commands::List { .. } | Commands::CompleteMe { .. } + ) + { + return Err(Self::command().error( + ErrorKind::ArgumentConflict, + "--dry-run is only valid for mutating commands", + )); + } + let (unset, supplied): (&[UnsetSetting], Vec<(UnsetSetting, bool)>) = match &self.command { + Commands::Change { + branch, + ignore, + fetch, + update, + shallow, + active, + use_git_default_sparse_checkout, + unset, + .. + } => ( + unset, + vec![ + (UnsetSetting::Branch, branch.is_some()), + (UnsetSetting::Ignore, ignore.is_some()), + (UnsetSetting::Fetch, fetch.is_some()), + (UnsetSetting::Update, update.is_some()), + (UnsetSetting::Shallow, shallow.is_some()), + (UnsetSetting::Active, active.is_some()), + ( + UnsetSetting::UseGitDefaultSparseCheckout, + use_git_default_sparse_checkout.is_some(), + ), + ], + ), + Commands::ChangeGlobal { + branch, + ignore, + fetch, + update, + use_git_default_sparse_checkout, + unset, + } => { + if !unset.iter().all(|setting| { + matches!( + setting, + UnsetSetting::Branch + | UnsetSetting::Ignore + | UnsetSetting::Fetch + | UnsetSetting::Update + | UnsetSetting::UseGitDefaultSparseCheckout + ) + }) { + return Err(Self::command().error( + ErrorKind::InvalidValue, + "Unsupported global setting in --unset", + )); + } + ( + unset, + vec![ + (UnsetSetting::Branch, branch.is_some()), + (UnsetSetting::Ignore, ignore.is_some()), + (UnsetSetting::Fetch, fetch.is_some()), + (UnsetSetting::Update, update.is_some()), + ( + UnsetSetting::UseGitDefaultSparseCheckout, + use_git_default_sparse_checkout.is_some(), + ), + ], + ) + } + Commands::Reset { names, .. } + | Commands::NukeItFromOrbit { + names: Some(names), .. + } => { + let mut seen = std::collections::HashSet::new(); + for name in names { + if name.trim().is_empty() { + return Err(Self::command() + .error(ErrorKind::InvalidValue, "Submodule target cannot be empty")); + } + if !seen.insert(name) { + return Err(Self::command().error( + ErrorKind::ArgumentConflict, + format!("Duplicate submodule target: {name}"), + )); + } + } + return Ok(()); + } + _ => return Ok(()), + }; + for (index, setting) in unset.iter().enumerate() { + if unset[..index].contains(setting) { + return Err(Self::command().error( + ErrorKind::ArgumentConflict, + format!("Duplicate --unset setting: {}", setting.as_str()), + )); + } + if supplied + .iter() + .any(|(field, present)| field == setting && *present) + { + return Err(Self::command().error( + ErrorKind::ArgumentConflict, + format!("Cannot set and unset {} together", setting.as_str()), + )); + } + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn change_contract() { + for args in [ + vec!["submod", "change", "module"], + vec!["submod", "cg"], + vec!["submod", "change", "module", "--append"], + vec![ + "submod", + "change", + "module", + "--clear-sparse-paths", + "--sparse-paths", + "src", + ], + vec![ + "submod", + "change", + "module", + "--clear-sparse-paths", + "--append", + "--sparse-paths", + "src", + ], + vec!["submod", "reset", "--all", "module"], + vec!["submod", "nuke-it", "--all", "module"], + ] { + assert!(Cli::try_parse_from(&args).is_err(), "{args:?}"); + } + for args in [ + vec![ + "submod", + "change", + "module", + "--shallow=false", + "--unset", + "shallow", + ], + vec!["submod", "reset", "module,module"], + vec!["submod", "nuke-it", "module", "module"], + vec!["submod", "change", "module", "--unset", "ignore,ignore"], + ] { + assert!( + Cli::try_parse_from(&args).unwrap().validate().is_err(), + "{args:?}" + ); + } + for (flags, expected) in [ + (vec!["--branch", "main"], None), + (vec!["--shallow"], Some(true)), + (vec!["--shallow=false"], Some(false)), + ] { + let cli = + Cli::try_parse_from([vec!["submod", "change", "module"], flags].concat()).unwrap(); + cli.validate().unwrap(); + let Commands::Change { shallow, .. } = cli.command else { + panic!("wrong command") + }; + assert_eq!(shallow, expected); + } + for args in [ + vec!["submod", "change", "module", "--clear-sparse-paths"], + vec!["submod", "change", "module", "--unset", "shallow,branch"], + vec!["submod", "cg", "--unset", "branch"], + vec!["submod", "cg", "--use-git-default-sparse-checkout=false"], + vec![ + "submod", + "global", + "--unset", + "use-git-default-sparse-checkout", + ], + vec![ + "submod", + "change", + "module", + "--append", + "--sparse-paths", + "src", + ], + ] { + Cli::try_parse_from(&args).unwrap().validate().unwrap(); + } + } +} diff --git a/src/config.rs b/src/config.rs index 3ef26d60..1eacef28 100644 --- a/src/config.rs +++ b/src/config.rs @@ -20,13 +20,11 @@ Features: - Manage submodule entries and defaults programmatically. "] -use crate::git_ops::GitOperations; use crate::options::SerializableBranch; use crate::options::{ - ConfigLevel, GitmodulesConvert, SerializableFetchRecurse, SerializableIgnore, - SerializableUpdate, + GitmodulesConvert, SerializableFetchRecurse, SerializableIgnore, SerializableUpdate, }; -use anyhow::Result; +use anyhow::{Context, Result}; use serde::de::Deserializer; use serde::ser::SerializeMap; use serde::{Deserialize, Serialize, Serializer}; @@ -34,8 +32,7 @@ use std::path::PathBuf; use std::{collections::HashMap, path::Path}; // TODO: Implement figment::Profile for modular configs use figment::{ - Figment, Metadata, Provider, Result as FigmentResult, - providers::{Format, Toml}, + Metadata, Provider, Result as FigmentResult, value::{Dict, Map, Value}, }; @@ -86,7 +83,7 @@ impl Default for SubmoduleGitOptions { Self { ignore: Some(SerializableIgnore::default()), fetch_recurse: Some(SerializableFetchRecurse::default()), - branch: Some(SerializableBranch::default()), + branch: None, update: Some(SerializableUpdate::default()), } } @@ -156,7 +153,7 @@ impl TryFrom for Git2SubmoduleOptions { })?, None => git2::SubmoduleUpdate::Default, }; - let branch = options.branch.map(|b| b.to_string()); + let branch = options.branch.map(|b| b.to_gitmodules()); let fetch_recurse = options.fetch_recurse.map(|fr| fr.to_gitmodules()); Ok(Self::new(ignore, update, branch, fetch_recurse)) } @@ -167,6 +164,9 @@ impl TryFrom for Git2SubmoduleOptions { /// And overridden by submodule-specific configurations #[derive(Debug, Default, Clone, Ord, PartialOrd, Eq, PartialEq, Hash, Serialize, Deserialize)] pub struct SubmoduleDefaults { + /// Branch inherited by modules without an explicit branch declaration. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub branch: Option, /// [`Ignore`][SerializableIgnore] setting for submodules pub ignore: Option, /// [`Update`][SerializableUpdate] setting for submodules @@ -201,6 +201,9 @@ impl SubmoduleDefaults { #[must_use] pub fn merge_from(&self, other: Self) -> Self { let mut mut_self = self.clone(); + if other.branch.is_some() { + mut_self.branch = other.branch; + } if other.ignore.is_some() { mut_self.ignore = other.ignore; } @@ -217,6 +220,7 @@ impl SubmoduleDefaults { let ignore = mut_self.ignore; let update = mut_self.update; Self { + branch: mut_self.branch, ignore: ignore.or_else(|| Some(SerializableIgnore::default())), fetch_recurse: mut_self .fetch_recurse @@ -308,6 +312,8 @@ pub struct SubmoduleUpdateOptions { pub recursive: bool, /// Whether to force the update pub force: bool, + /// Whether to advance to the configured remote tracking target. + pub remote: bool, } #[allow(dead_code)] @@ -319,6 +325,7 @@ impl SubmoduleUpdateOptions { strategy, recursive, force, + remote: false, } } @@ -329,6 +336,7 @@ impl SubmoduleUpdateOptions { strategy: self.strategy.clone(), recursive: self.recursive, force: true, // Set force to true + remote: self.remote, } } @@ -337,11 +345,9 @@ impl SubmoduleUpdateOptions { pub fn from_options(options: SubmoduleGitOptions) -> Self { Self { strategy: options.update.unwrap_or_default(), - recursive: matches!( - options.fetch_recurse, - Some(SerializableFetchRecurse::Always) - ), + recursive: false, force: false, // Default to not force + remote: false, } } } @@ -539,7 +545,7 @@ impl SubmoduleEntry { .cloned() .map_or_else(|| Some(name.to_string()), Some); let branch = - SerializableBranch::from_gitmodules(entries.get("branch").map_or("", |b| b.as_str())) + SerializableBranch::from_git_branch(entries.get("branch").map_or("", |b| b.as_str())) .ok(); let ignore = entries .get("ignore") @@ -704,26 +710,15 @@ impl From for SubmoduleEntry { #[derive(Debug, Clone, PartialEq, Eq)] pub struct SubmoduleEntries { submodules: Option>, - sparse_checkouts: Option>>, } impl<'de> Deserialize<'de> for SubmoduleEntries { /// Deserialize from the flat TOML format where each top-level key is a submodule name. - /// Accepts a map where each key maps to a [`SubmoduleEntry`], building both the - /// `submodules` map and the `sparse_checkouts` map from each entry's `sparse_paths`. + /// Each entry owns its sparse patterns. fn deserialize>(deserializer: D) -> Result { let map: HashMap = HashMap::deserialize(deserializer)?; - let mut sparse_checkouts: HashMap> = HashMap::new(); - for (name, entry) in &map { - if let Some(paths) = &entry.sparse_paths - && !paths.is_empty() - { - sparse_checkouts.insert(name.clone(), paths.clone()); - } - } Ok(Self { submodules: Some(map), - sparse_checkouts: Some(sparse_checkouts), }) } } @@ -748,7 +743,6 @@ impl Default for SubmoduleEntries { fn default() -> Self { Self { submodules: Some(HashMap::new()), - sparse_checkouts: Some(HashMap::new()), } } } @@ -761,27 +755,26 @@ impl SubmoduleEntries { submodules: Option>, sparse_checkouts: Option>>, ) -> Self { - Self { - submodules: submodules.or_else(|| Some(HashMap::new())), - sparse_checkouts: sparse_checkouts.or_else(|| Some(HashMap::new())), + let mut entries = Self { + submodules: Some(submodules.unwrap_or_default()), + }; + for (name, paths) in sparse_checkouts.unwrap_or_default() { + entries.add_checkout(name, &paths, true); } + entries } /// Add a submodule entry #[must_use] pub fn add_submodule(mut self, name: SubmoduleName, entry: SubmoduleEntry) -> Self { - let submodules = self.submodules.get_or_insert_with(HashMap::new); - submodules.insert(name, entry); + self.update_entry(name, entry); self } /// Remove a submodule entry #[must_use] - pub fn remove_submodule(&mut self, name: &str) -> Self { - if let Some(submodules) = &mut self.submodules { - submodules.remove(name); - } - self.clone() + pub fn remove_submodule(&mut self, name: &str) -> Option { + self.submodules.as_mut()?.remove(name) } /// Returns a list of all submodule names, or `None` if no submodules are configured. @@ -800,58 +793,42 @@ impl SubmoduleEntries { /// Get the sparse checkouts map #[must_use] - pub const fn sparse_checkouts(&self) -> Option<&HashMap>> { - self.sparse_checkouts.as_ref() + pub fn sparse_checkouts(&self) -> Option>> { + Some( + self.sparse_iter() + .map(|(name, paths)| (name.clone(), paths.clone())) + .collect(), + ) } - /// Add a sparse checkout + /// Add or replace patterns on the authoritative entry. pub fn add_checkout(&mut self, name: SubmoduleName, checkout: &[String], replace: bool) { - if let Some(sparse_checkouts) = &mut self.sparse_checkouts { - if let Some(existing_checkout) = sparse_checkouts.get(&name) { - if replace { - // Replace the existing checkout with the new one - sparse_checkouts.insert(name, checkout.to_vec()); - } else { - // Append to the existing checkout - let mut new_checkout = existing_checkout.clone(); - new_checkout.extend_from_slice(checkout); - sparse_checkouts.insert(name, new_checkout); - } - } else { - // No existing checkout, just insert the new one - sparse_checkouts.insert(name, checkout.to_vec()); + if let Some(entry) = self.submodules.as_mut().and_then(|m| m.get_mut(&name)) { + let paths = entry.sparse_paths.get_or_insert_with(Vec::new); + if replace { + paths.clear(); } - } else { - self.sparse_checkouts = Some(HashMap::from([(name, checkout.to_vec())])); + paths.extend_from_slice(checkout); } } - /// Remove a sparse checkout by name + /// Remove all sparse patterns. pub fn delete_checkout(&mut self, name: &str) { - if let Some(sparse_checkouts) = &mut self.sparse_checkouts { - sparse_checkouts.remove(name); - } + self.set_sparse_paths_for(name, Vec::new()); } - /// Remove a sparse checkout path + /// Remove a sparse pattern. pub fn remove_sparse_path(&mut self, name: &str, path: &str) { - if let Some(sparse_checkouts) = &mut self.sparse_checkouts - && let Some(paths) = sparse_checkouts.get_mut(name) - { - paths.retain(|p| p != path); - if paths.is_empty() { - sparse_checkouts.remove(name); // Remove the entry if no paths left + if let Some(entry) = self.submodules.as_mut().and_then(|m| m.get_mut(name)) { + if let Some(paths) = &mut entry.sparse_paths { + paths.retain(|p| p != path); } } } - /// Add a sparse path + /// Append a sparse pattern. pub fn add_sparse_path(&mut self, name: SubmoduleName, path: String) { - if let Some(sparse_checkouts) = &mut self.sparse_checkouts { - sparse_checkouts.entry(name).or_default().push(path); - } else { - self.sparse_checkouts = Some(HashMap::from([(name, vec![path])])); - } + self.add_checkout(name, &[path], false); } /// Get a submodule entry by name @@ -875,21 +852,19 @@ impl SubmoduleEntries { /// Get an iterator over all sparse checkouts pub fn sparse_iter(&self) -> impl Iterator)> { - self.sparse_checkouts - .as_ref() - .into_iter() - .flat_map(|s| s.iter()) + self.submodule_iter().filter_map(|(name, entry)| { + entry + .sparse_paths + .as_ref() + .filter(|p| !p.is_empty()) + .map(|paths| (name, paths)) + }) } /// Get an iterator that returns a tuple of submodule and sparse checkout - pub fn iter(&self) -> impl Iterator))> { + pub fn iter(&self) -> impl Iterator { self.submodule_iter().map(move |(name, entry)| { - let sparse = self - .sparse_checkouts - .as_ref() - .and_then(|s| s.get(name)) - .cloned() - .unwrap_or_else(Vec::new); + let sparse = entry.sparse_paths.as_deref().unwrap_or_default(); (name, (entry, sparse)) }) } @@ -906,44 +881,19 @@ impl SubmoduleEntries { } Self { submodules: Some(submodules), - sparse_checkouts: Some(HashMap::new()), } } /// Insert or replace a submodule entry by name. pub fn update_entry(&mut self, name: SubmoduleName, entry: SubmoduleEntry) { - // Ensure the submodules map exists and update/insert the entry. - let submodules = self.submodules.get_or_insert_with(HashMap::new); - - // Keep sparse_checkouts in sync with the entry's sparse paths. - match &entry.sparse_paths { - Some(paths) if !paths.is_empty() => { - let sparse_map = self.sparse_checkouts.get_or_insert_with(HashMap::new); - sparse_map.insert(name.clone(), paths.clone()); - } - _ => { - if let Some(sparse_map) = self.sparse_checkouts.as_mut() { - sparse_map.remove(&name); - } - } - } - submodules.insert(name, entry); + self.submodules + .get_or_insert_with(HashMap::new) + .insert(name, entry); } - /// Set sparse paths for an existing submodule entry in-place, keeping `sparse_checkouts` in sync. - /// - /// Does nothing if no submodule with `name` exists. + /// Set sparse paths for an existing entry. pub fn set_sparse_paths_for(&mut self, name: &str, paths: Vec) { if let Some(entry) = self.submodules.as_mut().and_then(|m| m.get_mut(name)) { - if paths.is_empty() { - entry.sparse_paths = None; - if let Some(sparse_map) = self.sparse_checkouts.as_mut() { - sparse_map.remove(name); - } - } else { - entry.sparse_paths = Some(paths.clone()); - let sparse_map = self.sparse_checkouts.get_or_insert_with(HashMap::new); - sparse_map.insert(name.to_string(), paths); - } + entry.sparse_paths = if paths.is_empty() { None } else { Some(paths) }; } } } @@ -958,7 +908,7 @@ impl IntoIterator for SubmoduleEntries { } /// Main configuration structure for the submod tool -#[derive(Debug, Default, Clone, Serialize, Deserialize)] +#[derive(Debug, Default, Clone, Serialize)] pub struct Config { /// Global default settings that apply to all submodules #[serde(default)] @@ -968,8 +918,227 @@ pub struct Config { pub submodules: SubmoduleEntries, } +impl<'de> Deserialize<'de> for Config { + fn deserialize>(deserializer: D) -> Result { + let table = toml::Table::deserialize(deserializer)?; + Self::from_table(table, None) + .map_err(|error| serde::de::Error::custom(format!("{error:#}"))) + } +} + #[allow(dead_code)] impl Config { + /// Parse and validate raw declarations, without resolving inherited fields. + pub fn parse(source: &str) -> Result { + let document = toml_edit::ImDocument::parse(source)?; + Self::from_table(toml::from_str(source)?, Some(&document)) + } + + fn from_table( + mut table: toml::Table, + document: Option<&toml_edit::ImDocument<&str>>, + ) -> Result { + let location = |section: &str, field: &str| { + let label = match (section.is_empty(), field.is_empty()) { + (true, _) => field.to_string(), + (_, true) => format!("[{section}]"), + _ => format!("[{section}].{field}"), + }; + let Some(document) = document else { + return label; + }; + let section_item = if section.is_empty() { + Some(document.as_item()) + } else { + document.get(section) + }; + let span = section_item.and_then(|item| { + item.as_table_like() + .and_then(|table| { + table + .get_key_value(field) + .or_else(|| { + (field == "fetchRecurse") + .then(|| { + table + .get_key_value("fetch") + .or_else(|| table.get_key_value("fetch_recurse")) + }) + .flatten() + }) + .and_then(|(key, value)| key.span().or_else(|| value.span())) + }) + .or_else(|| item.span()) + }); + span.map_or_else( + || label.clone(), + |span| { + let line = document.raw()[..span.start] + .bytes() + .filter(|byte| *byte == b'\n') + .count() + + 1; + format!("{label} (line {line})") + }, + ) + }; + if let Some(version) = table.remove("schema_version") { + anyhow::ensure!( + matches!(version.as_str(), Some("1.0.0" | "1.1.0")), + "{}: unsupported version {version}; expected 1.0.0 or 1.1.0", + location("", "schema_version") + ); + } + let mut defaults = SubmoduleDefaults::default(); + let mut entries = SubmoduleEntries::default(); + for (name, value) in table { + let mut fields = value + .as_table() + .cloned() + .with_context(|| format!("{}: expected a table", location(&name, "")))?; + let global = name == "defaults"; + for key in fields.keys() { + let common = matches!( + key.as_str(), + "branch" + | "ignore" + | "update" + | "fetchRecurse" + | "fetch" + | "fetch_recurse" + | "use_git_default_sparse_checkout" + ); + anyhow::ensure!( + common + || (!global + && matches!( + key.as_str(), + "url" | "path" | "branch" | "active" | "shallow" | "sparse_paths" + )), + "{}: unknown field", + location(&name, key) + ); + } + let spellings: Vec<_> = ["fetchRecurse", "fetch", "fetch_recurse"] + .into_iter() + .filter(|key| fields.contains_key(*key)) + .collect(); + anyhow::ensure!( + spellings.len() <= 1, + "{}: conflicting aliases {spellings:?}", + location(&name, "fetchRecurse") + ); + if let Some(alias) = spellings.first().filter(|alias| **alias != "fetchRecurse") { + let value = fields.remove(*alias).expect("present alias"); + fields.insert("fetchRecurse".into(), value); + eprintln!( + "warning: [{}].{alias} is legacy; use fetchRecurse", + crate::utilities::safe_human_text(&name) + ); + } + if matches!( + fields.get("fetchRecurse").and_then(toml::Value::as_str), + Some("true" | "false") + ) { + eprintln!( + "warning: [{}].fetchRecurse uses a legacy value; use always/never", + crate::utilities::safe_human_text(&name) + ); + } + if fields.get("branch").and_then(toml::Value::as_str) == Some("HEAD") { + eprintln!( + "warning: [{}].branch=HEAD is legacy remote-default tracking and explicitly overrides any global branch", + crate::utilities::safe_human_text(&name) + ); + } + for (key, value) in &fields { + let valid = match key.as_str() { + "active" | "shallow" | "use_git_default_sparse_checkout" => value.is_bool(), + "url" | "path" | "branch" => value.is_str(), + "sparse_paths" => value + .as_array() + .is_some_and(|paths| paths.iter().all(toml::Value::is_str)), + "ignore" => value.clone().try_into::().is_ok(), + "update" => value.clone().try_into::().is_ok(), + "fetchRecurse" => value.clone().try_into::().is_ok(), + _ => true, + }; + anyhow::ensure!( + valid, + "{}: invalid value or type {value}", + location(&name, key) + ); + if key == "branch" { + anyhow::ensure!( + value.clone().try_into::().is_ok(), + "{}: invalid branch {value}", + location(&name, "branch") + ); + } + } + if global { + defaults = toml::Value::Table(fields) + .try_into() + .with_context(|| location("defaults", ""))?; + } else { + let entry: SubmoduleEntry = toml::Value::Table(fields) + .try_into() + .with_context(|| location(&name, ""))?; + anyhow::ensure!( + entry.url.as_deref().is_some_and( + |url| !url.trim().is_empty() && !url.contains(['\0', '\n', '\r']) + ), + "{}: required nonempty URL without NUL/newlines", + location(&name, "url") + ); + let path = entry.path.as_deref().unwrap_or(&name); + anyhow::ensure!( + !path.trim().is_empty() && !path.contains(['\0', '\n', '\r']), + "{}: invalid path characters", + location(&name, "path") + ); + crate::utilities::normalize_submodule_path(Path::new(path)) + .with_context(|| location(&name, "path"))?; + if let Some(patterns) = &entry.sparse_paths { + anyhow::ensure!( + patterns.iter().all(|p| !p.contains(['\0', '\n', '\r'])), + "{}: patterns cannot contain NUL or newlines", + location(&name, "sparse_paths") + ); + } + entries.update_entry(name, entry); + } + } + Ok(Self::new(defaults, entries)) + } + + /// Resolve one entry without changing its raw declaration. + #[must_use] + pub fn effective_entry(&self, name: &str) -> Option { + let mut entry = self.submodules.get(name)?.clone(); + entry.path.get_or_insert_with(|| name.to_string()); + entry.branch = entry.branch.or_else(|| self.defaults.branch.clone()); + entry.ignore = entry + .ignore + .or(self.defaults.ignore) + .or(Some(SerializableIgnore::default())); + entry.update = entry + .update + .or_else(|| self.defaults.update.clone()) + .or(Some(SerializableUpdate::default())); + entry.fetch_recurse = entry + .fetch_recurse + .or(self.defaults.fetch_recurse) + .or(Some(SerializableFetchRecurse::default())); + entry.use_git_default_sparse_checkout = entry + .use_git_default_sparse_checkout + .or(self.defaults.use_git_default_sparse_checkout) + .or(Some(false)); + entry.active = Some(entry.active.unwrap_or(true)); + entry.shallow = Some(entry.shallow.unwrap_or(false)); + Some(entry) + } + /// Create a new configuration with the given defaults and submodules #[must_use] pub const fn new(defaults: SubmoduleDefaults, submodules: SubmoduleEntries) -> Self { @@ -998,32 +1167,22 @@ impl Config { /// Create a new configuration, resolving defaults #[must_use] pub fn apply_defaults(mut self) -> Self { - if let Some(submodules) = self.submodules.submodules.as_mut() { - for sub in submodules.values_mut() { - Self::apply_option_default( - &mut sub.ignore, - self.defaults.ignore.as_ref(), - SerializableIgnore::Unspecified, - ); - Self::apply_option_default( - &mut sub.fetch_recurse, - self.defaults.fetch_recurse.as_ref(), - SerializableFetchRecurse::Unspecified, - ); - Self::apply_option_default( - &mut sub.update, - self.defaults.update.as_ref(), - SerializableUpdate::Unspecified, - ); - // active is just a bool, no default logic needed - } + let resolved: Vec<_> = self + .get_submodules() + .filter_map(|(name, _)| { + self.effective_entry(name) + .map(|entry| (name.clone(), entry)) + }) + .collect(); + for (name, entry) in resolved { + self.submodules.update_entry(name, entry); } self } /// Add a submodule configuration pub fn add_submodule(&mut self, name: String, submodule: SubmoduleEntry) { - self.submodules = self.submodules.clone().add_submodule(name, submodule); + self.submodules.update_entry(name, submodule); } /// Get an iterator over all submodule configurations @@ -1037,9 +1196,7 @@ impl Config { } /// Get an iterator that returns a tuple of submodule and sparse checkout - pub fn entries( - &self, - ) -> impl Iterator))> { + pub fn entries(&self) -> impl Iterator { self.submodules.iter() } @@ -1050,33 +1207,6 @@ impl Config { self.submodules.get(name) } - /// Ensure submod.toml and .gitmodules stay in sync - pub fn sync_with_git_config(&self, git_ops: &mut dyn GitOperations) -> Result<()> { - // 1. Read current .gitmodules - let current_gitmodules = git_ops.read_gitmodules()?; - - // 2. Apply our global defaults logic - let target_gitmodules = self.submodules.clone(); - - // 3. Write updated .gitmodules if different - if current_gitmodules != target_gitmodules { - git_ops.write_gitmodules(&target_gitmodules)?; - } - - // 4. Update any git config values that need to be set - for (name, entry) in target_gitmodules.submodule_iter() { - if let Some(branch) = &entry.branch { - git_ops.set_config_value( - &format!("submodule.{name}.branch"), - branch.to_string().as_str(), - ConfigLevel::Local, - )?; - } - } - - Ok(()) - } - /// Overlay CLI-supplied options onto this configuration. /// /// Only fields explicitly set on the CLI override the current values: a @@ -1087,6 +1217,9 @@ impl Config { /// file's values (#62 P1). fn merge_cli_overrides(&mut self, cli: Self) { let cli_defaults = cli.defaults; + if cli_defaults.branch.is_some() { + self.defaults.branch = cli_defaults.branch; + } if cli_defaults.ignore.is_some() { self.defaults.ignore = cli_defaults.ignore; } @@ -1109,48 +1242,21 @@ impl Config { /// Load configuration from a file, merging with CLI options #[allow(clippy::unused_self)] pub fn load(&self, path: impl AsRef, cli_options: Self) -> anyhow::Result { - // 1) Read the file's values. NOTE: layering an empty `Config::default()` - // provider beneath the file (the previous approach) actively *erased* - // the file's `[defaults]` — that provider emits all-`None` defaults - // under its own figment profile (`REPO`), which then overrode the - // file's values. Rust-side defaults are filled by `apply_defaults()` - // below, not by a figment base layer (#62 P1). - let mut cfg: Self = Figment::from(Toml::file(path)).extract()?; - - // 2) CLI overrides the file, but only where the CLI actually set a value - // (None-aware — see `merge_cli_overrides`). + let path = path.as_ref(); + let mut cfg = Self::parse(&std::fs::read_to_string(path)?).map_err(|error| { + anyhow::anyhow!("Invalid configuration {}: {error:#}", path.display()) + })?; cfg.merge_cli_overrides(cli_options); - - // 3) post-process submodules - Ok(cfg.apply_defaults()) + Ok(cfg) } - /// load configuration from a file without CLI options - #[allow(clippy::unused_self, clippy::needless_pass_by_value)] + /// Load raw declarations from a file. pub fn load_from_file(&self, path: Option>) -> anyhow::Result { - let p: &dyn AsRef = match path { - Some(ref p) => p, - None => &".", - }; - // See `load`: an empty `Config::default()` base layer erases the file's - // `[defaults]`, so read the file directly and let `apply_defaults()` - // supply Rust-side defaults (#62 P1). - let cfg: Self = Figment::from(Toml::file(p)).extract()?; - Ok(cfg.apply_defaults()) - } - - /// Load configuration from config and merge with existing gitmodules options - #[allow(clippy::unused_self)] - pub fn load_with_git_sync( - &self, - path: impl AsRef, - git_ops: &mut dyn GitOperations, - cli_options: Self, - ) -> anyhow::Result { - let cfg = self.load(path, cli_options)?; - // Sync with git config - cfg.sync_with_git_config(git_ops)?; - Ok(cfg) + self.load( + path.as_ref() + .map_or(Path::new("submod.toml"), AsRef::as_ref), + Self::default(), + ) } } @@ -1204,15 +1310,217 @@ mod tests { // SubmoduleDefaults::merge_from // ================================================================ + #[test] + fn semantic_errors_identify_original_source_lines() { + for (source, field, line) in [ + ( + "# metadata\n\nschema_version = '9.0.0'\n", + "schema_version", + 3, + ), + ( + "# module\n[module]\nurl = 'repo'\n\nunknown = true\n", + "[module].unknown", + 5, + ), + ( + "[module]\nurl = 'repo'\n# policy\nactive = 'false'\n", + "[module].active", + 4, + ), + ( + "[defaults]\n# historical spelling\nfetch = true\n", + "[defaults].fetchRecurse", + 3, + ), + ( + "# quoted names and keys\n['a.b']\nurl = 'repo'\n'active' = 'false'\n", + "[a.b].active", + 4, + ), + ( + "# inline table\nmodule = { url = 'repo', active = 'false' }\n", + "[module].active", + 2, + ), + ] { + let error = Config::parse(source).unwrap_err().to_string(); + assert!(error.contains(field), "{error}"); + assert!(error.contains(&format!("line {line}")), "{error}"); + } + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("submod.toml"); + std::fs::write(&path, "# metadata\nschema_version='9.0.0'\n").unwrap(); + let error = Config::default() + .load(&path, Config::default()) + .unwrap_err() + .to_string(); + assert!(error.contains("schema_version (line 2)"), "{error}"); + assert!(error.contains(&path.display().to_string()), "{error}"); + } + + #[test] + fn global_branch_is_inherited_without_pinning_or_overriding_head() { + let mut config = Config::parse("[defaults]\nbranch='main'\n[inherited]\nurl='repo'\n[explicit]\nurl='repo'\nbranch='HEAD'\n").unwrap(); + assert_eq!( + config.defaults.branch, + Some(SerializableBranch::Name("main".into())) + ); + assert_eq!(config.get_submodule("inherited").unwrap().branch, None); + assert_eq!( + config.effective_entry("inherited").unwrap().branch, + config.defaults.branch + ); + assert_eq!( + config.effective_entry("explicit").unwrap().branch, + Some(SerializableBranch::Name("HEAD".into())) + ); + config.defaults.branch = Some(SerializableBranch::CurrentInSuperproject); + let saved = toml::to_string(&config).unwrap(); + let reloaded = Config::parse(&saved).unwrap(); + assert_eq!(reloaded.get_submodule("inherited").unwrap().branch, None); + assert_eq!( + reloaded.effective_entry("inherited").unwrap().branch, + Some(SerializableBranch::CurrentInSuperproject) + ); + assert_eq!( + reloaded.effective_entry("explicit").unwrap().branch, + Some(SerializableBranch::Name("HEAD".into())) + ); + for value in ["''", "'bad..branch'", "true", "1"] { + assert!( + Config::parse(&format!("[defaults]\nbranch={value}")) + .unwrap_err() + .to_string() + .contains("[defaults].branch") + ); + } + let merged = SubmoduleDefaults::default().merge_from(config.defaults.clone()); + assert_eq!(merged.branch, config.defaults.branch); + let mut cli = Config::default(); + cli.defaults.branch = Some(SerializableBranch::Name("develop".into())); + config.merge_cli_overrides(cli); + assert_eq!( + config.defaults.branch, + Some(SerializableBranch::Name("develop".into())) + ); + } + + #[test] + fn raw_defaults_survive_resolution_and_serialization() { + let mut config = Config::parse("[defaults]\nignore='dirty'\nuse_git_default_sparse_checkout=true\n[inherited]\nurl='repo'\n[explicit]\nurl='repo'\nignore='none'\nuse_git_default_sparse_checkout=false\n").unwrap(); + config.defaults.ignore = Some(SerializableIgnore::All); + assert_eq!(config.get_submodule("inherited").unwrap().ignore, None); + assert_eq!( + config.effective_entry("inherited").unwrap().ignore, + Some(SerializableIgnore::All) + ); + assert_eq!( + config.effective_entry("inherited").unwrap().path.as_deref(), + Some("inherited") + ); + assert_eq!(config.effective_entry("inherited").unwrap().branch, None); + assert_eq!( + config.effective_entry("explicit").unwrap().ignore, + Some(SerializableIgnore::None) + ); + assert_eq!( + config + .effective_entry("explicit") + .unwrap() + .use_git_default_sparse_checkout, + Some(false) + ); + let reloaded = Config::parse(&toml::to_string(&config).unwrap()).unwrap(); + assert_eq!(reloaded.get_submodule("inherited").unwrap().ignore, None); + assert_eq!( + reloaded.effective_entry("inherited").unwrap().ignore, + Some(SerializableIgnore::All) + ); + } + + #[test] + fn config_schema_aliases_and_legacy_values() { + for version in ["", "schema_version='1.0.0'\n", "schema_version='1.1.0'\n"] { + for key in ["fetchRecurse", "fetch", "fetch_recurse"] { + let source = + format!("{version}[module]\nurl='repo'\n{key}='true'\nbranch='HEAD'\n"); + let config: Config = toml::from_str(&source).unwrap(); + assert_eq!( + config.get_submodule("module").unwrap().fetch_recurse, + Some(SerializableFetchRecurse::Always) + ); + assert_eq!( + config.effective_entry("module").unwrap().branch, + Some(SerializableBranch::Name("HEAD".into())) + ); + } + } + Config::parse(include_str!("../sample_config/submod.toml")).unwrap(); + } + + #[test] + fn config_rejects_invalid_fields_before_actions() { + for (source, context) in [ + ("schema_version='9.0.0'", "schema_version"), + ("schema_version=1", "schema_version"), + ( + "[module]\nurl='repo'\nfetch='always'\nfetchRecurse='never'", + "conflicting aliases", + ), + ("[defaults]\nignroe='all'", "ignroe"), + ("[module]\nurl='repo'\nunknown=true", "unknown"), + ("[module]\npath='child'", "url"), + ("[module]\nurl=' '", "url"), + ("[module]\nurl='repo'\npath='../outside'", "path"), + ("[module]\nurl='repo'\npath='.'", "path"), + ("[module]\nurl='repo'\npath='.git/config'", "path"), + ("[module]\nurl='repo'\nactive='false'", "module"), + ("[module]\nurl='repo'\nupdate='!evil'", "module"), + ( + "[module]\nurl='repo'\nsparse_paths=[\"a\\nb\"]", + "sparse_paths", + ), + ("[module]\nurl='repo'\nsparse_paths=[true]", "module"), + ("module=true", "module"), + ] { + let error = Config::parse(source).unwrap_err(); + assert!( + format!("{error:#}").contains(context), + "{source}: {error:#}" + ); + } + } + + #[test] + fn sparse_patterns_have_one_authority() { + let mut config = Config::parse("[module]\nurl='repo'\nsparse_paths=['src/']").unwrap(); + config + .submodules + .add_sparse_path("module".into(), "docs/".into()); + let mut entry = config.get_submodule("module").unwrap().clone(); + assert_eq!(entry.sparse_paths.as_ref().unwrap().len(), 2); + entry.sparse_paths = Some(vec!["lib/".into()]); + config.submodules = config.submodules.add_submodule("module".into(), entry); + assert_eq!( + config.get_sparse_checkouts().next().unwrap().1, + &vec!["lib/".to_string()] + ); + let _ = config.submodules.remove_submodule("module"); + assert!(config.get_sparse_checkouts().next().is_none()); + } + #[test] fn test_defaults_merge_from_both_set() { let base = SubmoduleDefaults { + branch: None, ignore: Some(SerializableIgnore::All), fetch_recurse: Some(SerializableFetchRecurse::Always), update: Some(SerializableUpdate::Rebase), use_git_default_sparse_checkout: None, }; let other = SubmoduleDefaults { + branch: None, ignore: Some(SerializableIgnore::Dirty), fetch_recurse: None, update: Some(SerializableUpdate::Merge), @@ -1230,6 +1538,7 @@ mod tests { #[test] fn test_defaults_merge_from_empty_other() { let base = SubmoduleDefaults { + branch: None, ignore: Some(SerializableIgnore::All), fetch_recurse: Some(SerializableFetchRecurse::Never), update: Some(SerializableUpdate::Checkout), @@ -1247,6 +1556,7 @@ mod tests { fn test_defaults_merge_from_empty_base() { let base = SubmoduleDefaults::default(); let other = SubmoduleDefaults { + branch: None, ignore: Some(SerializableIgnore::Dirty), fetch_recurse: Some(SerializableFetchRecurse::Always), update: Some(SerializableUpdate::Merge), @@ -1276,12 +1586,14 @@ mod tests { fn test_defaults_merge_from_carries_other_sparse_default() { // Regression (#62 P2): merge_from dropped other.use_git_default_sparse_checkout. let base = SubmoduleDefaults { + branch: None, ignore: None, fetch_recurse: None, update: None, use_git_default_sparse_checkout: None, }; let other = SubmoduleDefaults { + branch: None, ignore: None, fetch_recurse: None, update: None, @@ -1299,12 +1611,14 @@ mod tests { fn test_defaults_merge_from_other_sparse_default_overrides_base() { // The override must win even when base already holds a value. let base = SubmoduleDefaults { + branch: None, ignore: None, fetch_recurse: None, update: None, use_git_default_sparse_checkout: Some(true), }; let other = SubmoduleDefaults { + branch: None, ignore: None, fetch_recurse: None, update: None, @@ -1322,6 +1636,7 @@ mod tests { fn test_defaults_merge_from_unset_other_sparse_default_preserves_base() { // When other leaves it unset, base's value must survive. let base = SubmoduleDefaults { + branch: None, ignore: None, fetch_recurse: None, update: None, @@ -1592,6 +1907,20 @@ mod tests { #[test] fn test_entries_add_checkout_replace() { let mut entries = SubmoduleEntries::default(); + entries.update_entry( + "mod1".into(), + SubmoduleEntry::new( + Some("repo".into()), + None, + None, + None, + None, + None, + None, + None, + None, + ), + ); entries.add_checkout("mod1".to_string(), &["src/".to_string()], false); assert_eq!( entries.sparse_checkouts().unwrap().get("mod1").unwrap(), @@ -1617,8 +1946,21 @@ mod tests { fn test_entries_add_checkout_when_none() { let mut entries = SubmoduleEntries { submodules: Some(HashMap::new()), - sparse_checkouts: None, }; + entries.update_entry( + "mod1".into(), + SubmoduleEntry::new( + Some("repo".into()), + None, + None, + None, + None, + None, + None, + None, + None, + ), + ); entries.add_checkout("mod1".to_string(), &["src/".to_string()], false); assert!(entries.sparse_checkouts().is_some()); assert_eq!( @@ -1630,6 +1972,20 @@ mod tests { #[test] fn test_entries_remove_sparse_path() { let mut entries = SubmoduleEntries::default(); + entries.update_entry( + "mod1".into(), + SubmoduleEntry::new( + Some("repo".into()), + None, + None, + None, + None, + None, + None, + None, + None, + ), + ); entries.add_checkout( "mod1".to_string(), &["src/".to_string(), "docs/".to_string()], @@ -1650,6 +2006,20 @@ mod tests { #[test] fn test_entries_add_sparse_path() { let mut entries = SubmoduleEntries::default(); + entries.update_entry( + "mod1".into(), + SubmoduleEntry::new( + Some("repo".into()), + None, + None, + None, + None, + None, + None, + None, + None, + ), + ); entries.add_sparse_path("mod1".to_string(), "src/".to_string()); assert_eq!( entries.sparse_checkouts().unwrap().get("mod1").unwrap(), @@ -1666,8 +2036,21 @@ mod tests { fn test_entries_add_sparse_path_when_none() { let mut entries = SubmoduleEntries { submodules: Some(HashMap::new()), - sparse_checkouts: None, }; + entries.update_entry( + "mod1".into(), + SubmoduleEntry::new( + Some("repo".into()), + None, + None, + None, + None, + None, + None, + None, + None, + ), + ); entries.add_sparse_path("mod1".to_string(), "src/".to_string()); assert!(entries.sparse_checkouts().is_some()); } @@ -1675,6 +2058,20 @@ mod tests { #[test] fn test_entries_delete_checkout() { let mut entries = SubmoduleEntries::default(); + entries.update_entry( + "mod1".into(), + SubmoduleEntry::new( + Some("repo".into()), + None, + None, + None, + None, + None, + None, + None, + None, + ), + ); entries.add_checkout("mod1".to_string(), &["src/".to_string()], false); entries.delete_checkout("mod1"); assert!(!entries.sparse_checkouts().unwrap().contains_key("mod1")); @@ -1905,6 +2302,7 @@ mod tests { #[test] fn test_config_apply_defaults() { let defaults = SubmoduleDefaults { + branch: None, ignore: Some(SerializableIgnore::Dirty), fetch_recurse: Some(SerializableFetchRecurse::Always), update: Some(SerializableUpdate::Rebase), @@ -1935,6 +2333,7 @@ mod tests { #[test] fn test_config_apply_defaults_entry_overrides() { let defaults = SubmoduleDefaults { + branch: None, ignore: Some(SerializableIgnore::Dirty), fetch_recurse: Some(SerializableFetchRecurse::Always), update: Some(SerializableUpdate::Rebase), @@ -2503,19 +2902,24 @@ update = "rebase" } #[test] - fn test_update_options_from_options_recursive_only_when_fetch_always() { - // fetchRecurse = always is the one value that flips `recursive` on. + fn test_update_options_from_options_never_selects_recursive_materialization() { + // fetchRecurse controls fetching nested history. Recursive checkout is + // an explicit lifecycle-command selection and remains off here. let always = SubmoduleUpdateOptions::from_options(SubmoduleGitOptions::new( None, Some(SerializableFetchRecurse::Always), None, Some(SerializableUpdate::Merge), )); - assert!(always.recursive, "fetchRecurse=always must set recursive"); + assert!( + !always.recursive, + "fetchRecurse=always must not select recursive materialization" + ); assert_eq!(always.strategy, SerializableUpdate::Merge); assert!(!always.force, "from_options never forces"); + assert!(!always.remote, "from_options never selects remote tracking"); - // Every other fetch_recurse value (incl. None) leaves recursive off. + // Every other fetch_recurse value (including None) also leaves it off. for fr in [ None, Some(SerializableFetchRecurse::OnDemand), @@ -2526,7 +2930,7 @@ update = "rebase" )); assert!( !opts.recursive, - "only fetchRecurse=always should set recursive, got {fr:?}" + "fetchRecurse must stay independent of recursive checkout, got {fr:?}" ); // An absent update strategy falls back to the Checkout default. assert_eq!(opts.strategy, SerializableUpdate::Checkout); diff --git a/src/git_manager.rs b/src/git_manager.rs index f1d43da4..76983885 100644 --- a/src/git_manager.rs +++ b/src/git_manager.rs @@ -42,10 +42,6 @@ Provides core logic for managing git submodules using the [`gitoxide`](https://g All operations return [`SubmoduleError`](src/git_manager.rs:14) for consistent error reporting. -## TODOs - -- TODO: Implement submodule addition using gitoxide APIs when available ([`add_submodule_with_gix`](src/git_manager.rs:278)). Until then, we need to make git2 a required dependency. - ## Usage Use this module as the backend for CLI commands to manage submodules in a repository. See the project [README](README.md) for usage examples and configuration details. @@ -57,7 +53,10 @@ use crate::git_ops::GitOpsManager; use crate::options::{ SerializableBranch, SerializableFetchRecurse, SerializableIgnore, SerializableUpdate, }; -use std::fs; +use crate::utilities::RepositoryContext; +use std::ffi::OsString; +use std::fs::{self, File, OpenOptions}; +use std::io::Write as _; use std::path::{Path, PathBuf}; /// The deny-all pattern prepended to sparse-checkout files in deny-all-by-default mode. @@ -105,15 +104,44 @@ pub enum SubmoduleError { }, /// Repository access or validation error - #[error("Repository not found or invalid")] + #[error("Repository error: {0}")] #[allow(dead_code)] - RepositoryError, + RepositoryError(String), + + /// A read-only check found repository state that differs from the declaration. + #[error("State drift: {0}")] + Drift(String), + + /// A preflighted batch stopped after some per-module outcomes were known. + #[error("{summary}Cause: {cause}")] + IncompleteBatch { + /// Structured completed, failed, and pending module results. + summary: OperationSummary, + /// Preserved native operation cause. + cause: String, + }, /// Submodule path is invalid or escapes repository root #[error("Invalid path: {0}")] InvalidPath(String), } +impl SubmoduleError { + /// Return the documented process status for this error category. + pub const fn exit_code(&self) -> u8 { + match self { + Self::ConfigError(_) | Self::InvalidPath(_) | Self::SubmoduleNotFound { .. } => 2, + Self::GitoxideError(_) + | Self::Git2Error(_) + | Self::CliError(_) + | Self::IoError(_) + | Self::RepositoryError(_) + | Self::Drift(_) + | Self::IncompleteBatch { .. } => 1, + } + } +} + /// Status information for a submodule #[derive(Debug, Clone, PartialEq, Eq)] #[allow(clippy::struct_excessive_bools)] @@ -158,23 +186,226 @@ pub enum SparseStatus { }, } -/// Main gitoxide-based submodule manager +/// Desired-state planner and reconciler for managed submodules pub struct GitManager { - /// The main git operations manager (gix-first, git2-fallback) + /// Native Git mutation boundary plus backend inspection reads git_ops: GitOpsManager, + /// Native Git paths and the resolved config for this invocation. + context: RepositoryContext, /// Configuration for submodules config: Config, + /// Exact bytes used to parse `config`; compared again immediately before replacement. + loaded_config_bytes: Option>, + /// Raw declarations parsed from `loaded_config_bytes`. + loaded_config: Config, + /// Fields explicitly named by the command, including canonicalization-only edits. + pending_edits: ConfigEditIntent, /// Path to the configuration file config_path: PathBuf, /// Whether to print verbose output verbose: bool, } +#[derive(Default)] +struct ConfigEditIntent { + defaults: std::collections::BTreeSet<&'static str>, + modules: std::collections::BTreeMap>, +} + +impl ConfigEditIntent { + fn default_field(&mut self, field: &'static str) { + self.defaults.insert(field); + } + + fn module_field(&mut self, name: &str, field: &'static str) { + self.modules + .entry(name.to_string()) + .or_default() + .insert(field); + } + + fn module_contains(&self, name: &str, field: &str) -> bool { + self.modules + .get(name) + .is_some_and(|fields| fields.contains(field)) + } +} + +struct MutationLocks { + paths: Vec, + _files: Vec, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum ReconcileScope { + Init, + Update, + Sync, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum ModuleOutcomeKind { + Changed, + Unchanged, + SkippedDisabled, + SkippedPolicy, + ChangedSkippedDisabled, + ChangedSkippedPolicy, + Failed, + Pending, +} + +#[derive(Clone, Debug)] +struct ReconcilePlan { + name: String, + path: String, + kind: ModuleOutcomeKind, + detail: String, + target: Option, + verbose_detail: Option, + registration_changed: bool, + metadata_changed: bool, + sparse_changed: bool, + remote_requested: bool, + recursive_requested: bool, + initial_head: Option, + initial_recursive_state: Option>, +} + +/// Structured results for one CLI lifecycle operation. +#[derive(Debug)] +pub struct OperationSummary { + operation: &'static str, + preview: bool, + modules: Vec, +} + +impl std::fmt::Display for OperationSummary { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + if self.modules.is_empty() { + return writeln!(formatter, "No submodules configured."); + } + + let mut changed = 0_usize; + let mut unchanged = 0_usize; + let mut skipped = 0_usize; + let mut failed = 0_usize; + let mut pending = 0_usize; + for module in &self.modules { + let status = if self.preview { + match module.kind { + ModuleOutcomeKind::Changed => "would-change", + ModuleOutcomeKind::Unchanged => "unchanged", + ModuleOutcomeKind::SkippedDisabled => "would-skip-disabled", + ModuleOutcomeKind::SkippedPolicy => "would-skip-policy", + ModuleOutcomeKind::ChangedSkippedDisabled => "would-change/would-skip-disabled", + ModuleOutcomeKind::ChangedSkippedPolicy => "would-change/would-skip-policy", + ModuleOutcomeKind::Failed => "would-fail", + ModuleOutcomeKind::Pending => "pending", + } + } else { + match module.kind { + ModuleOutcomeKind::Changed => "changed", + ModuleOutcomeKind::Unchanged => "unchanged", + ModuleOutcomeKind::SkippedDisabled => "skipped-disabled", + ModuleOutcomeKind::SkippedPolicy => "skipped-policy", + ModuleOutcomeKind::ChangedSkippedDisabled => "changed/skipped-disabled", + ModuleOutcomeKind::ChangedSkippedPolicy => "changed/skipped-policy", + ModuleOutcomeKind::Failed => "failed", + ModuleOutcomeKind::Pending => "pending", + } + }; + match module.kind { + ModuleOutcomeKind::Changed => changed += 1, + ModuleOutcomeKind::Unchanged => unchanged += 1, + ModuleOutcomeKind::SkippedDisabled | ModuleOutcomeKind::SkippedPolicy => { + skipped += 1; + } + ModuleOutcomeKind::ChangedSkippedDisabled + | ModuleOutcomeKind::ChangedSkippedPolicy => { + changed += 1; + skipped += 1; + } + ModuleOutcomeKind::Failed => failed += 1, + ModuleOutcomeKind::Pending => pending += 1, + } + write!( + formatter, + "{} at {}: {status}: {}", + crate::utilities::safe_human_text(&module.name), + crate::utilities::safe_human_text(&module.path), + crate::utilities::safe_human_text(&module.detail) + )?; + if let Some(target) = &module.target { + write!( + formatter, + " (target {})", + crate::utilities::safe_human_text(target) + )?; + } + writeln!(formatter)?; + if let Some(verbose) = &module.verbose_detail { + writeln!( + formatter, + " {}", + crate::utilities::safe_human_text(verbose) + )?; + } + } + writeln!( + formatter, + "{} summary: {changed} changed, {unchanged} unchanged, {skipped} skipped, {failed} failed, {pending} pending.", + self.operation + ) + } +} + +struct AddPlan { + name: String, + path: String, + raw_entry: SubmoduleEntry, + managed: SubmoduleEntry, + options: crate::config::SubmoduleAddOptions, + sparse_paths: Option>, + no_init: bool, +} + +struct NukePlan { + name: String, + effective: SubmoduleEntry, + options: crate::config::SubmoduleAddOptions, + registered: bool, +} + +struct ChangePlan { + move_paths: Option<(String, String)>, + metadata_targets: Vec<(String, String, SubmoduleEntry)>, +} + +impl Drop for MutationLocks { + fn drop(&mut self) { + for path in self.paths.iter().rev() { + let _ = fs::remove_file(path); + } + } +} + impl GitManager { /// Helper method to map git operations errors #[allow(clippy::needless_pass_by_value)] fn map_git_ops_error(err: anyhow::Error) -> SubmoduleError { - SubmoduleError::ConfigError(format!("Git operation failed: {err}")) + SubmoduleError::CliError(format!("Git operation failed: {err}")) + } + + /// Preserve filesystem failures discovered while validating an otherwise + /// lexical path. Unsafe syntax is an argument error; inability to inspect + /// a valid path is an operational I/O failure. + fn map_path_validation_error(error: anyhow::Error) -> SubmoduleError { + if let Some(io_error) = error.downcast_ref::() { + SubmoduleError::IoError(std::io::Error::new(io_error.kind(), format!("{error:#}"))) + } else { + SubmoduleError::InvalidPath(error.to_string()) + } } /// Restore `update_toml_config` method @@ -219,10 +450,294 @@ impl GitManager { /// and any unknown keys, *updates* the bodies of sections that already /// exist, and appends sections that are new. The previous implementation /// was append-only and silently dropped edits to existing sections (#62 P1). - fn save_config(&self) -> Result<(), SubmoduleError> { + fn save_config(&mut self) -> Result<(), SubmoduleError> { self.write_full_config() } + fn validate_identity(name: &str) -> Result<(), SubmoduleError> { + let normalized = crate::utilities::normalize_submodule_path(Path::new(name)); + if matches!(name, "defaults" | "schema_version") + || name.contains(['\0', '\n', '\r', '\\']) + || normalized + .as_ref() + .map_or(true, |normalized| normalized != Path::new(name)) + { + return Err(SubmoduleError::InvalidPath(format!( + "invalid submodule administrative name {name:?}" + ))); + } + Ok(()) + } + + fn paths_collide(left: &Path, right: &Path) -> bool { + let folded = |path: &Path| { + path.components() + .map(|component| component.as_os_str().to_string_lossy().to_lowercase()) + .collect::>() + }; + let left = folded(left); + let right = folded(right); + left.starts_with(&right) || right.starts_with(&left) + } + + fn validate_all_paths(&self) -> Result<(), SubmoduleError> { + let mut paths: Vec<(String, PathBuf)> = Vec::new(); + for (name, entry) in self.config.get_submodules() { + Self::validate_identity(name)?; + let raw = entry.path.as_deref().unwrap_or(name); + crate::utilities::validate_submodule_path(&self.context.worktree_root, Path::new(raw)) + .map_err(Self::map_path_validation_error)?; + let normalized = crate::utilities::normalize_submodule_path(Path::new(raw)) + .map_err(|error| SubmoduleError::InvalidPath(error.to_string()))?; + paths.push((name.clone(), normalized)); + } + for left in 0..paths.len() { + for right in left + 1..paths.len() { + let (left_name, left_path) = &paths[left]; + let (right_name, right_path) = &paths[right]; + if Self::paths_collide(left_path, right_path) { + return Err(SubmoduleError::InvalidPath(format!( + "managed paths for {left_name:?} and {right_name:?} collide or overlap" + ))); + } + } + } + Ok(()) + } + + fn validate_requested_path( + &self, + name: &str, + path: &Path, + replacing: Option<&str>, + ) -> Result { + Self::validate_identity(name)?; + crate::utilities::validate_submodule_path(&self.context.worktree_root, path) + .map_err(Self::map_path_validation_error)?; + let requested = crate::utilities::normalize_submodule_path(path) + .map_err(|error| SubmoduleError::InvalidPath(error.to_string()))?; + for (other_name, entry) in self.config.get_submodules() { + if replacing == Some(other_name.as_str()) { + continue; + } + let other = crate::utilities::normalize_submodule_path(Path::new( + entry.path.as_deref().unwrap_or(other_name), + )) + .map_err(|error| SubmoduleError::InvalidPath(error.to_string()))?; + if Self::paths_collide(&requested, &other) { + return Err(SubmoduleError::InvalidPath(format!( + "requested path collides or overlaps managed module {other_name:?}" + ))); + } + } + Ok(requested) + } + + fn registration_for_path(&self, path: &str) -> Result, SubmoduleError> { + self.git_ops + .registration_name(path) + .map_err(Self::map_git_ops_error) + } + + fn managed_settings(config: &Config, name: &str) -> Result { + let mut declared = config.submodules.get(name).cloned().ok_or_else(|| { + SubmoduleError::SubmoduleNotFound { + name: name.to_string(), + } + })?; + declared.branch = declared.branch.or_else(|| config.defaults.branch.clone()); + declared.ignore = declared.ignore.or(config.defaults.ignore); + declared.update = declared.update.or_else(|| config.defaults.update.clone()); + declared.fetch_recurse = declared.fetch_recurse.or(config.defaults.fetch_recurse); + declared.active = Some(declared.active.unwrap_or(true)); + Ok(declared) + } + + fn sync_effective_settings(&self, name: &str, path: &str) -> Result<(), SubmoduleError> { + let declared = Self::managed_settings(&self.config, name)?; + self.git_ops + .sync_submodule_settings(path, &declared) + .map_err(Self::map_git_ops_error) + } + + fn metadata_targets( + &self, + names: &[String], + ) -> Result, SubmoduleError> { + let mut targets = Vec::new(); + for name in names { + let settings = Self::managed_settings(&self.config, name)?; + let path = settings.path.clone().unwrap_or_else(|| name.to_string()); + if self.registration_for_path(&path)?.is_some() { + targets.push((name.clone(), path, settings)); + } + } + targets.sort_by(|left, right| left.0.cmp(&right.0)); + Ok(targets) + } + + fn preflight_metadata_targets( + &self, + targets: &[(String, String, SubmoduleEntry)], + ) -> Result<(), SubmoduleError> { + for (_, path, settings) in targets { + self.git_ops + .preflight_submodule_settings(path, settings) + .map_err(Self::map_git_ops_error)?; + } + Ok(()) + } + + fn apply_metadata_targets( + &self, + targets: &[(String, String, SubmoduleEntry)], + ) -> Result<(), SubmoduleError> { + for (_, path, settings) in targets { + self.git_ops + .sync_submodule_settings(path, settings) + .map_err(Self::map_git_ops_error)?; + } + Ok(()) + } + + fn config_lock_path(config_path: &Path) -> Result { + let config_path = if config_path.is_absolute() { + config_path.to_path_buf() + } else { + std::env::current_dir()?.join(config_path) + }; + let name = config_path.file_name().ok_or_else(|| { + SubmoduleError::InvalidPath("config path has no file name".to_string()) + })?; + let parent = config_path.parent().ok_or_else(|| { + SubmoduleError::InvalidPath("config path has no parent directory".to_string()) + })?; + let parent = parent.canonicalize().map_err(|error| { + SubmoduleError::ConfigError(format!( + "Could not resolve config directory {}: {error}", + parent.display() + )) + })?; + let mut lock_name = OsString::from(name); + lock_name.push(".submod.lock"); + Ok(parent.join(lock_name)) + } + + fn validate_config_destination(config_path: &Path) -> Result<(), SubmoduleError> { + match fs::symlink_metadata(config_path) { + Ok(metadata) if metadata.file_type().is_symlink() => { + return Err(SubmoduleError::ConfigError(format!( + "Refusing to write configuration through symlink {}", + config_path.display() + ))); + } + Ok(metadata) if !metadata.is_file() => { + return Err(SubmoduleError::ConfigError(format!( + "Configuration destination is not a file: {}", + config_path.display() + ))); + } + Ok(_) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(error.into()), + } + Ok(()) + } + + fn acquire_lock_paths(mut paths: Vec) -> Result { + paths.sort(); + paths.dedup(); + let mut files = Vec::with_capacity(paths.len()); + for path in &paths { + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10); + loop { + match OpenOptions::new().write(true).create_new(true).open(path) { + Ok(file) => { + files.push(file); + break; + } + Err(error) + if error.kind() == std::io::ErrorKind::AlreadyExists + && std::time::Instant::now() < deadline => + { + std::thread::sleep(std::time::Duration::from_millis(20)); + } + Err(error) => { + for acquired in paths.iter().take(files.len()).rev() { + let _ = fs::remove_file(acquired); + } + return Err(SubmoduleError::ConfigError(format!( + "Could not acquire lock {}: {error}. Finish the other submod operation or remove a verified stale lock", + path.display() + ))); + } + } + } + } + Ok(MutationLocks { + paths, + _files: files, + }) + } + + fn acquire_mutation_locks(&self) -> Result { + Self::validate_config_destination(&self.config_path)?; + Self::acquire_lock_paths(vec![ + self.context.common_dir.canonicalize()?.join("submod.lock"), + Self::config_lock_path(&self.config_path)?, + ]) + } + + fn acquire_output_lock(output: &Path) -> Result { + let output = if output.is_absolute() { + output.to_path_buf() + } else { + std::env::current_dir()?.join(output) + }; + Self::validate_config_destination(&output)?; + Self::acquire_lock_paths(vec![Self::config_lock_path(&output)?]) + } + + fn reload_locked_config(&mut self) -> Result<(), SubmoduleError> { + let (config, bytes) = Self::read_config_snapshot(&self.config_path).map_err(|error| { + SubmoduleError::ConfigError(format!("Failed to reload locked config: {error}")) + })?; + self.loaded_config = config.clone(); + self.config = config; + self.loaded_config_bytes = bytes; + self.pending_edits = ConfigEditIntent::default(); + self.validate_all_paths() + } + + fn read_config_snapshot(path: &Path) -> Result<(Config, Option>), SubmoduleError> { + let bytes = match fs::read(path) { + Ok(bytes) => Some(bytes), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => None, + Err(error) => { + return Err(SubmoduleError::ConfigError(format!( + "Failed to read configuration {}: {error}", + path.display() + ))); + } + }; + let Some(bytes) = bytes else { + return Ok((Config::default(), None)); + }; + let source = std::str::from_utf8(&bytes).map_err(|error| { + SubmoduleError::ConfigError(format!( + "Configuration {} is not valid UTF-8: {error}", + path.display() + )) + })?; + let config = Config::parse(source).map_err(|error| { + SubmoduleError::ConfigError(format!( + "Invalid configuration {}: {error}", + path.display() + )) + })?; + Ok((config, Some(bytes))) + } + /// Creates a new `GitManager` by loading configuration from the given path /// with default (non-verbose) output. /// @@ -241,20 +756,75 @@ impl GitManager { /// Creates a new `GitManager` with the specified verbosity level. pub fn with_verbose(config_path: PathBuf, verbose: bool) -> Result { - // Use GitOpsManager for repository detection and operations - let git_ops = GitOpsManager::new(Some(Path::new(".")), verbose) - .map_err(|_| SubmoduleError::RepositoryError)?; + let explicit_config = config_path != Path::new("submod.toml"); + Self::with_verbose_config(config_path, verbose, explicit_config) + } - let config = Config::default() - .load(&config_path, Config::default()) - .map_err(|e| SubmoduleError::ConfigError(format!("Failed to load config: {e}")))?; + /// Create a manager while retaining whether `--config` was explicitly supplied. + pub fn with_verbose_config( + config_path: PathBuf, + verbose: bool, + explicit_config: bool, + ) -> Result { + let invocation_dir = std::env::current_dir()?; + let explicit_config = explicit_config.then_some(config_path.as_path()); + if let Some(path) = explicit_config { + let candidate = invocation_dir.join(path); + match fs::symlink_metadata(&candidate) { + Ok(metadata) if !metadata.is_file() && !metadata.file_type().is_symlink() => { + return Err(SubmoduleError::ConfigError(format!( + "Explicit config path is not a file: {}", + candidate.display() + ))); + } + Ok(_) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + return Err(SubmoduleError::ConfigError(format!( + "Explicit config file not found: {}", + candidate.display() + ))); + } + Err(error) => return Err(error.into()), + } + } + let context = + RepositoryContext::discover(&invocation_dir, explicit_config).map_err(|error| { + SubmoduleError::RepositoryError(format!( + "Failed to discover the repository: {error}" + )) + })?; + let git_ops = + GitOpsManager::new(Some(&context.worktree_root), verbose).map_err(|error| { + SubmoduleError::RepositoryError(format!( + "Failed to open Git repository at {}: {error}", + context.worktree_root.display() + )) + })?; - Ok(Self { + let (config, loaded_config_bytes) = Self::read_config_snapshot(&context.config_path)?; + let manager = Self { git_ops, + config_path: context.config_path.clone(), + context, + loaded_config: config.clone(), config, - config_path, + loaded_config_bytes, + pending_edits: ConfigEditIntent::default(), verbose, - }) + }; + manager.validate_all_paths()?; + Ok(manager) + } + + /// Require an existing declaration file for commands that cannot create one. + pub fn require_config(&self) -> Result<(), SubmoduleError> { + if self.loaded_config_bytes.is_some() { + return Ok(()); + } + Err(SubmoduleError::ConfigError(format!( + "Configuration '{}' was not found. Run `submod generate-config --from-setup` to import existing Git submodules, or `submod add URL` to create it.", + self.config_path.display() + ))) } /// Creates a `GitManager` pointed at an explicit repository path. @@ -263,19 +833,29 @@ impl GitManager { /// being a git repository. #[cfg(test)] fn with_repo_path(config_path: PathBuf, repo_path: &Path) -> Result { - let git_ops = GitOpsManager::new(Some(repo_path), false) - .map_err(|_| SubmoduleError::RepositoryError)?; - - let config = Config::default() - .load(&config_path, Config::default()) - .map_err(|e| SubmoduleError::ConfigError(format!("Failed to load config: {e}")))?; + let context = + RepositoryContext::discover(repo_path, Some(&config_path)).map_err(|error| { + SubmoduleError::ConfigError(format!( + "Failed to resolve repository and config path: {error}" + )) + })?; + let git_ops = GitOpsManager::new(Some(&context.worktree_root), false).map_err(|error| { + SubmoduleError::ConfigError(format!("Failed to open Git repository: {error}")) + })?; - Ok(Self { + let (config, loaded_config_bytes) = Self::read_config_snapshot(&context.config_path)?; + let manager = Self { git_ops, + config_path: context.config_path.clone(), + context, + loaded_config: config.clone(), config, - config_path, + loaded_config_bytes, + pending_edits: ConfigEditIntent::default(), verbose: false, - }) + }; + manager.validate_all_paths()?; + Ok(manager) } /// Check submodule repository status using gix APIs @@ -285,8 +865,16 @@ impl GitManager { name: &str, ) -> Result { // NOTE: This is a legacy direct gix usage for status; could be refactored to use GitOpsManager if needed. - let submodule_repo = - gix::open(submodule_path).map_err(|_| SubmoduleError::RepositoryError)?; + let rooted_path = self.context.worktree_root.join(submodule_path); + self.git_ops + .verify_submodule_checkout(submodule_path) + .map_err(Self::map_git_ops_error)?; + let submodule_repo = gix::open(&rooted_path).map_err(|error| { + SubmoduleError::RepositoryError(format!( + "Failed to open submodule {name:?} at {}: {error}", + rooted_path.display() + )) + })?; // GITOXIDE API: Determine whether the worktree has uncommitted changes. // `is_dirty()` runs the real status computation (modified tracked files @@ -306,17 +894,18 @@ impl GitManager { // For now, consider all submodules active if they exist in config let is_active = self.config.submodules.contains_key(name); - // Check sparse checkout status - let sparse_status = - if let Some(sparse_checkouts) = self.config.submodules.sparse_checkouts() { - if let Some(expected_paths) = sparse_checkouts.get(name) { - self.check_sparse_checkout_status(submodule_path, expected_paths)? - } else { - SparseStatus::NotEnabled - } - } else { - SparseStatus::NotEnabled - }; + let effective = + self.config + .effective_entry(name) + .ok_or_else(|| SubmoduleError::SubmoduleNotFound { + name: name.to_string(), + })?; + let expected_sparse = if effective.use_git_default_sparse_checkout.unwrap_or(false) { + effective.sparse_paths.unwrap_or_default() + } else { + Self::build_deny_all_sparse_patterns(effective.sparse_paths.as_deref().unwrap_or(&[])) + }; + let sparse_status = self.check_sparse_checkout_status(submodule_path, &expected_sparse)?; // Check if submodule has its own submodules let has_submodules = submodule_repo .submodules() @@ -337,62 +926,35 @@ impl GitManager { /// Check whether the sparse-checkout configuration for a submodule matches /// the expected paths. /// - /// Returns [`SparseStatus::Correct`] when every expected path is present in - /// the configured file. Extra patterns in the file that are not in - /// `expected_paths` are **not** treated as a mismatch; the check is a - /// subset test (all expected ⊆ configured). Returns [`SparseStatus::Mismatch`] - /// when at least one expected path is absent from the file. + /// Returns [`SparseStatus::Correct`] only when the ordered user patterns match. + /// Extra, missing, or reordered patterns are a mismatch because each can change + /// which files Git materializes. pub fn check_sparse_checkout_status( &self, submodule_path: &str, expected_paths: &[String], ) -> Result { - // Try to find the sparse-checkout file for the submodule - let git_dir = self.get_git_directory(submodule_path)?; - let sparse_checkout_file = git_dir.join("info").join("sparse-checkout"); - if !sparse_checkout_file.exists() { - return Ok(SparseStatus::NotConfigured); - } - - let content = fs::read_to_string(&sparse_checkout_file)?; - let configured_paths: Vec = content - .lines() - .map(str::trim) - .filter(|line| !line.is_empty() && !line.starts_with('#')) - .map(std::string::ToString::to_string) - .collect(); - - // Filter the auto-managed deny-all prefix from both sides so that comparison - // reflects only the user-specified include patterns. - let configured_user: Vec = configured_paths - .iter() - .filter(|p| p.as_str() != SPARSE_DENY_ALL) - .cloned() - .collect(); - let expected_user: Vec = expected_paths - .iter() - .filter(|p| p.as_str() != SPARSE_DENY_ALL) - .cloned() - .collect(); - - let matches = expected_user - .iter() - .all(|path| configured_user.contains(path)); - - if matches { + let (enabled, cone, configured_paths) = self + .git_ops + .sparse_checkout_state(submodule_path) + .map_err(Self::map_git_ops_error)?; + if expected_paths.is_empty() && !enabled { + Ok(SparseStatus::NotEnabled) + } else if !enabled { + Ok(SparseStatus::NotConfigured) + } else if !cone && expected_paths == configured_paths { Ok(SparseStatus::Correct) } else { Ok(SparseStatus::Mismatch { - expected: expected_user, - actual: configured_user, + expected: expected_paths.to_vec(), + actual: configured_paths, }) } } - /// Add a submodule using the fallback chain: gitoxide -> git2 -> CLI #[allow(clippy::too_many_arguments, clippy::needless_pass_by_value)] - pub fn add_submodule( - &mut self, + fn prepare_add( + &self, name: String, path: String, url: String, @@ -404,127 +966,199 @@ impl GitManager { shallow: Option, no_init: bool, use_git_default_sparse_checkout: Option, - ) -> Result<(), SubmoduleError> { - let repo_root = self - .git_ops - .workdir() - .ok_or(SubmoduleError::RepositoryError)?; - if let Err(e) = - crate::utilities::validate_submodule_path(repo_root, std::path::Path::new(&path)) - { - return Err(SubmoduleError::InvalidPath(e.to_string())); - } - - if no_init { - self.update_toml_config( - name, - SubmoduleEntry { - path: Some(path.clone()), - url: Some(url), - branch, - ignore, - update, - fetch_recurse, - active: Some(!no_init), - shallow, - no_init: Some(no_init), - sparse_paths: None, - use_git_default_sparse_checkout, - }, - sparse_paths, - )?; - // When requested, only update configuration without touching repository state. - return Ok(()); + ) -> Result { + if self.config.get_submodule(&name).is_some() { + return Err(SubmoduleError::ConfigError(format!( + "submodule {name:?} is already declared" + ))); } - - // Clean up any existing submodule state using git commands - self.cleanup_existing_submodule(&path); - - let opts = crate::config::SubmoduleAddOptions { - name: name.clone(), - path: std::path::PathBuf::from(&path), - url: url.clone(), - branch: branch.clone(), + let path = self + .validate_requested_path(&name, Path::new(&path), None)? + .to_string_lossy() + .into_owned(); + let raw_entry = SubmoduleEntry { + path: Some(path.clone()), + url: Some(url.clone()), + branch, ignore, - update: update.clone(), + update, fetch_recurse, - shallow: shallow.unwrap_or(false), + active: None, + shallow, + no_init: None, + sparse_paths: sparse_paths.clone(), + use_git_default_sparse_checkout, + }; + let mut prospective = self.config.clone(); + prospective.add_submodule(name.clone(), raw_entry.clone()); + let managed = Self::managed_settings(&prospective, &name)?; + let options = crate::config::SubmoduleAddOptions { + name: name.clone(), + path: PathBuf::from(&path), + url, + branch: managed.branch.clone(), + ignore: managed.ignore, + update: managed.update.clone(), + fetch_recurse: managed.fetch_recurse, + shallow: managed.shallow.unwrap_or(false), no_init, }; - match self - .git_ops - .add_submodule(&opts) - .map_err(Self::map_git_ops_error) - { - Ok(()) => { - // Store the opt-out flag in config before configuring sparse checkout - // so that the helper can resolve it. - { - let entry = SubmoduleEntry { - path: Some(path.clone()), - url: Some(url.clone()), - branch: branch.clone(), - ignore, - update: update.clone(), - fetch_recurse, - active: Some(!no_init), - shallow, - no_init: Some(no_init), - sparse_paths: None, - use_git_default_sparse_checkout, - }; - self.config.add_submodule(name.clone(), entry); - } - // Configure after successful submodule creation - self.configure_submodule_post_creation(&name, &path, sparse_paths.clone())?; - self.update_toml_config( - name.clone(), - SubmoduleEntry { - path: Some(path), - url: Some(url), - branch, - ignore, - update, - fetch_recurse, - active: Some(!no_init), - shallow, - no_init: Some(no_init), - sparse_paths: None, // stored separately via configure_submodule_post_creation - use_git_default_sparse_checkout, - }, - sparse_paths, - )?; - println!("Added submodule {name}"); - Ok(()) - } - Err(e) => Err(e), - } - } - - /// Clean up existing submodule state using git commands only - fn cleanup_existing_submodule(&mut self, path: &str) { - // Best-effort cleanup of any existing submodule state - // These operations may fail if the submodule doesn't exist yet, which is fine, - // but other errors (permissions, corruption, etc.) should at least be visible. - if let Err(e) = self.git_ops.deinit_submodule(path, true) { - eprintln!("Warning: failed to deinit submodule at '{path}': {e:?}"); - } - if let Err(e) = self.git_ops.delete_submodule(path) { - eprintln!("Warning: failed to delete submodule at '{path}': {e:?}"); + if !no_init { + self.git_ops + .preflight_add_submodule(&options) + .map_err(Self::map_git_ops_error)?; } + Ok(AddPlan { + name, + path, + raw_entry, + managed, + options, + sparse_paths, + no_init, + }) } - /// Configure submodule for post-creation setup - fn configure_submodule_post_creation( + /// Validate and describe an add without locks, writes, staging, or remote access. + #[allow(clippy::too_many_arguments, clippy::needless_pass_by_value)] + pub fn preview_add_submodule( &self, - name: &str, - path: &str, + name: String, + path: String, + url: String, sparse_paths: Option>, - ) -> Result<(), SubmoduleError> { - // Only configure git-level sparse checkout if the submodule directory exists - // (it may not exist yet if --no-init was used) - let submodule_exists = std::path::Path::new(path).exists(); - if submodule_exists && let Some(patterns) = sparse_paths { + branch: Option, + ignore: Option, + fetch_recurse: Option, + update: Option, + shallow: Option, + no_init: bool, + use_git_default_sparse_checkout: Option, + ) -> Result<(), SubmoduleError> { + let plan = self.prepare_add( + name, + path, + url, + sparse_paths, + branch, + ignore, + fetch_recurse, + update, + shallow, + no_init, + use_git_default_sparse_checkout, + )?; + if plan.no_init { + println!( + "{} at {}: would-change: write the TOML declaration only; no Git metadata, index, checkout, or remote access.", + crate::utilities::safe_human_text(&plan.name), + crate::utilities::safe_human_text(&plan.path) + ); + } else { + let branch = plan.managed.branch.as_ref().map_or_else( + || "the remote default branch".to_string(), + |value| format!("branch {}", value.as_config_value()), + ); + let policy = if plan.managed.update == Some(SerializableUpdate::None) { + "; the explicit add still creates this initial checkout despite update=none" + } else { + "" + }; + println!( + "{} at {}: would-change: clone from {} using {}, stage its .gitmodules registration and resulting gitlink, reconcile managed Git settings, and write the TOML declaration{}.", + crate::utilities::safe_human_text(&plan.name), + crate::utilities::safe_human_text(&plan.path), + crate::utilities::safe_human_text(&plan.options.url), + crate::utilities::safe_human_text(&branch), + policy, + ); + } + Ok(()) + } + + /// Add a submodule through the shared native Git lifecycle. + #[allow(clippy::too_many_arguments, clippy::needless_pass_by_value)] + pub fn add_submodule( + &mut self, + name: String, + path: String, + url: String, + sparse_paths: Option>, + branch: Option, + ignore: Option, + fetch_recurse: Option, + update: Option, + shallow: Option, + no_init: bool, + use_git_default_sparse_checkout: Option, + ) -> Result<(), SubmoduleError> { + let _locks = self.acquire_mutation_locks()?; + self.reload_locked_config()?; + let plan = self.prepare_add( + name, + path, + url, + sparse_paths, + branch, + ignore, + fetch_recurse, + update, + shallow, + no_init, + use_git_default_sparse_checkout, + )?; + let AddPlan { + name, + path, + raw_entry, + managed, + options, + sparse_paths, + no_init, + } = plan; + + if no_init { + self.update_toml_config(name, raw_entry, sparse_paths)?; + // When requested, only update configuration without touching repository state. + return Ok(()); + } + + match self + .git_ops + .add_submodule(&options) + .map_err(Self::map_git_ops_error) + { + Ok(()) => { + self.git_ops + .sync_added_submodule_settings(&path, &managed) + .map_err(Self::map_git_ops_error)?; + // Store the opt-out flag in config before configuring sparse checkout + // so that the helper can resolve it. + self.config.add_submodule(name.clone(), raw_entry.clone()); + // Configure after successful submodule creation + self.configure_submodule_post_creation(&name, &path, sparse_paths.clone())?; + self.update_toml_config(name.clone(), raw_entry, sparse_paths)?; + println!( + "Added submodule {}", + crate::utilities::safe_human_text(&name) + ); + Ok(()) + } + Err(e) => Err(e), + } + } + + /// Configure submodule for post-creation setup + fn configure_submodule_post_creation( + &self, + name: &str, + path: &str, + sparse_paths: Option>, + ) -> Result<(), SubmoduleError> { + // Only configure git-level sparse checkout if the submodule directory exists + // (it may not exist yet if --no-init was used) + let submodule_exists = self.context.worktree_root.join(path).exists(); + if submodule_exists && let Some(patterns) = sparse_paths { let use_git_default = self.effective_use_git_default_sparse_checkout(name); self.configure_sparse_checkout(path, &patterns, use_git_default)?; } @@ -554,7 +1188,7 @@ impl GitManager { let normalized = Self::build_deny_all_sparse_patterns(patterns); if !normalized.is_empty() { eprintln!( - "ℹ️ submod uses a deny-all-by-default sparse-checkout model: `!/*` is \ + "submod uses a deny-all-by-default sparse-checkout model: `!/*` is \ automatically prepended so only the paths you list are checked out.\n\ To use git's default behavior instead, set \ `use_git_default_sparse_checkout = true` in your submod.toml (globally \ @@ -565,25 +1199,17 @@ impl GitManager { normalized }; - self.git_ops - .enable_sparse_checkout(submodule_path) - .map_err(|e| { - SubmoduleError::GitoxideError(format!("Enable sparse checkout failed: {e}")) - })?; - - self.git_ops - .set_sparse_patterns(submodule_path, &effective_patterns) - .map_err(|e| { - SubmoduleError::GitoxideError(format!("Set sparse patterns failed: {e}")) - })?; - - self.git_ops - .apply_sparse_checkout(submodule_path) - .map_err(|e| { - SubmoduleError::GitoxideError(format!("Apply sparse checkout failed: {e}")) - })?; - - println!("Configured sparse checkout"); + let changed = self + .git_ops + .reconcile_sparse_checkout(submodule_path, &effective_patterns) + .map_err(Self::map_git_ops_error)?; + if changed { + if effective_patterns.is_empty() { + println!("Disabled sparse checkout and restored the full checkout"); + } else { + println!("Configured sparse checkout"); + } + } Ok(()) } @@ -628,151 +1254,272 @@ impl GitManager { /// The per-submodule entry takes precedence over the global `[defaults]` setting. /// When neither is set, `false` is returned (submod's deny-all-by-default model). fn effective_use_git_default_sparse_checkout(&self, submodule_name: &str) -> bool { - let per_submodule = self - .config - .get_submodule(submodule_name) - .and_then(|e| e.use_git_default_sparse_checkout); - per_submodule.unwrap_or_else(|| { - self.config - .defaults - .use_git_default_sparse_checkout - .unwrap_or(false) - }) + self.config + .effective_entry(submodule_name) + .and_then(|entry| entry.use_git_default_sparse_checkout) + .unwrap_or(false) } - /// Get the actual git directory path, handling gitlinks in submodules - #[allow(clippy::unused_self)] + /// Get the actual Git directory after verifying the checkout identity. fn get_git_directory( &self, submodule_path: &str, ) -> Result { - let git_path = std::path::Path::new(submodule_path).join(".git"); - - if git_path.is_dir() { - // Regular git repository - Ok(git_path) - } else if git_path.is_file() { - // Gitlink - read the file to get the actual git directory - let content = fs::read_to_string(&git_path)?; - - let git_dir_line = content - .lines() - .find(|line| line.starts_with("gitdir: ")) - .ok_or_else(|| { - SubmoduleError::IoError(std::io::Error::new( - std::io::ErrorKind::InvalidData, - "Invalid gitlink file", - )) - })?; - - let git_dir_path = git_dir_line.strip_prefix("gitdir: ").unwrap().trim(); - - // Path might be relative to the submodule directory - let absolute_path = if std::path::Path::new(git_dir_path).is_absolute() { - std::path::PathBuf::from(git_dir_path) - } else { - std::path::Path::new(submodule_path).join(git_dir_path) - }; - - Ok(absolute_path) - } else { - // Use gix as fallback - gix::open(submodule_path) - .map(|repo| repo.git_dir().to_path_buf()) - .map_err(|_| SubmoduleError::RepositoryError) - } + self.git_ops + .verify_submodule_checkout(submodule_path) + .map_err(Self::map_git_ops_error)?; + crate::utilities::git_path( + &self.context.worktree_root.join(submodule_path), + &["--absolute-git-dir"], + ) + .map_err(Self::map_git_ops_error) } // Removed: apply_sparse_checkout_cli is obsolete; sparse checkout is handled by GitOpsManager abstraction. - /// Update submodule using CLI fallback (gix remote operations are complex for this use case) + /// Update a submodule to its parent-recorded commit using the effective strategy. pub fn update_submodule(&mut self, name: &str) -> Result<(), SubmoduleError> { + let _locks = self.acquire_mutation_locks()?; + self.reload_locked_config()?; + self.update_submodule_locked(name, false, false) + } + + fn update_submodule_locked( + &mut self, + name: &str, + remote: bool, + recursive: bool, + ) -> Result<(), SubmoduleError> { let config = self.config - .submodules - .get(name) + .effective_entry(name) .ok_or_else(|| SubmoduleError::SubmoduleNotFound { name: name.to_string(), })?; - - let submodule_path = config.path.as_ref().ok_or_else(|| { - SubmoduleError::ConfigError("No path configured for submodule".to_string()) - })?; - - // Prepare update options (use defaults for now) - let update_opts = crate::config::SubmoduleUpdateOptions::default(); + let submodule_path = config.path.clone().expect("effective path is populated"); + let registered = self.registration_for_path(&submodule_path)?.is_some(); + if registered { + self.sync_effective_settings(name, &submodule_path)?; + } + if !config.active.unwrap_or(true) || config.update == Some(SerializableUpdate::None) { + return Ok(()); + } + if !registered { + return Err(SubmoduleError::ConfigError(format!( + "submodule {name:?} is not registered; run init or sync" + ))); + } + let mut update_opts = + crate::config::SubmoduleUpdateOptions::from_options(config.git_options()); + update_opts.remote = remote; + update_opts.recursive = recursive; self.git_ops - .update_submodule(submodule_path, &update_opts) - .map_err(|e| { - SubmoduleError::GitoxideError(format!("GitOpsManager update failed: {e}")) - })?; + .update_submodule(&submodule_path, &update_opts) + .map_err(Self::map_git_ops_error)?; + self.git_ops + .verify_submodule_checkout(&submodule_path) + .map_err(Self::map_git_ops_error)?; + + let sparse_paths = config.sparse_paths.as_deref().unwrap_or(&[]); + let use_git_default = config.use_git_default_sparse_checkout.unwrap_or(false); + self.configure_sparse_checkout(&submodule_path, sparse_paths, use_git_default)?; - // Name every submodule that was updated, not just a trailing count: with - // only the count, a multi-submodule `update` gives no way to tell which - // ones it actually touched. - println!("✅ Updated {name}"); Ok(()) } /// Reset submodule using CLI operations - pub fn reset_submodule(&self, name: &str) -> Result<(), SubmoduleError> { - let config = - self.config - .submodules - .get(name) - .ok_or_else(|| SubmoduleError::SubmoduleNotFound { - name: name.to_string(), - })?; - - let submodule_path = config.path.as_ref().ok_or_else(|| { - SubmoduleError::ConfigError("No path configured for submodule".to_string()) - })?; - - println!("🔄 Hard resetting {name}..."); + pub fn reset_submodule(&mut self, name: &str) -> Result<(), SubmoduleError> { + self.reset_submodules(false, vec![name.to_string()]) + } - // Step 1: Stash changes - println!(" 📦 Stashing working changes..."); - match self.git_ops.stash_submodule(submodule_path, true) { - Ok(()) => {} - Err(e) => println!(" ⚠️ Stash warning: {e}"), + fn prepare_reset( + &self, + all: bool, + names: Vec, + ) -> Result, SubmoduleError> { + let mut names = if all { + self.config + .get_submodules() + .map(|(name, _)| name.clone()) + .collect::>() + } else { + names + }; + names.sort(); + if names.is_empty() { + return Err(SubmoduleError::ConfigError( + "No submodules specified for reset".to_string(), + )); } + if names.windows(2).any(|pair| pair[0] == pair[1]) { + return Err(SubmoduleError::ConfigError( + "Duplicate submodule target in reset selection".to_string(), + )); + } + names + .into_iter() + .map(|name| { + let effective = self + .config + .effective_entry(&name) + .ok_or_else(|| SubmoduleError::SubmoduleNotFound { name: name.clone() })?; + let path = effective + .path + .expect("effective submodule path is populated"); + let target = self + .git_ops + .preflight_reset_submodule(&path) + .map_err(Self::map_git_ops_error)?; + Ok((name, path, target)) + }) + .collect() + } - // Step 2: Hard reset - println!(" 🔄 Resetting to HEAD..."); - self.git_ops - .reset_submodule(submodule_path, true) - .map_err(|e| { - SubmoduleError::GitoxideError(format!("GitOpsManager reset failed: {e}")) - })?; + /// Preview preservation and reset targets without creating a stash or changing files. + pub fn preview_reset_submodules( + &self, + all: bool, + names: Vec, + ) -> Result<(), SubmoduleError> { + self.require_config()?; + for (name, path, target) in self.prepare_reset(all, names)? { + println!( + "Would preserve local work and reset submodule '{name}' at '{path}' to parent pin {target}.", + name = crate::utilities::safe_human_text(&name), + path = crate::utilities::safe_human_text(&path), + target = crate::utilities::safe_human_text(&target) + ); + } + Ok(()) + } - // Step 3: Clean untracked files - println!(" 🧹 Cleaning untracked files..."); - self.git_ops - .clean_submodule(submodule_path, true, true) - .map_err(|e| { - SubmoduleError::GitoxideError(format!("GitOpsManager clean failed: {e}")) - })?; + /// Reset a validated selection under one command lock and whole-batch preflight. + pub fn reset_submodules( + &mut self, + all: bool, + names: Vec, + ) -> Result<(), SubmoduleError> { + let _locks = self.acquire_mutation_locks()?; + self.reload_locked_config()?; + self.require_config()?; + let plans = self.prepare_reset(all, names)?; - println!("✅ {name} reset complete"); + let mut completed = Vec::new(); + for (index, (name, path, target)) in plans.iter().enumerate() { + eprintln!( + "Resetting {} to parent pin {}...", + crate::utilities::safe_human_text(name), + crate::utilities::safe_human_text(target) + ); + let stash = match self.git_ops.stash_submodule(path, true) { + Ok(stash) => stash, + Err(error) => { + let pending: Vec<&str> = plans[index + 1..] + .iter() + .map(|(name, _, _)| name.as_str()) + .collect(); + eprintln!("Reset stopped after preservation failed."); + eprintln!( + " completed: {}", + crate::utilities::safe_human_text(&completed.join(", ")) + ); + eprintln!( + " failed: {}: {}", + crate::utilities::safe_human_text(name), + crate::utilities::safe_human_text(&error.to_string()) + ); + eprintln!( + " pending: {}", + crate::utilities::safe_human_text(&pending.join(", ")) + ); + return Err(SubmoduleError::CliError(format!( + "Could not preserve work for {name}; reset was not attempted: {error}" + ))); + } + }; + let recovery = if let Some(oid) = &stash { + println!( + " Preserved local work in stash {}", + crate::utilities::safe_human_text(oid) + ); + let command = self + .git_ops + .stash_recovery_command(path, oid) + .map_err(Self::map_git_ops_error)?; + println!( + " Recovery (run inside {}): {command}", + crate::utilities::safe_human_text( + &self.context.worktree_root.join(path).to_string_lossy() + ), + command = crate::utilities::safe_human_text(&command), + ); + Some(command) + } else { + println!(" No local work needed a stash"); + None + }; + if let Err(error) = self.git_ops.reset_submodule(path, true) { + let pending: Vec<&str> = plans[index + 1..] + .iter() + .map(|(name, _, _)| name.as_str()) + .collect(); + eprintln!("Reset stopped after a runtime failure."); + eprintln!( + " completed: {}", + crate::utilities::safe_human_text(&completed.join(", ")) + ); + eprintln!( + " failed: {}: {}", + crate::utilities::safe_human_text(name), + crate::utilities::safe_human_text(&error.to_string()) + ); + eprintln!( + " pending: {}", + crate::utilities::safe_human_text(&pending.join(", ")) + ); + if let Some(command) = recovery { + eprintln!( + " preserved work (run inside {}): {command}", + crate::utilities::safe_human_text( + &self.context.worktree_root.join(path).to_string_lossy() + ), + command = crate::utilities::safe_human_text(&command), + ); + } + return Err(SubmoduleError::CliError(format!( + "Reset failed for {name}: {error}" + ))); + } + completed.push(name.clone()); + println!( + "{} reset to {}", + crate::utilities::safe_human_text(name), + crate::utilities::safe_human_text(target) + ); + } + println!( + "Reset summary: {} changed, 0 unchanged, 0 skipped, 0 failed.", + plans.len() + ); Ok(()) } /// Initialize submodule - add it first if not registered, then initialize pub fn init_submodule(&mut self, name: &str) -> Result<(), SubmoduleError> { - let (path_str, url_str, branch, ignore, update, fetch_recurse, shallow, sparse_paths_opt) = { - let config = self.config.get_submodule(name).ok_or_else(|| { + let _locks = self.acquire_mutation_locks()?; + self.reload_locked_config()?; + self.init_submodule_locked(name, false) + } + + fn init_submodule_locked(&mut self, name: &str, recursive: bool) -> Result<(), SubmoduleError> { + let (path_str, url_str, branch, ignore, update, fetch_recurse, sparse_paths_opt, active) = { + let config = self.config.effective_entry(name).ok_or_else(|| { SubmoduleError::SubmoduleNotFound { name: name.to_string(), } })?; - let path_str = config - .path - .as_ref() - .ok_or_else(|| { - SubmoduleError::ConfigError("No path configured for submodule".to_string()) - })? - .clone(); + let path_str = config.path.expect("effective path is populated"); let url_str = config .url @@ -782,204 +1529,1105 @@ impl GitManager { })? .clone(); - let sparse_paths_opt = self - .config - .submodules - .sparse_checkouts() - .and_then(|sparse_checkouts| sparse_checkouts.get(name).cloned()); - ( path_str, url_str, - config.branch.clone(), + config.branch, config.ignore, - config.update.clone(), + config.update, config.fetch_recurse, - config.shallow.unwrap_or(false), - sparse_paths_opt, + config.sparse_paths, + config.active.unwrap_or(true), ) }; - let submodule_path = Path::new(&path_str); + self.validate_requested_path(name, Path::new(&path_str), Some(name))?; + let submodule_path = self.context.worktree_root.join(&path_str); + let mut registered = self.registration_for_path(&path_str)?.is_some(); + let mut gitlink = self + .git_ops + .index_gitlink_oid(&path_str) + .map_err(Self::map_git_ops_error)?; + if !registered && gitlink.is_some() { + let managed = Self::managed_settings(&self.config, name)?; + self.git_ops + .restore_registration(name, &path_str, &managed) + .map_err(Self::map_git_ops_error)?; + registered = true; + } - if submodule_path.exists() && submodule_path.join(".git").exists() { - if self.verbose { - println!("✅ {name} already initialized"); - } - // Even if already initialized, check if we need to configure sparse checkout - if let Some(sparse_paths) = sparse_paths_opt { - let use_git_default = self.effective_use_git_default_sparse_checkout(name); - self.configure_sparse_checkout(&path_str, &sparse_paths, use_git_default)?; + if !active || update == Some(SerializableUpdate::None) { + if registered { + self.sync_effective_settings(name, &path_str)?; } return Ok(()); } + if submodule_path.exists() && submodule_path.join(".git").exists() { + self.git_ops + .verify_submodule_checkout(&path_str) + .map_err(Self::map_git_ops_error)?; + self.sync_effective_settings(name, &path_str)?; + let mut update_opts = crate::config::SubmoduleUpdateOptions::from_options( + crate::config::SubmoduleGitOptions::new(ignore, fetch_recurse, branch, update), + ); + update_opts.recursive = recursive; + self.git_ops + .update_submodule(&path_str, &update_opts) + .map_err(Self::map_git_ops_error)?; + let use_git_default = self.effective_use_git_default_sparse_checkout(name); + self.configure_sparse_checkout( + &path_str, + sparse_paths_opt.as_deref().unwrap_or(&[]), + use_git_default, + )?; + return Ok(()); + } + if self.verbose { - println!("🔄 Initializing {name}..."); + eprintln!( + "Initializing {}...", + crate::utilities::safe_human_text(name) + ); } - let workdir = std::path::Path::new("."); + if registered && gitlink.is_none() { + let managed = Self::managed_settings(&self.config, name)?; + self.git_ops + .complete_registration(&path_str, &managed) + .map_err(Self::map_git_ops_error)?; + gitlink = self + .git_ops + .index_gitlink_oid(&path_str) + .map_err(Self::map_git_ops_error)?; + debug_assert!(gitlink.is_some()); + } - // First check if submodule is registered in .gitmodules - let gitmodules_path = workdir.join(".gitmodules"); - let needs_add = if gitmodules_path.exists() { - let gitmodules_content = fs::read_to_string(&gitmodules_path)?; - !gitmodules_content.contains(&format!("path = {path_str}")) - } else { - true - }; + let needs_add = !registered; if needs_add { + let managed = Self::managed_settings(&self.config, name)?; // Submodule not registered yet, add it first via GitOpsManager let opts = crate::config::SubmoduleAddOptions { name: name.to_string(), path: std::path::PathBuf::from(&path_str), url: url_str, - branch, - ignore, - update, - fetch_recurse, - shallow, + branch: managed.branch, + ignore: managed.ignore, + update: managed.update, + fetch_recurse: managed.fetch_recurse, + shallow: managed.shallow.unwrap_or(false), no_init: false, }; self.git_ops .add_submodule(&opts) .map_err(Self::map_git_ops_error)?; + if recursive { + let mut update_opts = crate::config::SubmoduleUpdateOptions::from_options( + crate::config::SubmoduleGitOptions::new(ignore, fetch_recurse, branch, update), + ); + update_opts.recursive = true; + self.git_ops + .update_submodule(&path_str, &update_opts) + .map_err(Self::map_git_ops_error)?; + } } else { + self.sync_effective_settings(name, &path_str)?; // Submodule is registered, just initialize and update using GitOperations self.git_ops .init_submodule(&path_str) .map_err(Self::map_git_ops_error)?; - let update_opts = crate::config::SubmoduleUpdateOptions::default(); + let mut update_opts = crate::config::SubmoduleUpdateOptions::from_options( + crate::config::SubmoduleGitOptions::new(ignore, fetch_recurse, branch, update), + ); + update_opts.recursive = recursive; self.git_ops .update_submodule(&path_str, &update_opts) .map_err(Self::map_git_ops_error)?; } + self.sync_effective_settings(name, &path_str)?; + if self.verbose { - println!(" ✅ Initialized using git submodule commands: {path_str}"); + println!( + " Initialized using Git submodule commands: {}", + crate::utilities::safe_human_text(&path_str) + ); } - // Configure sparse checkout if specified - if let Some(sparse_paths) = sparse_paths_opt { - let use_git_default = self.effective_use_git_default_sparse_checkout(name); - self.configure_sparse_checkout(&path_str, &sparse_paths, use_git_default)?; - } + let use_git_default = self.effective_use_git_default_sparse_checkout(name); + self.configure_sparse_checkout( + &path_str, + sparse_paths_opt.as_deref().unwrap_or(&[]), + use_git_default, + )?; if self.verbose { - println!("✅ {name} initialized"); + println!("{} initialized", crate::utilities::safe_human_text(name)); } Ok(()) } - /// Check all submodules using gitoxide APIs where possible - #[allow(clippy::unnecessary_wraps)] - pub fn check_all_submodules(&self) -> Result<(), SubmoduleError> { - if self.verbose { - println!("Checking submodule configurations..."); + fn add_options_for( + &self, + name: &str, + ) -> Result { + let effective = + self.config + .effective_entry(name) + .ok_or_else(|| SubmoduleError::SubmoduleNotFound { + name: name.to_string(), + })?; + let managed = Self::managed_settings(&self.config, name)?; + Ok(crate::config::SubmoduleAddOptions { + name: name.to_string(), + path: PathBuf::from( + effective + .path + .expect("effective submodule path is populated"), + ), + url: effective + .url + .expect("validated effective submodule URL is populated"), + branch: managed.branch, + ignore: managed.ignore, + update: managed.update, + fetch_recurse: managed.fetch_recurse, + shallow: managed.shallow.unwrap_or(false), + no_init: false, + }) + } + + fn preflight_reconcile( + &self, + names: &[String], + scope: ReconcileScope, + remote: bool, + ) -> Result<(), SubmoduleError> { + self.validate_all_paths()?; + for name in names { + let effective = self + .config + .effective_entry(name) + .ok_or_else(|| SubmoduleError::SubmoduleNotFound { name: name.clone() })?; + let path = effective + .path + .as_deref() + .expect("effective submodule path is populated"); + let registered = self.registration_for_path(path)?.is_some(); + if !registered + && let Some(existing) = self + .git_ops + .registration_path(name) + .map_err(Self::map_git_ops_error)? + { + return Err(SubmoduleError::ConfigError(format!( + "managed entry {name:?} changed path from {existing:?} to {path:?} without an explicit Git-aware move; use `submod change {name} --path {path}`" + ))); + } + let gitlink = self + .git_ops + .index_gitlink_oid(path) + .map_err(Self::map_git_ops_error)?; + let skipped = !effective.active.unwrap_or(true) + || effective.update == Some(SerializableUpdate::None); + let managed = Self::managed_settings(&self.config, name)?; + match (registered, gitlink.is_some(), skipped) { + (true, true, _) | (true, false, true) => self + .git_ops + .preflight_submodule_settings(path, &managed) + .map_err(Self::map_git_ops_error)?, + (true, false, false) => self + .git_ops + .preflight_complete_registration(path, &managed) + .map_err(Self::map_git_ops_error)?, + (false, true, _) => self + .git_ops + .preflight_restore_registration(name, path, &managed) + .map_err(Self::map_git_ops_error)?, + (false, false, true) => {} + (false, false, false) => { + if scope == ReconcileScope::Update { + return Err(SubmoduleError::ConfigError(format!( + "submodule {name:?} is not registered; run init or sync" + ))); + } + let options = self.add_options_for(name)?; + self.git_ops + .preflight_add_submodule(&options) + .map_err(Self::map_git_ops_error)?; + } + } + if registered && !skipped && self.context.worktree_root.join(path).join(".git").exists() + { + let mut update_opts = + crate::config::SubmoduleUpdateOptions::from_options(effective.git_options()); + update_opts.remote = scope == ReconcileScope::Update && remote; + self.git_ops + .preflight_update_submodule(path, &update_opts) + .map_err(Self::map_git_ops_error)?; + let patterns = if effective.use_git_default_sparse_checkout.unwrap_or(false) { + effective.sparse_paths.clone().unwrap_or_default() + } else { + Self::build_deny_all_sparse_patterns( + effective.sparse_paths.as_deref().unwrap_or(&[]), + ) + }; + self.git_ops + .preflight_sparse_checkout(path, &patterns) + .map_err(Self::map_git_ops_error)?; + } } + Ok(()) + } - for (submodule_name, submodule) in self.config.get_submodules() { - // Handle missing path gracefully - report but don't fail - let Some(path_str) = submodule.path.as_ref() else { - // Always show errors regardless of verbosity - println!(" ❌ {submodule_name}: No path configured"); - continue; - }; + fn report_unmanaged_submodules(&self) -> Result<(), SubmoduleError> { + let managed: std::collections::HashSet = self + .config + .get_submodules() + .map(|(name, entry)| entry.path.clone().unwrap_or_else(|| name.clone())) + .collect(); + let mut unmanaged: Vec = self + .git_ops + .list_submodules() + .map_err(Self::map_git_ops_error)? + .into_iter() + .filter(|path| !managed.contains(path)) + .collect(); + unmanaged.sort(); + for path in unmanaged { + println!( + "Unmanaged Git submodule preserved: {}", + crate::utilities::safe_human_text(&path) + ); + } + Ok(()) + } - // Handle missing URL gracefully - report but don't fail - if submodule.url.is_none() { - println!(" ❌ {submodule_name}: No URL configured"); - continue; + /// Put registration-changing actions before metadata-only reconciliation. + /// + /// Native registration commands stage `.gitmodules`. Existing managed + /// metadata changes are deliberately left unstaged, so applying those + /// first would make a later add reject the command's own edit. The full + /// batch has already been preflighted before this ordering is computed. + fn registration_first_order(&self, names: &[String]) -> Result, SubmoduleError> { + let mut registration = Vec::new(); + let mut existing = Vec::new(); + for name in names { + let effective = self + .config + .effective_entry(name) + .ok_or_else(|| SubmoduleError::SubmoduleNotFound { name: name.clone() })?; + let path = effective + .path + .as_deref() + .expect("effective submodule path is populated"); + let registered = self.registration_for_path(path)?.is_some(); + let gitlink = self + .git_ops + .index_gitlink_oid(path) + .map_err(Self::map_git_ops_error)? + .is_some(); + if !registered || !gitlink { + registration.push(name.clone()); + } else { + existing.push(name.clone()); } + } + registration.extend(existing); + Ok(registration) + } - let submodule_path = Path::new(path_str); - let git_path = submodule_path.join(".git"); + fn prepare_reconcile( + &self, + scope: ReconcileScope, + remote: bool, + recursive: bool, + ) -> Result, SubmoduleError> { + let mut names: Vec = self + .config + .get_submodules() + .map(|(name, _)| name.clone()) + .collect(); + names.sort(); + if !names.is_empty() { + self.preflight_reconcile(&names, scope, remote)?; + names = self.registration_first_order(&names)?; + } + names + .into_iter() + .map(|name| self.reconcile_plan_for(name, scope, remote, recursive)) + .collect() + } - if !submodule_path.exists() { - println!(" ❌ {submodule_name}: Folder missing ({path_str})"); - continue; + fn reconcile_plan_for( + &self, + name: String, + scope: ReconcileScope, + remote: bool, + recursive: bool, + ) -> Result { + let effective = self + .config + .effective_entry(&name) + .ok_or_else(|| SubmoduleError::SubmoduleNotFound { name: name.clone() })?; + let path = effective + .path + .clone() + .expect("effective submodule path is populated"); + let registered = self.registration_for_path(&path)?.is_some(); + let parent_pin = self + .git_ops + .index_gitlink_oid(&path) + .map_err(Self::map_git_ops_error)?; + let initialized = self.context.worktree_root.join(&path).join(".git").exists(); + let managed = Self::managed_settings(&self.config, &name)?; + let verbose_detail = self.verbose.then(|| { + format!( + "effective: branch={}, update={:?}, ignore={:?}, active={}, shallow={}, sparse_paths={:?}", + effective + .branch + .as_ref() + .map_or_else(|| "".to_string(), |branch| branch.as_config_value()), + effective.update, + effective.ignore, + effective.active.unwrap_or(true), + effective.shallow.unwrap_or(false), + effective.sparse_paths.as_deref().unwrap_or(&[]), + ) + }); + let metadata_changed = registered + && !self + .git_ops + .submodule_settings_match(&path, &managed) + .map_err(Self::map_git_ops_error)?; + let initial_head = initialized + .then(|| { + self.git_ops + .submodule_head(&path) + .map_err(Self::map_git_ops_error) + }) + .transpose()?; + let eligible = + effective.active.unwrap_or(true) && effective.update != Some(SerializableUpdate::None); + let initial_recursive_state = (recursive && initialized && eligible) + .then(|| { + self.git_ops + .recursive_submodule_state(&path) + .map_err(Self::map_git_ops_error) + }) + .transpose()?; + let remote_requested = scope == ReconcileScope::Update && remote; + + if !effective.active.unwrap_or(true) { + let detail = if metadata_changed { + "checkout disabled; reconcile managed metadata without materializing or moving HEAD" + } else { + "checkout disabled; managed metadata already matches" + }; + return Ok(ReconcilePlan { + name, + path, + kind: if metadata_changed { + ModuleOutcomeKind::ChangedSkippedDisabled + } else { + ModuleOutcomeKind::SkippedDisabled + }, + detail: detail.to_string(), + target: parent_pin, + verbose_detail, + registration_changed: false, + metadata_changed, + sparse_changed: false, + remote_requested: false, + recursive_requested: false, + initial_head, + initial_recursive_state, + }); + } + if effective.update == Some(SerializableUpdate::None) { + let detail = if metadata_changed { + "update=none; reconcile managed metadata without fetching, materializing, or moving HEAD" + } else { + "update=none; managed metadata already matches" + }; + return Ok(ReconcilePlan { + name, + path, + kind: if metadata_changed { + ModuleOutcomeKind::ChangedSkippedPolicy + } else { + ModuleOutcomeKind::SkippedPolicy + }, + detail: detail.to_string(), + target: parent_pin, + verbose_detail, + registration_changed: false, + metadata_changed, + sparse_changed: false, + remote_requested: false, + recursive_requested: false, + initial_head, + initial_recursive_state, + }); + } + + if !registered || parent_pin.is_none() { + let branch = effective.branch.as_ref().map_or_else( + || "the remote default branch".to_string(), + |value| format!("branch {}", value.as_config_value()), + ); + let initial_action = if registered { + format!( + "complete registration, stage .gitmodules and its gitlink, then initialize from {branch}" + ) + } else if parent_pin.is_some() { + "restore the missing registration, stage .gitmodules, and materialize the recorded parent pin" + .to_string() + } else { + format!( + "register and initialize from {branch}; stage .gitmodules and the resulting gitlink" + ) + }; + let (detail, target) = if remote_requested { + ( + format!( + "{initial_action}; then fetch and update from the selected {branch}; final remote target unresolved until execution" + ), + None, + ) + } else { + (initial_action, parent_pin) + }; + return Ok(ReconcilePlan { + name, + path, + kind: ModuleOutcomeKind::Changed, + detail, + target, + verbose_detail, + registration_changed: true, + metadata_changed, + sparse_changed: false, + remote_requested, + recursive_requested: recursive, + initial_head, + initial_recursive_state, + }); + } + + if !initialized { + let branch = effective.branch.as_ref().map_or_else( + || "remote default branch".to_string(), + |value| format!("remote branch {}", value.as_config_value()), + ); + let (detail, target) = if remote_requested { + ( + format!( + "materialize the recorded parent pin, then fetch and update from the selected {branch}; final remote target unresolved until execution" + ), + None, + ) + } else { + ( + "materialize the existing registration at its recorded parent pin".to_string(), + parent_pin, + ) + }; + return Ok(ReconcilePlan { + name, + path, + kind: ModuleOutcomeKind::Changed, + detail, + target, + verbose_detail, + registration_changed: true, + metadata_changed, + sparse_changed: false, + remote_requested, + recursive_requested: recursive, + initial_head, + initial_recursive_state, + }); + } + + let mut work = Vec::new(); + if metadata_changed { + let url = effective.url.as_deref().unwrap_or(""); + work.push(format!( + "reconcile managed metadata and native-resolved local URL from {url} (portable edits remain unstaged)" + )); + } + let mut update = + crate::config::SubmoduleUpdateOptions::from_options(effective.git_options()); + update.remote = remote_requested; + update.recursive = recursive; + if update.remote { + let branch = effective.branch.as_ref().map_or_else( + || "remote default branch".to_string(), + |value| format!("remote branch {}", value.as_config_value()), + ); + work.push(format!( + "fetch and update from the selected {branch}; resolve its target during execution" + )); + } else if !self + .git_ops + .update_postcondition_matches(&path, &update) + .map_err(Self::map_git_ops_error)? + { + work.push("apply the configured strategy to the recorded parent pin".to_string()); + } + + let expected_sparse = if effective.use_git_default_sparse_checkout.unwrap_or(false) { + effective.sparse_paths.clone().unwrap_or_default() + } else { + Self::build_deny_all_sparse_patterns(effective.sparse_paths.as_deref().unwrap_or(&[])) + }; + let sparse_changed = !matches!( + self.check_sparse_checkout_status(&path, &expected_sparse)?, + SparseStatus::NotEnabled | SparseStatus::Correct + ); + if sparse_changed { + work.push("reconcile the ordered sparse-checkout policy".to_string()); + } + if recursive { + work.push("reconcile selected nested submodules recursively".to_string()); + } + + let (kind, detail) = if work.is_empty() { + ( + ModuleOutcomeKind::Unchanged, + "registration, metadata, checkout target, and sparse policy already match" + .to_string(), + ) + } else { + (ModuleOutcomeKind::Changed, work.join("; ")) + }; + Ok(ReconcilePlan { + name, + path, + kind, + detail, + target: if remote_requested { None } else { parent_pin }, + verbose_detail, + registration_changed: false, + metadata_changed, + sparse_changed, + remote_requested, + recursive_requested: recursive, + initial_head, + initial_recursive_state, + }) + } + + fn finalize_reconcile_plan( + &self, + mut plan: ReconcilePlan, + ) -> Result { + if matches!( + plan.kind, + ModuleOutcomeKind::SkippedDisabled + | ModuleOutcomeKind::SkippedPolicy + | ModuleOutcomeKind::ChangedSkippedDisabled + | ModuleOutcomeKind::ChangedSkippedPolicy + ) { + if plan.metadata_changed { + let managed = Self::managed_settings(&self.config, &plan.name)?; + if !self + .git_ops + .submodule_settings_match(&plan.path, &managed) + .map_err(Self::map_git_ops_error)? + { + return Err(SubmoduleError::CliError(format!( + "managed metadata postcondition failed for {:?} at {:?}", + plan.name, plan.path + ))); + } + plan.detail = match plan.kind { + ModuleOutcomeKind::ChangedSkippedDisabled => { + "managed metadata reconciled; checkout remained skipped-disabled without materializing or moving HEAD" + } + ModuleOutcomeKind::ChangedSkippedPolicy => { + "managed metadata reconciled; checkout remained skipped-policy (update=none) without fetching, materializing, or moving HEAD" + } + _ => unreachable!("metadata change uses a combined skipped outcome"), + } + .to_string(); + } else { + plan.detail = match plan.kind { + ModuleOutcomeKind::SkippedDisabled => { + "checkout skipped-disabled; managed metadata already matched" + } + ModuleOutcomeKind::SkippedPolicy => { + "checkout skipped-policy (update=none); managed metadata already matched" + } + _ => unreachable!("unchanged skip uses a plain skipped outcome"), + } + .to_string(); } + return Ok(plan); + } - if !git_path.exists() { - println!(" ❌ {submodule_name}: Not a git repository"); - continue; + let effective = self.config.effective_entry(&plan.name).ok_or_else(|| { + SubmoduleError::SubmoduleNotFound { + name: plan.name.clone(), } + })?; + let mut update = + crate::config::SubmoduleUpdateOptions::from_options(effective.git_options()); + update.remote = plan.remote_requested; + update.recursive = plan.recursive_requested; + let final_head = self + .git_ops + .submodule_head(&plan.path) + .map_err(Self::map_git_ops_error)?; + let target = self + .git_ops + .submodule_update_target(&plan.path, &update) + .map_err(Self::map_git_ops_error)?; + if !self + .git_ops + .update_postcondition_matches(&plan.path, &update) + .map_err(Self::map_git_ops_error)? + { + return Err(SubmoduleError::CliError(format!( + "checkout postcondition failed for {:?} at {:?}: expected target {}", + plan.name, plan.path, target + ))); + } + if plan.metadata_changed { + let managed = Self::managed_settings(&self.config, &plan.name)?; + if !self + .git_ops + .submodule_settings_match(&plan.path, &managed) + .map_err(Self::map_git_ops_error)? + { + return Err(SubmoduleError::CliError(format!( + "managed metadata postcondition failed for {:?} at {:?}", + plan.name, plan.path + ))); + } + } + let final_recursive_state = plan + .recursive_requested + .then(|| { + self.git_ops + .recursive_submodule_state(&plan.path) + .map_err(Self::map_git_ops_error) + }) + .transpose()?; + let head_changed = plan.initial_head.as_deref() != Some(final_head.as_str()); + let recursive_changed = plan.recursive_requested + && plan.initial_recursive_state.as_deref() != final_recursive_state.as_deref(); + let changed = plan.registration_changed + || plan.metadata_changed + || plan.sparse_changed + || head_changed + || recursive_changed; + + let mut details = Vec::new(); + if plan.registration_changed { + details.push("registration/materialization reconciled".to_string()); + } + if plan.metadata_changed { + details.push("managed metadata reconciled".to_string()); + } + if head_changed { + details.push(match plan.initial_head.as_deref() { + Some(initial) => format!("checkout moved from {initial} to {final_head}"), + None => format!("checkout materialized at {final_head}"), + }); + } + if plan.sparse_changed { + details.push("ordered sparse-checkout policy reconciled".to_string()); + } + if recursive_changed { + details.push("nested submodule state reconciled".to_string()); + } else if plan.recursive_requested { + details.push("nested submodules already matched".to_string()); + } + if plan.remote_requested && !head_changed { + details.push("selected remote target already satisfied".to_string()); + } + if details.is_empty() { + details.push( + "registration, managed metadata, checkout target, and sparse policy already matched" + .to_string(), + ); + } + plan.kind = if changed { + ModuleOutcomeKind::Changed + } else { + ModuleOutcomeKind::Unchanged + }; + plan.detail = details.join("; "); + plan.target = Some(target.clone()); + if let Some(verbose) = &mut plan.verbose_detail { + verbose.push_str(&format!( + ", result_head={final_head}, selected_target={target}" + )); + } + Ok(plan) + } - // GITOXIDE API: Use gix::open and status check - match self.check_submodule_repository_status(path_str, submodule_name) { - Ok(status) => { - if self.verbose { - println!("\n📁 {submodule_name}"); - println!(" ✅ Git repository exists"); + fn preview_reconcile( + &self, + scope: ReconcileScope, + remote: bool, + recursive: bool, + ) -> Result { + self.require_config()?; + Ok(OperationSummary { + operation: match scope { + ReconcileScope::Init => "Initialization preview", + ReconcileScope::Update => "Update preview", + ReconcileScope::Sync => "Sync preview", + }, + preview: true, + modules: self.prepare_reconcile(scope, remote, recursive)?, + }) + } - if status.is_clean { - println!(" ✅ Working tree is clean"); - } else { - println!(" ⚠️ Working tree has changes"); - } + /// Preview initialization using the same batch selection and preflights as execution. + pub fn preview_init_all_submodules( + &self, + recursive: bool, + ) -> Result { + self.preview_reconcile(ReconcileScope::Init, false, recursive) + } - if let Some(commit) = &status.current_commit { - println!(" ✅ Current commit: {}", &commit[..8]); - } + /// Preview an update using the same batch selection and preflights as execution. + pub fn preview_update_all_submodules( + &self, + remote: bool, + recursive: bool, + ) -> Result { + self.preview_reconcile(ReconcileScope::Update, remote, recursive) + } - if status.has_remotes { - println!(" ✅ Has remotes configured"); - } else { - println!(" ⚠️ No remotes configured"); - } + /// Preview synchronization using the same batch selection and preflights as execution. + pub fn preview_sync_all_submodules( + &self, + recursive: bool, + ) -> Result { + self.preview_reconcile(ReconcileScope::Sync, false, recursive) + } - match &status.sparse_status { - SparseStatus::NotEnabled => {} - SparseStatus::NotConfigured => { - println!(" ❌ Sparse checkout not configured"); - } - SparseStatus::Correct => { - println!(" ✅ Sparse checkout configured correctly"); - } - SparseStatus::Mismatch { expected, actual } => { - println!(" ❌ Sparse checkout mismatch"); - println!(" Expected: {expected:?}"); - println!(" Current: {actual:?}"); - } - } + fn reconcile_all( + &mut self, + scope: ReconcileScope, + remote: bool, + recursive: bool, + ) -> Result { + let _locks = self.acquire_mutation_locks()?; + self.reload_locked_config()?; + self.require_config()?; + let plans = self.prepare_reconcile(scope, remote, recursive)?; + if plans.is_empty() { + self.report_unmanaged_submodules()?; + return Ok(OperationSummary { + operation: match scope { + ReconcileScope::Init => "Initialization", + ReconcileScope::Update => "Update", + ReconcileScope::Sync => "Sync", + }, + preview: false, + modules: plans, + }); + } - // Show effective settings - self.show_effective_settings(submodule_name, submodule); - } else { - // Non-verbose: only print warnings/problems - if !status.is_clean { - println!(" ⚠️ {submodule_name}: Working tree has changes"); - } - if !status.has_remotes { - println!(" ⚠️ {submodule_name}: No remotes configured"); - } - match &status.sparse_status { - SparseStatus::NotEnabled | SparseStatus::Correct => {} - SparseStatus::NotConfigured => { - println!(" ❌ {submodule_name}: Sparse checkout not configured"); - } - SparseStatus::Mismatch { expected, actual } => { - println!(" ❌ {submodule_name}: Sparse checkout mismatch"); - println!(" Expected: {expected:?}"); - println!(" Current: {actual:?}"); + let mut outcomes = plans.clone(); + let mut unresolved = Vec::new(); + for (index, plan) in plans.iter().enumerate() { + let name = &plan.name; + let result = if plan.kind == ModuleOutcomeKind::Unchanged + && !plan.remote_requested + && !plan.recursive_requested + { + // The shared plan has already inspected registration, managed + // metadata, parent-pin strategy, and sparse state. Avoid a + // mutation-shaped native update when no action is required; + // finalization below still rechecks the checkout postcondition. + Ok(()) + } else { + match scope { + ReconcileScope::Init => self.init_submodule_locked(name, recursive), + ReconcileScope::Update if plan.registration_changed => { + self.init_submodule_locked(name, recursive).and_then(|()| { + if remote { + self.update_submodule_locked(name, true, recursive) + } else { + Ok(()) } - } + }) } + ReconcileScope::Update => self.update_submodule_locked(name, remote, recursive), + ReconcileScope::Sync => self + .init_submodule_locked(name, recursive) + .and_then(|()| self.update_submodule_locked(name, false, recursive)), } - Err(e) => { - println!(" ❌ {submodule_name}: Cannot analyze repository: {e}"); + }; + outcomes[index] = match result.and_then(|()| self.finalize_reconcile_plan(plan.clone())) + { + Ok(outcome) => outcome, + Err(error) => { + let mut completed = outcomes[..index].to_vec(); + let mut failed = plan.clone(); + failed.kind = ModuleOutcomeKind::Failed; + failed.detail = format!( + "{scope:?} failed while attempting {}: {error}; repair the reported cause and retry", + plan.detail + ); + completed.push(failed); + completed.extend(plans[index + 1..].iter().cloned().map(|mut pending| { + pending.kind = ModuleOutcomeKind::Pending; + pending.detail = + format!("not attempted; planned action: {}", pending.detail); + pending + })); + return Err(SubmoduleError::IncompleteBatch { + summary: OperationSummary { + operation: match scope { + ReconcileScope::Init => "Initialization incomplete", + ReconcileScope::Update => "Update incomplete", + ReconcileScope::Sync => "Sync incomplete", + }, + preview: false, + modules: completed, + }, + cause: format!( + "module {name:?} failed during {scope:?} at {:?}: {error}", + plan.path + ), + }); + } + }; + if matches!( + outcomes[index].kind, + ModuleOutcomeKind::SkippedDisabled + | ModuleOutcomeKind::SkippedPolicy + | ModuleOutcomeKind::ChangedSkippedDisabled + | ModuleOutcomeKind::ChangedSkippedPolicy + ) && self + .context + .worktree_root + .join(&plan.path) + .join(".git") + .exists() + && !self + .git_ops + .submodule_worktree_is_clean(&plan.path) + .map_err(Self::map_git_ops_error)? + { + outcomes[index].detail.push_str( + "; checkout changes were preserved and remain unresolved by this policy", + ); + unresolved.push(plan.name.clone()); + } + } + self.report_unmanaged_submodules()?; + if !unresolved.is_empty() { + return Err(SubmoduleError::IncompleteBatch { + summary: OperationSummary { + operation: match scope { + ReconcileScope::Init => "Initialization incomplete", + ReconcileScope::Update => "Update incomplete", + ReconcileScope::Sync => "Sync incomplete", + }, + preview: false, + modules: outcomes, + }, + cause: format!( + "local changes remain in policy-skipped module(s) {}; preserve or resolve them explicitly", + unresolved.join(", ") + ), + }); + } + Ok(OperationSummary { + operation: match scope { + ReconcileScope::Init => "Initialization", + ReconcileScope::Update => "Update", + ReconcileScope::Sync => "Sync", + }, + preview: false, + modules: outcomes, + }) + } + + /// Initialize all eligible declarations under one preflighted command lock. + pub fn init_all_submodules( + &mut self, + recursive: bool, + ) -> Result { + self.reconcile_all(ReconcileScope::Init, false, recursive) + } + + /// Update all eligible registrations under one preflighted command lock. + pub fn update_all_submodules( + &mut self, + remote: bool, + recursive: bool, + ) -> Result { + self.reconcile_all(ReconcileScope::Update, remote, recursive) + } + + /// Converge every eligible declaration and report unmanaged registrations. + pub fn sync_all_submodules( + &mut self, + recursive: bool, + ) -> Result { + self.reconcile_all(ReconcileScope::Sync, false, recursive) + } + + /// Inspect declarations, registrations, managed metadata, checkout targets, and sparse state. + pub fn check_all_submodules(&self) -> Result<(), SubmoduleError> { + self.require_config()?; + let mut drifted = Vec::new(); + for (submodule_name, _) in self.config.get_submodules() { + let submodule = self + .config + .effective_entry(submodule_name) + .expect("name came from config"); + let path_str = submodule + .path + .as_deref() + .expect("effective path is populated"); + let submodule_path = self.context.worktree_root.join(path_str); + let git_path = submodule_path.join(".git"); + let safe_name = crate::utilities::safe_human_text(submodule_name); + let safe_path = crate::utilities::safe_human_text(path_str); + + let skip_reason = if !submodule.active.unwrap_or(true) { + Some("disabled") + } else if submodule.update == Some(SerializableUpdate::None) { + Some("update-none") + } else { + None + }; + let mut issues = Vec::new(); + if !submodule_path.exists() { + if skip_reason.is_some() { + println!( + "{safe_name}: skipped-{} at {safe_path} (not materialized)", + skip_reason.expect("checked") + ); + continue; + } + issues.push("checkout is missing".to_string()); + } else if !git_path.exists() { + if skip_reason.is_some() && submodule_path.read_dir()?.next().is_none() { + println!( + "{safe_name}: skipped-{} at {safe_path} (not materialized)", + skip_reason.expect("checked") + ); + continue; + } + issues.push("checkout is not a Git repository".to_string()); + } else { + self.git_ops + .verify_submodule_checkout(path_str) + .map_err(|error| { + SubmoduleError::CliError(format!( + "Could not inspect module {submodule_name:?} at {path_str:?}: {error}" + )) + })?; + if self.registration_for_path(path_str)?.is_none() { + issues.push("portable Git registration is missing".to_string()); + } + let parent_pin = self + .git_ops + .index_gitlink_oid(path_str) + .map_err(Self::map_git_ops_error)?; + if parent_pin.is_none() { + issues.push("parent index gitlink is missing".to_string()); + } + let managed = Self::managed_settings(&self.config, submodule_name)?; + if !self + .git_ops + .submodule_settings_match(path_str, &managed) + .map_err(Self::map_git_ops_error)? + { + issues.push("managed Git metadata differs".to_string()); + } + if parent_pin.is_some() && skip_reason.is_none() { + let update = crate::config::SubmoduleUpdateOptions::from_options( + submodule.git_options(), + ); + if !self + .git_ops + .update_postcondition_matches(path_str, &update) + .map_err(Self::map_git_ops_error)? + { + issues + .push("checkout does not satisfy its parent-pin strategy".to_string()); + } + } + if !self + .git_ops + .submodule_worktree_is_clean(path_str) + .map_err(Self::map_git_ops_error)? + { + issues.push("working tree has changes".to_string()); + } + let expected_sparse = if submodule.use_git_default_sparse_checkout.unwrap_or(false) + { + submodule.sparse_paths.clone().unwrap_or_default() + } else { + Self::build_deny_all_sparse_patterns( + submodule.sparse_paths.as_deref().unwrap_or(&[]), + ) + }; + match self.check_sparse_checkout_status(path_str, &expected_sparse)? { + SparseStatus::NotEnabled | SparseStatus::Correct => {} + SparseStatus::NotConfigured => { + issues.push("sparse checkout is not configured".to_string()); + } + SparseStatus::Mismatch { expected, actual } => { + issues.push(format!( + "sparse patterns differ (expected {expected:?}, current {actual:?})" + )); + } + } + if self.verbose { + let head = self + .git_ops + .submodule_head(path_str) + .map_err(Self::map_git_ops_error)?; + println!(" context: path={safe_path}, head={head}"); + self.show_effective_settings(submodule_name, &submodule); + } + } + if issues.is_empty() { + if let Some(reason) = skip_reason { + println!("{safe_name}: skipped-{reason} (inspectable metadata matches)"); + } else { + println!("{safe_name}: unchanged (matches configured state)"); } + } else { + println!( + "{safe_name}: drift: {}", + crate::utilities::safe_human_text(&issues.join("; ")) + ); + drifted.push(submodule_name.clone()); } } - - Ok(()) + self.report_unmanaged_submodules()?; + if drifted.is_empty() { + println!("Check complete: all configured submodules match."); + Ok(()) + } else { + Err(SubmoduleError::Drift(format!( + "{} managed submodule(s) have drift: {}", + drifted.len(), + drifted.join(", ") + ))) + } } #[allow(clippy::unused_self)] fn show_effective_settings(&self, _name: &str, config: &SubmoduleEntry) { - println!(" 📋 Effective settings:"); + println!(" effective settings:"); if let Some(ignore) = &config.ignore { println!(" ignore = {ignore:?}"); @@ -1008,368 +2656,423 @@ impl GitManager { self.config.clone() } - /// Extract the submodule name from a TOML section header line, e.g. `[my-sub]` → `my-sub`. - /// Returns `None` if the line does not look like a section header. - fn section_name_from_header(header: &str) -> Option { - let inner = header.trim().strip_prefix('[')?.strip_suffix(']')?; - // Reject table-array headers like `[[...]]` - if inner.starts_with('[') { - return None; - } - if inner.starts_with('"') { - // Quoted: ["some name"] - let unquoted = inner.strip_prefix('"')?.strip_suffix('"')?; - // Un-escape backslash-escaped backslashes and quotes (order matters: \\ first) - Some(unquoted.replace("\\\\", "\\").replace("\\\"", "\"")) - } else { - Some(inner.to_string()) - } - } - - /// Serialize the given `SubmoduleEntry` to a list of key = value lines (no section header). - fn entry_to_kv_lines(entry: &SubmoduleEntry) -> Vec<(String, String)> { - let mut kv: Vec<(String, String)> = Vec::new(); - if let Some(path) = &entry.path { - kv.push(( - "path".into(), - format!("\"{}\"", path.replace('\\', "\\\\").replace('"', "\\\"")), - )); - } - if let Some(url) = &entry.url { - kv.push(( - "url".into(), - format!("\"{}\"", url.replace('\\', "\\\\").replace('"', "\\\"")), - )); - } - if let Some(branch) = &entry.branch { - let val = branch.to_string(); - if !val.is_empty() { - kv.push(( - "branch".into(), - format!("\"{}\"", val.replace('\\', "\\\\").replace('"', "\\\"")), - )); + fn values_match(existing: &toml_edit::Value, desired: &toml_edit::Value) -> bool { + match (existing, desired) { + (toml_edit::Value::String(left), toml_edit::Value::String(right)) => { + left.value() == right.value() } - } - if let Some(ignore) = &entry.ignore { - let val = ignore.to_string(); - if !val.is_empty() { - kv.push(("ignore".into(), format!("\"{val}\""))); - } - } - if let Some(fetch_recurse) = &entry.fetch_recurse { - let val = fetch_recurse.as_config_value(); - if !val.is_empty() { - kv.push(("fetchRecurse".into(), format!("\"{val}\""))); - } - } - if let Some(update) = &entry.update { - let val = update.to_string(); - if !val.is_empty() { - kv.push(("update".into(), format!("\"{val}\""))); + (toml_edit::Value::Boolean(left), toml_edit::Value::Boolean(right)) => { + left.value() == right.value() } - } - if let Some(active) = entry.active { - kv.push(("active".into(), active.to_string())); - } - if let Some(shallow) = entry.shallow - && shallow - { - kv.push(("shallow".into(), "true".into())); - } - if let Some(sparse_paths) = &entry.sparse_paths - && !sparse_paths.is_empty() - { - let joined = sparse_paths - .iter() - .map(|p| format!("\"{}\"", p.replace('\\', "\\\\").replace('"', "\\\""))) - .collect::>() - .join(", "); - kv.push(("sparse_paths".into(), format!("[{joined}]"))); - } - kv - } - - /// Known submodule key names (used to identify which lines to update vs. preserve). - const KNOWN_SUBMODULE_KEYS: &'static [&'static str] = &[ - "path", - "url", - "branch", - "ignore", - "fetchRecurse", - "fetch", - "update", - "active", - "shallow", - "sparse_paths", - ]; - - /// Known [defaults] key names. - const KNOWN_DEFAULTS_KEYS: &'static [&'static str] = - &["ignore", "fetchRecurse", "fetch", "update"]; - - /// Return the key name if `line` is a key = value assignment for one of `known_keys`, else None. - fn line_key<'a>(line: &str, known_keys: &[&'a str]) -> Option<&'a str> { - let trimmed = line.trim(); - // Skip comments and blank lines quickly - if trimmed.is_empty() || trimmed.starts_with('#') { - return None; - } - for key in known_keys { - // Match "key =" or "key=" at start of trimmed line - if let Some(rest) = trimmed.strip_prefix(key) - && (rest.starts_with('=') || rest.starts_with(" =")) - { - return Some(key); + (toml_edit::Value::Array(left), toml_edit::Value::Array(right)) => { + left.len() == right.len() + && left + .iter() + .zip(right.iter()) + .all(|(left, right)| Self::values_match(left, right)) } + _ => false, } - None } - /// Rewrite the config file while preserving existing comments, unknown keys, and formatting. - /// - /// For each existing section in the file: - /// - If the section name is still in the in-memory config: update key values in place, - /// preserving comments and the original order of known keys. - /// - If the section name is no longer in config: the section is omitted (deleted). - /// - /// Sections in the in-memory config that were not in the original file are appended at the end. - /// - /// The `[defaults]` section is handled similarly (updated in place or added if absent). - #[allow(clippy::cognitive_complexity)] - fn write_full_config(&self) -> Result<(), SubmoduleError> { - let existing = if self.config_path.exists() { - std::fs::read_to_string(&self.config_path) - .map_err(|e| SubmoduleError::ConfigError(format!("Failed to read config: {e}")))? - } else { - String::new() + fn set_document_value( + table: &mut dyn toml_edit::TableLike, + key: &str, + desired: Option, + ) { + let Some(mut desired) = desired else { + table.remove(key); + return; }; - - // Build the current submodule map sorted by name for deterministic append order - let current_entries: std::collections::BTreeMap = self - .config - .get_submodules() - .map(|(n, e)| (n.clone(), e)) - .collect(); - - // Track which names appeared in the existing file (so we know what to append) - let mut seen_names: std::collections::HashSet = std::collections::HashSet::new(); - let mut seen_defaults = false; - - // Parse the file into sections. - // Each element: (header_line, body_lines) - // Preamble (before any section header) stored as ("", preamble_lines). - let mut sections: Vec<(String, Vec)> = Vec::new(); - { - let mut preamble: Vec = Vec::new(); - let mut current_header: Option = None; - let mut current_body: Vec = Vec::new(); - for raw_line in existing.lines() { - let trimmed = raw_line.trim(); - // Detect a section header (but not a table-array `[[...]]`) - let is_header = trimmed.starts_with('[') - && !trimmed.starts_with("[[") - && trimmed.ends_with(']'); - if is_header { - if let Some(hdr) = current_header.take() { - sections.push((hdr, std::mem::take(&mut current_body))); - } else { - // Flush preamble - sections.push((String::new(), std::mem::take(&mut preamble))); - } - current_header = Some(raw_line.to_string()); - } else if let Some(ref _hdr) = current_header { - current_body.push(raw_line.to_string()); - } else { - preamble.push(raw_line.to_string()); + if let Some(existing) = table.get_mut(key) { + if let Some(value) = existing.as_value() { + if Self::values_match(value, &desired) { + return; } + *desired.decor_mut() = value.decor().clone(); } - // Flush last section or preamble - if let Some(hdr) = current_header { - sections.push((hdr, current_body)); - } else { - sections.push((String::new(), preamble)); - } + *existing = toml_edit::Item::Value(desired); + } else { + table.insert(key, toml_edit::Item::Value(desired)); } + } - let defaults = &self.config.defaults; - let defaults_kv: Vec<(String, String)> = { - let mut kv = Vec::new(); - if let Some(ignore) = &defaults.ignore { - let val = ignore.to_string(); - if !val.is_empty() { - kv.push(("ignore".into(), format!("\"{val}\""))); - } - } - if let Some(fetch_recurse) = &defaults.fetch_recurse { - let val = fetch_recurse.as_config_value(); - if !val.is_empty() { - kv.push(("fetchRecurse".into(), format!("\"{val}\""))); - } + fn string_value(value: Option) -> Option { + value.map(toml_edit::Value::from) + } + + fn sparse_value(value: Option<&Vec>) -> Option { + value.map(|paths| { + let mut array = toml_edit::Array::new(); + for path in paths { + array.push(path.as_str()); } - if let Some(update) = &defaults.update { - let val = update.to_string(); - if !val.is_empty() { - kv.push(("update".into(), format!("\"{val}\""))); + toml_edit::Value::Array(array) + }) + } + + fn default_field_changed( + &self, + field: &'static str, + old: &crate::config::SubmoduleDefaults, + new: &crate::config::SubmoduleDefaults, + ) -> bool { + self.pending_edits.defaults.contains(field) + || match field { + "branch" => old.branch != new.branch, + "ignore" => old.ignore != new.ignore, + "fetchRecurse" => old.fetch_recurse != new.fetch_recurse, + "update" => old.update != new.update, + "use_git_default_sparse_checkout" => { + old.use_git_default_sparse_checkout != new.use_git_default_sparse_checkout } + _ => false, } - kv - }; - - let mut output = String::new(); + } - for (header, body) in §ions { - if header.is_empty() { - // Preamble: write as-is - for line in body { - output.push_str(line); - output.push('\n'); + fn module_field_changed( + &self, + name: &str, + field: &'static str, + old: &SubmoduleEntry, + new: &SubmoduleEntry, + ) -> bool { + self.pending_edits.module_contains(name, field) + || match field { + "path" => old.path != new.path, + "url" => old.url != new.url, + "branch" => old.branch != new.branch, + "ignore" => old.ignore != new.ignore, + "fetchRecurse" => old.fetch_recurse != new.fetch_recurse, + "update" => old.update != new.update, + "active" => old.active != new.active, + "shallow" => old.shallow != new.shallow, + "sparse_paths" => old.sparse_paths != new.sparse_paths, + "use_git_default_sparse_checkout" => { + old.use_git_default_sparse_checkout != new.use_git_default_sparse_checkout } - continue; + _ => false, } + } - let sec_name = Self::section_name_from_header(header).unwrap_or_default(); - - if sec_name == "defaults" { - seen_defaults = true; - // Rewrite [defaults] section preserving comments - output.push_str(header); - output.push('\n'); - let new_body = - Self::merge_section_body(body, &defaults_kv, Self::KNOWN_DEFAULTS_KEYS); - for line in &new_body { - output.push_str(line); - output.push('\n'); - } + fn patch_defaults(&self, document: &mut toml_edit::DocumentMut) -> Result<(), SubmoduleError> { + const FIELDS: &[&str] = &[ + "branch", + "ignore", + "fetchRecurse", + "update", + "use_git_default_sparse_checkout", + ]; + let old = &self.loaded_config.defaults; + let new = &self.config.defaults; + if !FIELDS + .iter() + .any(|field| self.default_field_changed(field, old, new)) + { + return Ok(()); + } + if document.get("defaults").is_none() { + document + .as_table_mut() + .insert("defaults", toml_edit::Item::Table(toml_edit::Table::new())); + } + let table = document + .get_mut("defaults") + .and_then(toml_edit::Item::as_table_like_mut) + .ok_or_else(|| { + SubmoduleError::ConfigError("[defaults] must be a TOML table".to_string()) + })?; + for field in FIELDS { + if !self.default_field_changed(field, old, new) { continue; } - - // Submodule section - seen_names.insert(sec_name.clone()); - if let Some(entry) = current_entries.get(sec_name.as_str()) { - let kv = Self::entry_to_kv_lines(entry); - output.push_str(header); - output.push('\n'); - let new_body = Self::merge_section_body(body, &kv, Self::KNOWN_SUBMODULE_KEYS); - for line in &new_body { - output.push_str(line); - output.push('\n'); + let value = match *field { + "branch" => { + Self::string_value(new.branch.as_ref().map(SerializableBranch::as_config_value)) } + "ignore" => Self::string_value(new.ignore.map(|value| value.to_string())), + "fetchRecurse" => Self::string_value( + new.fetch_recurse + .map(|value| value.as_config_value().to_string()), + ), + "update" => Self::string_value(new.update.as_ref().map(ToString::to_string)), + "use_git_default_sparse_checkout" => new + .use_git_default_sparse_checkout + .map(toml_edit::Value::from), + _ => unreachable!(), + }; + if *field == "fetchRecurse" { + table.remove("fetch"); + table.remove("fetch_recurse"); } - // else: section was deleted from config — omit it + Self::set_document_value(table, field, value); } + Ok(()) + } - // Append [defaults] if it wasn't in the existing file - if !seen_defaults && !defaults_kv.is_empty() { - output.push_str("[defaults]\n"); - for (key, val) in &defaults_kv { - use std::fmt::Write as _; - let _ = writeln!(output, "{key} = {val}"); + fn patch_module_fields( + &self, + table: &mut dyn toml_edit::TableLike, + name: &str, + old: Option<&SubmoduleEntry>, + new: &SubmoduleEntry, + ) { + const FIELDS: &[&str] = &[ + "path", + "url", + "branch", + "ignore", + "fetchRecurse", + "update", + "active", + "shallow", + "sparse_paths", + "use_git_default_sparse_checkout", + ]; + for field in FIELDS { + let changed = old.is_none_or(|old| self.module_field_changed(name, field, old, new)); + if !changed { + continue; } - output.push('\n'); - } - - // Append submodule sections that weren't in the existing file (sorted for determinism) - for (name, entry) in ¤t_entries { - if !seen_names.contains(name.as_str()) { - let needs_quoting = name - .chars() - .any(|c| !c.is_alphanumeric() && c != '-' && c != '_'); - let escaped_name = name.replace('\\', "\\\\").replace('"', "\\\""); - let section_header = if needs_quoting { - format!("[\"{escaped_name}\"]") - } else { - format!("[{name}]") - }; - output.push_str(§ion_header); - output.push('\n'); - for (key, val) in Self::entry_to_kv_lines(entry) { - use std::fmt::Write as _; - let _ = writeln!(output, "{key} = {val}"); + let value = match *field { + "path" => Self::string_value(new.path.clone()), + "url" => Self::string_value(new.url.clone()), + "branch" => { + Self::string_value(new.branch.as_ref().map(SerializableBranch::as_config_value)) } - output.push('\n'); + "ignore" => Self::string_value(new.ignore.map(|value| value.to_string())), + "fetchRecurse" => Self::string_value( + new.fetch_recurse + .map(|value| value.as_config_value().to_string()), + ), + "update" => Self::string_value(new.update.as_ref().map(ToString::to_string)), + "active" => new.active.map(toml_edit::Value::from), + "shallow" => new.shallow.map(toml_edit::Value::from), + "sparse_paths" => Self::sparse_value(new.sparse_paths.as_ref()), + "use_git_default_sparse_checkout" => new + .use_git_default_sparse_checkout + .map(toml_edit::Value::from), + _ => unreachable!(), + }; + if *field == "fetchRecurse" { + table.remove("fetch"); + table.remove("fetch_recurse"); } + Self::set_document_value(table, field, value); } + } - std::fs::write(&self.config_path, &output).map_err(|e| { - SubmoduleError::ConfigError(format!("Failed to write config file: {e}")) + fn render_config_document(&self) -> Result, SubmoduleError> { + let source = std::str::from_utf8(self.loaded_config_bytes.as_deref().unwrap_or_default()) + .map_err(|error| { + SubmoduleError::ConfigError(format!("Loaded configuration is not UTF-8: {error}")) })?; - Ok(()) - } + let parsed = source + .parse::>() + .map_err(|error| { + SubmoduleError::ConfigError(format!("Failed to edit configuration: {error}")) + })?; + let mut document = parsed.into_mut(); + self.patch_defaults(&mut document)?; - /// Merge new key=value pairs into existing section body lines, preserving comments and - /// unknown keys. Known keys that appear in `body` are updated to the new value; known keys - /// absent from `body` but present in `new_kv` are appended at the end of the body. - /// Known keys in `body` that are absent from `new_kv` are removed. - fn merge_section_body( - body: &[String], - new_kv: &[(String, String)], - known_keys: &[&str], - ) -> Vec { - // Build a lookup of new values by key - let kv_map: std::collections::HashMap<&str, &str> = new_kv - .iter() - .map(|(k, v)| (k.as_str(), v.as_str())) + let old_names: std::collections::BTreeSet<_> = self + .loaded_config + .get_submodules() + .map(|(name, _)| name.clone()) .collect(); - - let mut emitted_keys: std::collections::HashSet<&str> = std::collections::HashSet::new(); - let mut result: Vec = Vec::new(); - - for line in body { - if let Some(key) = Self::line_key(line, known_keys) { - if let Some(new_val) = kv_map.get(key) { - // Replace existing key line with new value, preserving inline comment if any - let comment_part = Self::extract_inline_comment(line); - if comment_part.is_empty() { - result.push(format!("{key} = {new_val}")); - } else { - result.push(format!("{key} = {new_val} {comment_part}")); - } - emitted_keys.insert(key); - } - // else: key no longer present in new config → drop the line - } else { - // Not a known key line (comment, blank line, unknown key): preserve - result.push(line.clone()); + let new_names: std::collections::BTreeSet<_> = self + .config + .get_submodules() + .map(|(name, _)| name.clone()) + .collect(); + for removed in old_names.difference(&new_names) { + document.as_table_mut().remove(removed); + } + for name in &new_names { + let old = self.loaded_config.get_submodule(name); + let new = self + .config + .get_submodule(name) + .expect("name came from config"); + let changed = old.is_none() + || [ + "path", + "url", + "branch", + "ignore", + "fetchRecurse", + "update", + "active", + "shallow", + "sparse_paths", + "use_git_default_sparse_checkout", + ] + .iter() + .any(|field| { + old.is_some_and(|old| self.module_field_changed(name, field, old, new)) + }); + if !changed { + continue; } + if document.get(name).is_none() { + document + .as_table_mut() + .insert(name, toml_edit::Item::Table(toml_edit::Table::new())); + } + let table = document + .get_mut(name) + .and_then(toml_edit::Item::as_table_like_mut) + .ok_or_else(|| { + SubmoduleError::ConfigError(format!("[{name}] must be a TOML table")) + })?; + self.patch_module_fields(table, name, old, new); } + let rendered = document.to_string().into_bytes(); + let rendered_source = std::str::from_utf8(&rendered).expect("DocumentMut emits UTF-8"); + Config::parse(rendered_source).map_err(|error| { + SubmoduleError::ConfigError(format!( + "Refusing to replace configuration with invalid rendered TOML: {error}" + )) + })?; + Ok(rendered) + } - // Append any new keys (from new_kv) that were not already in the body - for (key, val) in new_kv { - if !emitted_keys.contains(key.as_str()) { - result.push(format!("{key} = {val}")); - } + fn atomic_replace_config( + path: &Path, + expected: Option<&[u8]>, + rendered: &[u8], + ) -> Result<(), SubmoduleError> { + Self::atomic_replace_config_with(path, expected, rendered, |temporary, path| { + temporary.persist(path).map_err(|error| { + SubmoduleError::ConfigError(format!( + "Failed to atomically replace configuration {}: {}", + path.display(), + error.error + )) + })?; + Ok(()) + }) + } + + fn atomic_replace_config_with( + path: &Path, + expected: Option<&[u8]>, + rendered: &[u8], + persist: F, + ) -> Result<(), SubmoduleError> + where + F: FnOnce(tempfile::NamedTempFile, &Path) -> Result<(), SubmoduleError>, + { + if expected.is_some_and(|expected| rendered == expected) { + return Ok(()); + } + Self::validate_config_destination(path)?; + let parent = path.parent().ok_or_else(|| { + SubmoduleError::InvalidPath("config path has no parent directory".to_string()) + })?; + let permissions = match fs::metadata(path) { + Ok(metadata) => Some(metadata.permissions()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => None, + Err(error) => return Err(error.into()), + }; + let mut temporary = tempfile::NamedTempFile::new_in(parent).map_err(|error| { + SubmoduleError::ConfigError(format!( + "Failed to create temporary configuration beside {}: {error}", + path.display() + )) + })?; + if let Some(permissions) = permissions { + temporary.as_file().set_permissions(permissions)?; + } + temporary.write_all(rendered)?; + temporary.flush()?; + temporary.as_file().sync_all()?; + + // This is intentionally the last check before replacement. A process that ignores + // submod's sibling lock can still race after this comparison and before rename. + Self::validate_config_destination(path)?; + let current = match fs::read(path) { + Ok(bytes) => Some(bytes), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => None, + Err(error) => return Err(error.into()), + }; + if current.as_deref() != expected { + return Err(SubmoduleError::ConfigError(format!( + "Configuration {} changed after it was loaded; no changes were written", + path.display() + ))); } + persist(temporary, path) + } - result + /// Patch raw declarations through `toml_edit`, validate, and atomically replace once. + fn write_full_config(&mut self) -> Result<(), SubmoduleError> { + let rendered = self.render_config_document()?; + Self::atomic_replace_config( + &self.config_path, + self.loaded_config_bytes.as_deref(), + &rendered, + )?; + self.loaded_config_bytes = Some(rendered); + self.loaded_config = self.config.clone(); + self.pending_edits = ConfigEditIntent::default(); + Ok(()) } - /// Extract an inline comment (e.g. `# ...`) from a TOML value line, if any. - /// Returns the comment portion including `#`, or an empty string. - fn extract_inline_comment(line: &str) -> &str { - // Find `#` that is not inside a quoted string. We use a simple heuristic: - // scan for ` #` (with space) OR `#` at the start of remaining content after - // the first `=`. TOML allows `key = value# comment` without a space. - // This heuristic won't handle `#` inside quoted values, but our generated TOML is safe. - if let Some(eq_pos) = line.find('=') { - let after_eq = &line[eq_pos + 1..]; - // Find the first unquoted `#` in the value portion - let mut in_quote = false; - for (i, ch) in after_eq.char_indices() { - match ch { - '"' => in_quote = !in_quote, - '#' if !in_quote => return &after_eq[i..], - _ => {} - } + fn collect_registered_paths( + repository: &Path, + prefix: &Path, + paths: &mut Vec<(String, bool)>, + ) -> Result<(), SubmoduleError> { + let git_ops = GitOpsManager::new(Some(repository), false).map_err(|error| { + SubmoduleError::RepositoryError(format!( + "Could not inspect nested repository at {}: {error}", + repository.display() + )) + })?; + let entries = git_ops.read_gitmodules().map_err(|error| { + SubmoduleError::CliError(format!( + "Could not inspect .gitmodules for '{}' at {}: {error}", + prefix.display(), + repository.join(".gitmodules").display() + )) + })?; + let mut entries = entries + .submodule_iter() + .map(|(name, entry)| { + ( + name.clone(), + entry.path.clone().unwrap_or_else(|| name.clone()), + ) + }) + .collect::>(); + entries.sort_by(|left, right| left.1.cmp(&right.1)); + for (_name, relative) in entries { + crate::utilities::validate_submodule_path(repository, Path::new(&relative)) + .map_err(Self::map_path_validation_error)?; + let display = prefix.join(&relative); + let checkout = repository.join(&relative); + let initialized = checkout.join(".git").exists(); + paths.push((display.to_string_lossy().into_owned(), initialized)); + if initialized { + git_ops + .verify_submodule_checkout(&relative) + .map_err(|error| { + SubmoduleError::RepositoryError(format!( + "Nested submodule at {} does not belong to its declared checkout: {error}", + display.display() + )) + })?; + Self::collect_registered_paths(&checkout, &display, paths)?; } } - "" + Ok(()) } - /// List all submodules from the config. If `recursive` is true, also lists - /// submodules found in the git repository (which may include nested ones). - #[allow(clippy::unnecessary_wraps)] + /// List configured submodules and, when requested, descend initialized Git registrations. pub fn list_submodules(&self, recursive: bool) -> Result<(), SubmoduleError> { - let submodules: Vec<_> = self.config.get_submodules().collect(); + let mut submodules: Vec<_> = self.config.get_submodules().collect(); + submodules.sort_by(|left, right| left.0.cmp(right.0)); if submodules.is_empty() && !recursive { println!("No submodules configured."); @@ -1381,37 +3084,60 @@ impl GitManager { } else { println!("Submodules:"); for (name, entry) in &submodules { - let path = entry.path.as_deref().unwrap_or(""); - let url = entry.url.as_deref().unwrap_or(""); + let effective = self + .config + .effective_entry(name) + .expect("name came from config"); + let path = effective.path.as_deref().unwrap_or(name); + let url = effective.url.as_deref().unwrap_or(""); let active = entry.active.unwrap_or(true); let active_str = if active { "active" } else { "disabled" }; - println!(" {name} [{active_str}]"); - println!(" path: {path}"); - println!(" url: {url}"); + println!( + " {} [{active_str}]", + crate::utilities::safe_human_text(name) + ); + println!(" path: {}", crate::utilities::safe_human_text(path)); + println!(" url: {}", crate::utilities::safe_human_text(url)); } } if recursive { - // Also list submodules found in the git repository (may include nested ones) - match self.git_ops.list_submodules() { - Ok(git_submodules) => { - let config_paths: std::collections::HashSet = submodules - .iter() - .filter_map(|(_, e)| e.path.clone()) - .collect(); - let extra: Vec<_> = git_submodules - .iter() - .filter(|p| !config_paths.contains(*p)) - .collect(); - if !extra.is_empty() { - println!("\nAdditional submodules found in git (not in config):"); - for path in extra { - println!(" {path}"); - } - } + let config_paths: std::collections::HashSet = submodules + .iter() + .map(|(name, entry)| entry.path.clone().unwrap_or_else(|| (*name).clone())) + .collect(); + let mut git_paths = Vec::new(); + Self::collect_registered_paths( + &self.context.worktree_root, + Path::new(""), + &mut git_paths, + )?; + git_paths.sort_by(|left, right| left.0.cmp(&right.0)); + git_paths.dedup_by(|left, right| left.0 == right.0); + let not_inspected = git_paths + .iter() + .filter(|(_, initialized)| !initialized) + .map(|(path, _)| path.as_str()) + .collect::>(); + let extra = git_paths + .iter() + .filter(|(path, _)| !config_paths.contains(path)) + .collect::>(); + if !extra.is_empty() { + println!("Additional submodules found in Git:"); + for (path, initialized) in extra { + let status = if *initialized { + "initialized" + } else { + "not inspected: checkout is uninitialized" + }; + println!(" {} [{status}]", crate::utilities::safe_human_text(path)); } - Err(e) => { - eprintln!("Warning: could not list git submodules: {e}"); + } + if !not_inspected.is_empty() { + println!("Recursive inspection skipped for uninitialized checkouts:"); + for path in not_inspected { + println!(" {}", crate::utilities::safe_human_text(path)); } } } @@ -1419,40 +3145,144 @@ impl GitManager { Ok(()) } - /// Update global default settings and save the config. - pub fn update_global_defaults( + fn prepare_global_defaults( &mut self, + branch: Option, ignore: Option, fetch_recurse: Option, update: Option, use_git_default_sparse_checkout: Option, - ) -> Result<(), SubmoduleError> { - if ignore.is_none() + unset: &[&str], + ) -> Result, SubmoduleError> { + if branch.is_none() + && ignore.is_none() && fetch_recurse.is_none() && update.is_none() && use_git_default_sparse_checkout.is_none() + && unset.is_empty() { return Err(SubmoduleError::ConfigError( "No settings provided to change.".to_string(), )); } + for field in unset { + match *field { + "branch" => self.config.defaults.branch = None, + "ignore" => self.config.defaults.ignore = None, + "fetch" => self.config.defaults.fetch_recurse = None, + "update" => self.config.defaults.update = None, + "use-git-default-sparse-checkout" => { + self.config.defaults.use_git_default_sparse_checkout = None; + } + _ => { + return Err(SubmoduleError::ConfigError(format!( + "Unsupported global setting to unset: {field}" + ))); + } + } + self.pending_edits.default_field(match *field { + "branch" => "branch", + "ignore" => "ignore", + "fetch" => "fetchRecurse", + "update" => "update", + "use-git-default-sparse-checkout" => "use_git_default_sparse_checkout", + _ => unreachable!("validated above"), + }); + } + if let Some(value) = branch { + self.config.defaults.branch = Some(value); + self.pending_edits.default_field("branch"); + } if let Some(i) = ignore { self.config.defaults.ignore = Some(i); + self.pending_edits.default_field("ignore"); } if let Some(f) = fetch_recurse { self.config.defaults.fetch_recurse = Some(f); + self.pending_edits.default_field("fetchRecurse"); } if let Some(u) = update { self.config.defaults.update = Some(u); + self.pending_edits.default_field("update"); } if let Some(v) = use_git_default_sparse_checkout { self.config.defaults.use_git_default_sparse_checkout = Some(v); + self.pending_edits + .default_field("use_git_default_sparse_checkout"); } - self.write_full_config() + let names: Vec = self + .config + .get_submodules() + .map(|(name, _)| name.clone()) + .collect(); + let targets = self.metadata_targets(&names)?; + self.preflight_metadata_targets(&targets)?; + Ok(targets) } - /// Disable a submodule by setting `active = false` in the config and deinitializing it. - pub fn disable_submodule(&mut self, name: &str) -> Result<(), SubmoduleError> { + /// Preview a global-default edit and its registered metadata targets. + pub fn preview_global_defaults( + &mut self, + branch: Option, + ignore: Option, + fetch_recurse: Option, + update: Option, + use_git_default_sparse_checkout: Option, + unset: &[&str], + ) -> Result<(), SubmoduleError> { + self.require_config()?; + let branch_text = branch.as_ref().map(ToString::to_string); + self.prepare_global_defaults( + branch, + ignore, + fetch_recurse, + update, + use_git_default_sparse_checkout, + unset, + )?; + let mut changes = unset + .iter() + .map(|field| format!("unset {field}")) + .collect::>(); + if let Some(branch) = branch_text { + changes.push(format!("branch={branch}")); + } + println!( + "Would change global defaults: {}.", + crate::utilities::safe_human_text(&changes.join(", ")) + ); + Ok(()) + } + + /// Update global default settings and save the config. + pub fn update_global_defaults( + &mut self, + branch: Option, + ignore: Option, + fetch_recurse: Option, + update: Option, + use_git_default_sparse_checkout: Option, + unset: &[&str], + ) -> Result<(), SubmoduleError> { + let _locks = self.acquire_mutation_locks()?; + self.reload_locked_config()?; + self.require_config()?; + let targets = self.prepare_global_defaults( + branch, + ignore, + fetch_recurse, + update, + use_git_default_sparse_checkout, + unset, + )?; + self.write_full_config()?; + self.apply_metadata_targets(&targets) + } + + fn prepare_disable( + &mut self, + name: &str, + ) -> Result, SubmoduleError> { let entry = self .config .get_submodule(name) @@ -1461,11 +3291,6 @@ impl GitManager { })? .clone(); - let path = entry.path.as_deref().unwrap_or(name).to_string(); - - // Deinit from git (best-effort; ignore errors if not initialized) - let _ = self.git_ops.deinit_submodule(&path, false); - // Update the entry in config let mut updated = entry; updated.active = Some(false); @@ -1473,106 +3298,109 @@ impl GitManager { .submodules .update_entry(name.to_string(), updated); - // Update .gitmodules - if let Ok(mut entries) = self.git_ops.read_gitmodules() { - // Find by name, or fall back to finding by path - let gitmodules_name = if entries.get(name).is_some() { - Some(name.to_string()) - } else { - entries - .submodule_iter() - .find(|(_, e)| e.path.as_deref() == Some(path.as_str())) - .map(|(n, _)| n.clone()) - }; + let targets = self.metadata_targets(&[name.to_string()])?; + self.preflight_metadata_targets(&targets)?; + Ok(targets) + } - if let Some(gm_name) = gitmodules_name { - let mut gitmodules_entry = entries.get(&gm_name).cloned().unwrap(); - gitmodules_entry.active = Some(false); - entries.update_entry(gm_name, gitmodules_entry); - let _ = self.git_ops.write_gitmodules(&entries); - } - } + /// Preview disabling a declaration while preserving its checkout and repository. + pub fn preview_disable_submodule(&mut self, name: &str) -> Result<(), SubmoduleError> { + self.require_config()?; + self.prepare_disable(name)?; + println!( + "Would disable submodule '{}' and preserve its checkout and history.", + crate::utilities::safe_human_text(name) + ); + Ok(()) + } + /// Disable a submodule while preserving its checkout and stored repository. + pub fn disable_submodule(&mut self, name: &str) -> Result<(), SubmoduleError> { + let _locks = self.acquire_mutation_locks()?; + self.reload_locked_config()?; + self.require_config()?; + let targets = self.prepare_disable(name)?; self.write_full_config()?; - println!("Disabled submodule '{name}'."); + self.apply_metadata_targets(&targets)?; + println!( + "Disabled submodule '{}'.", + crate::utilities::safe_human_text(name) + ); Ok(()) } - /// Delete a submodule: deinit, remove from filesystem, and remove from config. - pub fn delete_submodule_by_name(&mut self, name: &str) -> Result<(), SubmoduleError> { - let entry = self - .config - .get_submodule(name) - .ok_or_else(|| SubmoduleError::SubmoduleNotFound { - name: name.to_string(), - })? - .clone(); - + fn prepare_delete(&self, name: &str, force: bool) -> Result<(String, bool), SubmoduleError> { + let entry = + self.config + .get_submodule(name) + .ok_or_else(|| SubmoduleError::SubmoduleNotFound { + name: name.to_string(), + })?; let path = entry.path.as_deref().unwrap_or(name).to_string(); - - // Deinit (best-effort — submodule may not be registered in .gitmodules) - let _ = self.git_ops.deinit_submodule(&path, true); - // Git-layer delete (best-effort — submodule may only be in our config, not .gitmodules) - if let Err(e) = self.git_ops.delete_submodule(&path) { - eprintln!("Note: git cleanup for '{name}' skipped: {e}"); - // Still try to remove the directory from the filesystem directly - let dir = std::path::Path::new(&path); - if dir.exists() { - let _ = fs::remove_dir_all(dir); - } + let registered = self.registration_for_path(&path)?.is_some(); + if registered { + self.git_ops + .preflight_delete_submodule(&path, force) + .map_err(Self::map_git_ops_error)?; } + Ok((path, registered)) + } - // Ensure thorough cleanup of git state left behind by git2 (which uses the *path* - // as the submodule key rather than the *name*). The gix/git2 cleanups above key - // on the name, so path-based artifacts may linger and prevent a clean re-add. - if let Some(workdir) = self.git_ops.workdir() { - let workdir = workdir.to_path_buf(); - - // Remove path-based git config section (created by git2 during add) - if path != name { - let _ = std::process::Command::new("git") - .args(["config", "--remove-section", &format!("submodule.{path}")]) - .current_dir(&workdir) - .output(); + /// Preview exact Git-aware deletion without changing the declaration or checkout. + pub fn preview_delete_submodule_by_name( + &self, + name: &str, + force: bool, + ) -> Result<(), SubmoduleError> { + self.require_config()?; + let (path, registered) = self.prepare_delete(name, force)?; + let scope = if registered { + if force { + "discard selected checkout content, retain its repository, and remove its registration and declaration" + } else { + "remove its checkout, registration, and declaration while retaining its repository" } - // Also ensure name-based config section is gone - let _ = std::process::Command::new("git") - .args(["config", "--remove-section", &format!("submodule.{name}")]) - .current_dir(&workdir) - .output(); + } else { + "remove only its config declaration" + }; + println!( + "Would delete submodule '{}' at '{}': {scope}.", + crate::utilities::safe_human_text(name), + crate::utilities::safe_human_text(&path) + ); + Ok(()) + } - // Remove path-based .git/modules directory (created by git2 using path as key) - let path_modules_dir = workdir.join(".git").join("modules").join(&path); - if path_modules_dir.exists() { - let _ = fs::remove_dir_all(&path_modules_dir); - } - // Also ensure name-based .git/modules directory is gone - let name_modules_dir = workdir.join(".git").join("modules").join(name); - if name_modules_dir.exists() { - let _ = fs::remove_dir_all(&name_modules_dir); - } + /// Delete a submodule: deinit, remove from filesystem, and remove from config. + pub fn delete_submodule_by_name( + &mut self, + name: &str, + force: bool, + ) -> Result<(), SubmoduleError> { + let _locks = self.acquire_mutation_locks()?; + self.reload_locked_config()?; + self.require_config()?; + let (path, registered) = self.prepare_delete(name, force)?; + if registered { + self.git_ops + .delete_submodule(&path, force) + .map_err(Self::map_git_ops_error)?; } // Remove from config let _ = self.config.submodules.remove_submodule(name); self.write_full_config()?; - // Reopen the git repository to flush any cached state (git2 caches internal state - // about submodules and will fail on subsequent add_submodule calls if not refreshed). - if let Err(e) = self.git_ops.reopen() { - eprintln!( - "Warning: failed to refresh git repository state after deleting submodule '{name}': {e}" - ); - } - - println!("Deleted submodule '{name}'."); + println!( + "Deleted submodule '{}'.", + crate::utilities::safe_human_text(name) + ); Ok(()) } - /// Change settings of an existing submodule. If `path` changes, the submodule is - /// deleted and re-cloned at the new location. + /// Prepare a change completely before its first write or Git mutation. #[allow(clippy::too_many_arguments, clippy::needless_pass_by_value)] - pub fn change_submodule( + fn prepare_change_submodule( &mut self, name: &str, path: Option, @@ -1586,7 +3414,9 @@ impl GitManager { url: Option, active: Option, use_git_default_sparse_checkout: Option, - ) -> Result<(), SubmoduleError> { + unset: &[&str], + clear_sparse_paths: bool, + ) -> Result { let entry = self .config .get_submodule(name) @@ -1595,64 +3425,35 @@ impl GitManager { })? .clone(); - let new_path = path.as_ref().map(|p| p.to_string_lossy().to_string()); + let new_path = path + .map(|path| { + path.into_string().map_err(|_| { + SubmoduleError::InvalidPath( + "submodule path is not valid Unicode for TOML storage".to_string(), + ) + }) + }) + .transpose()?; + let mut move_paths = None; - // If path is changing, delete and re-add + // Move initialized registrations through Git; config-only declarations remain metadata-only. if let Some(ref np) = new_path { let old_path = entry.path.as_deref().unwrap_or(name); if np != old_path { - let sub_url = url - .as_deref() - .or(entry.url.as_deref()) - .ok_or_else(|| { - SubmoduleError::ConfigError( - "Cannot re-clone submodule: no URL available.".to_string(), - ) - })? - .to_string(); - - // Delete old then re-add at new path - self.delete_submodule_by_name(name)?; - - // Compute effective branch: caller's value if provided, else preserve existing - let effective_branch = if branch.is_some() { - SerializableBranch::set_branch(branch.clone()) - .map_err(|e| SubmoduleError::ConfigError(e.to_string()))? - } else { - entry.branch.clone().unwrap_or_default() - }; - - // Compute effective sparse paths: caller's value if provided, else preserve existing - let effective_sparse = if let Some(ref sp) = sparse_paths { - let paths: Vec = - sp.iter().map(|p| p.to_string_lossy().to_string()).collect(); - if paths.is_empty() { None } else { Some(paths) } - } else { - entry.sparse_paths.clone().filter(|v| !v.is_empty()) - }; - - let effective_ignore = ignore.or(entry.ignore); - let effective_fetch = fetch.or(entry.fetch_recurse); - let effective_update = update.or(entry.update); - // Preserve shallow/active from entry unless caller explicitly set them - let effective_shallow = shallow.or(entry.shallow); - let effective_git_default = - use_git_default_sparse_checkout.or(entry.use_git_default_sparse_checkout); - - self.add_submodule( - name.to_string(), - np.clone(), - sub_url, - effective_sparse, - Some(effective_branch), - effective_ignore, - effective_fetch, - effective_update, - effective_shallow, - false, - effective_git_default, - )?; - return Ok(()); + let normalized = self.validate_requested_path(name, Path::new(np), Some(name))?; + let destination = self.context.worktree_root.join(&normalized); + if fs::symlink_metadata(&destination).is_ok() { + return Err(SubmoduleError::InvalidPath(format!( + "destination {} is occupied", + destination.display() + ))); + } + if self.registration_for_path(old_path)?.is_some() { + self.git_ops + .preflight_move_submodule(old_path, np) + .map_err(Self::map_git_ops_error)?; + move_paths = Some((old_path.to_string(), np.clone())); + } } } @@ -1666,34 +3467,80 @@ impl GitManager { })? .clone(); let mut updated = entry; + for field in unset { + match *field { + "branch" => updated.branch = None, + "ignore" => updated.ignore = None, + "fetch" => updated.fetch_recurse = None, + "update" => updated.update = None, + "shallow" => updated.shallow = None, + "active" => updated.active = None, + "use-git-default-sparse-checkout" => { + updated.use_git_default_sparse_checkout = None; + } + _ => { + return Err(SubmoduleError::ConfigError(format!( + "Unsupported submodule setting to unset: {field}" + ))); + } + } + self.pending_edits.module_field( + name, + match *field { + "branch" => "branch", + "ignore" => "ignore", + "fetch" => "fetchRecurse", + "update" => "update", + "shallow" => "shallow", + "active" => "active", + "use-git-default-sparse-checkout" => "use_git_default_sparse_checkout", + _ => unreachable!("validated above"), + }, + ); + } if let Some(np) = new_path { updated.path = Some(np); + self.pending_edits.module_field(name, "path"); } if let Some(b) = branch { updated.branch = SerializableBranch::set_branch(Some(b)) .map(Some) .map_err(|err| SubmoduleError::ConfigError(err.to_string()))?; + self.pending_edits.module_field(name, "branch"); } if let Some(i) = ignore { updated.ignore = Some(i); + self.pending_edits.module_field(name, "ignore"); } if let Some(f) = fetch { updated.fetch_recurse = Some(f); + self.pending_edits.module_field(name, "fetchRecurse"); } if let Some(u) = update { updated.update = Some(u); + self.pending_edits.module_field(name, "update"); } if let Some(new_url) = url { updated.url = Some(new_url); + self.pending_edits.module_field(name, "url"); } if let Some(a) = active { updated.active = Some(a); + self.pending_edits.module_field(name, "active"); } if let Some(s) = shallow { updated.shallow = Some(s); + self.pending_edits.module_field(name, "shallow"); } if let Some(v) = use_git_default_sparse_checkout { updated.use_git_default_sparse_checkout = Some(v); + self.pending_edits + .module_field(name, "use_git_default_sparse_checkout"); + } + + if clear_sparse_paths { + updated.sparse_paths = Some(Vec::new()); + self.pending_edits.module_field(name, "sparse_paths"); } // Update sparse paths @@ -1714,26 +3561,142 @@ impl GitManager { } else { updated.sparse_paths = Some(new_paths); } + self.pending_edits.module_field(name, "sparse_paths"); } self.config .submodules .update_entry(name.to_string(), updated); } - self.write_full_config()?; - println!("Updated submodule '{name}'."); + let metadata_targets = if let Some((old_path, new_path)) = &move_paths { + let settings = Self::managed_settings(&self.config, name)?; + self.git_ops + .preflight_submodule_settings(old_path, &settings) + .map_err(Self::map_git_ops_error)?; + vec![(name.to_string(), new_path.clone(), settings)] + } else { + let targets = self.metadata_targets(&[name.to_string()])?; + self.preflight_metadata_targets(&targets)?; + targets + }; + Ok(ChangePlan { + move_paths, + metadata_targets, + }) + } + + /// Preview a configuration change after the same validation as execution. + #[allow(clippy::too_many_arguments, clippy::needless_pass_by_value)] + pub fn preview_change_submodule( + &mut self, + name: &str, + path: Option, + branch: Option, + sparse_paths: Option>, + append_sparse: bool, + ignore: Option, + fetch: Option, + update: Option, + shallow: Option, + url: Option, + active: Option, + use_git_default_sparse_checkout: Option, + unset: &[&str], + clear_sparse_paths: bool, + ) -> Result<(), SubmoduleError> { + self.require_config()?; + let plan = self.prepare_change_submodule( + name, + path, + branch, + sparse_paths, + append_sparse, + ignore, + fetch, + update, + shallow, + url, + active, + use_git_default_sparse_checkout, + unset, + clear_sparse_paths, + )?; + if let Some((old, new)) = plan.move_paths { + println!( + "Would change submodule '{}' and move it from '{}' to '{}'.", + crate::utilities::safe_human_text(name), + crate::utilities::safe_human_text(&old), + crate::utilities::safe_human_text(&new) + ); + } else { + println!( + "Would change submodule '{}' and reconcile its managed Git settings.", + crate::utilities::safe_human_text(name) + ); + } Ok(()) } - /// Nuke (deinit + delete + remove from config) all or specific submodules. - /// If `kill` is false, reinitializes them after deletion. - pub fn nuke_submodules( + /// Change settings of an existing submodule, moving its checkout through Git when requested. + #[allow(clippy::too_many_arguments, clippy::needless_pass_by_value)] + pub fn change_submodule( &mut self, + name: &str, + path: Option, + branch: Option, + sparse_paths: Option>, + append_sparse: bool, + ignore: Option, + fetch: Option, + update: Option, + shallow: Option, + url: Option, + active: Option, + use_git_default_sparse_checkout: Option, + unset: &[&str], + clear_sparse_paths: bool, + ) -> Result<(), SubmoduleError> { + let _locks = self.acquire_mutation_locks()?; + self.reload_locked_config()?; + self.require_config()?; + let plan = self.prepare_change_submodule( + name, + path, + branch, + sparse_paths, + append_sparse, + ignore, + fetch, + update, + shallow, + url, + active, + use_git_default_sparse_checkout, + unset, + clear_sparse_paths, + )?; + if let Some((old, new)) = &plan.move_paths { + self.git_ops + .move_submodule(old, new) + .map_err(Self::map_git_ops_error)?; + } + self.write_full_config()?; + self.apply_metadata_targets(&plan.metadata_targets)?; + println!( + "Updated submodule '{}'.", + crate::utilities::safe_human_text(name) + ); + Ok(()) + } + + fn prepare_nuke( + &self, all: bool, names: Option>, kill: bool, - ) -> Result<(), SubmoduleError> { - let targets: Vec = if all { + force: bool, + ) -> Result, SubmoduleError> { + let mut targets: Vec = if all { self.config .get_submodules() .map(|(n, _)| n.clone()) @@ -1747,57 +3710,256 @@ impl GitManager { "No submodules specified. Use --all or provide names.".to_string(), )); } + targets.sort(); + if targets.windows(2).any(|pair| pair[0] == pair[1]) { + return Err(SubmoduleError::ConfigError( + "Duplicate submodule target in nuke selection".to_string(), + )); + } - // Snapshot entries before deleting (needed for reinit) - let snapshots: Vec<(String, SubmoduleEntry)> = targets - .iter() - .filter_map(|n| self.config.get_submodule(n).map(|e| (n.clone(), e.clone()))) - .collect(); - - // Validate all targets exist before starting + let mut snapshots = Vec::with_capacity(targets.len()); for name in &targets { - if self.config.get_submodule(name).is_none() { - return Err(SubmoduleError::SubmoduleNotFound { name: name.clone() }); + self.config + .get_submodule(name) + .ok_or_else(|| SubmoduleError::SubmoduleNotFound { name: name.clone() })?; + let effective = self.config.effective_entry(name).expect("raw entry exists"); + let path = effective + .path + .as_deref() + .expect("effective path is populated"); + let registration_name = self.registration_for_path(path)?; + let registered = registration_name.is_some(); + let managed = Self::managed_settings(&self.config, name)?; + let options = crate::config::SubmoduleAddOptions { + name: registration_name.unwrap_or_else(|| name.clone()), + path: PathBuf::from(path), + url: effective.url.clone().expect("validated URL is populated"), + branch: managed.branch.clone(), + ignore: managed.ignore, + fetch_recurse: managed.fetch_recurse, + update: managed.update.clone(), + shallow: managed.shallow.unwrap_or(false), + no_init: false, + }; + if registered { + if kill { + self.git_ops + .preflight_delete_submodule(path, force) + .map_err(Self::map_git_ops_error)?; + } else { + self.git_ops + .preflight_submodule_settings(path, &managed) + .map_err(Self::map_git_ops_error)?; + self.git_ops + .preflight_rebuild_submodule(path, force) + .map_err(Self::map_git_ops_error)?; + } + } else if !kill { + self.git_ops + .preflight_add_submodule(&options) + .map_err(Self::map_git_ops_error)?; } + snapshots.push(NukePlan { + name: name.clone(), + effective, + options, + registered, + }); } + if !kill { + // Native registration may stage `.gitmodules`; complete every + // config-only structural add before registered rebuilds create + // reviewable unstaged metadata deltas. + snapshots.sort_by(|left, right| { + left.registered + .cmp(&right.registered) + .then_with(|| left.name.cmp(&right.name)) + }); + } + Ok(snapshots) + } - for name in &targets { - println!("💥 Nuking submodule '{name}'..."); - self.delete_submodule_by_name(name)?; + /// Preview a rebuild or permanent removal after the same whole-selection preflight. + pub fn preview_nuke_submodules( + &self, + all: bool, + names: Option>, + kill: bool, + force: bool, + ) -> Result<(), SubmoduleError> { + self.require_config()?; + for plan in self.prepare_nuke(all, names, kill, force)? { + let path = plan.effective.path.as_deref().unwrap_or(&plan.name); + if kill { + println!( + "Would permanently remove submodule '{}' at '{}'{} and retain only its recoverable Git repository.", + crate::utilities::safe_human_text(&plan.name), + crate::utilities::safe_human_text(path), + if force { + ", discarding selected checkout content" + } else { + "" + } + ); + } else { + println!( + "Would rebuild submodule '{}' at '{}'{} while retaining its declaration, parent pin, and repository history.", + crate::utilities::safe_human_text(&plan.name), + crate::utilities::safe_human_text(path), + if force { + ", discarding selected checkout content" + } else { + "" + } + ); + } } + Ok(()) + } - if !kill { - // Reinitialize each deleted submodule - for (name, entry) in snapshots { - let url = match entry.url.clone() { - Some(u) if !u.is_empty() => u, - _ => { - eprintln!("Skipping reinit of '{name}': no URL in config entry."); - continue; + /// Nuke (deinit + delete + remove from config) all or specific submodules. + /// If `kill` is false, reinitializes them after deletion. + pub fn nuke_submodules( + &mut self, + all: bool, + names: Option>, + kill: bool, + force: bool, + ) -> Result<(), SubmoduleError> { + let _locks = self.acquire_mutation_locks()?; + self.reload_locked_config()?; + self.require_config()?; + let snapshots = self.prepare_nuke(all, names, kill, force)?; + + let mut completed = Vec::new(); + for (index, plan) in snapshots.iter().enumerate() { + let name = &plan.name; + let effective = &plan.effective; + let options = &plan.options; + let registered = plan.registered; + eprintln!( + "{} submodule '{}'...", + if kill { "Removing" } else { "Rebuilding" }, + crate::utilities::safe_human_text(name) + ); + let path = effective + .path + .as_deref() + .expect("effective path is populated"); + let result = (|| -> Result<(), SubmoduleError> { + let managed = Self::managed_settings(&self.config, name)?; + if registered { + if kill { + self.git_ops + .delete_submodule(path, force) + .map_err(Self::map_git_ops_error)?; + } else { + let mut update = crate::config::SubmoduleUpdateOptions::from_options( + effective.git_options(), + ); + if update.strategy == SerializableUpdate::None { + update.strategy = SerializableUpdate::Checkout; + } + self.git_ops + .rebuild_submodule(path, update, &managed, force) + .map_err(Self::map_git_ops_error)?; } - }; - println!("🔄 Reinitializing submodule '{name}'..."); - let path = entry.path.as_deref().unwrap_or(&name).to_string(); - let sparse = entry.sparse_paths.clone().filter(|paths| !paths.is_empty()); - self.add_submodule( - name.clone(), - path, - url, - sparse, - entry.branch.clone(), - entry.ignore, - entry.fetch_recurse, - entry.update, - entry.shallow, - false, - entry.use_git_default_sparse_checkout, - )?; + } else if !kill { + self.git_ops + .add_submodule(options) + .map_err(Self::map_git_ops_error)?; + } + if !kill { + if registered { + self.git_ops + .sync_submodule_settings(path, &managed) + .map_err(Self::map_git_ops_error)?; + } else { + self.git_ops + .sync_added_submodule_settings(path, &managed) + .map_err(Self::map_git_ops_error)?; + } + println!( + "Reinitialized submodule '{}'.", + crate::utilities::safe_human_text(name) + ); + if let Some(patterns) = &effective.sparse_paths { + self.configure_sparse_checkout( + path, + patterns, + effective.use_git_default_sparse_checkout.unwrap_or(false), + )?; + } + } + Ok(()) + })(); + if let Err(error) = result { + let pending: Vec<&str> = snapshots[index + 1..] + .iter() + .map(|plan| plan.name.as_str()) + .collect(); + eprintln!("Nuke stopped with a partial outcome after a runtime failure."); + eprintln!( + " completed: {}", + crate::utilities::safe_human_text(&completed.join(", ")) + ); + eprintln!( + " failed: {}: {}", + crate::utilities::safe_human_text(name), + crate::utilities::safe_human_text(&error.to_string()) + ); + eprintln!( + " pending: {}", + crate::utilities::safe_human_text(&pending.join(", ")) + ); + if kill { + eprintln!( + " completed removals already removed their checkout and registration; TOML declarations remain until every selected removal succeeds, and retained repositories remain recoverable" + ); + } else { + eprintln!( + " intended declarations, gitlinks, and retained repositories remain; repair the reported cause and retry" + ); + eprintln!( + " if native deinit left a deletion-only checkout, inspect it and rerun this nuke command with --force only when no local work is needed" + ); + } + return Err(error); + } + completed.push(name.clone()); + } + + if kill { + for plan in &snapshots { + let _ = self.config.submodules.remove_submodule(&plan.name); + } + self.write_full_config()?; + for plan in &snapshots { + println!( + "{}: changed: removed its checkout, Git registration, and TOML declaration; retained repository history remains recoverable", + crate::utilities::safe_human_text(&plan.name) + ); } } + println!( + "Nuke summary: {} changed, 0 unchanged, 0 skipped, 0 failed.", + snapshots.len() + ); + Ok(()) } + /// Preview config generation using the same source inspection and rendering as execution. + pub fn preview_generate_config( + output: &std::path::Path, + from_setup: bool, + template: bool, + force: bool, + ) -> Result<(), SubmoduleError> { + Self::generate_config_inner(output, from_setup, template, force, true) + } + /// Generate a config file. If `from_setup` is true, reads `.gitmodules` from the repo. /// If `template` is true, writes an annotated sample config. /// If the output file exists and `force` is false, returns an error. @@ -1807,7 +3969,45 @@ impl GitManager { template: bool, force: bool, ) -> Result<(), SubmoduleError> { - if output.exists() && !force { + Self::generate_config_inner(output, from_setup, template, force, false) + } + + fn generate_config_inner( + output: &std::path::Path, + from_setup: bool, + template: bool, + force: bool, + dry_run: bool, + ) -> Result<(), SubmoduleError> { + Self::validate_config_destination(output)?; + let output_lock = Self::config_lock_path(output)?; + let setup_context = if from_setup { + Some( + RepositoryContext::discover(&std::env::current_dir()?, None).map_err(|error| { + SubmoduleError::RepositoryError(format!( + "Cannot import .gitmodules from the current directory: {error}" + )) + })?, + ) + } else { + None + }; + let _lock = if dry_run { + None + } else if let Some(context) = &setup_context { + Some(Self::acquire_lock_paths(vec![ + context.common_dir.canonicalize()?.join("submod.lock"), + output_lock, + ])?) + } else { + Some(Self::acquire_output_lock(output)?) + }; + let existing = match fs::read(output) { + Ok(bytes) => Some(bytes), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => None, + Err(error) => return Err(error.into()), + }; + if existing.is_some() && !force { return Err(SubmoduleError::ConfigError(format!( "Output file '{}' already exists. Use --force to overwrite.", output.display() @@ -1815,20 +4015,37 @@ impl GitManager { } if template { - // Write an annotated sample config let sample = include_str!("../sample_config/submod.toml"); - std::fs::write(output, sample).map_err(SubmoduleError::IoError)?; - println!("Generated template config at '{}'.", output.display()); + Config::parse(sample).map_err(|error| { + SubmoduleError::ConfigError(format!("Bundled template is invalid: {error}")) + })?; + if dry_run { + println!( + "Would generate template config at '{}'.", + crate::utilities::safe_human_text(&output.to_string_lossy()) + ); + return Ok(()); + } + Self::atomic_replace_config(output, existing.as_deref(), sample.as_bytes())?; + println!( + "Generated template config at '{}'.", + crate::utilities::safe_human_text(&output.to_string_lossy()) + ); return Ok(()); } if from_setup { // Read .gitmodules from the repo and convert to our config format - let git_ops = - crate::git_ops::GitOpsManager::new(Some(std::path::Path::new(".")), false) - .map_err(|_| SubmoduleError::RepositoryError)?; + let context = setup_context.expect("from_setup context was resolved before locking"); + let git_ops = crate::git_ops::GitOpsManager::new(Some(&context.worktree_root), false) + .map_err(|error| { + SubmoduleError::RepositoryError(format!( + "Cannot inspect repository at {}: {error}", + context.worktree_root.display() + )) + })?; let mut entries = git_ops.read_gitmodules().map_err(|e| { - SubmoduleError::ConfigError(format!("Failed to read .gitmodules: {e}")) + SubmoduleError::CliError(format!("Failed to inspect .gitmodules: {e}")) })?; // Populate sparse_paths from the actual sparse-checkout config for each submodule. @@ -1841,10 +4058,29 @@ impl GitManager { }) .collect(); for (name, path) in names_and_paths { - if let Ok(patterns) = git_ops.get_sparse_patterns(&path) - && !patterns.is_empty() - { - entries.set_sparse_paths_for(&name, patterns); + let checkout = context.worktree_root.join(&path); + match fs::symlink_metadata(&checkout) { + Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue, + Ok(_) => {} + Err(error) => { + return Err(SubmoduleError::ConfigError(format!( + "Failed to inspect checkout for {name:?} at {path:?}: {error}" + ))); + } + } + if !checkout.join(".git").exists() { + continue; + } + match git_ops.get_sparse_patterns(&path) { + Ok(patterns) if !patterns.is_empty() => { + entries.set_sparse_paths_for(&name, patterns); + } + Ok(_) => {} + Err(error) => { + return Err(SubmoduleError::ConfigError(format!( + "Failed to inspect sparse checkout for {name:?} at {path:?}: {error}" + ))); + } } } @@ -1852,24 +4088,50 @@ impl GitManager { let config = Config::new(crate::config::SubmoduleDefaults::default(), entries); // Serialize using write_full_config logic but to the output path - let tmp_manager = Self { + let mut tmp_manager = Self { git_ops, + loaded_config: Config::default(), config, + context, config_path: output.to_path_buf(), + loaded_config_bytes: None, + pending_edits: ConfigEditIntent::default(), verbose: false, }; - tmp_manager.write_full_config()?; + let rendered = tmp_manager.render_config_document()?; + if dry_run { + println!( + "Would generate config from .gitmodules at '{}'.", + crate::utilities::safe_human_text(&output.to_string_lossy()) + ); + return Ok(()); + } + Self::atomic_replace_config(output, existing.as_deref(), &rendered)?; + tmp_manager.loaded_config_bytes = Some(rendered); println!( "Generated config from .gitmodules at '{}'.", - output.display() + crate::utilities::safe_human_text(&output.to_string_lossy()) ); return Ok(()); } // Neither template nor from-setup: write an empty config let empty = "[defaults]\n"; - std::fs::write(output, empty).map_err(SubmoduleError::IoError)?; - println!("Generated empty config at '{}'.", output.display()); + Config::parse(empty).map_err(|error| { + SubmoduleError::ConfigError(format!("Generated empty config is invalid: {error}")) + })?; + if dry_run { + println!( + "Would generate empty config at '{}'.", + crate::utilities::safe_human_text(&output.to_string_lossy()) + ); + return Ok(()); + } + Self::atomic_replace_config(output, existing.as_deref(), empty.as_bytes())?; + println!( + "Generated empty config at '{}'.", + crate::utilities::safe_human_text(&output.to_string_lossy()) + ); Ok(()) } } @@ -1890,22 +4152,31 @@ mod tests { GitManager::with_repo_path(config_path, repo_dir).expect("Failed to create GitManager") } + fn create_test_manager_with_submodule(repo_dir: &Path, config_path: PathBuf) -> GitManager { + git2::Repository::init(repo_dir).expect("Failed to init parent repo"); + let child = repo_dir.join("submodule"); + git2::Repository::init(&child).expect("Failed to init child repo"); + fs::write( + repo_dir.join(".gitmodules"), + "[submodule \"submodule\"]\n\tpath = submodule\n\turl = https://example.invalid/submodule.git\n", + ) + .unwrap(); + fs::write(&config_path, "[defaults]\n").unwrap(); + GitManager::with_repo_path(config_path, repo_dir).expect("Failed to create GitManager") + } + #[test] fn test_sparse_checkout_not_configured() { let temp_dir = tempdir().unwrap(); - let submodule_path = temp_dir.path().join("submodule"); - fs::create_dir(&submodule_path).unwrap(); - - // Create .git directory but NO sparse-checkout file - let git_dir = submodule_path.join(".git"); - fs::create_dir(&git_dir).unwrap(); - - let manager = create_test_manager(temp_dir.path(), temp_dir.path().join("submod.toml")); + let manager = create_test_manager_with_submodule( + temp_dir.path(), + temp_dir.path().join("submod.toml"), + ); let expected_paths: Vec = vec!["path/a".to_string()]; let status = manager - .check_sparse_checkout_status(&submodule_path.to_string_lossy(), &expected_paths) + .check_sparse_checkout_status("submodule", &expected_paths) .unwrap(); assert_eq!(status, SparseStatus::NotConfigured); @@ -1914,21 +4185,32 @@ mod tests { #[test] fn test_sparse_checkout_correct() { let temp_dir = tempdir().unwrap(); + let manager = create_test_manager_with_submodule( + temp_dir.path(), + temp_dir.path().join("submod.toml"), + ); let submodule_path = temp_dir.path().join("submodule"); - fs::create_dir(&submodule_path).unwrap(); // Create .git/info/sparse-checkout with the expected paths let info_dir = submodule_path.join(".git").join("info"); fs::create_dir_all(&info_dir).unwrap(); let content = format!("{SPARSE_DENY_ALL}\npath/a\npath/b\n"); fs::write(info_dir.join("sparse-checkout"), content).unwrap(); + let repo = git2::Repository::open(&submodule_path).unwrap(); + let mut child_config = repo.config().unwrap(); + child_config.set_bool("core.sparseCheckout", true).unwrap(); + child_config + .set_bool("core.sparseCheckoutCone", false) + .unwrap(); - let manager = create_test_manager(temp_dir.path(), temp_dir.path().join("submod.toml")); - - let expected_paths = vec!["path/a".to_string(), "path/b".to_string()]; + let expected_paths = vec![ + SPARSE_DENY_ALL.to_string(), + "path/a".to_string(), + "path/b".to_string(), + ]; let status = manager - .check_sparse_checkout_status(&submodule_path.to_string_lossy(), &expected_paths) + .check_sparse_checkout_status("submodule", &expected_paths) .unwrap(); assert_eq!(status, SparseStatus::Correct); @@ -1936,27 +4218,48 @@ mod tests { #[test] fn test_sparse_checkout_correct_with_extras() { - // When the sparse-checkout file contains all expected paths plus additional - // ones, the result is still Correct (subset check, not equality). + // Extra patterns alter the materialized tree and must be reported. let temp_dir = tempdir().unwrap(); + let manager = create_test_manager_with_submodule( + temp_dir.path(), + temp_dir.path().join("submod.toml"), + ); let submodule_path = temp_dir.path().join("submodule"); - fs::create_dir(&submodule_path).unwrap(); let info_dir = submodule_path.join(".git").join("info"); fs::create_dir_all(&info_dir).unwrap(); // File has path/a, path/b AND an extra path/c not in expected_paths let content = format!("{SPARSE_DENY_ALL}\npath/a\npath/b\npath/c\n"); fs::write(info_dir.join("sparse-checkout"), content).unwrap(); + let repo = git2::Repository::open(&submodule_path).unwrap(); + let mut child_config = repo.config().unwrap(); + child_config.set_bool("core.sparseCheckout", true).unwrap(); + child_config + .set_bool("core.sparseCheckoutCone", false) + .unwrap(); - let manager = create_test_manager(temp_dir.path(), temp_dir.path().join("submod.toml")); - - let expected_paths = vec!["path/a".to_string(), "path/b".to_string()]; + let expected_paths = vec![ + SPARSE_DENY_ALL.to_string(), + "path/a".to_string(), + "path/b".to_string(), + ]; let status = manager - .check_sparse_checkout_status(&submodule_path.to_string_lossy(), &expected_paths) + .check_sparse_checkout_status("submodule", &expected_paths) .unwrap(); - assert_eq!(status, SparseStatus::Correct); + assert_eq!( + status, + SparseStatus::Mismatch { + expected: expected_paths, + actual: vec![ + SPARSE_DENY_ALL.into(), + "path/a".into(), + "path/b".into(), + "path/c".into(), + ], + } + ); } #[test] @@ -2068,33 +4371,146 @@ mod tests { ); } + #[test] + fn r10_atomic_compare_preserves_external_edits_and_presence() { + let temp_dir = tempdir().unwrap(); + let config_path = temp_dir.path().join("submod.toml"); + let loaded = b"[defaults]\nignore = \"dirty\"\n"; + let external = b"[defaults]\nignore = \"all\" # external\n"; + let rendered = b"[defaults]\nignore = \"none\"\n"; + fs::write(&config_path, external).unwrap(); + + let error = GitManager::atomic_replace_config(&config_path, Some(loaded), rendered) + .expect_err("an external edit after the locked snapshot must be rejected"); + assert!(error.to_string().contains("changed after it was loaded")); + assert_eq!(fs::read(&config_path).unwrap(), external); + Config::parse(std::str::from_utf8(external).unwrap()).unwrap(); + + let appeared = temp_dir.path().join("appeared.toml"); + fs::write(&appeared, []).unwrap(); + GitManager::atomic_replace_config(&appeared, None, b"[defaults]\n") + .expect_err("an absent destination that appears must be rejected"); + assert_eq!(fs::read(&appeared).unwrap(), b""); + + let deleted = temp_dir.path().join("deleted.toml"); + GitManager::atomic_replace_config(&deleted, Some(b""), b"[defaults]\n") + .expect_err("an originally empty destination that disappears must be rejected"); + assert!(!deleted.exists()); + + let names: Vec<_> = fs::read_dir(temp_dir.path()) + .unwrap() + .map(|entry| entry.unwrap().file_name()) + .collect(); + assert_eq!(names.len(), 2, "temporary siblings must be cleaned up"); + } + + #[test] + fn r10_atomic_commit_failure_preserves_original_and_cleans_temporary() { + let temp_dir = tempdir().unwrap(); + let config_path = temp_dir.path().join("submod.toml"); + let original = b"[defaults]\nignore = \"dirty\"\n"; + let rendered = b"[defaults]\nignore = \"all\"\n"; + fs::write(&config_path, original).unwrap(); + + let error = GitManager::atomic_replace_config_with( + &config_path, + Some(original), + rendered, + |_temporary, _path| { + Err(SubmoduleError::ConfigError( + "injected failure at atomic commit boundary".to_string(), + )) + }, + ) + .expect_err("commit failure must be returned"); + assert!(error.to_string().contains("injected failure")); + assert_eq!(fs::read(&config_path).unwrap(), original); + Config::parse(std::str::from_utf8(original).unwrap()).unwrap(); + let names: Vec<_> = fs::read_dir(temp_dir.path()) + .unwrap() + .map(|entry| entry.unwrap().file_name()) + .collect(); + assert_eq!(names, [config_path.file_name().unwrap()]); + } + + #[test] + fn r10_stale_manager_reloads_after_acquiring_locks() { + let temp_dir = tempdir().unwrap(); + let config_path = temp_dir.path().join("submod.toml"); + git2::Repository::init(temp_dir.path()).unwrap(); + fs::write(&config_path, "# retained\n[defaults]\n").unwrap(); + let mut first = GitManager::with_repo_path(config_path.clone(), temp_dir.path()).unwrap(); + let mut stale = GitManager::with_repo_path(config_path.clone(), temp_dir.path()).unwrap(); + + first + .update_global_defaults(None, Some(SerializableIgnore::All), None, None, None, &[]) + .unwrap(); + stale + .update_global_defaults( + None, + None, + Some(SerializableFetchRecurse::Never), + None, + None, + &[], + ) + .unwrap(); + + let source = fs::read_to_string(config_path).unwrap(); + let current = Config::parse(&source).unwrap(); + assert_eq!(current.defaults.ignore, Some(SerializableIgnore::All)); + assert_eq!( + current.defaults.fetch_recurse, + Some(SerializableFetchRecurse::Never) + ); + assert!(source.contains("# retained")); + } + #[test] fn test_sparse_checkout_mismatch() { let temp_dir = tempdir().unwrap(); + let manager = create_test_manager_with_submodule( + temp_dir.path(), + temp_dir.path().join("submod.toml"), + ); let submodule_path = temp_dir.path().join("submodule"); - fs::create_dir(&submodule_path).unwrap(); // sparse-checkout has only path/a; path/b is expected but absent let info_dir = submodule_path.join(".git").join("info"); fs::create_dir_all(&info_dir).unwrap(); let content = format!("{SPARSE_DENY_ALL}\npath/a\n"); fs::write(info_dir.join("sparse-checkout"), content).unwrap(); - - let manager = create_test_manager(temp_dir.path(), temp_dir.path().join("submod.toml")); + let repo = git2::Repository::open(&submodule_path).unwrap(); + let mut child_config = repo.config().unwrap(); + child_config.set_bool("core.sparseCheckout", true).unwrap(); + child_config + .set_bool("core.sparseCheckoutCone", false) + .unwrap(); let expected_paths = vec![ + SPARSE_DENY_ALL.to_string(), "path/a".to_string(), "path/b".to_string(), // expected but not configured ]; let status = manager - .check_sparse_checkout_status(&submodule_path.to_string_lossy(), &expected_paths) + .check_sparse_checkout_status("submodule", &expected_paths) .unwrap(); match status { SparseStatus::Mismatch { expected, actual } => { - assert_eq!(expected, vec!["path/a".to_string(), "path/b".to_string()]); - assert_eq!(actual, vec!["path/a".to_string()]); + assert_eq!( + expected, + vec![ + SPARSE_DENY_ALL.to_string(), + "path/a".to_string(), + "path/b".to_string() + ] + ); + assert_eq!( + actual, + vec![SPARSE_DENY_ALL.to_string(), "path/a".to_string()] + ); } _ => panic!("Expected Mismatch, got {status:?}"), } diff --git a/src/git_ops/git2_ops.rs b/src/git_ops/git2_ops.rs index 42a09a69..89ec9bbb 100644 --- a/src/git_ops/git2_ops.rs +++ b/src/git_ops/git2_ops.rs @@ -2,18 +2,13 @@ // // SPDX-License-Identifier: LicenseRef-PlainMIT OR MIT -use super::{DetailedSubmoduleStatus, GitConfig, GitOperations, SubmoduleStatusFlags}; -use crate::config::{ - SubmoduleAddOptions, SubmoduleEntries, SubmoduleEntry, SubmoduleUpdateOptions, -}; -use crate::options::{ - ConfigLevel, GitmodulesConvert, SerializableBranch, SerializableFetchRecurse, - SerializableIgnore, SerializableUpdate, -}; +use super::{DetailedSubmoduleStatus, GitConfig, SubmoduleStatusFlags}; +use crate::config::{SubmoduleEntries, SubmoduleEntry}; +use crate::options::{ConfigLevel, SerializableBranch, SerializableFetchRecurse}; use anyhow::{Context, Result}; use std::collections::HashMap; use std::path::Path; -/// Git2 implementation providing complete fallback coverage +/// Read-only Git2 repository inspection and fallback reads pub struct Git2Operations { repo: git2::Repository, } @@ -33,16 +28,60 @@ impl Git2Operations { pub(super) fn workdir(&self) -> Option<&std::path::Path> { self.repo.workdir() } + + /// Resolve the portable `.gitmodules` section name for an exact checkout path. + /// + /// libgit2 can report `Submodule::name()` as the checkout path when the section + /// nickname differs from that path. Configuration and status APIs, however, + /// are keyed by the portable section name. Read that identity from + /// `.gitmodules` instead of conflating the two namespaces. + fn registered_name_for_path(&self, path: &str) -> Result> { + let Some(workdir) = self.repo.workdir() else { + return Ok(None); + }; + let gitmodules = workdir.join(".gitmodules"); + if !gitmodules.is_file() { + return Ok(None); + } + + let config = git2::Config::open(&gitmodules)?; + let mut matches = Vec::new(); + let mut entries = config.entries(Some(r"^submodule\..*\.path$"))?; + while let Some(entry) = entries.next() { + let entry = entry?; + if entry.value().ok() != Some(path) { + continue; + } + let key = entry.name()?; + let Some(name) = key + .strip_prefix("submodule.") + .and_then(|key| key.strip_suffix(".path")) + else { + continue; + }; + matches.push(name.to_string()); + } + matches.sort(); + matches.dedup(); + match matches.as_slice() { + [] => Ok(None), + [name] => Ok(Some(name.clone())), + _ => anyhow::bail!("Multiple .gitmodules sections register path {path:?}"), + } + } + /// Convert git2 submodule to our `SubmoduleEntry` format fn convert_git2_submodule_to_entry( &self, submodule: &git2::Submodule, ) -> Result<(String, SubmoduleEntry)> { - let name = submodule.name().unwrap_or("").to_string(); let path = submodule.path().to_string_lossy().to_string(); + let name = self + .registered_name_for_path(&path)? + .unwrap_or_else(|| submodule.name().unwrap_or("").to_string()); // git2 0.21 splits what used to be one Option: the Result is UTF-8 // validity, the Option is whether a URL is configured at all. Both still - // collapse to "" here, matching the previous behaviour. + // collapse to "" here, matching the previous behavior. let url = submodule.url().ok().flatten().unwrap_or("").to_string(); // Get branch from config let branch = self.get_submodule_branch(&name)?; @@ -189,8 +228,9 @@ impl Git2Operations { .with_context(|| format!("Failed to open config at level {level:?}")) } } -impl GitOperations for Git2Operations { - fn read_gitmodules(&self) -> Result { +impl Git2Operations { + /// Read `.gitmodules` registrations through libgit2 (no mutation, no fetch). + pub fn read_gitmodules(&self) -> Result { let mut submodules = HashMap::new(); // Iterate through all submodules self.repo @@ -210,93 +250,9 @@ impl GitOperations for Git2Operations { None, // sparse_checkouts will be populated separately if needed )) } - fn write_gitmodules(&mut self, config: &SubmoduleEntries) -> Result<()> { - let workdir = self - .repo - .workdir() - .ok_or_else(|| anyhow::anyhow!("Repository has no working directory"))?; - let gitmodules_path = workdir.join(".gitmodules"); - - // Ensure the .gitmodules file exists so git2 can open it. - if !gitmodules_path.exists() { - std::fs::write(&gitmodules_path, "")?; - } - - let mut gitmodules_config = git2::Config::open(&gitmodules_path)?; - - for (name, entry) in config.submodule_iter() { - let section = format!("submodule.{name}"); - - // 1. Write to .gitmodules - if let Some(path) = &entry.path { - gitmodules_config.set_str(&format!("{section}.path"), path)?; - } - if let Some(url) = &entry.url { - gitmodules_config.set_str(&format!("{section}.url"), url)?; - } - if let Some(branch) = &entry.branch { - gitmodules_config.set_str(&format!("{section}.branch"), &branch.to_string())?; - } - if let Some(update) = &entry.update { - gitmodules_config.set_str(&format!("{section}.update"), &update.to_gitmodules())?; - } - if let Some(ignore) = &entry.ignore { - gitmodules_config.set_str(&format!("{section}.ignore"), &ignore.to_gitmodules())?; - } - if let Some(fetch_recurse) = &entry.fetch_recurse { - gitmodules_config.set_str( - &format!("{section}.fetchRecurseSubmodules"), - &fetch_recurse.to_gitmodules(), - )?; - } - if let Some(active) = entry.active { - gitmodules_config.set_str( - &format!("{section}.active"), - if active { "true" } else { "false" }, - )?; - } - // 2. Sync to local repository config (.git/config) if the submodule exists in the repository - if let Ok(mut submodule) = self - .repo - .find_submodule(entry.path.as_deref().unwrap_or(name)) - { - let mut repo_config = self.repo.config()?; - if let Some(ignore) = &entry.ignore { - let ignore_str = match ignore { - SerializableIgnore::All => "all", - SerializableIgnore::Dirty => "dirty", - SerializableIgnore::Untracked => "untracked", - SerializableIgnore::None => "none", - SerializableIgnore::Unspecified => continue, - }; - repo_config.set_str(&format!("submodule.{name}.ignore"), ignore_str)?; - } - if let Some(update) = &entry.update { - let update_str = match update { - SerializableUpdate::Checkout => "checkout", - SerializableUpdate::Rebase => "rebase", - SerializableUpdate::Merge => "merge", - SerializableUpdate::None => "none", - SerializableUpdate::Unspecified => continue, - }; - repo_config.set_str(&format!("submodule.{name}.update"), update_str)?; - } - if let Some(active) = entry.active { - let active_str = if active { "true" } else { "false" }; - repo_config.set_str(&format!("submodule.{name}.active"), active_str)?; - } - if let Some(url) = &entry.url - && submodule.url().ok().flatten() != Some(url.as_str()) - { - repo_config.set_str(&format!("submodule.{name}.url"), url)?; - } - submodule.sync()?; - } - } - Ok(()) - } - fn read_git_config(&self, level: ConfigLevel) -> Result { + /// Read Git configuration at `level` through libgit2 (no mutation). + pub fn read_git_config(&self, level: ConfigLevel) -> Result { let config = self.get_config_at_level(level)?; let mut entries = HashMap::new(); // Iterate through config entries @@ -307,202 +263,16 @@ impl GitOperations for Git2Operations { })?; Ok(GitConfig { entries }) } - fn write_git_config(&self, config: &GitConfig, level: ConfigLevel) -> Result<()> { - let mut git_config = self.get_config_at_level(level)?; - for (key, value) in &config.entries { - git_config.set_str(key, value)?; - } - Ok(()) - } - fn set_config_value(&self, key: &str, value: &str, level: ConfigLevel) -> Result<()> { - let mut config = self.get_config_at_level(level)?; - config - .set_str(key, value) - .with_context(|| format!("Failed to set config value {key}={value}"))?; - Ok(()) - } - fn add_submodule(&mut self, opts: &SubmoduleAddOptions) -> Result<()> { - // 1. Create submodule entry in .gitmodules and index - let mut sub = self - .repo - .submodule(&opts.url, opts.path.as_path(), true) - .with_context(|| { - format!( - "Failed to create submodule entry for '{}' from '{}'", - opts.name, opts.url - ) - })?; - - // 2. Configure clone options - let mut update_opts = git2::SubmoduleUpdateOptions::new(); - let mut fetch_opts = git2::FetchOptions::new(); - if opts.shallow { - fetch_opts.depth(1); - } - update_opts.fetch(fetch_opts); - - // 3. Clone the submodule repository - sub.clone(Some(&mut update_opts)).with_context(|| { - format!( - "Failed to clone submodule '{}' from '{}'", - opts.name, opts.url - ) - })?; - - // 4. Add to index and finalize - sub.add_to_index(true) - .with_context(|| format!("Failed to add submodule '{}' to index", opts.name))?; - sub.add_finalize() - .with_context(|| format!("Failed to finalize submodule '{}'", opts.name))?; - - // 5. Apply optional configuration via git config. - // git2's submodule() keys the submodule by path; use the path as the config key. - let path_str = opts.path.to_string_lossy(); - let mut config = self - .repo - .config() - .with_context(|| "Failed to open git config")?; - - // Set branch if specified - if let Some(branch) = &opts.branch { - let branch_key = format!("submodule.{path_str}.branch"); - config - .set_str(&branch_key, &branch.to_string()) - .with_context(|| format!("Failed to set branch for submodule '{}'", opts.name))?; - } - - // Set ignore rule if specified and not the sentinel Unspecified value - if let Some(ignore) = &opts.ignore - && !matches!(ignore, SerializableIgnore::Unspecified) - { - let ignore_key = format!("submodule.{path_str}.ignore"); - config - .set_str(&ignore_key, &ignore.to_string()) - .with_context(|| format!("Failed to set ignore for submodule '{}'", opts.name))?; - } - - // Set fetch recurse if specified and not the sentinel Unspecified value - if let Some(fetch_recurse) = &opts.fetch_recurse - && !matches!(fetch_recurse, SerializableFetchRecurse::Unspecified) - { - let fetch_key = format!("submodule.{path_str}.fetchRecurseSubmodules"); - config - .set_str(&fetch_key, &fetch_recurse.to_string()) - .with_context(|| { - format!("Failed to set fetchRecurse for submodule '{}'", opts.name) - })?; - } - - // Set update strategy if specified and not the sentinel Unspecified value - if let Some(update) = &opts.update - && !matches!(update, SerializableUpdate::Unspecified) - { - let update_key = format!("submodule.{path_str}.update"); - config - .set_str(&update_key, &update.to_string()) - .with_context(|| format!("Failed to set update for submodule '{}'", opts.name))?; - } - - Ok(()) - } - fn init_submodule(&mut self, path: &str) -> Result<()> { - let mut submodule = self - .repo - .find_submodule(path) - .with_context(|| format!("Submodule not found: {path}"))?; - - submodule.init(false)?; // false = don't overwrite existing config - Ok(()) - } - fn update_submodule(&mut self, path: &str, opts: &SubmoduleUpdateOptions) -> Result<()> { - let mut submodule = self - .repo - .find_submodule(path) - .with_context(|| format!("Submodule not found: {path}"))?; - // Create update options - let mut update_opts = git2::SubmoduleUpdateOptions::new(); - update_opts.allow_fetch(true); - // Set update strategy (git2 has limited support for different strategies) - match opts.strategy { - SerializableUpdate::Rebase | SerializableUpdate::Merge => { - // git2 doesn't support rebase/merge directly, use checkout - eprintln!( - "Warning: git2 doesn't support rebase/merge update strategies, using checkout" - ); - } - SerializableUpdate::None => return Ok(()), - SerializableUpdate::Checkout | SerializableUpdate::Unspecified => { - // Default behavior / Use default - } - } - submodule.update(true, Some(&mut update_opts))?; - Ok(()) - } - fn delete_submodule(&mut self, path: &str) -> Result<()> { - // git2 doesn't have direct submodule deletion, so we need to do it manually - // 1. Deinitialize the submodule - self.deinit_submodule(path, true)?; - // 2. Remove from index - let mut index = self.repo.index()?; - index.remove_path(Path::new(path))?; - index.write()?; - // 3. Remove the directory - let workdir = self - .repo - .workdir() - .ok_or_else(|| anyhow::anyhow!("Repository has no working directory"))?; - let submodule_path = workdir.join(path); - - if submodule_path.exists() { - std::fs::remove_dir_all(&submodule_path) - .with_context(|| format!("Failed to remove submodule directory: {path}"))?; - } - // 4. Remove from .gitmodules (this is complex with git2, might need manual file editing) - // For now, we'll leave this to be handled by higher-level logic - Ok(()) - } - fn deinit_submodule(&mut self, path: &str, force: bool) -> Result<()> { + /// Detailed libgit2 submodule status (no mutation, no fetch). + pub fn get_submodule_status(&self, path: &str) -> Result { let submodule = self .repo .find_submodule(path) .with_context(|| format!("Submodule not found: {path}"))?; - // git2 doesn't have a direct deinit method, so we need to: - // 1. Remove the submodule's config entries - // 2. Remove the submodule's working directory if force is true - let mut config = self.repo.config()?; - let name = submodule.name().unwrap_or(path); - // Remove config entries - let keys_to_remove = [ - format!("submodule.{name}.url"), - format!("submodule.{name}.active"), - format!("submodule.{name}.branch"), - format!("submodule.{name}.fetchRecurseSubmodules"), - ]; - for key in &keys_to_remove { - let _ = config.remove(key); // Ignore errors if key doesn't exist - } - // Remove working directory if force is true - if force { - let workdir = self - .repo - .workdir() - .ok_or_else(|| anyhow::anyhow!("Repository has no working directory"))?; - let submodule_path = workdir.join(path); - - if submodule_path.exists() { - std::fs::remove_dir_all(&submodule_path) - .with_context(|| format!("Failed to remove submodule directory: {path}"))?; - } - } - Ok(()) - } - fn get_submodule_status(&self, path: &str) -> Result { - let submodule = self - .repo - .find_submodule(path) - .with_context(|| format!("Submodule not found: {path}"))?; - let name = submodule.name().unwrap_or(path).to_string(); + let name = self + .registered_name_for_path(path)? + .unwrap_or_else(|| submodule.name().unwrap_or(path).to_string()); let url = submodule .url() .ok() @@ -512,8 +282,13 @@ impl GitOperations for Git2Operations { // Get status let status = self .repo - .submodule_status(path, git2::SubmoduleIgnore::Unspecified)?; - let status_flags = self.convert_git2_status_to_flags(status); + .submodule_status(&name, git2::SubmoduleIgnore::Unspecified)?; + let mut status_flags = self.convert_git2_status_to_flags(status); + if !status_flags.contains(SubmoduleStatusFlags::IN_CONFIG) + && self.registered_name_for_path(path)?.as_deref() == Some(name.as_str()) + { + status_flags.insert(SubmoduleStatusFlags::IN_CONFIG); + } // Get OIDs let head_oid = submodule.head_id().map(|oid| oid.to_string()); let index_oid = submodule.index_id().map(|oid| oid.to_string()); @@ -552,7 +327,9 @@ impl GitOperations for Git2Operations { sparse_patterns, }) } - fn list_submodules(&self) -> Result> { + + /// List registered submodule paths through libgit2 (no mutation, no fetch). + pub fn list_submodules(&self) -> Result> { let submodules = self.repo.submodules()?; let paths = submodules .iter() @@ -560,150 +337,9 @@ impl GitOperations for Git2Operations { .collect(); Ok(paths) } - fn fetch_submodule(&self, path: &str) -> Result<()> { - let submodule = self - .repo - .find_submodule(path) - .with_context(|| format!("Submodule not found: {path}"))?; - // Open the submodule repository - let sub_repo = submodule - .open() - .with_context(|| format!("Failed to open submodule repository: {path}"))?; - // Find the origin remote - let mut remote = sub_repo - .find_remote("origin") - .with_context(|| format!("Failed to find origin remote for submodule: {path}"))?; - // Fetch from origin - remote - .fetch(&[] as &[&str], None, None) - .with_context(|| format!("Failed to fetch submodule: {path}"))?; - Ok(()) - } - fn reset_submodule(&self, path: &str, hard: bool) -> Result<()> { - let submodule = self - .repo - .find_submodule(path) - .with_context(|| format!("Submodule not found: {path}"))?; - // Open the submodule repository - let sub_repo = submodule - .open() - .with_context(|| format!("Failed to open submodule repository: {path}"))?; - // Get HEAD commit - let head = sub_repo.head()?; - let commit = head.peel_to_commit()?; - // Reset to HEAD - let reset_type = if hard { - git2::ResetType::Hard - } else { - git2::ResetType::Soft - }; - sub_repo - .reset(commit.as_object(), reset_type, None) - .with_context(|| format!("Failed to reset submodule: {path}"))?; - Ok(()) - } - fn clean_submodule(&self, path: &str, force: bool, remove_directories: bool) -> Result<()> { - let submodule = self - .repo - .find_submodule(path) - .with_context(|| format!("Submodule not found: {path}"))?; - // Open the submodule repository - let sub_repo = submodule - .open() - .with_context(|| format!("Failed to open submodule repository: {path}"))?; - // Get status to find untracked files - let mut status_opts = git2::StatusOptions::new(); - status_opts.include_untracked(true); - status_opts.include_ignored(false); - let statuses = sub_repo.statuses(Some(&mut status_opts))?; - // Remove untracked files - for entry in statuses.iter() { - if entry.status().is_wt_new() - && let Ok(file_path) = entry.path() - { - let full_path = sub_repo - .workdir() - .ok_or_else(|| anyhow::anyhow!("Submodule has no working directory"))? - .join(file_path); - if full_path.is_file() { - if force { - std::fs::remove_file(&full_path).with_context(|| { - format!("Failed to remove file: {}", full_path.display()) - })?; - } - } else if full_path.is_dir() && remove_directories && force { - std::fs::remove_dir_all(&full_path).with_context(|| { - format!("Failed to remove directory: {}", full_path.display()) - })?; - } - } - } - Ok(()) - } - fn stash_submodule(&self, path: &str, include_untracked: bool) -> Result<()> { - let submodule = self - .repo - .find_submodule(path) - .with_context(|| format!("Submodule not found: {path}"))?; - // Open the submodule repository - let mut sub_repo = submodule - .open() - .with_context(|| format!("Failed to open submodule repository: {path}"))?; - // Create stash - let signature = sub_repo - .signature() - .or_else(|_| git2::Signature::now("submod", "submod@localhost"))?; - let mut stash_flags = git2::StashFlags::DEFAULT; - if include_untracked { - stash_flags |= git2::StashFlags::INCLUDE_UNTRACKED; - } - sub_repo - .stash_save(&signature, "submod stash", Some(stash_flags)) - .with_context(|| format!("Failed to stash changes in submodule: {path}"))?; - Ok(()) - } - fn enable_sparse_checkout(&self, path: &str) -> Result<()> { - let submodule = self - .repo - .find_submodule(path) - .with_context(|| format!("Submodule not found: {path}"))?; - // Open the submodule repository - let sub_repo = submodule - .open() - .with_context(|| format!("Failed to open submodule repository: {path}"))?; - // Enable sparse checkout in config - let mut config = sub_repo.config()?; - config - .set_bool("core.sparseCheckout", true) - .with_context(|| format!("Failed to enable sparse checkout for submodule: {path}"))?; - Ok(()) - } - fn set_sparse_patterns(&self, path: &str, patterns: &[String]) -> Result<()> { - let submodule = self - .repo - .find_submodule(path) - .with_context(|| format!("Submodule not found: {path}"))?; - // Open the submodule repository - let sub_repo = submodule - .open() - .with_context(|| format!("Failed to open submodule repository: {path}"))?; - // Write patterns to .git/info/sparse-checkout - let git_dir = sub_repo.path(); - let sparse_checkout_file = git_dir.join("info").join("sparse-checkout"); - // Create info directory if it doesn't exist - if let Some(parent) = sparse_checkout_file.parent() { - std::fs::create_dir_all(parent).with_context(|| { - format!("Failed to create info directory for submodule: {path}") - })?; - } - // Write patterns - let content = patterns.join("\n"); - std::fs::write(&sparse_checkout_file, content).with_context(|| { - format!("Failed to write sparse checkout patterns for submodule: {path}") - })?; - Ok(()) - } - fn get_sparse_patterns(&self, path: &str) -> Result> { + + /// Read the sparse-checkout patterns of an initialized child (no mutation). + pub fn get_sparse_patterns(&self, path: &str) -> Result> { let submodule = self .repo .find_submodule(path) @@ -729,15 +365,8 @@ impl GitOperations for Git2Operations { .collect(); Ok(patterns) } - fn apply_sparse_checkout(&self, _path: &str) -> Result<()> { - // git2 doesn't have direct sparse checkout application - // We need to use gix_command or implement it manually - // For now, return an error to indicate this needs manual implementation - Err(anyhow::anyhow!( - "git2 sparse checkout application not implemented, consider using gix_command" - )) - } } + impl Git2Operations { /// Get sparse checkout information for a submodule #[allow(dead_code)] diff --git a/src/git_ops/gix_ops.rs b/src/git_ops/gix_ops.rs index 8915112d..d9692dce 100644 --- a/src/git_ops/gix_ops.rs +++ b/src/git_ops/gix_ops.rs @@ -1,40 +1,22 @@ // SPDX-FileCopyrightText: 2025 Adam Poulemanos <89049923+bashandbone@users.noreply.github.com> // // SPDX-License-Identifier: LicenseRef-PlainMIT OR MIT -// TODO: This module is very not-DRY...but it's low priority right now. use anyhow::{Context, Result}; -use gix::bstr::ByteSlice; + use std::collections::HashMap; use std::path::Path; -use crate::git_ops::simple_gix::fetch_repo; - -/// Parse a gix config file from raw bytes -fn gix_file_from_bytes(bytes: Vec) -> Result { - let mut owned_bytes: Vec = bytes; - gix::config::File::from_bytes_owned( - &mut owned_bytes, - gix::config::file::Metadata::from(gix::config::Source::Local), - gix::config::file::init::Options::default(), - ) - .map_err(|e| anyhow::anyhow!("Failed to parse gix config file: {e}")) -} - -use super::{DetailedSubmoduleStatus, GitConfig, GitOperations, SubmoduleStatusFlags}; -use crate::config::{SubmoduleAddOptions, SubmoduleEntries, SubmoduleUpdateOptions}; -use crate::options::{ConfigLevel, GitmodulesConvert}; -use crate::utilities; +use super::GitConfig; +use crate::config::SubmoduleEntries; +use crate::options::ConfigLevel; /// Primary implementation using gix (gitoxide) #[derive(Debug, Clone, PartialEq)] pub struct GixOperations { repo: gix::Repository, - /// Whether to surface the underlying gitoxide fetch report on stderr. - verbose: bool, } impl GixOperations { - /// Create a new `GixOperations` instance. Quiet by default; see - /// [`GixOperations::with_verbose`]. + /// Create a read-only `GixOperations` instance. pub fn new(repo_path: Option<&Path>) -> Result { let repo = match repo_path { Some(path) => gix::open(path) @@ -42,17 +24,7 @@ impl GixOperations { None => gix::discover(".") .with_context(|| "Failed to discover repository in current directory")?, }; - Ok(Self { - repo, - verbose: false, - }) - } - - /// Set whether gitoxide's own fetch report is echoed to stderr. - #[must_use] - pub const fn with_verbose(mut self, verbose: bool) -> Self { - self.verbose = verbose; - self + Ok(Self { repo }) } /// Try to perform operation with gix, return error if not supported @@ -90,32 +62,11 @@ impl GixOperations { } crate::config::SubmoduleEntries::from_gitmodules(sections_map) } - /// Get the name of the current branch in the superproject - fn get_superproject_branch(&self) -> Result { - self.repo - .head_ref() - .map_err(|e| anyhow::anyhow!("Failed to get HEAD reference: {e}"))? - .map(|r| r.name().shorten().to_string()) - .ok_or_else(|| anyhow::anyhow!("HEAD is detached, not on a branch")) - } - - /// Convert gix submodule status to our status flags - #[allow(dead_code, clippy::unused_self)] - fn convert_gix_status_to_flags(&self, status: &gix::submodule::Status) -> SubmoduleStatusFlags { - let mut flags = SubmoduleStatusFlags::empty(); - // Map gix status to our flags - // Note: This is a simplified mapping as gix status structure may differ - if status.is_dirty() == Some(true) { - flags |= SubmoduleStatusFlags::WD_WD_MODIFIED; - } - // Add more mappings as needed based on gix::submodule::Status structure - flags - } } -impl GitOperations for GixOperations { - /// Read the .gitmodules file and convert it to `SubmoduleEntries` - fn read_gitmodules(&self) -> Result { +impl GixOperations { + /// Read `.gitmodules` registrations through gix (no mutation, no fetch). + pub fn read_gitmodules(&self) -> Result { let mutable_self = self.clone(); mutable_self.try_gix_operation(|repo| { let gitmodules_path = repo @@ -136,92 +87,8 @@ impl GitOperations for GixOperations { }) } - /// Write the submodule entries to the .gitmodules file - fn write_gitmodules(&mut self, config: &SubmoduleEntries) -> Result<()> { - self.try_gix_operation(|repo| { - let mut git_config = gix::config::File::new(gix::config::file::Metadata::api()); - - // Convert SubmoduleEntries to gix config format - for (name, entry) in config.submodule_iter() { - let subsection_name = name.as_bytes().as_bstr(); - - if let Some(path) = &entry.path { - git_config.set_raw_value_by( - "submodule", - Some(subsection_name), - "path", - path.as_bytes().as_bstr(), - )?; - } - if let Some(url) = &entry.url { - git_config.set_raw_value_by( - "submodule", - Some(subsection_name), - "url", - url.as_bytes().as_bstr(), - )?; - } - if let Some(branch) = &entry.branch { - let value = branch.to_string(); - git_config.set_raw_value_by( - "submodule", - Some(subsection_name), - "branch", - value.as_bytes().as_bstr(), - )?; - } - if let Some(update) = &entry.update { - let value = update.to_gitmodules(); - git_config.set_raw_value_by( - "submodule", - Some(subsection_name), - "update", - value.as_bytes().as_bstr(), - )?; - } - if let Some(ignore) = &entry.ignore { - let value = ignore.to_gitmodules(); - git_config.set_raw_value_by( - "submodule", - Some(subsection_name), - "ignore", - value.as_bytes().as_bstr(), - )?; - } - if let Some(fetch_recurse) = &entry.fetch_recurse { - let value = fetch_recurse.to_gitmodules(); - git_config.set_raw_value_by( - "submodule", - Some(subsection_name), - "fetchRecurseSubmodules", - value.as_bytes().as_bstr(), - )?; - } - if let Some(active) = entry.active { - let value = if active { "true" } else { "false" }; - git_config.set_raw_value_by( - "submodule", - Some(subsection_name), - "active", - value.as_bytes().as_bstr(), - )?; - } - } - - // Write to .gitmodules file - let gitmodules_path = repo - .workdir() - .ok_or_else(|| anyhow::anyhow!("Repository has no working directory"))? - .join(".gitmodules"); - - let mut file = std::fs::File::create(&gitmodules_path)?; - git_config.write_to(&mut file)?; - Ok(()) - }) - } - - /// Read the Git configuration at the specified level - fn read_git_config(&self, level: ConfigLevel) -> Result { + /// Read Git configuration at `level` through gix (no mutation). + pub fn read_git_config(&self, level: ConfigLevel) -> Result { self.clone().try_gix_operation_mut(|repo| { let config_snapshot = repo.config_snapshot(); let mut entries = HashMap::new(); @@ -248,452 +115,8 @@ impl GitOperations for GixOperations { }) } - /// Write the Git configuration to the repository - fn write_git_config(&self, config: &GitConfig, level: ConfigLevel) -> Result<()> { - // gix::config::File<'static> requires 'static lifetimes for all string arguments - // passed to set_raw_value_by (Key: TryFrom<&'static str>, subsection: &'static BStr). - // We use Box::leak to produce 'static references. Memory leaked is minimal - // (a few bytes per key) and acceptable in this WIP implementation. - // TODO: Replace with a gix API that accepts owned data when available. - let parsed: Vec<( - &'static str, - Option<&'static gix::bstr::BStr>, - &'static str, - Vec, - )> = config - .entries - .iter() - .map(|(key, value)| { - let mut parts = key.splitn(3, '.'); - let section: &'static str = - Box::leak(parts.next().unwrap_or("").to_owned().into_boxed_str()); - let subsection: Option<&'static gix::bstr::BStr> = parts.next().map(|s| { - let bytes: &'static [u8] = Box::leak(s.as_bytes().to_vec().into_boxed_slice()); - bytes.as_bstr() - }); - let name: &'static str = - Box::leak(parts.next().unwrap_or("").to_owned().into_boxed_str()); - (section, subsection, name, value.as_bytes().to_vec()) - }) - .collect(); - - self.try_gix_operation(|repo| { - let config_path = match level { - ConfigLevel::Local | ConfigLevel::Worktree => repo.git_dir().join("config"), - _ => { - return Err(anyhow::anyhow!( - "Only local config writing is supported with gix" - )); - } - }; - let bytes = if config_path.exists() { - std::fs::read(&config_path)? - } else { - Vec::new() - }; - let mut config_file = gix_file_from_bytes(bytes).with_context(|| { - format!("Failed to read config file at {}", config_path.display()) - })?; - for (section, subsection, name, value) in &parsed { - config_file.set_raw_value_by(*section, *subsection, *name, value.as_bstr())?; - } - let mut output = std::fs::File::create(&config_path)?; - config_file.write_to(&mut output)?; - Ok(()) - }) - } - - /// Set a configuration value in the repository - fn set_config_value(&self, key: &str, value: &str, level: ConfigLevel) -> Result<()> { - // Merge with existing config - let existing = self.read_git_config(level)?; - let mut merged = existing.entries; - merged.insert(key.to_string(), value.to_string()); - let merged_config = GitConfig { entries: merged }; - self.write_git_config(&merged_config, level) - } - - /// Add a new submodule to the repository - fn add_submodule(&mut self, opts: &SubmoduleAddOptions) -> Result<()> { - // gix does not support cloning in add_submodule; fall through to git2/CLI. - Err(anyhow::anyhow!( - "gix add_submodule not implemented: use git2 or CLI fallback for '{}'", - opts.name - )) - } - - /// Initialize a submodule by reading its configuration and setting it up - fn init_submodule(&mut self, path: &str) -> Result<()> { - // 1. Read .gitmodules to get submodule configuration - let entries = self.read_gitmodules()?; - - // 2. Find the submodule entry by path - let submodule_entry = entries - .submodule_iter() - .find(|(_, entry)| entry.path.as_ref() == Some(&path.to_string())) - .ok_or_else(|| anyhow::anyhow!("Submodule '{path}' not found in .gitmodules"))?; - - let (name, entry) = submodule_entry; - let url = entry - .url - .as_ref() - .ok_or_else(|| anyhow::anyhow!("Submodule '{name}' has no URL configured"))?; - - self.try_gix_operation(|repo| { - // 3. Set up submodule configuration in .git/config - let config_snapshot = repo.config_snapshot(); - let mut config_file = config_snapshot.to_owned(); - - // Set submodule URL in local config - let _url_key = format!("submodule.{name}.url"); - config_file.set_raw_value_by( - "submodule", - Some(name.as_bytes().as_bstr()), - "url", - url.as_bytes().as_bstr(), - )?; - - // Set submodule active flag - let _active_key = format!("submodule.{name}.active"); - config_file.set_raw_value_by( - "submodule", - Some(name.as_bytes().as_bstr()), - "active", - b"true".as_bstr(), - )?; - - // 4. Check if submodule directory exists and is empty - let workdir = repo - .workdir() - .ok_or_else(|| anyhow::anyhow!("Repository has no working directory"))?; - let submodule_path = workdir.join(path); - - if !submodule_path.exists() { - std::fs::create_dir_all(&submodule_path)?; - } else if submodule_path.read_dir()?.next().is_some() { - // Directory exists and is not empty - this is fine for init - // (unlike clone which would fail) - } - - // 5. Clone the submodule if it doesn't exist yet - if !submodule_path.join(".git").exists() { - // Clone the submodule repository using gix - let mut prepare = gix::prepare_clone(url.clone(), &submodule_path)?; - if entry.shallow == Some(true) { - prepare = prepare - .with_shallow(gix::remote::fetch::Shallow::DepthAtRemote(1.try_into()?)); - } - let should_interrupt = - std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); - let progress = gix::progress::Discard; - let (_checkout, _outcome) = - prepare.fetch_then_checkout(progress, &should_interrupt)?; - } - - Ok(()) - }) - } - - /// Update a submodule to the latest commit in its remote repository - fn update_submodule(&mut self, path: &str, opts: &SubmoduleUpdateOptions) -> Result<()> { - let entries = self.read_gitmodules()?; - let submodule_entry = entries - .submodule_iter() - .find(|(_, entry)| entry.path.as_ref() == Some(&path.to_string())) - .ok_or_else(|| anyhow::anyhow!("Submodule '{path}' not found in .gitmodules"))?; - let (name, entry) = submodule_entry; - let url = entry - .url - .as_ref() - .ok_or_else(|| anyhow::anyhow!("Submodule '{name}' has no URL configured"))?; - let workdir = self - .repo - .workdir() - .ok_or_else(|| anyhow::anyhow!("Repository has no working directory"))?; - let submodule_path = workdir.join(path); - - if !submodule_path.exists() || !submodule_path.join(".git").exists() { - // Use gix::prepare_clone for proper remote operations - let mut prepare = gix::prepare_clone(url.clone(), &submodule_path)?; - if entry.shallow == Some(true) { - prepare = - prepare.with_shallow(gix::remote::fetch::Shallow::DepthAtRemote(1.try_into()?)); - } - let should_interrupt = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); - let progress = gix::progress::Discard; - let (checkout, _outcome) = prepare.fetch_then_checkout(progress, &should_interrupt)?; - if let Some(branch) = &entry.branch { - let mut config_file = checkout.repo().config_snapshot().to_owned(); - match branch { - crate::options::SerializableBranch::Name(branch_name) => { - config_file.set_raw_value_by( - "branch", - Some(branch_name.as_bytes().as_bstr()), - "remote", - b"origin".as_bstr(), - )?; - config_file.set_raw_value_by( - "branch", - Some(branch_name.as_bytes().as_bstr()), - "merge", - format!("refs/heads/{branch_name}").as_bytes().as_bstr(), - )?; - } - crate::options::SerializableBranch::CurrentInSuperproject => { - // Set branch to current branch in superproject - let superproject_branch = self.get_superproject_branch()?; - config_file.set_raw_value_by( - "branch", - Some(superproject_branch.as_bytes().as_bstr()), - "remote", - b"origin".as_bstr(), - )?; - config_file.set_raw_value_by( - "branch", - Some(superproject_branch.as_bytes().as_bstr()), - "merge", - format!("refs/heads/{superproject_branch}") - .as_bytes() - .as_bstr(), - )?; - } - } - } - } else { - // Submodule exists — fetch updates using sync fetch_repo - // Pass None to let gix resolve the default remote (which has refspecs configured). - // Passing the URL string would create a bare remote without refspecs. - let submodule_repo = gix::open(&submodule_path)?; - fetch_repo( - submodule_repo, - None, - entry.shallow == Some(true), - self.verbose, - ) - .map_err(|e| anyhow::anyhow!("Failed to fetch submodule: {e}"))?; - match opts.strategy { - crate::options::SerializableUpdate::Checkout - | crate::options::SerializableUpdate::Unspecified => { - // The fetch above only updated the object store and remote - // tracking refs; the worktree still needs to be checked out - // to the commit recorded as the superproject's gitlink. gix - // has no worktree-checkout for an existing submodule here, so - // delegate to git2's `submodule.update()`, which performs the - // checkout. Without this, `update` silently fetched but left - // the worktree stranded behind its recorded commit (#62 P1). - return Err(anyhow::anyhow!( - "gix cannot checkout submodule to its recorded commit; falling back to git2" - )); - } - crate::options::SerializableUpdate::Merge => { - return Err(anyhow::anyhow!( - "Merge strategy not yet implemented with gix" - )); - } - crate::options::SerializableUpdate::Rebase => { - return Err(anyhow::anyhow!( - "Rebase strategy not yet implemented with gix" - )); - } - crate::options::SerializableUpdate::None => { - // No update - } - } - } - Ok(()) - } - - /// Delete a submodule by removing its configuration and content - fn delete_submodule(&mut self, path: &str) -> Result<()> { - // 1. Read .gitmodules to get submodule configuration (outside closure) - let mut entries = self.read_gitmodules()?; - - // 2. Find the submodule entry by path - let submodule_name = entries - .submodule_iter() - .find(|(_, entry)| entry.path.as_ref() == Some(&path.to_string())) - .map(|(name, _)| name.clone()) - .ok_or_else(|| anyhow::anyhow!("Submodule '{path}' not found in .gitmodules"))?; - - // 3. Remove from .gitmodules - let _ = entries.remove_submodule(&submodule_name); - self.write_gitmodules(&entries)?; - - self.try_gix_operation_mut(|repo| { - // 4. Remove from git index using gix (fixed API usage) - let index_path = repo.git_dir().join("index"); - if index_path.exists() { - let mut index = gix::index::File::at( - &index_path, - gix::hash::Kind::Sha1, - false, - gix::index::decode::Options::default(), - )?; - // Remove all entries matching the submodule path prefix - let remove_prefix = path; - index.remove_entries(|_idx, path, _entry| { - let path_str = std::str::from_utf8(path).unwrap_or(""); - path_str.starts_with(remove_prefix) - }); - let mut index_file = std::fs::OpenOptions::new() - .write(true) - .truncate(true) - .open(&index_path)?; - index.write_to(&mut index_file, gix::index::write::Options::default())?; - let mut index_file = std::fs::OpenOptions::new() - .write(true) - .truncate(true) - .open(&index_path)?; - index.write_to(&mut index_file, gix::index::write::Options::default())?; - } - - // 5. Remove submodule configuration from .git/config - let config_snapshot = repo.config_snapshot(); - let _config_file = config_snapshot.to_owned(); - let _config_file = config_snapshot.to_owned(); - - // Remove all submodule.{name}.* entries - let _section_name = format!("submodule.{submodule_name}"); - let _section_name = format!("submodule.{submodule_name}"); - // Note: gix config API for removing sections is complex - // For now, we'll fall back to manual removal or git2 for this part - // This is acceptable as it's a less common operation - - // 6. Remove the submodule directory from working tree - let workdir = repo - .workdir() - .ok_or_else(|| anyhow::anyhow!("Repository has no working directory"))?; - let submodule_path = workdir.join(path); - - if submodule_path.exists() { - std::fs::remove_dir_all(&submodule_path).with_context(|| { - format!( - "Failed to remove submodule directory at {}", - submodule_path.display() - ) - })?; - } - - // 7. Remove .git/modules/{name} directory if it exists - let modules_path = repo.git_dir().join("modules").join(&submodule_name); - if modules_path.exists() { - std::fs::remove_dir_all(&modules_path).with_context(|| { - format!( - "Failed to remove submodule git directory at {}", - modules_path.display() - ) - })?; - } - - Ok(()) - }) - } - - /// Deinitialize a submodule, removing its configuration and content - fn deinit_submodule(&mut self, path: &str, force: bool) -> Result<()> { - let entries = self.read_gitmodules()?; - let submodule_name = entries - .submodule_iter() - .find(|(_, entry)| entry.path.as_ref() == Some(&path.to_string())) - .map(|(name, _)| name.clone()) - .ok_or_else(|| anyhow::anyhow!("Submodule '{path}' not found in .gitmodules"))?; - self.clone().try_gix_operation_mut(|repo| { - // 1. Get the submodule directory - let workdir = repo.workdir() - .ok_or_else(|| anyhow::anyhow!("Repository has no working directory"))?; - let submodule_path = workdir.join(path); - - // 2. Check if submodule has uncommitted changes (unless force is true) - if !force && submodule_path.exists() && submodule_path.join(".git").exists() { - if let Ok(submodule_repo) = gix::open(&submodule_path) { - // Check for uncommitted changes using gix. - // The is_dirty() method will return true if there are uncommitted changes, - // including untracked files and modifications to tracked files. - match submodule_repo.is_dirty() { - Ok(is_dirty) => { - if is_dirty { - return Err(anyhow::anyhow!( - "Submodule '{path}' has uncommitted changes. Use force=true to override." - )); - } - } - Err(err) => { - // If we can't determine dirty status reliably, assume it might have changes - return Err(anyhow::anyhow!( - "Submodule '{path}' might have uncommitted changes. Use force=true to override.\nError: {err}" - )); - } - } - } else { - return Err(anyhow::anyhow!( - "Submodule '{path}' might have uncommitted changes. Use force=true to override." - )); - } - } - - // 4. Remove submodule configuration from .git/config - let config_snapshot = repo.config_snapshot(); - let _config_file = config_snapshot.to_owned(); - let _config_file = config_snapshot.to_owned(); - - // Remove submodule.{name}.url and submodule.{name}.active - // Note: gix config API for removing specific keys is complex - // For a complete implementation, we might need to fall back to git2 - // or implement more sophisticated config manipulation - - // 5. Clear the submodule working directory - if submodule_path.exists() { - if force { - // Force removal of all content - std::fs::remove_dir_all(&submodule_path) - .with_context(|| format!("Failed to remove submodule directory at {}", submodule_path.display()))?; - - // Recreate empty directory to maintain the path structure - std::fs::create_dir_all(&submodule_path)?; - } else { - // Only remove .git directory and tracked files, preserve untracked files - let git_dir = submodule_path.join(".git"); - if git_dir.exists() { - if git_dir.is_dir() { - std::fs::remove_dir_all(&git_dir)?; - } else { - // .git is a file (gitdir reference) - std::fs::remove_file(&git_dir)?; - } - } - - // Remove tracked files by checking out empty tree - // This is complex to implement properly with gix - // For now, we'll do a simple approach by removing all files - // except untracked ones (which is hard to determine without proper status) - // We'll just remove common git-tracked file patterns - for entry in std::fs::read_dir(&submodule_path)? { - let entry = entry?; - let path = entry.path(); - if path.is_file() { - std::fs::remove_file(&path).ok(); // Ignore errors for individual files - } - } - } - } - - // 6. Remove .git/modules/{name} directory if it exists - let modules_path = repo.git_dir().join("modules").join(&submodule_name); - if modules_path.exists() { - std::fs::remove_dir_all(&modules_path) - .with_context(|| format!("Failed to remove submodule git directory at {}", modules_path.display()))?; - } - - Ok(()) - }) - } - /// Get the status of a submodule - fn get_submodule_status(&self, _path: &str) -> Result { - Err(anyhow::anyhow!( - "get_submodule_status not yet implemented with gix" - )) - } - fn list_submodules(&self) -> Result> { + /// List registered submodule paths through gix (no mutation, no fetch). + pub fn list_submodules(&self) -> Result> { self.try_gix_operation(|repo| { let mut submodule_paths = Vec::new(); if let Some(submodule_iter) = repo.submodules()? { @@ -705,79 +128,4 @@ impl GitOperations for GixOperations { Ok(submodule_paths) }) } - fn fetch_submodule(&self, path: &str) -> Result<()> { - // Pass None to let gix resolve the default remote (which has refspecs configured). - let submodule_repo = utilities::repo_from_path(&std::path::PathBuf::from(path))?; - fetch_repo(submodule_repo, None, false, self.verbose) - .map_err(|e| anyhow::anyhow!("Failed to fetch submodule: {e}")) - } - - fn reset_submodule(&self, _path: &str, _hard: bool) -> Result<()> { - // gix doesn't support submodule reset yet - Err(anyhow::anyhow!( - "gix submodule reset not yet supported, falling back to git2" - )) - } - fn clean_submodule(&self, _path: &str, _force: bool, _remove_directories: bool) -> Result<()> { - // gix doesn't support submodule cleaning yet - Err(anyhow::anyhow!( - "gix submodule cleaning not yet supported, falling back to git2" - )) - } - fn stash_submodule(&self, _path: &str, _include_untracked: bool) -> Result<()> { - // gix doesn't support stashing yet - Err(anyhow::anyhow!( - "gix stashing not yet supported, falling back to git2" - )) - } - fn enable_sparse_checkout(&self, _path: &str) -> Result<()> { - // Defer to git2 which correctly handles submodule paths - Err(anyhow::anyhow!( - "gix sparse checkout setup not implemented for submodule paths, falling back to git2" - )) - } - fn set_sparse_patterns(&self, _path: &str, _patterns: &[String]) -> Result<()> { - // Defer to git2 which correctly handles submodule paths - Err(anyhow::anyhow!( - "gix sparse patterns not implemented for submodule paths, falling back to git2" - )) - } - fn get_sparse_patterns(&self, _path: &str) -> Result> { - // Defer to git2 which correctly handles submodule paths - Err(anyhow::anyhow!( - "gix get sparse patterns not implemented for submodule paths, falling back to git2" - )) - } - fn apply_sparse_checkout(&self, path: &str) -> Result<()> { - self.try_gix_operation(|repo| { - // Get sparse checkout patterns - let patterns = self.get_sparse_patterns(path)?; - if patterns.is_empty() { - return Ok(()); // No patterns to apply - } - - // Load the index - let index_path = repo.git_dir().join("index"); - let _index = gix::index::File::at( - &index_path, - gix::hash::Kind::Sha1, - false, - gix::index::decode::Options::default(), - )?; - - // Use a simpler approach since remove_entries closure signature is complex - // Fall back to git2 for now for sparse checkout application - Err(anyhow::anyhow!( - "gix sparse checkout application is complex, falling back to git2" - )) - }) - } -} - -impl From for GixOperations { - fn from(git_ops: super::GitOpsManager) -> Self { - git_ops - .gix_ops - .expect("GixOperations should always be initialized") - } } diff --git a/src/git_ops/mod.rs b/src/git_ops/mod.rs index 1c33bda8..dfbc899f 100644 --- a/src/git_ops/mod.rs +++ b/src/git_ops/mod.rs @@ -2,31 +2,36 @@ // // SPDX-License-Identifier: LicenseRef-PlainMIT OR MIT #![doc = r" -This module provides a unified interface for performing git operations using both `gix` and `git2` libraries. -It implements a gix-first, git2-fallback strategy to ensure robust functionality across different environments and use cases. +This module is the native Git mutation boundary: every lifecycle mutation +(add/init/update/move/deinit/delete/reset/stash/clean/sparse) runs through +one checked `git` invocation path on `GitOpsManager`. There are no competing +backend mutation implementations and no cross-backend retry after a failure. -The `GitOpsManager` struct manages the operations and automatically falls back to `git2` if a `gix` operation fails, -providing seamless integration for submodule management and configuration tasks. - -We prefer Gix, but it's still unstable and several core features are missing, so we use git2 as a fallback for those features and for stability. +`gix` and `git2` survive only as read backends behind `try_with_fallback` +(gix first, git2 fallback) for inspection reads: gitmodules, config, status, +list, and sparse patterns. "] /// git2-based git operations implementation pub mod git2_ops; /// gitoxide (gix)-based git operations implementation pub mod gix_ops; -pub mod simple_gix; pub use git2_ops::Git2Operations; pub use gix_ops::GixOperations; use anyhow::{Context, Result}; use bitflags::bitflags; use std::collections::HashMap; -use std::path::Path; +use std::ffi::{OsStr, OsString}; +use std::io::Write; +use std::path::{Path, PathBuf}; +use std::process::{Command, Output, Stdio}; -use crate::config::{SubmoduleAddOptions, SubmoduleEntries, SubmoduleUpdateOptions}; +use crate::config::{ + SubmoduleAddOptions, SubmoduleEntries, SubmoduleEntry, SubmoduleUpdateOptions, +}; use crate::options::{ - ConfigLevel, SerializableBranch, SerializableFetchRecurse, SerializableIgnore, - SerializableUpdate, + ConfigLevel, GitmodulesConvert, SerializableBranch, SerializableFetchRecurse, + SerializableIgnore, SerializableUpdate, }; /// Represents git configuration state @@ -110,7 +115,8 @@ pub struct DetailedSubmoduleStatus { pub sparse_patterns: Vec, } -/// Main trait for git operations with gix-first, git2-fallback strategy +/// Git operations served by `GitOpsManager`: native Git mutations plus +/// gix-first, git2-fallback inspection reads. pub trait GitOperations { // Config operations /// Read .gitmodules configuration @@ -135,7 +141,7 @@ pub trait GitOperations { /// Update a submodule fn update_submodule(&mut self, path: &str, opts: &SubmoduleUpdateOptions) -> Result<()>; /// Delete a submodule completely - fn delete_submodule(&mut self, path: &str) -> Result<()>; + fn delete_submodule(&mut self, path: &str, force: bool) -> Result<()>; /// Deinitialize a submodule fn deinit_submodule(&mut self, path: &str, force: bool) -> Result<()>; /// Get detailed status of a submodule @@ -153,7 +159,7 @@ pub trait GitOperations { /// Clean a submodule fn clean_submodule(&self, path: &str, force: bool, remove_directories: bool) -> Result<()>; /// Stash changes in a submodule - fn stash_submodule(&self, path: &str, include_untracked: bool) -> Result<()>; + fn stash_submodule(&self, path: &str, include_untracked: bool) -> Result>; // Sparse checkout operations /// Enable sparse checkout for a submodule @@ -170,453 +176,2799 @@ pub trait GitOperations { pub struct GitOpsManager { gix_ops: Option, git2_ops: Git2Operations, + worktree: PathBuf, + git_dir: PathBuf, verbose: bool, - force_cli_add: bool, } -/// Implement `GitOperations` for `GitOpsManager`, using gix first and falling back to git2 if gix fails -impl GitOpsManager { - /// Create a new `GitOpsManager` with automatic fallback - pub fn new(repo_path: Option<&Path>, verbose: bool) -> Result { - let gix_ops = GixOperations::new(repo_path) - .ok() - .map(|ops| ops.with_verbose(verbose)); - let git2_ops = Git2Operations::new(repo_path) - .with_context(|| "Failed to initialize git2 operations")?; +#[derive(Debug, Clone, PartialEq, Eq)] +struct ModuleConfigSnapshot { + local: Vec<(String, String)>, + worktree: Option>, +} - Ok(Self { - gix_ops, - git2_ops, - verbose, - force_cli_add: false, - }) +/// Native Git mutations with supported repository inspection backends. +impl GitOpsManager { + fn managed_portable_fields(entry: &SubmoduleEntry) -> Vec<(&'static str, Option)> { + vec![ + ("url", entry.url.clone()), + ( + "branch", + entry.branch.as_ref().map(GitmodulesConvert::to_gitmodules), + ), + ("ignore", entry.ignore.map(|value| value.to_string())), + ("update", entry.update.as_ref().map(ToString::to_string)), + ( + "fetchRecurseSubmodules", + entry.fetch_recurse.map(|value| value.to_gitmodules()), + ), + ("shallow", entry.shallow.map(|value| value.to_string())), + ] } - /// Create a `GitOpsManager` with the gix backend disabled, forcing every - /// operation through git2 (and, for `add_submodule`, the CLI last resort). - /// - /// gix is an optional optimistic backend: when it is absent, `GitOpsManager` - /// is designed to fall straight through to git2 for every operation. This - /// constructor makes that git2-only mode reachable so the fallback path can - /// be exercised for correct results rather than left as dead code. - #[allow(dead_code)] - pub fn without_gix(repo_path: Option<&Path>, verbose: bool) -> Result { - let git2_ops = Git2Operations::new(repo_path) - .with_context(|| "Failed to initialize git2 operations")?; - - Ok(Self { - gix_ops: None, - git2_ops, - verbose, - force_cli_add: false, - }) + fn config_value(&self, scope: &str, key: &str) -> Result> { + let output = match scope { + "portable" => self.git_output(["config", "--file", ".gitmodules", "--get", key])?, + "staged" => self.git_output(["config", "--blob", ":0:.gitmodules", "--get", key])?, + "local" => self.git_output(["config", "--local", "--get", key])?, + "worktree" => self.git_output(["config", "--worktree", "--get", key])?, + _ => unreachable!("known Git config scope"), + }; + match output.status.code() { + Some(0) => Ok(Some( + String::from_utf8(output.stdout)?.trim_end().to_string(), + )), + Some(1) => Ok(None), + _ => anyhow::bail!( + "Could not inspect {scope} Git key {key}: {}", + String::from_utf8_lossy(&output.stderr).trim() + ), + } } - /// Create a `GitOpsManager` that routes `add_submodule` straight to its CLI - /// last-resort path, bypassing both the gix and git2 backends. - /// - /// The CLI branch in `add_submodule` only runs when *both* in-process - /// backends fail — a condition that cannot be reproduced offline with real - /// inputs, since git2 and the git CLI clone from the same URL. This - /// fault-injection seam makes that otherwise-unreachable last resort - /// exercisable for correct results: the entire CLI branch — including its - /// cleanup of partial state left by a failed git2 attempt — runs unmodified. - #[allow(dead_code)] - pub fn forcing_cli_add(repo_path: Option<&Path>, verbose: bool) -> Result { - let mut manager = Self::new(repo_path, verbose)?; - manager.force_cli_add = true; - Ok(manager) + fn worktree_config_enabled(&self) -> Result { + let output = self.git_output(["config", "--bool", "extensions.worktreeConfig"])?; + match output.status.code() { + Some(0) => Ok(String::from_utf8(output.stdout)?.trim() == "true"), + Some(1) => Ok(false), + _ => anyhow::bail!( + "Could not inspect extensions.worktreeConfig: {}", + String::from_utf8_lossy(&output.stderr).trim() + ), + } } - /// Whether the optimistic gix backend is currently active. When `false`, - /// every operation is served by git2 (the fallback backend). - #[allow(dead_code)] - pub const fn gix_enabled(&self) -> bool { - self.gix_ops.is_some() + fn set_config_value_exact(&self, scope: &str, key: &str, desired: Option<&str>) -> Result<()> { + let mut args = vec![OsString::from("config"), OsString::from("--no-includes")]; + match scope { + "portable" => args.extend([OsString::from("--file"), OsString::from(".gitmodules")]), + "local" => args.push(OsString::from("--local")), + "worktree" => args.push(OsString::from("--worktree")), + _ => unreachable!("writable Git config scope"), + } + if let Some(value) = desired { + args.extend([OsString::from(key), OsString::from(value)]); + self.git(args)?; + } else { + args.extend([OsString::from("--unset-all"), OsString::from(key)]); + let output = self.git_output(args)?; + if !output.status.success() && !matches!(output.status.code(), Some(1 | 5)) { + anyhow::bail!( + "Could not unset {scope} Git key {key}: {}", + String::from_utf8_lossy(&output.stderr).trim() + ); + } + } + Ok(()) } - /// Whether `add_submodule` is forced through its CLI last-resort path, - /// bypassing both in-process backends. Normally `false`. - #[allow(dead_code)] - pub const fn forces_cli_add(&self) -> bool { - self.force_cli_add + fn module_config_values(&self, scope: &str, name: &str) -> Result> { + let mut args = vec![OsString::from("config"), OsString::from("--no-includes")]; + match scope { + "local" => args.push(OsString::from("--local")), + "worktree" => args.push(OsString::from("--worktree")), + _ => unreachable!("known module config scope"), + } + args.extend([ + OsString::from("-z"), + OsString::from("--get-regexp"), + OsString::from(format!( + r"^submodule\.{}\.", + Self::config_key_regex_literal(name) + )), + ]); + let output = self.git_output(args)?; + match output.status.code() { + Some(0) => output + .stdout + .split(|byte| *byte == 0) + .filter(|record| !record.is_empty()) + .map(|record| { + let newline = record + .iter() + .position(|byte| *byte == b'\n') + .context("Git returned a malformed module configuration entry")?; + Ok(( + String::from_utf8(record[..newline].to_vec())?, + String::from_utf8(record[newline + 1..].to_vec())?, + )) + }) + .collect(), + Some(1) => Ok(Vec::new()), + _ => anyhow::bail!( + "Could not inspect {scope} Git settings for submodule {name:?}: {}", + String::from_utf8_lossy(&output.stderr).trim() + ), + } } - /// Return the working directory of the underlying git repository, if any. - pub fn workdir(&self) -> Option<&std::path::Path> { - self.git2_ops.workdir() + fn snapshot_module_config(&self, name: &str) -> Result { + Ok(ModuleConfigSnapshot { + local: self.module_config_values("local", name)?, + worktree: self + .worktree_config_enabled()? + .then(|| self.module_config_values("worktree", name)) + .transpose()?, + }) } - /// Reopen the repository from the working directory to refresh any cached state. - /// This is needed after destructive operations (e.g., submodule delete) so that the - /// in-memory git2 repository object reflects the updated on-disk state. - /// - /// Returns an error if the git2 repository (the required backend) cannot be reopened. - /// A gix reopen failure is non-fatal since gix is an optional optimistic backend. - pub fn reopen(&mut self) -> Result<()> { - let workdir = self - .git2_ops - .workdir() - .ok_or_else(|| anyhow::anyhow!("Cannot reopen repository: no working directory"))? - .to_path_buf(); + fn restore_module_config_values( + &self, + scope: &str, + name: &str, + values: &[(String, String)], + ) -> Result<()> { + if self.module_config_values(scope, name)? == values { + return Ok(()); + } - // git2 is the required backend — propagate its reopen error. - self.git2_ops = Git2Operations::new(Some(&workdir)).with_context(|| { - format!("Failed to reopen git2 repository at {}", workdir.display()) - })?; + let mut remove = vec![OsString::from("config"), OsString::from("--no-includes")]; + match scope { + "local" => remove.push(OsString::from("--local")), + "worktree" => remove.push(OsString::from("--worktree")), + _ => unreachable!("known module config scope"), + } + remove.extend([ + OsString::from("--remove-section"), + OsString::from(format!("submodule.{name}")), + ]); + let output = self.git_output(remove)?; + if !output.status.success() && output.status.code() != Some(1) { + anyhow::bail!( + "Could not clear {scope} Git settings for submodule {name:?}: {}", + String::from_utf8_lossy(&output.stderr).trim() + ); + } - // gix is an optional optimistic backend — log failures but don't fail. - match GixOperations::new(Some(&workdir)) { - Ok(new_gix) => { - self.gix_ops = Some(new_gix.with_verbose(self.verbose)); - } - Err(e) => { - if self.verbose { - eprintln!( - "Warning: failed to reopen gix repository at {}: {}", - workdir.display(), - e - ); - } + for (key, value) in values { + let mut add = vec![OsString::from("config"), OsString::from("--no-includes")]; + match scope { + "local" => add.push(OsString::from("--local")), + "worktree" => add.push(OsString::from("--worktree")), + _ => unreachable!("known module config scope"), } + add.extend([ + OsString::from("--add"), + OsString::from(key), + OsString::from(value), + ]); + self.git(add)?; } - + anyhow::ensure!( + self.module_config_values(scope, name)? == values, + "Git did not restore the exact {scope} settings for submodule {name:?}" + ); Ok(()) } - /// Try gix first, fall back to git2 - fn try_with_fallback(&self, gix_op: F1, git2_op: F2) -> Result - where - F1: FnOnce(&GixOperations) -> Result, - F2: FnOnce(&Git2Operations) -> Result, - { - if let Some(ref gix) = self.gix_ops { - match gix_op(gix) { - Ok(result) => return Ok(result), - Err(e) => { - if self.verbose { - eprintln!("gix operation failed, falling back to git2: {e}"); - } - } - } + fn restore_module_config(&self, name: &str, snapshot: &ModuleConfigSnapshot) -> Result<()> { + self.restore_module_config_values("local", name, &snapshot.local)?; + if let Some(values) = &snapshot.worktree { + self.restore_module_config_values("worktree", name, values)?; } + Ok(()) + } - git2_op(&self.git2_ops) + fn module_config_key_is_managed(name: &str, key: &str) -> bool { + let prefix = format!("submodule.{name}."); + key.strip_prefix(&prefix).is_some_and(|field| { + [ + "url", + "branch", + "ignore", + "update", + "fetchRecurseSubmodules", + "shallow", + "active", + ] + .iter() + .any(|managed| field.eq_ignore_ascii_case(managed)) + }) } - /// Try gix first, fall back to git2 (mutable version) - fn try_with_fallback_mut(&mut self, gix_op: F1, git2_op: F2) -> Result - where - F1: FnOnce(&mut GixOperations) -> Result, - F2: FnOnce(&mut Git2Operations) -> Result, - { - if let Some(ref mut gix) = self.gix_ops { - match gix_op(gix) { - Ok(result) => return Ok(result), - Err(e) => { - if self.verbose { - eprintln!("gix operation failed, falling back to git2: {e}"); - } - } - } + fn preserve_unmanaged_module_config( + name: &str, + desired: &ModuleConfigSnapshot, + original: &ModuleConfigSnapshot, + ) -> ModuleConfigSnapshot { + let merge = |desired: &[(String, String)], original: &[(String, String)]| { + desired + .iter() + .filter(|(key, _)| Self::module_config_key_is_managed(name, key)) + .chain( + original + .iter() + .filter(|(key, _)| !Self::module_config_key_is_managed(name, key)), + ) + .cloned() + .collect() + }; + ModuleConfigSnapshot { + local: merge(&desired.local, &original.local), + worktree: desired + .worktree + .as_ref() + .map(|desired| merge(desired, original.worktree.as_deref().unwrap_or_default())), } - git2_op(&mut self.git2_ops) } -} -/// Implement `GitOperations` for `GitOpsManager`, using gix first and falling back to git2 if gix fails -impl GitOperations for GitOpsManager { - fn read_gitmodules(&self) -> Result { - self.try_with_fallback( - GitOperations::read_gitmodules, - GitOperations::read_gitmodules, - ) + fn config_value_in(&self, repository: &Path, key: &str) -> Result> { + let output = Command::new("git") + .args(["config", "--get", key]) + .current_dir(repository) + .env("GIT_OPTIONAL_LOCKS", "0") + .output() + .with_context(|| format!("Failed to inspect Git config in {}", repository.display()))?; + match output.status.code() { + Some(0) => Ok(Some( + String::from_utf8(output.stdout)?.trim_end().to_string(), + )), + Some(1) => Ok(None), + _ => anyhow::bail!( + "Could not inspect Git key {key} in {}: {}", + repository.display(), + String::from_utf8_lossy(&output.stderr).trim() + ), + } } - fn write_gitmodules(&mut self, config: &SubmoduleEntries) -> Result<()> { - self.try_with_fallback_mut( - |gix| gix.write_gitmodules(config), - |git2| git2.write_gitmodules(config), - ) + fn set_config_value_in(&self, repository: &Path, key: &str, value: &str) -> Result<()> { + if self.config_value_in(repository, key)?.as_deref() == Some(value) { + return Ok(()); + } + let output = Command::new("git") + .args(["config", "--no-includes", "--local", key, value]) + .current_dir(repository) + .env("GIT_OPTIONAL_LOCKS", "0") + .output() + .with_context(|| format!("Failed to update Git config in {}", repository.display()))?; + anyhow::ensure!( + output.status.success(), + "Could not set Git key {key} in {}: {}", + repository.display(), + String::from_utf8_lossy(&output.stderr).trim() + ); + Ok(()) } - fn read_git_config(&self, level: ConfigLevel) -> Result { - self.try_with_fallback( - |gix| gix.read_git_config(level), - |git2| git2.read_git_config(level), - ) + fn default_remote_in(&self, repository: &Path) -> Result { + let branch = Command::new("git") + .args(["symbolic-ref", "--quiet", "--short", "HEAD"]) + .current_dir(repository) + .env("GIT_OPTIONAL_LOCKS", "0") + .output() + .with_context(|| format!("Failed to inspect HEAD in {}", repository.display()))?; + match branch.status.code() { + Some(0) => { + let branch = String::from_utf8(branch.stdout)?.trim_end().to_string(); + Ok(self + .config_value_in(repository, &format!("branch.{branch}.remote"))? + .unwrap_or_else(|| "origin".to_string())) + } + Some(1) => Ok("origin".to_string()), + _ => anyhow::bail!( + "Could not inspect HEAD in {}: {}", + repository.display(), + String::from_utf8_lossy(&branch.stderr).trim() + ), + } } - fn write_git_config(&self, config: &GitConfig, level: ConfigLevel) -> Result<()> { - self.try_with_fallback( - |gix| gix.write_git_config(config, level), - |git2| git2.write_git_config(config, level), - ) + fn starts_dot_component(value: &str, parent: bool) -> bool { + let prefix = if parent { ".." } else { "." }; + value + .strip_prefix(prefix) + .is_some_and(|rest| rest.starts_with('/') || rest.starts_with('\\')) } - fn set_config_value(&self, key: &str, value: &str, level: ConfigLevel) -> Result<()> { - self.try_with_fallback( - |gix| gix.set_config_value(key, value, level), - |git2| git2.set_config_value(key, value, level), - ) + fn remote_url_is_relative(value: &str) -> bool { + if Path::new(value).is_absolute() + || value.starts_with('/') + || value.starts_with('\\') + || value.contains("://") + { + return false; + } + match value.find(':') { + Some(colon) => value[..colon].contains(['/', '\\']), + None => true, + } } - fn add_submodule(&mut self, opts: &SubmoduleAddOptions) -> Result<()> { - // Try gix first (not yet implemented → falls through), then git2 which now uses - // the correct `submodule.clone() + add_finalize()` sequence. - // CLI is kept as a last-resort safety net and sets current_dir to the superproject - // workdir so it works regardless of the process's CWD. - // - // The `force_cli_add` fault-injection seam short-circuits both in-process - // backends so the CLI last resort can be exercised directly (see - // `GitOpsManager::forcing_cli_add`). - let in_process = if self.force_cli_add { - Err(anyhow::anyhow!( - "in-process git backends bypassed (forcing CLI last resort)" + /// Match Git's `relative_url()` for the two values written by `submodule sync`. + fn resolve_relative_submodule_url( + remote_url: &str, + relative_url: &str, + up_path: Option<&str>, + ) -> Result { + if !Self::starts_dot_component(relative_url, false) + && !Self::starts_dot_component(relative_url, true) + { + return Ok(relative_url.to_string()); + } + + let base_is_relative = Self::remote_url_is_relative(remote_url); + let mut base = remote_url.trim_end_matches(['/', '\\']).to_string(); + anyhow::ensure!(!base.is_empty(), "Git remote URL is empty"); + if base_is_relative + && !Self::starts_dot_component(&base, false) + && !Self::starts_dot_component(&base, true) + { + base.insert_str(0, "./"); + } + + let mut remainder = relative_url; + let mut colon_separator = false; + loop { + if Self::starts_dot_component(remainder, true) { + remainder = &remainder[3..]; + if let Some(separator) = base.rfind(['/', '\\']) { + base.truncate(separator); + } else if let Some(colon) = base.rfind(':') { + base.truncate(colon); + colon_separator = true; + } else if base_is_relative || base == "." { + anyhow::bail!("cannot strip one component from Git remote URL {remote_url:?}"); + } else { + base = ".".to_string(); + } + } else if Self::starts_dot_component(remainder, false) { + remainder = &remainder[2..]; + } else { + break; + } + } + + let separator = if colon_separator { ':' } else { '/' }; + let mut resolved = format!("{base}{separator}{remainder}"); + if relative_url.ends_with(['/', '\\']) { + resolved.pop(); + } + if let Some(stripped) = resolved.strip_prefix("./") { + resolved = stripped.to_string(); + } + if base_is_relative && let Some(up_path) = up_path { + resolved.insert_str(0, up_path); + } + Ok(resolved) + } + + fn expected_synced_urls( + &self, + path: &Path, + url: &str, + ) -> Result<(String, Option<(String, String)>)> { + if !Self::starts_dot_component(url, false) && !Self::starts_dot_component(url, true) { + let child = self.worktree.join(path); + let selected = child + .join(".git") + .exists() + .then(|| self.default_remote_in(&child)) + .transpose()?; + return Ok(( + url.to_string(), + selected.map(|remote| (remote, url.to_string())), + )); + } + + let parent_remote = self.default_remote_in(&self.worktree)?; + let parent_base = self + .config_value_in(&self.worktree, &format!("remote.{parent_remote}.url"))? + .unwrap_or_else(|| self.worktree.to_string_lossy().into_owned()); + let parent_url = Self::resolve_relative_submodule_url(&parent_base, url, None)?; + let child = self.worktree.join(path); + let child_expected = if child.join(".git").exists() { + let child_remote = self.default_remote_in(&child)?; + let up_path = "../".repeat(path.components().count()); + Some(( + child_remote, + Self::resolve_relative_submodule_url(&parent_base, url, Some(&up_path))?, )) } else { - self.try_with_fallback_mut( - |gix| gix.add_submodule(opts), - |git2| git2.add_submodule(opts), - ) + None }; - in_process.or_else(|git2_err| { - let workdir = self - .git2_ops - .workdir() - .ok_or_else(|| anyhow::anyhow!("Repository has no working directory"))?; - - // Clean up potentially partially initialized submodule path before fallback - let sub_path = workdir.join(&opts.path); - if sub_path.exists() { - std::fs::remove_dir_all(&sub_path) - .context("Failed to clean up partially initialized submodule path before CLI fallback")?; - } - - // git2 also adds the submodule to .gitmodules, which will cause CLI to fail - // if we don't clean it up. - let gitmodules_path = workdir.join(".gitmodules"); - if gitmodules_path.exists() { - // If it fails to read or write we just ignore it as it's a fallback cleanup - if let Ok(content) = std::fs::read_to_string(&gitmodules_path) { - let mut new_content = String::new(); - let mut in_target_section = false; - // git2 may key the `.gitmodules` section by either the name or the - // path (it uses the path as the section key when name != path), so - // match both to avoid leaving a stale entry behind. - let target_name = format!("\"{}\"", opts.name); - let target_path = format!("\"{}\"", opts.path.display()); - for line in content.lines() { - if line.starts_with("[submodule \"") { - in_target_section = - line.contains(&target_name) || line.contains(&target_path); - } - if !in_target_section { - new_content.push_str(line); - new_content.push('\n'); - } - } - std::fs::write(&gitmodules_path, new_content) - .context("Failed to rewrite .gitmodules during fallback cleanup")?; + Ok((parent_url, child_expected)) + } + + fn reconcile_rebuild_child_url(&self, path: &Path, entry: &SubmoduleEntry) -> Result<()> { + let Some(url) = &entry.url else { + return Ok(()); + }; + let (_, child) = self.expected_synced_urls(path, url)?; + let Some((remote, expected)) = child else { + return Ok(()); + }; + let child = self.worktree.join(path); + self.set_config_value_in(&child, &format!("remote.{remote}.url"), &expected) + } + + fn preflight_portable_key(&self, key: &str, desired: Option<&str>) -> Result> { + let portable = self.config_value("portable", key)?; + if portable.as_deref() != desired { + let staged = self.config_value("staged", key)?; + anyhow::ensure!( + staged == portable, + ".gitmodules has an unstaged edit to managed key {key}; preserve or resolve it before reconciliation" + ); + } + Ok(portable) + } + + /// Validate one existing registration and every metadata key before a batch writes. + pub fn preflight_submodule_settings(&self, path: &str, entry: &SubmoduleEntry) -> Result<()> { + let path = self.validated_path(Path::new(path))?; + let name = self + .registered_name_for_path(&path)? + .with_context(|| format!("No exact submodule registration exists for {path:?}"))?; + Self::validate_name(&name)?; + self.preflight_native_locks()?; + self.preflight_native_metadata()?; + self.read_native_gitmodules()?; + let destination = self.worktree.join(&path); + if destination.join(".git").exists() { + self.validated_child(&path)?; + let context = crate::utilities::RepositoryContext::discover(&destination, None)?; + for lock in [ + context.common_dir.join("config.lock"), + context.git_dir.join("config.worktree.lock"), + ] { + match std::fs::symlink_metadata(&lock) { + Ok(_) => anyhow::bail!( + "Git lock exists at {}; finish the other Git operation or remove a verified stale lock", + lock.display() + ), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(error.into()), } } - - // Also git2 might have added it to .git/config - let gitconfig_path = workdir.join(".git").join("config"); - if gitconfig_path.exists() { - // Remove by name (our submodule name) - let _ = std::process::Command::new("git") - .args([ - "config", - "--remove-section", - &format!("submodule.{}", opts.name), - ]) - .current_dir(workdir) - .output(); - // Remove by path (git2 uses path as key when name != path) - let path_key = opts.path.display().to_string(); - if path_key != opts.name { - let _ = std::process::Command::new("git") - .args([ - "config", - "--remove-section", - &format!("submodule.{path_key}"), - ]) - .current_dir(workdir) - .output(); + for config in [ + context.common_dir.join("config"), + context.git_dir.join("config.worktree"), + ] { + match std::fs::symlink_metadata(&config) { + Ok(metadata) if metadata.file_type().is_symlink() => anyhow::bail!( + "Refusing to write child Git metadata through symlink {}", + config.display() + ), + Ok(_) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(error.into()), } } + } else if let Ok(mut children) = destination.read_dir() { + anyhow::ensure!( + children.next().is_none(), + "Submodule destination {} contains unrelated content", + destination.display() + ); + } + let prefix = format!("submodule.{name}"); + for (field, desired) in Self::managed_portable_fields(entry) { + self.preflight_portable_key(&format!("{prefix}.{field}"), desired.as_deref())?; + } + Ok(()) + } - // Also git2 might have created the internal git directory - let internal_git_dir = workdir.join(".git").join("modules").join(&opts.name); - if internal_git_dir.exists() { - std::fs::remove_dir_all(&internal_git_dir) - .context("Failed to clean up partially initialized internal git directory before CLI fallback")?; - } - - // git2's repo.submodule() uses the *path* (not the name) as the key for the - // internal modules directory, so ".git/modules/lib/reinit" may exist even when - // ".git/modules/" has already been cleaned up. Remove both. - let path_internal_git_dir = workdir.join(".git").join("modules").join(&opts.path); - if path_internal_git_dir.exists() { - std::fs::remove_dir_all(&path_internal_git_dir) - .context("Failed to clean up partially initialized internal git directory (by path) before CLI fallback")?; - } - - // And removed from index - let _ = std::process::Command::new("git") - .args(["rm", "--cached", "-r", "--ignore-unmatch", "--"]) - .arg(&opts.path) - .current_dir(workdir) - .output(); - - let mut cmd = std::process::Command::new("git"); - cmd.args(["-c", "protocol.file.allow=always"]) - .current_dir(workdir) - .arg("submodule") - .arg("add") - .arg("--name") - .arg(&opts.name); - if let Some(branch) = &opts.branch { - let branch_str = branch.to_string(); - // "." is the gitmodules/git-config token meaning "track the same branch as - // the superproject" (SerializableBranch::CurrentInSuperproject). It is only - // meaningful as a stored config value; passing it as `--branch .` to - // `git submodule add` is invalid and causes: - // fatal: 'HEAD' is not a valid branch name - // Skip the flag so git resolves the remote's default branch automatically. - if branch_str != "." { - cmd.arg("--branch").arg(&branch_str); + fn add_preconditions(&self, opts: &SubmoduleAddOptions) -> Result { + Self::validate_name(&opts.name)?; + let path = self.validated_path(&opts.path)?; + if let Some(existing) = self.registration_path(&opts.name)? { + anyhow::ensure!( + self.validated_path(Path::new(&existing))? == path, + "Git registration {:?} already exists at {:?}; use `submod change {} --path ...` for an explicit Git-aware move", + opts.name, + existing, + opts.name + ); + } + if opts.branch == Some(SerializableBranch::CurrentInSuperproject) { + self.current_superproject_branch()?; + } + self.preflight_native_locks()?; + self.preflight_native_metadata()?; + self.preflight_gitmodules_layers()?; + let destination = self.worktree.join(&path); + match std::fs::symlink_metadata(&destination) { + Ok(_) => anyhow::bail!( + "Destination {} is occupied; add never replaces existing content", + destination.display() + ), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(error.into()), + } + let retained_git_dir = self.validated_module_storage(&opts.name)?; + match std::fs::symlink_metadata(&retained_git_dir) { + Ok(metadata) if metadata.is_dir() && !metadata.file_type().is_symlink() => { + if !self.retained_repo_matches_url(&retained_git_dir, &opts.url)? { + anyhow::bail!( + "A retained submodule repository exists at {} but its origin does not match the requested URL; refusing to attach it", + retained_git_dir.display() + ); } + Ok(true) } - if opts.shallow { - cmd.arg("--depth").arg("1"); - } - cmd.arg("--").arg(&opts.url).arg(&opts.path); - let output = cmd.output().context("Failed to run git submodule add")?; - if output.status.success() { - Ok(()) - } else { - Err(anyhow::anyhow!( - "Failed to add submodule (git2 failed with: {}). CLI output: {}", - git2_err, - String::from_utf8_lossy(&output.stderr).trim() - )) + Ok(_) => anyhow::bail!( + "Refusing unsafe retained submodule repository at {}", + retained_git_dir.display() + ), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false), + Err(error) => Err(error.into()), + } + } + + fn current_superproject_branch(&self) -> Result { + let output = self.git_output(["symbolic-ref", "--quiet", "--short", "HEAD"])?; + anyhow::ensure!( + output.status.success(), + "The superproject HEAD is detached; branch='.' requires a symbolic branch before any submodule mutation" + ); + Ok(String::from_utf8(output.stdout)?.trim_end().to_string()) + } + + /// Validate an add completely without contacting the configured remote. + pub fn preflight_add_submodule(&self, opts: &SubmoduleAddOptions) -> Result<()> { + self.add_preconditions(opts).map(|_| ()) + } + + /// Validate the knowable removal and re-add requirements before a rebuild starts. + /// Remote availability is deliberately not probed during preflight. + pub fn preflight_rebuild_submodule(&self, path: &str, force: bool) -> Result<()> { + let path = self.validated_path(Path::new(path))?; + let name = self.registered_name_for_path(&path)?.with_context(|| { + format!( + "No exact registration exists for rebuild at {}", + path.display() + ) + })?; + self.index_gitlink_oid(path.to_str().context("Submodule path is not valid UTF-8")?)? + .context("Registered submodule has no stage-0 gitlink to rebuild")?; + self.preflight_native_locks()?; + self.preflight_native_metadata()?; + self.preflight_gitmodules_layers()?; + let destination = self.worktree.join(&path); + if destination.join(".git").exists() { + self.preflight_child_locks(&path, true)?; + if !force { + self.ensure_no_discardable_checkout_data( + path.to_str().context("Submodule path is not valid UTF-8")?, + ) + .with_context(|| { + "Rebuild would discard checkout content. Preserve it, or rerun the same nuke command with --force only if no local work is needed" + })?; } - }) + } else { + let storage = self.validated_module_storage(&name)?; + self.preflight_gitdir_locks(&storage, true)?; + } + Ok(()) } - fn init_submodule(&mut self, path: &str) -> Result<()> { - self.try_with_fallback_mut( - |gix| gix.init_submodule(path), - |git2| git2.init_submodule(path), - ) + /// Rebuild a registered checkout without changing its logical name, gitlink, or storage. + pub(crate) fn rebuild_submodule( + &mut self, + path: &str, + mut opts: SubmoduleUpdateOptions, + entry: &SubmoduleEntry, + force: bool, + ) -> Result<()> { + // The manager preflights every selected module before the first + // mutation. Repeating the layer check here would reject an intended + // `.gitmodules` delta produced by an earlier completed rebuild. + let path_buf = self.validated_materialization_target(Path::new(path))?; + let name = self + .registered_name_for_path(&path_buf)? + .context("Registered submodule disappeared before rebuild")?; + let original_config = self.snapshot_module_config(&name)?; + // Update the selected retained child remote before deinit removes the + // checkout. This lets the later native update fetch a newly selected + // pin without writing `.gitmodules` early enough to block deinit. + self.reconcile_rebuild_child_url(&path_buf, entry)?; + let pathspec = Self::literal_pathspec(&path_buf); + self.git([ + OsStr::new("submodule"), + OsStr::new("absorbgitdirs"), + OsStr::new("--"), + pathspec.as_os_str(), + ])?; + // Native deinit refuses the command's own unstaged metadata delta. + // Revalidate all tracked, untracked, ignored, and nested content at + // the mutation boundary before allowing Git to bypass that check. + if !force { + self.ensure_no_discardable_checkout_data(path)?; + } + if let Err(operation) = self.deinit_submodule(path, true) { + return match self.restore_module_config(&name, &original_config) { + Ok(()) => Err(operation), + Err(restoration) => Err(operation.context(format!( + "The failed deinit also failed to restore exact Git settings: {restoration:#}" + ))), + }; + } + if let Err(operation) = self.sync_submodule_settings(path, entry) { + return match self.restore_module_config(&name, &original_config) { + Ok(()) => Err(operation), + Err(restoration) => Err(operation.context(format!( + "Metadata reconciliation also failed to restore exact Git settings: {restoration:#}" + ))), + }; + } + let desired_config = self.snapshot_module_config(&name)?; + let restored_config = + Self::preserve_unmanaged_module_config(&name, &desired_config, &original_config); + opts.force = force; + let operation = self + .update_submodule(path, &opts) + .and_then(|()| self.sync_submodule_settings(path, entry)); + let restoration = self.restore_module_config(&name, &restored_config); + match (operation, restoration) { + (Ok(()), Ok(())) => Ok(()), + (Err(operation), Ok(())) => Err(operation), + (Ok(()), Err(restoration)) => Err(restoration.context( + "Submodule checkout was rebuilt, but its exact Git settings were not restored", + )), + (Err(operation), Err(restoration)) => Err(operation.context(format!( + "The rebuild also failed to restore exact Git settings: {restoration:#}" + ))), + } } - fn update_submodule(&mut self, path: &str, opts: &SubmoduleUpdateOptions) -> Result<()> { - self.try_with_fallback_mut( - |gix| gix.update_submodule(path, opts), - |git2| git2.update_submodule(path, opts), - ) + /// Validate reconstruction of a uniquely managed registration around an existing gitlink. + pub fn preflight_restore_registration( + &self, + name: &str, + path: &str, + entry: &SubmoduleEntry, + ) -> Result<()> { + Self::validate_name(name)?; + let path = self.validated_path(Path::new(path))?; + anyhow::ensure!( + self.registered_name_for_path(&path)?.is_none(), + "An exact registration already exists for {}", + path.display() + ); + anyhow::ensure!( + self.index_gitlink_oid(path.to_str().context("Submodule path is not valid UTF-8")?)? + .is_some(), + "No gitlink exists for {}", + path.display() + ); + anyhow::ensure!(entry.url.is_some(), "Managed registration has no URL"); + self.preflight_native_locks()?; + self.preflight_native_metadata()?; + self.preflight_gitmodules_layers()?; + let existing_name = self.config_value("portable", &format!("submodule.{name}.path"))?; + anyhow::ensure!( + existing_name.is_none(), + "Portable registration name {name:?} already refers to another path" + ); + let destination = self.worktree.join(&path); + match std::fs::symlink_metadata(&destination) { + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Ok(metadata) if metadata.is_dir() && destination.read_dir()?.next().is_none() => {} + Ok(_) => anyhow::bail!( + "Cannot reconstruct registration while {} contains content", + destination.display() + ), + Err(error) => return Err(error.into()), + } + Ok(()) } - fn delete_submodule(&mut self, path: &str) -> Result<()> { - self.try_with_fallback_mut( - |gix| gix.delete_submodule(path), - |git2| git2.delete_submodule(path), - ) + /// Recreate one portable registration without changing its existing gitlink object. + pub fn restore_registration( + &self, + name: &str, + path: &str, + entry: &SubmoduleEntry, + ) -> Result<()> { + self.preflight_restore_registration(name, path, entry)?; + let before = self + .index_gitlink_oid(path)? + .expect("preflight required a gitlink"); + let prefix = format!("submodule.{name}"); + self.set_config_value_exact("portable", &format!("{prefix}.path"), Some(path))?; + for (field, desired) in Self::managed_portable_fields(entry) { + self.set_config_value_exact( + "portable", + &format!("{prefix}.{field}"), + desired.as_deref(), + )?; + } + self.git(["add", "--", ".gitmodules"])?; + self.sync_submodule_settings(path, entry)?; + anyhow::ensure!( + self.index_gitlink_oid(path)?.as_deref() == Some(before.as_str()), + "Registration reconstruction changed the existing gitlink" + ); + anyhow::ensure!( + self.registered_name_for_path(Path::new(path))?.as_deref() == Some(name), + "Registration reconstruction did not create the intended exact identity" + ); + Ok(()) } - fn deinit_submodule(&mut self, path: &str, force: bool) -> Result<()> { - self.try_with_fallback_mut( - |gix| gix.deinit_submodule(path, force), - |git2| git2.deinit_submodule(path, force), - ) + /// Validate controlled completion of a portable registration with no index gitlink. + pub fn preflight_complete_registration( + &self, + path: &str, + entry: &SubmoduleEntry, + ) -> Result<()> { + let path = self.validated_path(Path::new(path))?; + let name = self + .registered_name_for_path(&path)? + .with_context(|| format!("No exact registration exists for {}", path.display()))?; + anyhow::ensure!( + self.index_gitlink_oid(path.to_str().context("Submodule path is not valid UTF-8")?)? + .is_none(), + "A gitlink already exists for {}", + path.display() + ); + self.preflight_submodule_settings( + path.to_str().context("Submodule path is not valid UTF-8")?, + entry, + )?; + // Completion eventually delegates to `git submodule add`, which stages + // `.gitmodules`. Refuse a pre-existing unstaged layer before removing + // an otherwise reusable empty destination. + self.preflight_gitmodules_layers()?; + let destination = self.worktree.join(&path); + match std::fs::symlink_metadata(&destination) { + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Ok(metadata) if metadata.is_dir() && destination.read_dir()?.next().is_none() => {} + Ok(_) => anyhow::bail!( + "Cannot complete registration while {} contains unrelated content", + destination.display() + ), + Err(error) => return Err(error.into()), + } + let retained = self.validated_module_storage(&name)?; + match std::fs::symlink_metadata(&retained) { + Ok(metadata) if metadata.is_dir() && !metadata.file_type().is_symlink() => { + let url = entry + .url + .as_deref() + .context("Managed registration has no URL")?; + anyhow::ensure!( + self.retained_repo_matches_url(&retained, url)?, + "Retained repository for {name:?} does not match the managed URL" + ); + } + Ok(_) => anyhow::bail!( + "Refusing unsafe retained submodule repository at {}", + retained.display() + ), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(error.into()), + } + Ok(()) } - fn get_submodule_status(&self, path: &str) -> Result { - self.try_with_fallback( - |gix| gix.get_submodule_status(path), - |git2| git2.get_submodule_status(path), - ) + /// Complete an exact existing registration using its Git section identity. + pub fn complete_registration(&mut self, path: &str, entry: &SubmoduleEntry) -> Result<()> { + self.preflight_complete_registration(path, entry)?; + let path_buf = self.validated_path(Path::new(path))?; + let name = self + .registered_name_for_path(&path_buf)? + .expect("completion preflight required an exact registration"); + let destination = self.worktree.join(&path_buf); + if destination.is_dir() && destination.read_dir()?.next().is_none() { + std::fs::remove_dir(&destination)?; + } + let opts = SubmoduleAddOptions { + name, + path: path_buf, + url: entry + .url + .clone() + .context("Managed registration has no URL")?, + branch: entry.branch.clone(), + ignore: entry.ignore, + update: entry.update.clone(), + fetch_recurse: entry.fetch_recurse, + shallow: entry.shallow.unwrap_or(false), + no_init: false, + }; + self.add_submodule(&opts)?; + self.sync_added_submodule_settings(path, entry) } - fn list_submodules(&self) -> Result> { - self.try_with_fallback( - GitOperations::list_submodules, - GitOperations::list_submodules, - ) + /// Create a new `GitOpsManager` with automatic fallback + pub fn new(repo_path: Option<&Path>, verbose: bool) -> Result { + let gix_ops = GixOperations::new(repo_path).ok(); + let git2_ops = Git2Operations::new(repo_path) + .with_context(|| "Failed to initialize git2 operations")?; + let worktree = git2_ops + .workdir() + .context("Repository has no working tree")? + .to_path_buf(); + let git_dir = crate::utilities::git_path(&worktree, &["--absolute-git-dir"])? + .canonicalize() + .context("Failed to resolve repository Git directory")?; + + Ok(Self { + gix_ops, + git2_ops, + worktree, + git_dir, + verbose, + }) } - fn fetch_submodule(&self, path: &str) -> Result<()> { - self.try_with_fallback( - |gix| gix.fetch_submodule(path), - |git2| git2.fetch_submodule(path), - ) + /// Create a manager with git2-only fallback reads. Mutations use native Git. + #[allow(dead_code)] + pub fn without_gix(repo_path: Option<&Path>, verbose: bool) -> Result { + let git2_ops = Git2Operations::new(repo_path) + .with_context(|| "Failed to initialize git2 operations")?; + let worktree = git2_ops + .workdir() + .context("Repository has no working tree")? + .to_path_buf(); + let git_dir = crate::utilities::git_path(&worktree, &["--absolute-git-dir"])? + .canonicalize() + .context("Failed to resolve repository Git directory")?; + + Ok(Self { + gix_ops: None, + git2_ops, + worktree, + git_dir, + verbose, + }) } - fn reset_submodule(&self, path: &str, hard: bool) -> Result<()> { - self.try_with_fallback( - |gix| gix.reset_submodule(path, hard), - |git2| git2.reset_submodule(path, hard), - ) + /// Whether the optimistic gix backend is currently active. When `false`, + /// fallback reads are served by git2. Mutations always use native Git. + #[allow(dead_code)] + pub const fn gix_enabled(&self) -> bool { + self.gix_ops.is_some() } - fn clean_submodule(&self, path: &str, force: bool, remove_directories: bool) -> Result<()> { - self.try_with_fallback( - |gix| gix.clean_submodule(path, force, remove_directories), - |git2| git2.clean_submodule(path, force, remove_directories), - ) + /// Return the working directory of the underlying git repository, if any. + pub fn workdir(&self) -> Option<&std::path::Path> { + Some(&self.worktree) } - fn stash_submodule(&self, path: &str, include_untracked: bool) -> Result<()> { - self.try_with_fallback( - |gix| gix.stash_submodule(path, include_untracked), - |git2| git2.stash_submodule(path, include_untracked), - ) + /// Require an exact registered checkout whose Git worktree is the requested path. + pub fn verify_submodule_checkout(&self, path: &str) -> Result<()> { + self.validated_child(Path::new(path)).map(|_| ()) } - fn enable_sparse_checkout(&self, path: &str) -> Result<()> { - self.try_with_fallback( - |gix| gix.enable_sparse_checkout(path), - |git2| git2.enable_sparse_checkout(path), - ) + /// Return the one exact portable registration for a checkout path. + pub fn registration_name(&self, path: &str) -> Result> { + let path = self.validated_path(Path::new(path))?; + self.registered_name_for_path(&path) } - fn set_sparse_patterns(&self, path: &str, patterns: &[String]) -> Result<()> { - self.try_with_fallback( - |gix| gix.set_sparse_patterns(path, patterns), - |git2| git2.set_sparse_patterns(path, patterns), - ) + /// Return the path held by one exact portable Git section name. + pub fn registration_path(&self, name: &str) -> Result> { + Self::validate_name(name)?; + self.config_value("portable", &format!("submodule.{name}.path")) } - fn get_sparse_patterns(&self, path: &str) -> Result> { - self.try_with_fallback( - |gix| gix.get_sparse_patterns(path), - |git2| git2.get_sparse_patterns(path), - ) + /// Return the exact mode-160000 index object for a path, if present. + pub fn index_gitlink_oid(&self, path: &str) -> Result> { + let path = self.validated_path(Path::new(path))?; + let output = self.git_output([ + OsStr::new("ls-files"), + OsStr::new("--stage"), + OsStr::new("--"), + Self::literal_pathspec(&path).as_os_str(), + ])?; + anyhow::ensure!( + output.status.success(), + "Could not inspect index gitlink for {}", + path.display() + ); + if output.stdout.is_empty() { + return Ok(None); + } + let text = String::from_utf8(output.stdout)?; + let mut records = text.lines(); + let record = records + .next() + .context("Git returned an empty index record")?; + anyhow::ensure!( + records.next().is_none(), + "Managed submodule path {} has unmerged or duplicate index stages", + path.display() + ); + let mut fields = record.split_whitespace(); + anyhow::ensure!( + fields.next() == Some("160000"), + "Index path is not a gitlink" + ); + let oid = fields + .next() + .context("Git returned a gitlink without an object ID")?; + anyhow::ensure!( + fields.next() == Some("0"), + "Managed submodule path {} has an unmerged index entry instead of stage 0", + path.display() + ); + Ok(Some(oid.to_string())) } - fn apply_sparse_checkout(&self, path: &str) -> Result<()> { - self.try_with_fallback( - |gix| gix.apply_sparse_checkout(path), - |git2| git2.apply_sparse_checkout(path), - ) - .or_else(|_| { - // CLI fallback: use git read-tree to apply sparse checkout - let output = std::process::Command::new("git") - .current_dir(path) - .args(["read-tree", "-mu", "HEAD"]) - .output() - .context("Failed to run git read-tree")?; - if output.status.success() { - Ok(()) + fn remote_update_target(&self, path: &Path) -> Result { + let name = self + .registered_name_for_path(path)? + .context("Remote update requires an exact submodule registration")?; + let child = self.worktree.join(path); + let remote = self.default_remote_in(&child)?; + let branch = match self.config_value("portable", &format!("submodule.{name}.branch"))? { + Some(branch) if branch == "." => self.current_superproject_branch()?, + Some(branch) => branch, + None => { + let remote_head = format!("refs/remotes/{remote}/HEAD"); + let output = Command::new("git") + .args(["symbolic-ref", "--quiet", &remote_head]) + .current_dir(&child) + .env("GIT_OPTIONAL_LOCKS", "0") + .output()?; + anyhow::ensure!( + output.status.success(), + "Could not resolve the default tracking branch for remote {remote:?}" + ); + let reference = String::from_utf8(output.stdout)?.trim_end().to_string(); + return Ok(String::from_utf8( + self.child_git( + path.to_str().context("Submodule path is not valid UTF-8")?, + ["rev-parse", &reference], + )? + .stdout, + )? + .trim_end() + .to_string()); + } + }; + let reference = format!("refs/remotes/{remote}/{branch}"); + Ok(String::from_utf8( + self.child_git( + path.to_str().context("Submodule path is not valid UTF-8")?, + ["rev-parse", &reference], + )? + .stdout, + )? + .trim_end() + .to_string()) + } + + fn update_postcondition_holds( + &self, + path: &Path, + opts: &SubmoduleUpdateOptions, + ) -> Result { + if opts.strategy == SerializableUpdate::None { + return Ok(true); + } + let parent_target = self + .index_gitlink_oid(path.to_str().context("Submodule path is not valid UTF-8")?)? + .context("Parent index has no mode-160000 target for submodule update")?; + let destination = self.worktree.join(path); + if !destination.join(".git").exists() { + return Ok(false); + } + let target = if opts.remote { + self.remote_update_target(path)? + } else { + parent_target + }; + let path_text = path.to_str().context("Submodule path is not valid UTF-8")?; + let head = self.child_git(path_text, ["rev-parse", "HEAD"])?; + let head = String::from_utf8(head.stdout)?.trim_end().to_string(); + match opts.strategy { + SerializableUpdate::Checkout | SerializableUpdate::Unspecified => Ok(head == target), + SerializableUpdate::Merge | SerializableUpdate::Rebase => { + let child = self.worktree.join(path); + let output = Command::new("git") + .args(["merge-base", "--is-ancestor", &target, "HEAD"]) + .current_dir(&child) + .env("GIT_OPTIONAL_LOCKS", "0") + .output() + .with_context(|| { + format!("Failed to verify update ancestry in {}", child.display()) + })?; + match output.status.code() { + Some(0) => Ok(true), + Some(1) => Ok(false), + _ => anyhow::bail!( + "Could not verify update ancestry in {}: {}", + child.display(), + String::from_utf8_lossy(&output.stderr).trim() + ), + } + } + SerializableUpdate::None => Ok(true), + } + } + + /// Report whether an initialized checkout satisfies its configured parent-pin strategy. + pub fn update_postcondition_matches( + &self, + path: &str, + opts: &SubmoduleUpdateOptions, + ) -> Result { + let path = self.validated_path(Path::new(path))?; + self.update_postcondition_holds(&path, opts) + } + + /// Inspect tracked and untracked checkout changes without refreshing the index. + pub fn submodule_worktree_is_clean(&self, path: &str) -> Result { + let output = self.child_git_output( + path, + [ + "status", + "--porcelain=v1", + "-z", + "--untracked-files=all", + "--ignore-submodules=none", + ], + )?; + anyhow::ensure!( + output.status.success(), + "Could not inspect working tree at {}: {}", + self.worktree.join(path).display(), + String::from_utf8_lossy(&output.stderr).trim() + ); + Ok(output.stdout.is_empty()) + } + + /// Read the exact checkout HEAD after validating its repository identity. + pub fn submodule_head(&self, path: &str) -> Result { + let output = self.child_git(path, ["rev-parse", "HEAD"])?; + Ok(String::from_utf8(output.stdout)?.trim_end().to_string()) + } + + /// Read the recursive native submodule state without refreshing an index. + /// + /// The raw bytes are used only as an before/after observation for lifecycle + /// summaries; they are never rendered or interpreted as trusted text. + pub fn recursive_submodule_state(&self, path: &str) -> Result> { + let path = self.validated_child(Path::new(path))?; + let path = path.to_str().context("Submodule path is not valid UTF-8")?; + let output = self.child_git_output(path, ["submodule", "status", "--recursive"])?; + anyhow::ensure!( + output.status.success(), + "Could not inspect recursive submodule state at {}: {}", + path, + String::from_utf8_lossy(&output.stderr).trim() + ); + Ok(output.stdout) + } + + /// Resolve the target commit selected by an update after any requested + /// remote fetch has completed. + pub fn submodule_update_target( + &self, + path: &str, + opts: &SubmoduleUpdateOptions, + ) -> Result { + let path = self.validated_child(Path::new(path))?; + if opts.remote { + self.remote_update_target(&path) + } else { + self.index_gitlink_oid(path.to_str().context("Submodule path is not valid UTF-8")?)? + .context("Parent index has no mode-160000 target for submodule update") + } + } + + /// Compare every managed portable/local/worktree setting without writing metadata. + pub fn submodule_settings_match(&self, path: &str, entry: &SubmoduleEntry) -> Result { + let path = self.validated_path(Path::new(path))?; + let Some(name) = self.registered_name_for_path(&path)? else { + return Ok(false); + }; + let prefix = format!("submodule.{name}"); + let fields = Self::managed_portable_fields(entry); + let worktree_config = self.worktree_config_enabled()?; + for (field, desired) in &fields { + let key = format!("{prefix}.{field}"); + if self.config_value("portable", &key)? != *desired { + return Ok(false); + } + if *field == "url" && desired.is_some() { + let (expected_local, expected_child) = + self.expected_synced_urls(&path, desired.as_deref().expect("URL is present"))?; + if self.config_value("local", &key)?.as_deref() != Some(expected_local.as_str()) { + return Ok(false); + } + if let Some((remote, expected)) = expected_child { + let child = self.worktree.join(&path); + if self + .config_value_in(&child, &format!("remote.{remote}.url"))? + .as_deref() + != Some(expected.as_str()) + { + return Ok(false); + } + } + } else if self.config_value("local", &key)? != *desired { + return Ok(false); + } + if worktree_config && self.config_value("worktree", &key)?.is_some() { + return Ok(false); + } + } + let active_key = format!("{prefix}.active"); + let active = entry.active.map(|value| value.to_string()); + if self.config_value("local", &active_key)? != active { + return Ok(false); + } + if worktree_config && self.config_value("worktree", &active_key)?.is_some() { + return Ok(false); + } + Ok(true) + } + + /// Validate a known parent-pin transition before any module in a batch mutates. + pub fn preflight_update_submodule( + &self, + path: &str, + opts: &SubmoduleUpdateOptions, + ) -> Result<()> { + if opts.strategy == SerializableUpdate::None { + return Ok(()); + } + let path = self.validated_materialization_target(Path::new(path))?; + self.read_native_gitmodules()?; + self.preflight_native_locks()?; + self.preflight_native_metadata()?; + if !opts.remote && !opts.recursive && self.update_postcondition_holds(&path, opts)? { + return Ok(()); + } + let destination = self.worktree.join(&path); + if destination.join(".git").exists() + && !opts.force + && !self.is_incomplete_materialization(&path)? + { + self.ensure_clean_checkout( + path.to_str().context("Submodule path is not valid UTF-8")?, + )?; + } + Ok(()) + } + + /// Inspect the exact native sparse-checkout mode and ordered pattern sequence. + pub fn sparse_checkout_state(&self, path: &str) -> Result<(bool, bool, Vec)> { + let path = self.validated_child(Path::new(path))?; + let child = self.worktree.join(&path); + let enabled = self + .config_value_in(&child, "core.sparseCheckout")? + .is_some_and(|value| value.eq_ignore_ascii_case("true")); + let cone = self + .config_value_in(&child, "core.sparseCheckoutCone")? + .is_some_and(|value| value.eq_ignore_ascii_case("true")); + let output = self.child_git_output_at_validated( + &path, + [ + "rev-parse", + "--path-format=absolute", + "--git-path", + "info/sparse-checkout", + ], + )?; + anyhow::ensure!( + output.status.success(), + "Could not locate sparse-checkout patterns in {}: {}", + child.display(), + String::from_utf8_lossy(&output.stderr).trim() + ); + let sparse_file = crate::utilities::git_path_from_stdout(output.stdout)?; + let patterns = match std::fs::read_to_string(sparse_file) { + Ok(content) => content.lines().map(str::to_string).collect(), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Vec::new(), + Err(error) => return Err(error.into()), + }; + Ok((enabled, cone, patterns)) + } + + fn sparse_checkout_matches(&self, path: &str, expected: &[String]) -> Result { + let (enabled, cone, actual) = self.sparse_checkout_state(path)?; + if expected.is_empty() { + Ok(!enabled) + } else { + Ok(enabled && !cone && actual == expected) + } + } + + /// Validate a sparse policy change before any module in the command mutates. + pub fn preflight_sparse_checkout(&self, path: &str, expected: &[String]) -> Result<()> { + for pattern in expected { + anyhow::ensure!( + !pattern.contains(['\0', '\n', '\r']), + "Sparse pattern contains an invalid record boundary" + ); + } + if self.sparse_checkout_matches(path, expected)? { + return Ok(()); + } + self.ensure_clean_checkout(path)?; + let child = self.worktree.join(self.validated_child(Path::new(path))?); + let context = crate::utilities::RepositoryContext::discover(&child, None)?; + for lock in [ + context.git_dir.join("index.lock"), + context.common_dir.join("config.lock"), + context.git_dir.join("config.worktree.lock"), + ] { + match std::fs::symlink_metadata(&lock) { + Ok(_) => anyhow::bail!( + "Git lock exists at {}; sparse checkout was not changed", + lock.display() + ), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(error.into()), + } + } + let sparse_file = crate::utilities::git_path( + &child, + &[ + "--path-format=absolute", + "--git-path", + "info/sparse-checkout", + ], + )?; + match std::fs::symlink_metadata(&sparse_file) { + Ok(metadata) if metadata.file_type().is_symlink() => anyhow::bail!( + "Refusing to write sparse policy through symlink {}", + sparse_file.display() + ), + Ok(_) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(error.into()), + } + Ok(()) + } + + /// Apply native non-cone sparse policy, or disable it for a full checkout. + pub fn reconcile_sparse_checkout(&self, path: &str, expected: &[String]) -> Result { + self.preflight_sparse_checkout(path, expected)?; + if self.sparse_checkout_matches(path, expected)? { + return Ok(false); + } + if expected.is_empty() { + self.child_git(path, ["sparse-checkout", "disable"])?; + } else { + self.set_sparse_patterns(path, expected)?; + } + anyhow::ensure!( + self.sparse_checkout_matches(path, expected)?, + "Git sparse-checkout state did not reach the requested policy" + ); + Ok(true) + } + + /// Reconcile the managed policy fields for one exact registration. + pub fn sync_submodule_settings(&self, path: &str, entry: &SubmoduleEntry) -> Result<()> { + self.preflight_submodule_settings(path, entry)?; + let path = self.validated_path(Path::new(path))?; + let name = self + .registered_name_for_path(&path)? + .with_context(|| format!("No exact submodule registration exists for {path:?}"))?; + let prefix = format!("submodule.{name}"); + let fields = Self::managed_portable_fields(entry); + let worktree_config = self.worktree_config_enabled()?; + let mut url_requires_sync = false; + for (field, desired) in &fields { + let key = format!("{prefix}.{field}"); + if self.preflight_portable_key(&key, desired.as_deref())? != *desired { + self.set_config_value_exact("portable", &key, desired.as_deref())?; + url_requires_sync |= *field == "url"; + } + if *field != "url" && self.config_value("local", &key)? != *desired { + self.set_config_value_exact("local", &key, desired.as_deref())?; + } + if worktree_config && self.config_value("worktree", &key)?.is_some() { + self.set_config_value_exact("worktree", &key, None)?; + url_requires_sync |= *field == "url"; + } + } + + let url_key = format!("{prefix}.url"); + if let Some(url) = &entry.url { + let (expected_local_url, expected_child_url) = self.expected_synced_urls(&path, url)?; + let local_url = self.config_value("local", &url_key)?; + url_requires_sync |= local_url.as_deref() != Some(expected_local_url.as_str()); + if let Some((remote, expected)) = &expected_child_url { + let child = self.worktree.join(&path); + url_requires_sync |= self + .config_value_in(&child, &format!("remote.{remote}.url"))? + .as_deref() + != Some(expected.as_str()); + } + if url_requires_sync { + if self + .index_gitlink_oid(path.to_str().context("Submodule path is not valid UTF-8")?)? + .is_none() + { + // Native `submodule sync -- ` ignores a portable + // registration that has no stage-0 gitlink. This state is + // valid for disabled declarations, so reconcile the parent + // cache directly while retaining the portable declaration. + self.set_config_value_exact( + "local", + &url_key, + Some(expected_local_url.as_str()), + )?; + } else { + let active_override = format!("{prefix}.active=true"); + self.git([ + OsStr::new("-c"), + OsStr::new(&active_override), + OsStr::new("submodule"), + OsStr::new("sync"), + OsStr::new("--"), + Self::literal_pathspec(&path).as_os_str(), + ])?; + } + } + } else if self.config_value("local", &url_key)?.is_some() { + self.set_config_value_exact("local", &url_key, None)?; + } + + let active_key = format!("{prefix}.active"); + let active = entry.active.map(|value| value.to_string()); + if self.config_value("local", &active_key)? != active { + self.set_config_value_exact("local", &active_key, active.as_deref())?; + } + if worktree_config && self.config_value("worktree", &active_key)?.is_some() { + self.set_config_value_exact("worktree", &active_key, None)?; + } + + for (field, desired) in &fields { + let key = format!("{prefix}.{field}"); + anyhow::ensure!( + self.config_value("portable", &key)? == *desired, + "Git did not retain intended .gitmodules value for {key}" + ); + if *field == "url" && desired.is_some() { + let (expected_local, expected_child) = + self.expected_synced_urls(&path, desired.as_deref().expect("URL is present"))?; + let resolved = self.config_value("local", &key)?; + anyhow::ensure!( + resolved.as_deref() == Some(expected_local.as_str()), + "Git did not resolve local URL for {key}" + ); + if let Some((remote, expected)) = expected_child { + let child = self.worktree.join(&path); + anyhow::ensure!( + self.config_value_in(&child, &format!("remote.{remote}.url"))? + .as_deref() + == Some(expected.as_str()), + "Git did not synchronize the selected child URL for {key}" + ); + } } else { - Err(anyhow::anyhow!( - "git read-tree failed: {}", + anyhow::ensure!( + self.config_value("local", &key)? == *desired, + "Git did not retain intended local value for {key}" + ); + } + if worktree_config { + anyhow::ensure!( + self.config_value("worktree", &key)?.is_none(), + "Git did not remove worktree override for {key}" + ); + } + } + anyhow::ensure!( + self.config_value("local", &active_key)? == active, + "Git did not retain intended activation for {active_key}" + ); + Ok(()) + } + + /// Finish metadata reconciliation as part of a structural registration. + /// Registration already owns staging `.gitmodules`, so stage only that + /// exact file after applying effective managed settings. + pub(crate) fn sync_added_submodule_settings( + &self, + path: &str, + entry: &SubmoduleEntry, + ) -> Result<()> { + self.sync_submodule_settings(path, entry)?; + self.git(["add", "--", ".gitmodules"])?; + let unstaged = self.git_output(["diff", "--quiet", "--", ".gitmodules"])?; + anyhow::ensure!( + unstaged.status.success(), + "Git registration left an unstaged .gitmodules delta" + ); + Ok(()) + } + + /// Validate an initialized submodule move without changing checkout or metadata. + pub fn preflight_move_submodule(&self, old_path: &str, new_path: &str) -> Result<()> { + let old = self.validated_path(Path::new(old_path))?; + let new = self.validated_path(Path::new(new_path))?; + let old_str = old.to_str().context("Submodule path is not valid UTF-8")?; + self.validated_child(&old)?; + self.ensure_clean_checkout(old_str)?; + if std::fs::symlink_metadata(self.worktree.join(&new)).is_ok() { + anyhow::bail!( + "Destination {} is occupied; move never replaces existing content", + self.worktree.join(&new).display() + ); + } + self.preflight_native_locks()?; + self.preflight_native_metadata()?; + self.preflight_gitmodules_layers()?; + Ok(()) + } + + /// Move an initialized submodule through native Git and verify both registration and index. + pub fn move_submodule(&mut self, old_path: &str, new_path: &str) -> Result<()> { + self.preflight_move_submodule(old_path, new_path)?; + let old = self.validated_path(Path::new(old_path))?; + let new = self.validated_path(Path::new(new_path))?; + self.git([ + OsStr::new("mv"), + OsStr::new("--"), + old.as_os_str(), + new.as_os_str(), + ])?; + if self.registered_name_for_path(&old)?.is_some() + || self.registered_name_for_path(&new)?.is_none() + { + anyhow::bail!("Git moved the checkout but did not update its exact registration"); + } + let new_pathspec = Self::literal_pathspec(&new); + let index = self.git([ + OsStr::new("ls-files"), + OsStr::new("--stage"), + OsStr::new("--"), + new_pathspec.as_os_str(), + ])?; + if !String::from_utf8_lossy(&index.stdout).starts_with("160000 ") { + anyhow::bail!("Git moved the checkout but did not create the new gitlink"); + } + Ok(()) + } + + /// Reopen the repository from the working directory to refresh any cached state. + /// This is needed after destructive operations (e.g., submodule delete) so that the + /// in-memory git2 repository object reflects the updated on-disk state. + /// + /// Returns an error if the git2 repository (the required backend) cannot be reopened. + /// A gix reopen failure is non-fatal since gix is an optional optimistic backend. + /// A manager constructed without gix (e.g. via [`GitOpsManager::without_gix`]) + /// stays without it: reopen preserves the backend policy, it never upgrades one. + pub fn reopen(&mut self) -> Result<()> { + let workdir = self + .git2_ops + .workdir() + .ok_or_else(|| anyhow::anyhow!("Cannot reopen repository: no working directory"))? + .to_path_buf(); + + // git2 is the required backend — propagate its reopen error. + self.git2_ops = Git2Operations::new(Some(&workdir)).with_context(|| { + format!("Failed to reopen git2 repository at {}", workdir.display()) + })?; + + // gix is an optional optimistic backend — log failures but don't fail, + // and never enable it on a manager that was constructed without it. + if self.gix_ops.is_some() { + match GixOperations::new(Some(&workdir)) { + Ok(new_gix) => { + self.gix_ops = Some(new_gix); + } + Err(e) => { + if self.verbose { + eprintln!( + "Warning: failed to reopen gix repository at {}: {}", + crate::utilities::safe_human_text(&workdir.to_string_lossy()), + crate::utilities::safe_human_text(&e.to_string()) + ); + } + } + } + } + + Ok(()) + } + + fn git_output(&self, args: I) -> Result + where + I: IntoIterator, + S: AsRef, + { + Command::new("git") + .args(["-c", "diff.autoRefreshIndex=false"]) + .args(args) + .current_dir(&self.worktree) + .env("GIT_OPTIONAL_LOCKS", "0") + .output() + .context("Failed to execute Git") + } + + fn git(&self, args: I) -> Result + where + I: IntoIterator, + S: AsRef, + { + let output = self.git_output(args)?; + if output.status.success() { + Ok(output) + } else { + Err(anyhow::anyhow!( + "Git exited {}: {}", + output.status, + String::from_utf8_lossy(&output.stderr).trim() + )) + } + } + + fn git_path(&self, path: &str) -> Result { + crate::utilities::git_path( + &self.worktree, + &["--path-format=absolute", "--git-path", path], + ) + } + + fn literal_pathspec(path: &Path) -> OsString { + let mut pathspec = OsString::from(":(literal)"); + pathspec.push(path.as_os_str()); + pathspec + } + + fn validated_module_storage(&self, name: &str) -> Result { + Self::validate_name(name)?; + let relative = Path::new("modules").join(name); + crate::utilities::validate_submodule_path(&self.git_dir, &relative) + .context("Unsafe submodule storage path")?; + let expected = self.git_dir.join(&relative); + let reported = self.git_path( + relative + .to_str() + .context("Submodule storage name is not valid UTF-8")?, + )?; + if reported != expected { + anyhow::bail!( + "Git resolved submodule storage outside the expected Git directory: {}", + reported.display() + ); + } + Ok(reported) + } + + fn retained_repo_matches_url(&self, git_dir: &Path, url: &str) -> Result { + let config = git_dir.join("config"); + let metadata = match std::fs::symlink_metadata(&config) { + Ok(metadata) => metadata, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false), + Err(error) => return Err(error.into()), + }; + if metadata.file_type().is_symlink() || !metadata.is_file() { + anyhow::bail!( + "Refusing retained submodule repository with unsafe config at {}", + config.display() + ); + } + + let output = Command::new("git") + .current_dir(&self.worktree) + .env("GIT_OPTIONAL_LOCKS", "0") + .args(["config", "--no-includes", "--file"]) + .arg(&config) + .args(["--get", "remote.origin.url"]) + .output() + .context("Failed to inspect retained submodule repository")?; + if !output.status.success() { + return match output.status.code() { + Some(1) => Ok(false), + _ => anyhow::bail!( + "Could not inspect retained submodule origin: {}", String::from_utf8_lossy(&output.stderr).trim() - )) + ), + }; + } + let configured = output.stdout.strip_suffix(b"\n").unwrap_or(&output.stdout); + Ok(configured == url.as_bytes()) + } + + fn validated_path(&self, path: &Path) -> Result { + let path = crate::utilities::normalize_submodule_path(path)?; + crate::utilities::validate_submodule_path(&self.worktree, &path)?; + Ok(path) + } + + fn validate_name(name: &str) -> Result<()> { + let normalized = crate::utilities::normalize_submodule_path(Path::new(name)); + if name.contains(['\0', '\n', '\r', '\\']) + || normalized + .as_ref() + .map_or(true, |normalized| normalized != Path::new(name)) + { + anyhow::bail!("Invalid submodule administrative name: {name:?}"); + } + Ok(()) + } + + fn config_key_regex_literal(value: &str) -> String { + let mut escaped = String::with_capacity(value.len()); + for character in value.chars() { + if matches!( + character, + '\\' | '.' | '^' | '$' | '|' | '?' | '*' | '+' | '(' | ')' | '[' | ']' | '{' | '}' + ) { + escaped.push('\\'); } - }) + escaped.push(character); + } + escaped + } + + fn registered_name_for_path(&self, path: &Path) -> Result> { + if !self.worktree.join(".gitmodules").is_file() { + return Ok(None); + } + let output = self.git_output([ + OsStr::new("config"), + OsStr::new("-z"), + OsStr::new("--file"), + OsStr::new(".gitmodules"), + OsStr::new("--get-regexp"), + OsStr::new(r"^submodule\..*\.path$"), + ])?; + if !output.status.success() { + return match output.status.code() { + Some(1) => Ok(None), + _ => Err(anyhow::anyhow!( + "Could not read .gitmodules registrations: {}", + String::from_utf8_lossy(&output.stderr).trim() + )), + }; + } + let requested = path.as_os_str(); + let mut found = Vec::new(); + for record in output + .stdout + .split(|byte| *byte == 0) + .filter(|r| !r.is_empty()) + { + let Some(newline) = record.iter().position(|byte| *byte == b'\n') else { + anyhow::bail!("Git returned a malformed .gitmodules registration"); + }; + let key = std::str::from_utf8(&record[..newline])?; + let value = &record[newline + 1..]; + #[cfg(unix)] + let value: OsString = { + use std::os::unix::ffi::OsStringExt; + OsString::from_vec(value.to_vec()) + }; + #[cfg(not(unix))] + let value = OsString::from(String::from_utf8(value.to_vec())?); + if value == requested { + let name = key + .strip_prefix("submodule.") + .and_then(|key| key.strip_suffix(".path")) + .context("Git returned an invalid submodule path key")?; + found.push(name.to_string()); + } + } + match found.as_slice() { + [] => Ok(None), + [name] => Ok(Some(name.clone())), + _ => anyhow::bail!( + "Multiple .gitmodules registrations use path {}", + path.display() + ), + } + } + + fn read_native_gitmodules(&self) -> Result { + if !self.worktree.join(".gitmodules").is_file() { + return Ok(SubmoduleEntries::default()); + } + let output = self.git(["config", "-z", "--file", ".gitmodules", "--list"])?; + let mut entries: HashMap> = HashMap::new(); + for record in output + .stdout + .split(|byte| *byte == 0) + .filter(|r| !r.is_empty()) + { + let newline = record + .iter() + .position(|byte| *byte == b'\n') + .context("Git returned malformed .gitmodules data")?; + let key = std::str::from_utf8(&record[..newline])?; + let value = std::str::from_utf8(&record[newline + 1..])?; + let Some(key) = key.strip_prefix("submodule.") else { + continue; + }; + let Some((name, field)) = key.rsplit_once('.') else { + continue; + }; + Self::validate_name(name)?; + let field = match field.to_ascii_lowercase().as_str() { + "path" => "path", + "url" => "url", + "branch" => "branch", + "ignore" => "ignore", + "update" => { + if value.starts_with('!') { + anyhow::bail!( + "Custom submodule update commands are not supported for {name:?}" + ); + } + "update" + } + "fetchrecursesubmodules" => "fetchRecurseSubmodules", + "shallow" => "shallow", + _ => continue, + }; + entries + .entry(name.to_string()) + .or_default() + .insert(field.to_string(), value.to_string()); + } + Ok(SubmoduleEntries::from_gitmodules(entries)) + } + + fn validated_child(&self, path: &Path) -> Result { + let path = self.validated_path(path)?; + let registered_name = self.registered_name_for_path(&path)?.with_context(|| { + format!( + "No exact .gitmodules registration exists for {}", + path.display() + ) + })?; + self.validated_module_storage(®istered_name)?; + let intended = self.worktree.join(&path); + let admin = intended.join(".git"); + let mut declared_git_dir = None; + if let Ok(metadata) = std::fs::symlink_metadata(&admin) + && metadata.is_file() + { + let bytes = std::fs::read(&admin) + .with_context(|| format!("Could not read submodule gitfile {}", admin.display()))?; + let line = bytes.strip_suffix(b"\n").unwrap_or(&bytes); + let line = line.strip_suffix(b"\r").unwrap_or(line); + let raw_target = line + .strip_prefix(b"gitdir: ") + .with_context(|| format!("Malformed submodule gitfile at {}", admin.display()))?; + #[cfg(unix)] + let target = { + use std::os::unix::ffi::OsStringExt; + PathBuf::from(OsString::from_vec(raw_target.to_vec())) + }; + #[cfg(not(unix))] + let target = + PathBuf::from(String::from_utf8(raw_target.to_vec()).with_context(|| { + format!( + "Submodule gitfile target at {} is not valid Unicode", + admin.display() + ) + })?); + let target = if target.is_absolute() { + target + } else { + intended.join(target) + }; + match std::fs::symlink_metadata(&target) { + Ok(_) if target.is_dir() => { + declared_git_dir = Some(target); + } + Ok(_) => anyhow::bail!( + "Submodule gitfile {} does not point to a Git directory", + admin.display() + ), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => anyhow::bail!( + "Submodule gitfile {} points to missing Git directory {}", + admin.display(), + target.display() + ), + Err(error) => return Err(error.into()), + } + } + let intended_root = intended + .canonicalize() + .with_context(|| format!("Submodule checkout is missing: {}", intended.display()))?; + let worktree_root = crate::utilities::git_path(&intended_root, &["--show-toplevel"]) + .with_context(|| format!("Invalid submodule repository at {}", intended.display()))?; + if worktree_root != intended_root { + anyhow::bail!( + "Submodule path {} resolves to unrelated worktree {}", + intended.display(), + worktree_root.display() + ); + } + let metadata = std::fs::symlink_metadata(intended_root.join(".git"))?; + let git_dir_is_valid = if metadata.is_file() { + declared_git_dir.is_some_and(|path| path.is_dir()) + } else { + metadata.is_dir() + }; + if metadata.file_type().is_symlink() || !git_dir_is_valid { + anyhow::bail!( + "Invalid submodule Git administrative identity at {}", + intended.display() + ); + } + Ok(path) + } + + fn has_only_git_admin(&self, path: &Path) -> Result { + let path = self.validated_child(path)?; + let child = self.worktree.join(&path); + for entry in child.read_dir()? { + if entry?.file_name() != OsStr::new(".git") { + return Ok(false); + } + } + Ok(true) + } + + /// A failed native initialization can leave a gitfile, no child index, and + /// staged deletions for the remote tip. No user worktree content exists. + fn is_incomplete_materialization(&self, path: &Path) -> Result { + if !self.has_only_git_admin(path)? { + return Ok(false); + } + let path = self.validated_child(path)?; + let status = self.child_git( + path.to_str().context("Submodule path is not valid UTF-8")?, + [ + "status", + "--porcelain=v1", + "--untracked-files=all", + "--ignored", + "--ignore-submodules=none", + ], + )?; + let index = self.child_git( + path.to_str().context("Submodule path is not valid UTF-8")?, + ["ls-files", "--stage"], + )?; + Ok(!status.stdout.is_empty() + && index.stdout.is_empty() + && String::from_utf8_lossy(&status.stdout) + .lines() + .all(|line| line.starts_with("D "))) + } + + /// Validate an exact registration and any checkout already occupying its path. + /// A missing or empty directory is a valid materialization target; anything + /// populated must be the intended Git worktree before native Git can touch it. + fn validated_materialization_target(&self, path: &Path) -> Result { + let path = self.validated_path(path)?; + let registered_name = self.registered_name_for_path(&path)?.with_context(|| { + format!( + "No exact .gitmodules registration exists for {}", + path.display() + ) + })?; + self.validated_module_storage(®istered_name)?; + let target = self.worktree.join(&path); + match std::fs::symlink_metadata(&target) { + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(path), + Err(error) => Err(error.into()), + Ok(metadata) if !metadata.is_dir() || metadata.file_type().is_symlink() => { + anyhow::bail!( + "Submodule materialization target {} is occupied", + target.display() + ) + } + Ok(_) => match std::fs::symlink_metadata(target.join(".git")) { + Ok(_) => self.validated_child(&path), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + if target.read_dir()?.next().is_none() { + Ok(path) + } else { + anyhow::bail!( + "Submodule materialization target {} contains unrelated data", + target.display() + ) + } + } + Err(error) => Err(error.into()), + }, + } + } + + fn preflight_native_locks(&self) -> Result<()> { + let mut locks = vec![ + self.git_path("index.lock")?, + self.git_path("config.lock")?, + self.git_path("config.worktree.lock")?, + ]; + locks.push(self.worktree.join(".gitmodules.lock")); + for lock in locks { + match std::fs::symlink_metadata(&lock) { + Ok(_) => anyhow::bail!( + "Git lock exists at {}; finish the other Git operation or remove a verified stale lock", + lock.display() + ), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(error.into()), + } + } + Ok(()) + } + + fn preflight_native_metadata(&self) -> Result<()> { + for path in [ + self.worktree.join(".gitmodules"), + self.git_path("config")?, + self.git_path("config.worktree")?, + ] { + match std::fs::symlink_metadata(&path) { + Ok(metadata) if metadata.file_type().is_symlink() => anyhow::bail!( + "Refusing to write Git metadata through symlink {}", + path.display() + ), + Ok(_) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(error.into()), + } + } + Ok(()) + } + + fn preflight_gitmodules_layers(&self) -> Result<()> { + if !self.worktree.join(".gitmodules").exists() { + return Ok(()); + } + let output = self.git_output(["diff", "--quiet", "--", ".gitmodules"])?; + match output.status.code() { + Some(0) => Ok(()), + Some(1) => anyhow::bail!( + ".gitmodules has unstaged edits; stage or stash them before changing submodule registration" + ), + _ => Err(anyhow::anyhow!( + "Could not inspect .gitmodules layers: {}", + String::from_utf8_lossy(&output.stderr).trim() + )), + } + } + + fn child_git_output(&self, path: &str, args: I) -> Result + where + I: IntoIterator, + S: AsRef, + { + let path = self.validated_child(Path::new(path))?; + self.child_git_output_at_validated(&path, args) + } + + /// Run a child Git command after the caller has validated the exact checkout. + fn child_git_output_at_validated(&self, path: &Path, args: I) -> Result + where + I: IntoIterator, + S: AsRef, + { + let child = self.worktree.join(path); + Command::new("git") + .args(["-c", "diff.autoRefreshIndex=false"]) + .args(args) + .current_dir(&child) + .env("GIT_OPTIONAL_LOCKS", "0") + .output() + .with_context(|| format!("Failed to execute Git in {}", child.display())) + } + + fn child_git(&self, path: &str, args: I) -> Result + where + I: IntoIterator, + S: AsRef, + { + let path = self.validated_path(Path::new(path))?; + let child = self.worktree.join(&path); + let output = self.child_git_output( + path.to_str().context("Submodule path is not valid UTF-8")?, + args, + )?; + if output.status.success() { + Ok(output) + } else { + Err(anyhow::anyhow!( + "Git in {} exited {}: {}", + child.display(), + output.status, + String::from_utf8_lossy(&output.stderr).trim() + )) + } + } + + fn child_ref_oid(&self, path: &str, reference: &str) -> Result> { + let output = + self.child_git_output(path, ["rev-parse", "--verify", "--quiet", reference])?; + match output.status.code() { + Some(0) => Ok(Some( + String::from_utf8(output.stdout)?.trim_end().to_string(), + )), + Some(1) => Ok(None), + _ => anyhow::bail!( + "Could not inspect {reference} in submodule {path}: {}", + String::from_utf8_lossy(&output.stderr).trim() + ), + } + } + + /// Build a shell-safe, in-directory recovery command for an exact stash. + /// + /// `git stash branch` checks out the stash's original base before applying + /// its index and worktree state, so recovery remains correct after reset + /// moved the checkout to a different parent pin. + pub fn stash_recovery_command(&self, path: &str, oid: &str) -> Result { + anyhow::ensure!( + !oid.is_empty() && oid.bytes().all(|byte| byte.is_ascii_hexdigit()), + "Invalid stash object ID returned by Git" + ); + self.child_git(path, ["cat-file", "-e", &format!("{oid}^{{commit}}")])?; + let short = &oid[..oid.len().min(12)]; + for suffix in 0..1000 { + let branch = if suffix == 0 { + format!("submod-recovery-{short}") + } else { + format!("submod-recovery-{short}-{suffix}") + }; + if self + .child_ref_oid(path, &format!("refs/heads/{branch}"))? + .is_none() + { + return Ok(format!("git stash branch {branch} {oid}")); + } + } + anyhow::bail!("Could not choose an unused recovery branch for stash {oid}") + } + + fn ensure_clean_checkout(&self, path: &str) -> Result<()> { + let path = self.validated_path(Path::new(path))?; + let child = self.worktree.join(&path); + if !child.join(".git").exists() { + if std::fs::symlink_metadata(&child).is_ok() && child.read_dir()?.next().is_some() { + anyhow::bail!( + "Submodule path {} contains data but is not an initialized checkout", + child.display() + ); + } + return Ok(()); + } + let output = self.child_git( + path.to_str().context("Submodule path is not valid UTF-8")?, + [ + "status", + "--porcelain=v1", + "--untracked-files=all", + "--ignored", + "--ignore-submodules=none", + ], + )?; + if !output.stdout.is_empty() { + anyhow::bail!( + "Submodule checkout at {} is dirty or contains local changes (tracked, untracked, or ignored); preserve it before removal", + child.display() + ); + } + Ok(()) + } + + fn ensure_no_discardable_checkout_data(&self, path: &str) -> Result<()> { + self.ensure_clean_checkout(path)?; + let path = self.validated_path(Path::new(path))?; + let child = self.worktree.join(&path); + if !child.join(".git").exists() { + return Ok(()); + } + let nested = self.child_git( + path.to_str().context("Submodule path is not valid UTF-8")?, + ["ls-files", "--stage", "-z"], + )?; + if nested + .stdout + .split(|byte| *byte == 0) + .any(|record| record.starts_with(b"160000 ")) + { + anyhow::bail!( + "Submodule checkout at {} contains nested registrations whose ignored data cannot be proven safe; inspect them and use --force only if their worktree data may be discarded", + child.display() + ); + } + Ok(()) + } + + fn ensure_lock_absent(lock: &Path) -> Result<()> { + match std::fs::symlink_metadata(lock) { + Ok(_) => anyhow::bail!( + "Git lock exists at {}; finish the other Git operation or remove a verified stale lock", + lock.display() + ), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(error.into()), + } + } + + fn preflight_gitdir_locks(&self, git_dir: &Path, include_stash: bool) -> Result<()> { + let mut locks = vec![ + git_dir.join("index.lock"), + git_dir.join("config.lock"), + git_dir.join("config.worktree.lock"), + ]; + if include_stash { + locks.push(git_dir.join("refs/stash.lock")); + } + for lock in locks { + Self::ensure_lock_absent(&lock)?; + } + Ok(()) + } + + fn preflight_child_locks(&self, path: &Path, include_stash: bool) -> Result<()> { + let path = self.validated_child(path)?; + let child = self.worktree.join(&path); + let mut names = vec!["index.lock", "config.lock", "config.worktree.lock"]; + if include_stash { + names.push("refs/stash.lock"); + } + for name in names { + let lock = crate::utilities::git_path( + &child, + &["--path-format=absolute", "--git-path", name], + )?; + Self::ensure_lock_absent(&lock)?; + } + Ok(()) + } + + /// Validate exact native removal without changing registration, checkout, or config. + pub fn preflight_delete_submodule(&self, path: &str, force: bool) -> Result<()> { + let path = self.validated_materialization_target(Path::new(path))?; + self.preflight_native_locks()?; + self.preflight_native_metadata()?; + self.preflight_gitmodules_layers()?; + self.index_gitlink_oid(path.to_str().context("Submodule path is not valid UTF-8")?)? + .context("Registered submodule has no stage-0 gitlink to delete")?; + if self.worktree.join(&path).join(".git").exists() { + self.preflight_child_locks(&path, true)?; + if !force { + self.ensure_no_discardable_checkout_data( + path.to_str().context("Submodule path is not valid UTF-8")?, + )?; + } + } + Ok(()) + } + + fn reset_target(&self, path: &Path) -> Result { + self.index_gitlink_oid(path.to_str().context("Submodule path is not valid UTF-8")?)? + .with_context(|| { + format!( + "Parent index has no stage-0 mode-160000 target for {}", + path.display() + ) + }) + } + + fn preflight_reset_preservation(&self, path: &str) -> Result<()> { + let path = self.validated_child(Path::new(path))?; + let child = self.worktree.join(&path); + let stash_lock = crate::utilities::git_path( + &child, + &["--path-format=absolute", "--git-path", "refs/stash.lock"], + )?; + match std::fs::symlink_metadata(&stash_lock) { + Ok(_) => anyhow::bail!( + "Git stash lock exists at {}; finish the other Git operation or remove a verified stale lock", + stash_lock.display() + ), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(error.into()), + } + + let target = self.reset_target(&path)?; + let target_tree = self.child_git( + path.to_str().context("Submodule path is not valid UTF-8")?, + ["ls-tree", "-r", "-z", "--name-only", &target], + )?; + let tracked: Vec<&[u8]> = target_tree + .stdout + .split(|byte| *byte == 0) + .filter(|record| !record.is_empty()) + .collect(); + let status = self.child_git( + path.to_str().context("Submodule path is not valid UTF-8")?, + [ + "status", + "--porcelain=v1", + "-z", + "--untracked-files=all", + "--ignored", + ], + )?; + for record in status.stdout.split(|byte| *byte == 0) { + if record.len() < 4 || (&record[..3] != b"!! " && &record[..3] != b"?? ") { + continue; + } + let local = record[3..].strip_suffix(b"/").unwrap_or(&record[3..]); + let is_ignored = &record[..3] == b"!! "; + let is_nested_repo = if is_ignored { + true + } else { + #[cfg(unix)] + let relative = { + use std::os::unix::ffi::OsStringExt; + PathBuf::from(OsString::from_vec(local.to_vec())) + }; + #[cfg(not(unix))] + let relative = PathBuf::from(String::from_utf8(local.to_vec())?); + child.join(relative).join(".git").exists() + }; + if !is_ignored && !is_nested_repo { + continue; + } + let collides = tracked.iter().any(|target_path| { + *target_path == local + || target_path + .strip_prefix(local) + .is_some_and(|suffix| suffix.starts_with(b"/")) + || local + .strip_prefix(*target_path) + .is_some_and(|suffix| suffix.starts_with(b"/")) + }); + if collides { + anyhow::bail!( + "Reset target {target} would overwrite ignored content or a nested repository at {}", + String::from_utf8_lossy(local) + ); + } + } + Ok(()) + } + + /// Validate a reset target and every preservation precondition without mutation. + pub fn preflight_reset_submodule(&self, path: &str) -> Result { + self.read_native_gitmodules()?; + let path = self.validated_child(Path::new(path))?; + let path_text = path.to_str().context("Submodule path is not valid UTF-8")?; + self.preflight_native_locks()?; + self.preflight_child_locks(&path, true)?; + self.preflight_reset_preservation(path_text)?; + self.reset_target(&path) + } + + /// Try gix first, fall back to git2 + fn try_with_fallback(&self, gix_op: F1, git2_op: F2) -> Result + where + F1: FnOnce(&GixOperations) -> Result, + F2: FnOnce(&Git2Operations) -> Result, + { + if let Some(ref gix) = self.gix_ops { + match gix_op(gix) { + Ok(result) => return Ok(result), + Err(e) => { + if self.verbose { + eprintln!( + "gix operation failed, falling back to git2: {}", + crate::utilities::safe_human_text(&e.to_string()) + ); + } + } + } + } + + git2_op(&self.git2_ops) + } +} + +/// Native Git mutations with supported repository inspection backends. +impl GitOperations for GitOpsManager { + fn read_gitmodules(&self) -> Result { + self.read_native_gitmodules() + } + + fn write_gitmodules(&mut self, config: &SubmoduleEntries) -> Result<()> { + self.preflight_native_locks()?; + self.preflight_native_metadata()?; + self.preflight_gitmodules_layers()?; + for (name, entry) in config.submodule_iter() { + Self::validate_name(name)?; + let prefix = format!("submodule.{name}"); + if let Some(path) = &entry.path { + self.validated_path(Path::new(path))?; + self.git([ + "config", + "--file", + ".gitmodules", + &format!("{prefix}.path"), + path, + ])?; + } + if let Some(url) = &entry.url { + self.git([ + "config", + "--file", + ".gitmodules", + &format!("{prefix}.url"), + url, + ])?; + } + for (key, value) in [ + ( + "branch", + entry.branch.as_ref().map(GitmodulesConvert::to_gitmodules), + ), + ("ignore", entry.ignore.map(|value| value.to_string())), + ("update", entry.update.as_ref().map(ToString::to_string)), + ( + "fetchRecurseSubmodules", + entry.fetch_recurse.map(|value| value.to_gitmodules()), + ), + ] { + if let Some(value) = value.filter(|value| !value.is_empty()) { + self.git([ + "config", + "--file", + ".gitmodules", + &format!("{prefix}.{key}"), + &value, + ])?; + } + } + } + Ok(()) + } + + fn read_git_config(&self, level: ConfigLevel) -> Result { + self.try_with_fallback( + |gix| gix.read_git_config(level), + |git2| git2.read_git_config(level), + ) + } + + fn write_git_config(&self, config: &GitConfig, level: ConfigLevel) -> Result<()> { + if level != ConfigLevel::Local { + anyhow::bail!("Only repository-local Git config writes are supported"); + } + self.preflight_native_locks()?; + self.preflight_native_metadata()?; + for (key, value) in &config.entries { + self.git(["config", "--local", key, value])?; + } + Ok(()) + } + + fn set_config_value(&self, key: &str, value: &str, level: ConfigLevel) -> Result<()> { + if level != ConfigLevel::Local { + anyhow::bail!("Only repository-local Git config writes are supported"); + } + self.preflight_native_locks()?; + self.preflight_native_metadata()?; + self.git(["config", "--local", key, value])?; + Ok(()) + } + + fn add_submodule(&mut self, opts: &SubmoduleAddOptions) -> Result<()> { + let path = self.validated_path(&opts.path)?; + let reuse_retained = self.add_preconditions(opts)?; + + let mut command = Command::new("git"); + command + .current_dir(&self.worktree) + .env("GIT_OPTIONAL_LOCKS", "0") + .args(["submodule", "add", "--name"]) + .arg(&opts.name); + if reuse_retained { + command.arg("--force"); + } + let stored_branch = opts.branch.as_ref().map(GitmodulesConvert::to_gitmodules); + if let Some(branch) = stored_branch.as_deref() { + if branch == "." { + let current = self.git(["symbolic-ref", "--quiet", "--short", "HEAD"])?; + command + .arg("--branch") + .arg(String::from_utf8(current.stdout)?.trim()); + } else if branch != "HEAD" { + command.arg("--branch").arg(branch); + } + } + if opts.shallow { + command.args(["--depth", "1"]); + } + command.arg("--").arg(&opts.url).arg(&path); + let output = command + .output() + .context("Failed to execute git submodule add")?; + if !output.status.success() { + anyhow::bail!( + "git submodule add failed; partial state was preserved for recovery: {}", + String::from_utf8_lossy(&output.stderr).trim() + ); + } + + self.validated_child(&path) + .context("Git created a submodule checkout outside the requested path")?; + + let prefix = format!("submodule.{}", opts.name); + for (key, value) in [ + ("branch", stored_branch), + ("ignore", opts.ignore.map(|value| value.to_string())), + ("update", opts.update.as_ref().map(ToString::to_string)), + ( + "fetchRecurseSubmodules", + opts.fetch_recurse.map(|value| value.to_gitmodules()), + ), + ("shallow", opts.shallow.then(|| "true".to_string())), + ] { + if let Some(value) = value.filter(|value| !value.is_empty()) { + self.git([ + "config", + "--file", + ".gitmodules", + &format!("{prefix}.{key}"), + &value, + ])?; + } + } + let pathspec = Self::literal_pathspec(&path); + self.git([ + OsStr::new("add"), + OsStr::new("--"), + OsStr::new(".gitmodules"), + pathspec.as_os_str(), + ])?; + + let registered = self.git([ + "config", + "--file", + ".gitmodules", + "--get", + &format!("{prefix}.path"), + ])?; + let registered = registered + .stdout + .strip_suffix(b"\n") + .unwrap_or(®istered.stdout); + if registered != path.as_os_str().as_encoded_bytes() { + anyhow::bail!("git submodule add did not record the requested path"); + } + let index = self.git([ + OsStr::new("ls-files"), + OsStr::new("--stage"), + OsStr::new("--"), + pathspec.as_os_str(), + ])?; + if !String::from_utf8_lossy(&index.stdout).starts_with("160000 ") { + anyhow::bail!("git submodule add did not create a mode-160000 gitlink"); + } + Ok(()) + } + + fn init_submodule(&mut self, path: &str) -> Result<()> { + let path = self.validated_materialization_target(Path::new(path))?; + self.preflight_native_locks()?; + self.preflight_native_metadata()?; + let pathspec = Self::literal_pathspec(&path); + self.git([ + OsStr::new("submodule"), + OsStr::new("init"), + OsStr::new("--"), + pathspec.as_os_str(), + ])?; + Ok(()) + } + + fn update_submodule(&mut self, path: &str, opts: &SubmoduleUpdateOptions) -> Result<()> { + if opts.strategy == SerializableUpdate::None { + return Ok(()); + } + self.preflight_update_submodule(path, opts)?; + let path = self.validated_materialization_target(Path::new(path))?; + let repair_incomplete = self.worktree.join(&path).join(".git").exists() + && self.is_incomplete_materialization(&path)?; + if !opts.remote && !opts.recursive && self.update_postcondition_holds(&path, opts)? { + return Ok(()); + } + let mut args: Vec = ["submodule", "update", "--init"] + .into_iter() + .map(OsString::from) + .collect(); + match opts.strategy { + SerializableUpdate::Checkout | SerializableUpdate::Unspecified => { + args.push("--checkout".into()) + } + SerializableUpdate::Merge => args.push("--merge".into()), + SerializableUpdate::Rebase => args.push("--rebase".into()), + SerializableUpdate::None => return Ok(()), + } + if opts.recursive { + args.push("--recursive".into()); + } + if opts.force || repair_incomplete { + args.push("--force".into()); + } + if opts.remote { + args.push("--remote".into()); + } + args.push("--".into()); + args.push(Self::literal_pathspec(&path)); + self.git(args)?; + anyhow::ensure!( + self.update_postcondition_holds(&path, opts)?, + "Git submodule update did not reach the required parent-pin postcondition" + ); + Ok(()) + } + + fn delete_submodule(&mut self, path: &str, force: bool) -> Result<()> { + let path = self.validated_path(Path::new(path))?; + let path_str = path.to_str().context("Submodule path is not valid UTF-8")?; + let registered_name = self + .registered_name_for_path(&path)? + .context("No exact .gitmodules registration exists for deletion")?; + self.preflight_delete_submodule(path_str, force)?; + let pathspec = Self::literal_pathspec(&path); + let head_entry = self.git_output([ + OsStr::new("ls-tree"), + OsStr::new("-z"), + OsStr::new("HEAD"), + OsStr::new("--"), + pathspec.as_os_str(), + ])?; + if !head_entry.status.success() { + anyhow::bail!( + "Could not inspect the parent commit before deletion: {}", + String::from_utf8_lossy(&head_entry.stderr).trim() + ); + } + if !head_entry.stdout.is_empty() { + let mut args: Vec = vec!["rm".into()]; + if force { + args.push("--force".into()); + } + args.push("--".into()); + args.push(pathspec.clone()); + self.git(args)?; + } else { + // A newly added gitlink has no HEAD entry, so ordinary `git rm` refuses it as + // staged. Deinitialize the already-verified clean checkout, remove only the exact + // cached gitlink, then remove its exact portable section with Git's config parser. + self.git([ + OsStr::new("submodule"), + OsStr::new("deinit"), + OsStr::new("--force"), + OsStr::new("--"), + pathspec.as_os_str(), + ])?; + self.git([ + OsStr::new("rm"), + OsStr::new("--cached"), + OsStr::new("--"), + pathspec.as_os_str(), + ])?; + self.git([ + OsStr::new("config"), + OsStr::new("--file"), + OsStr::new(".gitmodules"), + OsStr::new("--remove-section"), + OsStr::new(&format!("submodule.{registered_name}")), + ])?; + self.git(["add", "--", ".gitmodules"])?; + } + let local_pattern = format!( + r"^submodule\.{}\.", + Self::config_key_regex_literal(®istered_name) + ); + let local_section_exists = self.git_output([ + OsStr::new("config"), + OsStr::new("--local"), + OsStr::new("--name-only"), + OsStr::new("--get-regexp"), + OsStr::new(&local_pattern), + ])?; + match local_section_exists.status.code() { + Some(0) => { + self.git([ + OsStr::new("config"), + OsStr::new("--local"), + OsStr::new("--remove-section"), + OsStr::new(&format!("submodule.{registered_name}")), + ])?; + } + Some(1) => {} + _ => { + anyhow::bail!( + "Could not inspect the exact local Git registration: {}", + String::from_utf8_lossy(&local_section_exists.stderr).trim() + ); + } + } + match std::fs::symlink_metadata(self.worktree.join(&path)) { + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Ok(metadata) + if metadata.is_dir() && self.worktree.join(&path).read_dir()?.next().is_none() => + { + std::fs::remove_dir(self.worktree.join(&path)).with_context(|| { + format!( + "Could not remove Git's empty checkout placeholder at {}", + self.worktree.join(&path).display() + ) + })?; + } + Ok(_) => anyhow::bail!( + "Git removed the registration but left content at {}; preserving it for recovery", + self.worktree.join(&path).display() + ), + Err(error) => return Err(error.into()), + } + let index = self.git([ + OsStr::new("ls-files"), + OsStr::new("--stage"), + OsStr::new("--"), + pathspec.as_os_str(), + ])?; + if !index.stdout.is_empty() { + anyhow::bail!("Git reported removal but the exact gitlink remains in the index"); + } + if self.registered_name_for_path(&path)?.is_some() { + anyhow::bail!("Git reported removal but the exact .gitmodules registration remains"); + } + Ok(()) + } + + fn deinit_submodule(&mut self, path: &str, force: bool) -> Result<()> { + let path = self.validated_materialization_target(Path::new(path))?; + let path_str = path.to_str().context("Submodule path is not valid UTF-8")?; + self.preflight_native_locks()?; + self.preflight_native_metadata()?; + if !force { + self.ensure_no_discardable_checkout_data(path_str)?; + } + let mut args: Vec = vec!["submodule".into(), "deinit".into()]; + if force { + args.push("--force".into()); + } + args.push("--".into()); + args.push(Self::literal_pathspec(&path)); + self.git(args)?; + Ok(()) + } + + fn get_submodule_status(&self, path: &str) -> Result { + self.git2_ops.get_submodule_status(path) + } + + fn list_submodules(&self) -> Result> { + self.try_with_fallback( + GixOperations::list_submodules, + Git2Operations::list_submodules, + ) + } + + fn fetch_submodule(&self, path: &str) -> Result<()> { + self.preflight_native_locks()?; + self.child_git(path, ["fetch"])?; + Ok(()) + } + + fn reset_submodule(&self, path: &str, hard: bool) -> Result<()> { + let target = self.preflight_reset_submodule(path)?; + let mut args = vec!["submodule", "update", "--init", "--checkout"]; + if hard { + args.push("--force"); + } + args.push("--"); + let mut args: Vec = args.into_iter().map(OsString::from).collect(); + args.push(Self::literal_pathspec(Path::new(path))); + self.git(args)?; + let head = self.child_git(path, ["rev-parse", "HEAD"])?; + anyhow::ensure!( + String::from_utf8(head.stdout)?.trim_end() == target, + "Reset completed without moving {path} to parent pin {target}" + ); + Ok(()) + } + + fn clean_submodule(&self, path: &str, force: bool, remove_directories: bool) -> Result<()> { + self.preflight_native_locks()?; + let mut args = vec!["clean"]; + if force { + args.push("-f"); + } + if remove_directories { + args.push("-d"); + } + args.push("--"); + self.child_git(path, args)?; + Ok(()) + } + + fn stash_submodule(&self, path: &str, include_untracked: bool) -> Result> { + self.read_native_gitmodules()?; + self.preflight_reset_preservation(path)?; + self.preflight_native_locks()?; + let before = self.child_ref_oid(path, "refs/stash")?; + let mut args = vec!["stash", "push"]; + if include_untracked { + args.push("--include-untracked"); + } + args.extend(["--message", "submod reset preservation"]); + self.child_git(path, args)?; + let after = self.child_ref_oid(path, "refs/stash")?; + if after == before { + return Ok(None); + } + let oid = after.context("Git reported a stash but refs/stash is missing")?; + self.child_git(path, ["cat-file", "-e", &format!("{oid}^{{commit}}")])?; + Ok(Some(oid)) + } + + fn enable_sparse_checkout(&self, path: &str) -> Result<()> { + self.preflight_native_locks()?; + self.child_git(path, ["sparse-checkout", "init", "--no-cone"])?; + Ok(()) + } + + fn set_sparse_patterns(&self, path: &str, patterns: &[String]) -> Result<()> { + let path = self.validated_child(Path::new(path))?; + self.preflight_native_locks()?; + let child = self.worktree.join(path); + let mut command = Command::new("git") + .args(["sparse-checkout", "set", "--no-cone", "--stdin"]) + .current_dir(&child) + .env("GIT_OPTIONAL_LOCKS", "0") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .with_context(|| format!("Failed to execute Git in {}", child.display()))?; + { + let stdin = command + .stdin + .as_mut() + .context("Git sparse-checkout stdin unavailable")?; + for pattern in patterns { + writeln!(stdin, "{pattern}")?; + } + } + let output = command.wait_with_output()?; + if !output.status.success() { + anyhow::bail!( + "git sparse-checkout set failed: {}", + String::from_utf8_lossy(&output.stderr).trim() + ); + } + Ok(()) + } + + fn get_sparse_patterns(&self, path: &str) -> Result> { + let output = self.child_git( + path, + [ + "rev-parse", + "--path-format=absolute", + "--git-path", + "info/sparse-checkout", + ], + )?; + let sparse_file = crate::utilities::git_path_from_stdout(output.stdout)?; + match std::fs::read_to_string(sparse_file) { + Ok(content) => Ok(content.lines().map(str::to_string).collect()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(Vec::new()), + Err(error) => Err(error.into()), + } + } + + fn apply_sparse_checkout(&self, path: &str) -> Result<()> { + self.preflight_native_locks()?; + self.child_git(path, ["sparse-checkout", "reapply"])?; + Ok(()) } } diff --git a/src/git_ops/simple_gix.rs b/src/git_ops/simple_gix.rs deleted file mode 100644 index a5f96d2e..00000000 --- a/src/git_ops/simple_gix.rs +++ /dev/null @@ -1,137 +0,0 @@ -// SPDX-License-Identifier: LicenseRef-PlainMIT OR MIT -// -// SPDX-FileCopyrightText: 2018-2025 Sebastian Thiel and [contributors](https://github.com/byron/gitoxide/contributors) -// SPDX-FileCopyrightText: 2025 Adam Poulemanos <89049923+bashandbone@users.noreply.github.com> - -//! A series of functions that mirror gix cli functionality. Sometimes it's just easier to copy what's already there. -//! -//! This module is adapted and simplified from the `gix` CLI (/) and its supporting `gitoxide-core` crate. - -use anyhow::Result; -use gitoxide_core::repository::fetch::{ - Options as FetchOptions, PROGRESS_RANGE as FetchProgressRange, -}; -use gix::{features::progress, progress::prodash}; -use prodash::render::line; -use std::io::stderr; - -/// A standard range for line renderer. -pub fn setup_line_renderer_range( - progress: &std::sync::Arc, - levels: std::ops::RangeInclusive, -) -> line::JoinHandle { - prodash::render::line( - std::io::stderr(), - std::sync::Arc::downgrade(progress), - prodash::render::line::Options { - level_filter: Some(levels), - frames_per_second: 6.0, - initial_delay: Some(std::time::Duration::from_secs(1)), - timestamp: true, - throughput: true, - hide_cursor: true, - ..prodash::render::line::Options::default() - } - .auto_configure(prodash::render::line::StreamKind::Stderr), - ) -} - -/// Get a progress tree for use with prodash. -#[must_use] -pub fn progress_tree(trace: bool) -> std::sync::Arc { - prodash::tree::root::Options { - message_buffer_capacity: if trace { 10_000 } else { 200 }, - ..Default::default() - } - .into() -} - -/// Run a function with progress tracking, returning its result alongside the -/// bytes it wrote to the `out` and `err` handles it was given. -/// -/// `gitoxide-core` writes CLI-shaped reports to those handles — for `fetch`, -/// the refspec mapping and a per-ref status line. That is narration for someone -/// running `gix` directly, not part of `submod`'s output contract, so it is -/// captured and handed back rather than written to this process's own streams. -/// The caller decides where, and whether, it is shown. -pub fn get_progress( - func_name: &str, - range: Option>, - run: impl FnOnce( - progress::DoOrDiscard, - &mut dyn std::io::Write, - &mut dyn std::io::Write, - ) -> T, -) -> (T, Vec, Vec) { - let standard_range = 2..=2; - let range = range.unwrap_or_else(|| standard_range.clone()); - let progress = progress_tree(false); - let sub_progress = progress.add_child(func_name); - - let handle = setup_line_renderer_range(&progress, range); - - let mut out = Vec::::new(); - let mut err = Vec::::new(); - - let result = gix::trace::coarse!("run").into_scope(|| { - run( - progress::DoOrDiscard::from(Some(sub_progress)), - &mut out, - &mut err, - ) - }); - - handle.shutdown_and_wait(); - (result, out, err) -} - -/// Fetch options for the `fetch` command, with an option for shallow fetching. -const fn fetch_options(remote: Option, shallow: bool) -> FetchOptions { - let shallow = if shallow { - gix::remote::fetch::Shallow::DepthAtRemote(std::num::NonZeroU32::new(1).unwrap()) - } else { - gix::remote::fetch::Shallow::NoChange - }; - FetchOptions { - format: gitoxide_core::OutputFormat::Human, - dry_run: false, - remote, - ref_specs: Vec::new(), - shallow, - handshake_info: false, - negotiation_info: false, - open_negotiation_graph: None, - } -} - -/// Fetch updates from a remote repository. -/// -/// The fetch report `gitoxide-core` produces never reaches stdout: stdout -/// carries `submod`'s own output, and callers parse it. The report is written to -/// stderr when the caller asked for verbose output, or when the fetch failed and -/// it is the only detail available to explain why. -pub fn fetch_repo( - repo: gix::Repository, - remote: Option, - shallow: bool, - verbose: bool, -) -> Result<()> { - let (inner_result, out, err) = - get_progress("fetch", Some(FetchProgressRange), |progress, out, err| { - gitoxide_core::repository::fetch( - repo, - progress, - out, - err, - fetch_options(remote, shallow), - ) - }); - - if verbose || inner_result.is_err() { - let mut sink = stderr(); - std::io::Write::write_all(&mut sink, &out)?; - std::io::Write::write_all(&mut sink, &err)?; - } - - inner_result.map_err(|e| anyhow::anyhow!("Fetch failed: {e}")) -} diff --git a/src/lib.rs b/src/lib.rs index cfcd8fcd..5f6aef84 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -16,9 +16,9 @@ pub mod options; pub mod shells; pub mod utilities; -/// Gitoxide-based submodule management implementation +/// Submodule reconciliation and lifecycle management pub mod git_manager; -/// Git operations layer with gix-first, git2-fallback strategy +/// Native Git mutations with gix and git2 repository reads pub mod git_ops; pub use config::{ diff --git a/src/long_abouts.rs b/src/long_abouts.rs index e298112b..47f7c9b0 100644 --- a/src/long_abouts.rs +++ b/src/long_abouts.rs @@ -4,7 +4,8 @@ //! A series of multiline strings for long-about text. We put them here to keep the command module somewhat readable. pub const COMPLETE_ME: &str = r#" -Generates shell completion script for the specified shell. +Generates a shell completion script from the current command model on stdout. +Save the output and load it using your shell's completion configuration. Supported shells: - `bash`: Bourne Again SHell @@ -15,7 +16,7 @@ Supported shells: - `nu` | `nushell`: Nushell Usage: - submod completeme [shell] >> /path/to/completion_script + submod completeme > /path/to/completion_script Examples for common shells and script locations: - Bash: `submod completeme bash > ~/.bash_completion.d/submod` or `submod completeme bash > ~/.config/bash_completion/submod` @@ -28,5 +29,5 @@ Usage: - Zsh: `submod completeme zsh > ~/.zsh/completions/_submod` or `submod completeme zsh > ~/.zfunc/_submod` - - Nushell: `submod completeme nu > "$NUSHELL_CONFIG_DIR/scripts/completions/submod.nu" && echo 'use completions/submod.nu' >> "$NU_CONFIG_PATH"` + - Nushell: `submod completeme nu` (save stdout as submod.nu and load it from your Nushell configuration) "#; diff --git a/src/main.rs b/src/main.rs index 3a9cd51b..98be60c4 100644 --- a/src/main.rs +++ b/src/main.rs @@ -23,27 +23,90 @@ and syncing submodules with features like sparse checkout. Exits with an error if any operation fails. "] mod commands; -mod config; -mod git_manager; -mod git_ops; mod long_abouts; -mod options; -mod shells; -mod utilities; use crate::commands::{Cli, Commands}; -use crate::git_manager::GitManager; -use crate::options::SerializableBranch as Branch; -use crate::utilities::{get_name, get_sparse_paths, set_path}; -use anyhow::Result; -use clap::Parser; +use clap::{CommandFactory, FromArgMatches, error::ErrorKind, parser::ValueSource}; use clap_complete::generate; +use submod::git_manager::{GitManager, SubmoduleError}; +use submod::options::SerializableBranch as Branch; +use submod::utilities::{get_name, get_sparse_paths, safe_human_text, set_path}; + +struct AppError { + code: u8, + message: String, + structured: bool, +} + +impl AppError { + fn validation(message: impl Into) -> Self { + Self { + code: 2, + message: message.into(), + structured: false, + } + } + + fn operation(context: &str, error: SubmoduleError) -> Self { + let code = error.exit_code(); + if let SubmoduleError::IncompleteBatch { summary, cause } = error { + Self { + code, + message: format!("{context}:\n{summary}Cause: {}", safe_human_text(&cause)), + structured: true, + } + } else { + Self { + code, + message: format!("{context}: {error}"), + structured: false, + } + } + } +} + #[cfg_attr(coverage_nightly, coverage(off))] -fn main() -> Result<()> { - let cli = Cli::parse(); +fn main() -> std::process::ExitCode { + match run() { + Ok(()) => std::process::ExitCode::SUCCESS, + Err(error) => { + if error.structured { + eprintln!("{}", error.message); + } else { + eprintln!("{}", safe_human_text(&error.message)); + } + std::process::ExitCode::from(error.code) + } + } +} + +fn run() -> Result<(), AppError> { + let matches = match Cli::command().try_get_matches() { + Ok(matches) => matches, + Err(error) + if matches!( + error.kind(), + ErrorKind::DisplayHelp | ErrorKind::DisplayVersion + ) => + { + error.print().map_err(|print_error| AppError { + code: 1, + message: format!("Failed to print command help: {print_error}"), + structured: false, + })?; + return Ok(()); + } + Err(error) => return Err(AppError::validation(error.to_string())), + }; + let config_explicit = matches.value_source("config") == Some(ValueSource::CommandLine); + let cli = + Cli::from_arg_matches(&matches).map_err(|error| AppError::validation(error.to_string()))?; + cli.validate() + .map_err(|error| AppError::validation(error.to_string()))?; // config-path is always set because it has a default value, "submod.toml" let config_path = cli.config.clone(); let verbose = cli.verbose; + let dry_run = cli.dry_run; match cli.command { Commands::Add { @@ -59,30 +122,46 @@ fn main() -> Result<()> { shallow, no_init, } => { - // Validate sparse paths for null bytes let sparse_paths_vec = get_sparse_paths(sparse_paths) - .map_err(|e| anyhow::anyhow!("Invalid sparse paths: {e}"))?; + .map_err(|e| AppError::validation(format!("Invalid sparse paths: {e}")))?; let set_name = get_name(name, Some(url.clone()), path.clone()) - .map_err(|e| anyhow::anyhow!("Failed to get submodule name: {e}"))?; + .map_err(|e| AppError::validation(format!("Invalid submodule name: {e}")))?; let set_path = path.map_or_else(|| set_name.clone(), set_path); let set_url = url.trim().to_string(); - let set_branch = Branch::set_branch(branch) - .map_err(|e| anyhow::anyhow!("Failed to set branch: {e}"))?; + let set_branch = branch + .map(|branch| Branch::set_branch(Some(branch))) + .transpose() + .map_err(|e| AppError::validation(format!("Invalid branch: {e}")))?; - let mut manager = GitManager::with_verbose(config_path, verbose) - .map_err(|e| anyhow::anyhow!("Failed to create manager: {e}"))?; + let mut manager = + GitManager::with_verbose_config(config_path, verbose, config_explicit) + .map_err(|e| AppError::operation("Cannot prepare add", e))?; - manager - .add_submodule( + let result = if dry_run { + manager.preview_add_submodule( + set_name, + set_path, + set_url, + sparse_paths_vec, + set_branch, + ignore, + fetch, + update, + Some(shallow), + no_init, + use_git_default_sparse_checkout, + ) + } else { + manager.add_submodule( set_name, set_path, set_url, sparse_paths_vec, - Some(set_branch), + set_branch, ignore, fetch, update, @@ -90,131 +169,91 @@ fn main() -> Result<()> { no_init, use_git_default_sparse_checkout, ) - .map_err(|e| anyhow::anyhow!("Failed to add submodule: {e}"))?; + }; + result.map_err(|e| AppError::operation("Add failed", e))?; } Commands::Check => { - let manager = GitManager::with_verbose(config_path, verbose) - .map_err(|e| anyhow::anyhow!("Failed to create manager: {e}"))?; + let manager = GitManager::with_verbose_config(config_path, verbose, config_explicit) + .map_err(|e| AppError::operation("Cannot prepare check", e))?; + manager + .require_config() + .map_err(|e| AppError::operation("Check failed", e))?; manager .check_all_submodules() - .map_err(|e| anyhow::anyhow!("Failed to check submodules: {e}"))?; + .map_err(|e| AppError::operation("Check failed", e))?; } - Commands::Init => { - let mut manager = GitManager::with_verbose(config_path, verbose) - .map_err(|e| anyhow::anyhow!("Failed to create manager: {e}"))?; - - // Collect names first to avoid borrow conflict - let names: Vec = manager - .config() - .get_submodules() - .map(|(n, _)| n.clone()) - .collect(); - for name in &names { - manager - .init_submodule(name) - .map_err(|e| anyhow::anyhow!("Failed to init submodule {name}: {e}"))?; + Commands::Init { recursive } => { + let mut manager = + GitManager::with_verbose_config(config_path, verbose, config_explicit) + .map_err(|e| AppError::operation("Cannot prepare initialization", e))?; + manager + .require_config() + .map_err(|e| AppError::operation("Initialization failed", e))?; + let summary = if dry_run { + manager.preview_init_all_submodules(recursive) + } else { + manager.init_all_submodules(recursive) } + .map_err(|e| AppError::operation("Initialization failed", e))?; + print!("{summary}"); } - Commands::Update => { - let mut manager = GitManager::with_verbose(config_path, verbose) - .map_err(|e| anyhow::anyhow!("Failed to create manager: {e}"))?; - - // Collect names first to avoid borrow conflict - let names: Vec = manager - .config() - .get_submodules() - .map(|(n, _)| n.clone()) - .collect(); - if names.is_empty() { - println!("No submodules configured"); + Commands::Update { remote, recursive } => { + let mut manager = + GitManager::with_verbose_config(config_path, verbose, config_explicit) + .map_err(|e| AppError::operation("Cannot prepare update", e))?; + manager + .require_config() + .map_err(|e| AppError::operation("Update failed", e))?; + let summary = if dry_run { + manager.preview_update_all_submodules(remote, recursive) } else { - let count = names.len(); - for name in &names { - manager - .update_submodule(name) - .map_err(|e| anyhow::anyhow!("Failed to update submodule {name}: {e}"))?; - } - println!("Updated {count} submodule(s)"); + manager.update_all_submodules(remote, recursive) } + .map_err(|e| AppError::operation("Update failed", e))?; + print!("{summary}"); } Commands::Reset { all, names } => { - let manager = GitManager::with_verbose(config_path, verbose) - .map_err(|e| anyhow::anyhow!("Failed to create manager: {e}"))?; - - let submodules_to_reset: Vec = if all { - manager - .config() - .get_submodules() - .map(|(name, _)| name.clone()) - .collect() + let mut manager = + GitManager::with_verbose_config(config_path, verbose, config_explicit) + .map_err(|e| AppError::operation("Cannot prepare reset", e))?; + manager + .require_config() + .map_err(|e| AppError::operation("Reset failed", e))?; + let result = if dry_run { + manager.preview_reset_submodules(all, names) } else { - names + manager.reset_submodules(all, names) }; - - if submodules_to_reset.is_empty() { - return Err(anyhow::anyhow!( - "No submodules specified for reset. Use --all to reset all submodules or specify submodule names." - )); - } - - for name in submodules_to_reset { - manager - .reset_submodule(&name) - .map_err(|e| anyhow::anyhow!("Failed to reset submodule {name}: {e}"))?; - } + result.map_err(|e| AppError::operation("Reset failed", e))?; } - Commands::Sync => { - let mut manager = GitManager::with_verbose(config_path, verbose) - .map_err(|e| anyhow::anyhow!("Failed to create manager: {e}"))?; - - let start = std::time::Instant::now(); - - // Collect names first to avoid borrow conflict - let names: Vec = manager - .config() - .get_submodules() - .map(|(n, _)| n.clone()) - .collect(); - - if names.is_empty() { - println!("No submodules configured"); - return Ok(()); - } - - let name_list = names.join(", "); - if verbose { - println!("🔄 Running full sync: check, init, update"); - } else { - println!("Syncing submodules: {name_list}"); - } - - // Run check, init, and update in sequence + Commands::Sync { recursive } => { + let mut manager = + GitManager::with_verbose_config(config_path, verbose, config_explicit) + .map_err(|e| AppError::operation("Cannot prepare sync", e))?; manager - .check_all_submodules() - .map_err(|e| anyhow::anyhow!("Failed to check submodules: {e}"))?; - - for name in &names { - manager - .init_submodule(name) - .map_err(|e| anyhow::anyhow!("Failed to init submodule {name}: {e}"))?; - } - - for name in &names { - manager - .update_submodule(name) - .map_err(|e| anyhow::anyhow!("Failed to update submodule {name}: {e}"))?; + .require_config() + .map_err(|e| AppError::operation("Sync failed", e))?; + if dry_run { + let summary = manager + .preview_sync_all_submodules(recursive) + .map_err(|e| AppError::operation("Sync preview failed", e))?; + print!("{summary}"); + } else { + eprintln!("Reconciling configured submodules..."); + let summary = manager + .sync_all_submodules(recursive) + .map_err(|e| AppError::operation("Sync failed", e))?; + print!("{summary}"); } - - let elapsed = start.elapsed(); - println!("✅ Sync complete ({:.1}s)", elapsed.as_secs_f64()); } - // TODO: Implement missing commands Commands::Change { name, path, branch, sparse_paths, append, + clear_sparse_paths, + unset, use_git_default_sparse_checkout, ignore, fetch, @@ -223,10 +262,18 @@ fn main() -> Result<()> { url, active, } => { - let mut manager = GitManager::with_verbose(config_path, verbose) - .map_err(|e| anyhow::anyhow!("Failed to create manager: {e}"))?; + let mut manager = + GitManager::with_verbose_config(config_path, verbose, config_explicit) + .map_err(|e| AppError::operation("Cannot prepare change", e))?; manager - .change_submodule( + .require_config() + .map_err(|e| AppError::operation("Change failed", e))?; + let unset = unset + .iter() + .map(|setting| setting.as_str()) + .collect::>(); + let result = if dry_run { + manager.preview_change_submodule( &name, path, branch, @@ -235,45 +282,113 @@ fn main() -> Result<()> { ignore, fetch, update, - Some(shallow), + shallow, url, active, use_git_default_sparse_checkout, + &unset, + clear_sparse_paths, ) - .map_err(|e| anyhow::anyhow!("Failed to change submodule: {e}"))?; + } else { + manager.change_submodule( + &name, + path, + branch, + sparse_paths, + append, + ignore, + fetch, + update, + shallow, + url, + active, + use_git_default_sparse_checkout, + &unset, + clear_sparse_paths, + ) + }; + result.map_err(|e| AppError::operation("Change failed", e))?; } Commands::ChangeGlobal { + unset, + branch, ignore, fetch, update, use_git_default_sparse_checkout, } => { - let mut manager = GitManager::with_verbose(config_path, verbose) - .map_err(|e| anyhow::anyhow!("Failed to create manager: {e}"))?; + let branch = branch + .map(|value| Branch::set_branch(Some(value))) + .transpose() + .map_err(|error| AppError::validation(format!("Invalid global branch: {error}")))?; + let mut manager = + GitManager::with_verbose_config(config_path, verbose, config_explicit) + .map_err(|e| AppError::operation("Cannot prepare global change", e))?; manager - .update_global_defaults(ignore, fetch, update, use_git_default_sparse_checkout) - .map_err(|e| anyhow::anyhow!("Failed to update global settings: {e}"))?; + .require_config() + .map_err(|e| AppError::operation("Global change failed", e))?; + let unset = unset + .iter() + .map(|setting| setting.as_str()) + .collect::>(); + let result = if dry_run { + manager.preview_global_defaults( + branch, + ignore, + fetch, + update, + use_git_default_sparse_checkout, + &unset, + ) + } else { + manager.update_global_defaults( + branch, + ignore, + fetch, + update, + use_git_default_sparse_checkout, + &unset, + ) + }; + result.map_err(|e| AppError::operation("Global change failed", e))?; } Commands::List { recursive } => { - let manager = GitManager::with_verbose(config_path, verbose) - .map_err(|e| anyhow::anyhow!("Failed to create manager: {e}"))?; + let manager = GitManager::with_verbose_config(config_path, verbose, config_explicit) + .map_err(|e| AppError::operation("Cannot prepare list", e))?; + manager + .require_config() + .map_err(|e| AppError::operation("List failed", e))?; manager .list_submodules(recursive) - .map_err(|e| anyhow::anyhow!("Failed to list submodules: {e}"))?; + .map_err(|e| AppError::operation("List failed", e))?; } - Commands::Delete { name } => { - let mut manager = GitManager::with_verbose(config_path, verbose) - .map_err(|e| anyhow::anyhow!("Failed to create manager: {e}"))?; + Commands::Delete { name, force } => { + let mut manager = + GitManager::with_verbose_config(config_path, verbose, config_explicit) + .map_err(|e| AppError::operation("Cannot prepare delete", e))?; manager - .delete_submodule_by_name(&name) - .map_err(|e| anyhow::anyhow!("Failed to delete submodule: {e}"))?; + .require_config() + .map_err(|e| AppError::operation("Delete failed", e))?; + let result = if dry_run { + manager.preview_delete_submodule_by_name(&name, force) + } else { + manager.delete_submodule_by_name(&name, force) + }; + result.map_err(|e| AppError::operation("Delete failed", e))?; } Commands::Disable { name } => { - let mut manager = GitManager::with_verbose(config_path, verbose) - .map_err(|e| anyhow::anyhow!("Failed to create manager: {e}"))?; + let mut manager = + GitManager::with_verbose_config(config_path, verbose, config_explicit) + .map_err(|e| AppError::operation("Cannot prepare disable", e))?; manager - .disable_submodule(&name) - .map_err(|e| anyhow::anyhow!("Failed to disable submodule: {e}"))?; + .require_config() + .map_err(|e| AppError::operation("Disable failed", e))?; + let result = if dry_run { + manager.preview_disable_submodule(&name) + } else { + manager.disable_submodule(&name) + }; + result.map_err(|e| AppError::operation("Disable failed", e))?; } Commands::GenerateConfig { output, @@ -281,15 +396,31 @@ fn main() -> Result<()> { force, template, } => { - GitManager::generate_config(&output, from_setup.is_some(), template, force) - .map_err(|e| anyhow::anyhow!("Failed to generate config: {e}"))?; + let result = if dry_run { + GitManager::preview_generate_config(&output, from_setup, template, force) + } else { + GitManager::generate_config(&output, from_setup, template, force) + }; + result.map_err(|e| AppError::operation("Config generation failed", e))?; } - Commands::NukeItFromOrbit { all, names, kill } => { - let mut manager = GitManager::with_verbose(config_path, verbose) - .map_err(|e| anyhow::anyhow!("Failed to create manager: {e}"))?; + Commands::NukeItFromOrbit { + all, + names, + kill, + force, + } => { + let mut manager = + GitManager::with_verbose_config(config_path, verbose, config_explicit) + .map_err(|e| AppError::operation("Cannot prepare rebuild", e))?; manager - .nuke_submodules(all, names, kill) - .map_err(|e| anyhow::anyhow!("Failed to nuke submodules: {e}"))?; + .require_config() + .map_err(|e| AppError::operation("Rebuild failed", e))?; + let result = if dry_run { + manager.preview_nuke_submodules(all, names, kill, force) + } else { + manager.nuke_submodules(all, names, kill, force) + }; + result.map_err(|e| AppError::operation("Rebuild failed", e))?; } Commands::CompleteMe { shell } => { let mut cmd = ::command(); diff --git a/src/options.rs b/src/options.rs index cc39ce3f..d48591b0 100644 --- a/src/options.rs +++ b/src/options.rs @@ -281,8 +281,10 @@ pub enum SerializableFetchRecurse { #[default] OnDemand, /// Fetch all populated submodules, regardless of changes. In some cases, this can be faster because we don't have to check for changes; but more fetches can also mean more data transfer. + #[serde(alias = "true")] Always, /// Submodules are never fetched. This is useful if you want to manage submodules manually or if you don't want to fetch them at all. + #[serde(alias = "false")] Never, /// Used as a sentinel value internally; do not use in a submod.toml or submod CLI command. #[serde(skip)] @@ -414,7 +416,7 @@ pub enum SerializableBranch { impl Serialize for SerializableBranch { fn serialize(&self, serializer: S) -> Result { - serializer.serialize_str(&self.to_gitmodules()) + serializer.serialize_str(&self.as_config_value()) } } @@ -426,12 +428,6 @@ impl<'de> Deserialize<'de> for SerializableBranch { /// Empty or whitespace-only strings are rejected with a deserialization error. fn deserialize>(deserializer: D) -> Result { let s = String::deserialize(deserializer)?; - // Backward compatibility: accept the previously-documented alias - // "current-in-superproject" in addition to the spellings handled - // by `from_gitmodules`. - if s == "current-in-superproject" { - return Ok(Self::CurrentInSuperproject); - } Self::from_gitmodules(&s).map_err(|()| { serde::de::Error::custom(format!( "invalid branch value: {s:?}; expected \".\", \"current\", \"current-in-super-project\", \"superproject\", \"super\", or a non-empty, non-whitespace branch name" @@ -441,6 +437,46 @@ impl<'de> Deserialize<'de> for SerializableBranch { } impl SerializableBranch { + /// TOML spelling escapes literal branch names that coincide with user aliases. + pub fn as_config_value(&self) -> String { + match self { + Self::Name(name) + if matches!( + name.as_str(), + "current" + | "current-in-super-project" + | "current-in-superproject" + | "superproject" + | "super" + ) => + { + format!("refs/heads/{name}") + } + Self::Name(name) => name.clone(), + Self::CurrentInSuperproject => ".".into(), + } + } + + /// Import Git's branch setting: only `.` is special; aliases are literal names. + pub fn from_git_branch(value: &str) -> Result { + if value == "." { + return Ok(Self::CurrentInSuperproject); + } + let name = value.trim(); + if name.is_empty() || name.starts_with('-') { + return Err(()); + } + let reference = if name.starts_with("refs/heads/") { + name.to_string() + } else { + format!("refs/heads/{name}") + }; + if name != "HEAD" && !git2::Reference::is_valid_name(&reference) { + return Err(()); + } + Ok(Self::Name(name.to_string())) + } + /// Get the current branch name from the superproject repository. pub fn current_in_superproject() -> Result { get_current_repository() @@ -462,25 +498,24 @@ impl GitmodulesConvert for SerializableBranch { fn to_gitmodules(&self) -> String { match self { Self::CurrentInSuperproject => ".".to_string(), - Self::Name(name) => name.clone(), + Self::Name(name) => name.strip_prefix("refs/heads/").unwrap_or(name).to_string(), } } /// Convert from gitmodules string (what you would get from the .gitmodules or .git/config) fn from_gitmodules(options: &str) -> Result { - if options == "." - || options == "current" - || options == "current-in-super-project" - || options == "superproject" - || options == "super" - { + let value = options.trim(); + if matches!( + value, + "." | "current" + | "current-in-super-project" + | "current-in-superproject" + | "superproject" + | "super" + ) { return Ok(Self::CurrentInSuperproject); } - let trimmed = options.trim(); - if trimmed.is_empty() { - return Err(()); - } - Ok(Self::Name(trimmed.to_string())) + Self::from_git_branch(value) } /// Convert from gitmodules bytes (what you would get from the .gitmodules or .git/config) @@ -496,7 +531,7 @@ impl TryFrom for SerializableBranch { fn try_from(value: Branch) -> Result { Ok(match value { Branch::CurrentInSuperproject => Self::CurrentInSuperproject, - Branch::Name(name) => Self::Name(name.to_string()), + Branch::Name(name) => Self::from_git_branch(&name.to_string())?, _ => return Err(()), // Handle unsupported variants }) } @@ -508,7 +543,12 @@ impl TryFrom for Branch { fn try_from(value: SerializableBranch) -> Result { Ok(match value { SerializableBranch::CurrentInSuperproject => Self::CurrentInSuperproject, - SerializableBranch::Name(name) => Self::Name(name.as_bytes().into()), + SerializableBranch::Name(name) => Self::Name( + name.strip_prefix("refs/heads/") + .unwrap_or(&name) + .as_bytes() + .into(), + ), _ => return Err(()), // Handle unsupported variants }) } @@ -516,10 +556,7 @@ impl TryFrom for Branch { impl std::fmt::Display for SerializableBranch { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Self::CurrentInSuperproject => write!(f, "."), - Self::Name(name) => write!(f, "{name}"), - } + f.write_str(&self.as_config_value()) } } @@ -527,15 +564,7 @@ impl FromStr for SerializableBranch { type Err = (); fn from_str(s: &str) -> Result { - if s == "." - || s == "current" - || s == "current-in-super-project" - || s == "superproject" - || s == "super" - { - return Ok(Self::CurrentInSuperproject); - } - Ok(Self::Name(s.to_string())) + Self::from_gitmodules(s) } } @@ -548,18 +577,10 @@ impl Default for SerializableBranch { #[allow(dead_code)] impl SerializableBranch { - /// Parse an optional branch string into a `SerializableBranch`, defaulting to the repo's current branch. + /// Parse an explicitly supplied branch; callers retain `None` for omission. pub fn set_branch(branch: Option) -> Result { - branch.map_or_else( - || Ok(Self::default()), - |b| { - if b.is_empty() { - Ok(Self::default()) - } else { - Self::from_str(b.trim()).map_err(|()| anyhow::anyhow!("Invalid branch string")) - } - }, - ) + let value = branch.ok_or_else(|| anyhow::anyhow!("Branch is absent; retain None"))?; + Self::from_str(&value).map_err(|()| anyhow::anyhow!("Invalid branch string: {value:?}")) } } @@ -568,7 +589,7 @@ impl GixGit2Convert for SerializableBranch { type GixType = gix_submodule::config::Branch; /// Convert from a `git2` type to a `gix_submodule` type fn from_git2(git2: Self::Git2Type) -> Result { - Self::from_gitmodules(git2.as_str()) // Handle unsupported variants + Self::from_git_branch(git2.as_str()) // Handle unsupported variants } /// Convert from a `gix_submodule` type to a `submod` type @@ -727,6 +748,98 @@ impl GixGit2Convert for SerializableUpdate { mod tests { use super::*; + #[test] + fn literal_alias_uses_config_escape_and_native_git_branch() { + for name in [ + "current", + "super", + "superproject", + "current-in-superproject", + "current-in-super-project", + ] { + let imported = SerializableBranch::from_git_branch(name).unwrap(); + let escaped = SerializableBranch::from_str(&format!("refs/heads/{name}")).unwrap(); + for branch in [imported, escaped] { + assert_eq!(branch.as_config_value(), format!("refs/heads/{name}")); + assert_eq!(branch.to_gitmodules(), name); + let native: Branch = branch.try_into().unwrap(); + assert!(matches!(native, Branch::Name(value) if value.to_string() == name)); + } + } + } + + #[test] + fn literal_git_alias_roundtrips_as_full_ref() { + #[derive(Serialize, Deserialize)] + struct Value { + branch: SerializableBranch, + } + let branch = SerializableBranch::from_git_branch("current").unwrap(); + assert_eq!(branch, SerializableBranch::Name("current".into())); + let serialized = toml::to_string(&Value { branch }).unwrap(); + assert!(serialized.contains("refs/heads/current")); + let decoded: Value = toml::from_str(&serialized).unwrap(); + assert_eq!( + decoded.branch, + SerializableBranch::Name("refs/heads/current".into()) + ); + } + + #[test] + fn branch_parsers_share_validation_and_literal_alias_escape() { + #[derive(Deserialize)] + struct Value { + branch: SerializableBranch, + } + for name in [ + ".", + "current", + "current-in-super-project", + "current-in-superproject", + "superproject", + "super", + "main", + "feature/topic", + "refs/heads/super", + "HEAD", + ] { + let parsed = SerializableBranch::from_str(name).unwrap(); + let serde: Value = toml::from_str(&format!("branch={name:?}")).unwrap(); + assert_eq!(serde.branch, parsed); + assert_eq!(SerializableBranch::from_gitmodules(name).unwrap(), parsed); + assert_eq!( + SerializableBranch::set_branch(Some(name.into())).unwrap(), + parsed + ); + } + assert_eq!( + SerializableBranch::from_str("refs/heads/super").unwrap(), + SerializableBranch::Name("refs/heads/super".into()) + ); + for name in [ + "", + " ", + "feature..topic", + "bad name", + "a@{b", + "-topic", + "topic.lock", + "refs/heads/", + "a\\b", + "a\nb", + ] { + assert!(SerializableBranch::from_str(name).is_err(), "{name:?}"); + assert!( + SerializableBranch::from_gitmodules(name).is_err(), + "{name:?}" + ); + assert!( + toml::from_str::(&format!("branch={name:?}")).is_err(), + "{name:?}" + ); + } + } + #[test] fn test_branch_deserialize_from_toml_rejects_empty_and_whitespace() { // Use a wrapper struct because TOML top-level must be a table; @@ -1234,16 +1347,13 @@ mod tests { } #[test] - fn test_branch_set_branch_empty_returns_default() { - let result = SerializableBranch::set_branch(Some(String::new())).unwrap(); - // Empty string → default - assert_eq!(result, SerializableBranch::default()); + fn test_branch_set_branch_empty_is_rejected() { + assert!(SerializableBranch::set_branch(Some(String::new())).is_err()); } #[test] - fn test_branch_set_branch_none_returns_default() { - let result = SerializableBranch::set_branch(None).unwrap(); - assert_eq!(result, SerializableBranch::default()); + fn test_branch_set_branch_none_is_not_defaulted() { + assert!(SerializableBranch::set_branch(None).is_err()); } #[test] diff --git a/src/shells.rs b/src/shells.rs index 1750207e..484b3905 100644 --- a/src/shells.rs +++ b/src/shells.rs @@ -52,7 +52,7 @@ impl clap::ValueEnum for Shell { Self::Fish => PossibleValue::new("fish"), Self::PowerShell => PossibleValue::new("powershell").alias("pwsh"), Self::Zsh => PossibleValue::new("zsh"), - Self::Nushell => PossibleValue::new("nushell"), + Self::Nushell => PossibleValue::new("nushell").alias("nu"), }) } } @@ -304,6 +304,10 @@ mod tests { #[test] fn test_shell_from_str_alias() { + assert_eq!( + ::from_str("nu").unwrap(), + Shell::Nushell + ); assert_eq!( ::from_str("pwsh").unwrap(), Shell::PowerShell diff --git a/src/utilities.rs b/src/utilities.rs index 8ebfd833..ce6743e3 100644 --- a/src/utilities.rs +++ b/src/utilities.rs @@ -4,10 +4,182 @@ //! Utility functions for working with `Gitoxide` APIs commonly used across the codebase. #![allow(dead_code)] -use anyhow::Result; +use anyhow::{Context as _, Result}; use git2::Repository as Git2Repository; use gix::open::Options; -use std::path::PathBuf; +use std::path::{Component, Path, PathBuf}; + +/// Render untrusted human text without terminal controls or URL credentials. +/// +/// This is a display boundary only: never use it on Git records, stored values, +/// command arguments, or generated completion scripts. Compose intentional line +/// separators after sanitizing fields; embedded newlines and tabs are escaped. +#[must_use] +pub fn safe_human_text(input: &str) -> String { + fn looks_like_host_port(authority: &str) -> bool { + let Some((host, port)) = authority.rsplit_once(':') else { + return false; + }; + !host.is_empty() + && (host == "localhost" + || host.contains('.') + || (host.starts_with('[') && host.ends_with(']')) + || host.parse::().is_ok()) + && port.parse::().is_ok_and(|port| port != 0) + } + + let mut redacted = String::with_capacity(input.len()); + let mut remaining = input; + while let Some(marker) = remaining.find("://") { + let authority_start = marker + 3; + redacted.push_str(&remaining[..authority_start]); + remaining = &remaining[authority_start..]; + let mut authority_end = remaining + .find(|c: char| c.is_whitespace() || matches!(c, '/' | '?' | '#' | '"' | '<' | '>')) + .unwrap_or(remaining.len()); + // Adjacent URLs in diagnostic prose may have no separating whitespace. + if let Some(next_marker) = remaining.find("://") { + let scheme_start = remaining[..next_marker] + .char_indices() + .rev() + .find(|(_, c)| !c.is_ascii_alphanumeric() && !matches!(c, '+' | '-' | '.')) + .map_or(0, |(index, c)| index + c.len_utf8()); + authority_end = authority_end.min(scheme_start); + } + let ordinary_authority_end = authority_end; + let ordinary_authority = &remaining[..ordinary_authority_end]; + let malformed_authority_delimiter = remaining[authority_end..] + .chars() + .next() + .is_some_and(|c| c.is_control() || matches!(c, '"' | '<' | '>')); + // A malformed but accepted display value can put a control, quote, or angle + // bracket inside password text. Look past that delimiter only when the + // ordinary authority already contains the user/password separator. This + // avoids treating a later email address in diagnostic prose as URL userinfo. + if !ordinary_authority.contains('@') + && ordinary_authority.contains(':') + && (!looks_like_host_port(ordinary_authority) || malformed_authority_delimiter) + { + let extended_end = remaining + .find(|c: char| matches!(c, '/' | '?' | '#' | ',' | ')' | ']')) + .unwrap_or(remaining.len()); + if remaining[..extended_end].contains('@') { + authority_end = extended_end; + } + } + let authority = &remaining[..authority_end]; + if let Some(at) = authority.rfind('@') { + redacted.push_str("[redacted]@"); + redacted.push_str(&authority[at + 1..]); + } else { + redacted.push_str(&remaining[..authority_end]); + } + remaining = &remaining[authority_end..]; + } + redacted.push_str(remaining); + let mut safe = String::with_capacity(redacted.len()); + for c in redacted.chars() { + if c.is_control() { + safe.extend(c.escape_default()); + } else { + safe.push(c); + } + } + safe +} + +/// Repository locations resolved by native Git, including linked worktrees. +#[derive(Debug, Clone)] +pub struct RepositoryContext { + /// Canonical directory from which the command was invoked. + pub invocation_dir: PathBuf, + /// Worktree root used for all module checkout paths. + pub worktree_root: PathBuf, + /// Worktree-specific Git metadata directory. + pub git_dir: PathBuf, + /// Shared Git metadata directory, also shared by linked worktrees. + pub common_dir: PathBuf, + /// Default root config or explicit invocation-relative config. + pub config_path: PathBuf, +} + +impl RepositoryContext { + /// Discover a worktree and resolve its config; an explicit config must exist. + pub fn discover(invocation_dir: &Path, explicit_config: Option<&Path>) -> Result { + let invocation_dir = invocation_dir.canonicalize()?; + let worktree_root = git_path(&invocation_dir, &["--show-toplevel"]).map_err(|error| { + anyhow::anyhow!("A non-bare repository worktree is required: {error}") + })?; + let git_dir = git_path(&invocation_dir, &["--absolute-git-dir"])?; + let common_dir = git_path( + &invocation_dir, + &["--path-format=absolute", "--git-common-dir"], + )?; + let config_path = match explicit_config { + Some(path) => { + let path = invocation_dir.join(path); + match std::fs::symlink_metadata(&path) { + Ok(metadata) if !metadata.is_file() && !metadata.file_type().is_symlink() => { + anyhow::bail!("Explicit config path is not a file: {}", path.display()); + } + Ok(_) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + anyhow::bail!("Explicit config file not found: {}", path.display()) + } + Err(error) => return Err(error.into()), + } + path + } + None => worktree_root.join("submod.toml"), + }; + Ok(Self { + invocation_dir, + worktree_root, + git_dir, + common_dir, + config_path, + }) + } +} + +pub(crate) fn git_path(directory: &Path, args: &[&str]) -> Result { + let output = std::process::Command::new("git") + .arg("--no-optional-locks") + .arg("rev-parse") + .args(args) + .current_dir(directory) + .output()?; + if !output.status.success() { + anyhow::bail!( + "git rev-parse {} failed in {}: {}", + args.join(" "), + directory.display(), + String::from_utf8_lossy(&output.stderr) + ); + } + git_path_from_stdout(output.stdout) +} + +/// Decode a single path printed by Git without losing Unix path bytes. +pub(crate) fn git_path_from_stdout(mut bytes: Vec) -> Result { + if bytes.last() == Some(&b'\n') { + bytes.pop(); + } + #[cfg(unix)] + let path = { + use std::os::unix::ffi::OsStringExt; + PathBuf::from(std::ffi::OsString::from_vec(bytes)) + }; + #[cfg(not(unix))] + let path = PathBuf::from(String::from_utf8(bytes)?); + if !path.is_absolute() { + anyhow::bail!( + "Git returned a non-absolute repository path: {}", + path.display() + ); + } + Ok(path) +} /// Get the current repository using git2, with an optional provided repository. If no repository is provided, it will attempt to discover one in the current directory. pub fn get_current_git2_repository( @@ -123,7 +295,10 @@ pub fn path_to_string_lossy(path: &std::path::Path) -> String { return s.to_string(); } let lossy = path.to_string_lossy(); - eprintln!("Warning: Path contains non-UTF-8 characters, using lossy conversion: {lossy}"); + eprintln!( + "Warning: Path contains non-UTF-8 characters, using lossy conversion: {}", + safe_human_text(&lossy) + ); lossy.to_string() } @@ -225,94 +400,56 @@ pub fn get_name( } } -/// Validate a submodule path to ensure it is not absolute and does not escape -/// the repository root via directory traversal (`..`) or symbolic links. -pub fn validate_submodule_path( - repo_root: &std::path::Path, - path: &std::path::Path, -) -> Result<(), anyhow::Error> { - if path.is_absolute() { - return Err(anyhow::anyhow!("Submodule path cannot be absolute")); - } - - let repo_root = repo_root - .canonicalize() - .unwrap_or_else(|_| repo_root.to_path_buf()); - - let mut current = repo_root.clone(); +/// Normalize harmless dots while rejecting root and administrative destinations. +pub fn normalize_submodule_path(path: &Path) -> Result { + let mut normalized = PathBuf::new(); for component in path.components() { match component { - std::path::Component::Normal(c) => { - current.push(c); - // If current exists and is a symlink, read it and check target - if current.is_symlink() { - let target = std::fs::read_link(¤t)?; - let resolved = if target.is_absolute() { - target - } else { - current.parent().unwrap().join(target) - }; - let canonical_resolved = resolved - .canonicalize() - .unwrap_or_else(|_| normalize_path_only(&resolved)); - if !canonical_resolved.starts_with(&repo_root) { - return Err(anyhow::anyhow!( - "Submodule path escapes repository root via symlink: {}", - canonical_resolved.display() - )); - } - current = canonical_resolved; - } - } - std::path::Component::ParentDir => { - current.pop(); - if !current.starts_with(&repo_root) { - return Err(anyhow::anyhow!("Submodule path escapes repository root")); + Component::Normal(name) => { + let git_alias = name.to_str().is_some_and(|name| { + name.trim_end_matches(['.', ' ']) + .eq_ignore_ascii_case(".git") + }); + if name.as_encoded_bytes().eq_ignore_ascii_case(b".git") || git_alias { + anyhow::bail!("Submodule path cannot contain Git administrative components"); } + normalized.push(name); } - std::path::Component::CurDir => {} - std::path::Component::Prefix(_) | std::path::Component::RootDir => { - return Err(anyhow::anyhow!( - "Submodule path cannot contain root or prefix components" - )); + Component::CurDir => {} + Component::ParentDir => anyhow::bail!("Submodule path cannot contain '..' components"), + Component::RootDir | Component::Prefix(_) => { + anyhow::bail!("Submodule path cannot contain root or prefix components") } } } - - // Also check the final resolved path - let final_canonical = current - .canonicalize() - .unwrap_or_else(|_| normalize_path_only(¤t)); - if !final_canonical.starts_with(&repo_root) { - return Err(anyhow::anyhow!( - "Submodule path resolves outside repository root" - )); + if normalized.as_os_str().is_empty() { + anyhow::bail!("Submodule path must be a nonempty strict descendant of the repository root"); } - - Ok(()) + Ok(normalized) } -fn normalize_path_only(path: &std::path::Path) -> std::path::PathBuf { - use std::path::{Component, PathBuf}; - let mut normalized = PathBuf::new(); - for component in path.components() { - match component { - Component::ParentDir => { - normalized.pop(); - } - Component::CurDir => {} - Component::Normal(c) => { - normalized.push(c); - } - Component::RootDir => { - normalized.push(Component::RootDir); +/// Validate a mutation destination without following any symlink components. +pub fn validate_submodule_path(repo_root: &Path, path: &Path) -> Result<()> { + let normalized = normalize_submodule_path(path)?; + let mut current = repo_root + .canonicalize() + .with_context(|| format!("Could not inspect repository root {}", repo_root.display()))?; + for component in normalized.components() { + current.push(component); + match std::fs::symlink_metadata(¤t) { + Ok(metadata) if metadata.file_type().is_symlink() => { + anyhow::bail!("Submodule path contains a symlink: {}", current.display()); } - Component::Prefix(p) => { - normalized.push(Component::Prefix(p)); + Ok(_) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => { + return Err(error).with_context(|| { + format!("Could not inspect submodule path {}", current.display()) + }); } } } - normalized + Ok(()) } #[cfg(test)] @@ -320,6 +457,132 @@ fn normalize_path_only(path: &std::path::Path) -> std::path::PathBuf { mod tests { use super::*; + fn checked_git(directory: &Path, args: &[&str]) { + let output = std::process::Command::new("git") + .args(["-c", "commit.gpgsign=false"]) + .args(args) + .current_dir(directory) + .output() + .unwrap(); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + } + + #[test] + fn repository_context_nested_and_linked_worktrees() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().canonicalize().unwrap(); + checked_git(&root, &["init", "main"]); + let main = root.join("main"); + checked_git( + &main, + &[ + "-c", + "user.name=Fixture", + "-c", + "user.email=fixture@example.invalid", + "commit", + "--allow-empty", + "-m", + "initial", + ], + ); + checked_git(&main, &["worktree", "add", "-b", "linked", "../linked"]); + for name in ["main", "linked"] { + let worktree = root.join(name); + let nested = worktree.join("nested/deeper"); + std::fs::create_dir_all(&nested).unwrap(); + let context = RepositoryContext::discover(&nested, None).unwrap(); + assert_eq!(context.invocation_dir, nested); + assert_eq!(context.worktree_root, worktree); + assert_eq!(context.common_dir, main.join(".git")); + assert_eq!( + context.git_dir, + if name == "main" { + main.join(".git") + } else { + main.join(".git/worktrees/linked") + } + ); + assert_eq!(context.config_path, worktree.join("submod.toml")); + std::fs::write(nested.join("custom.toml"), "").unwrap(); + let explicit = + RepositoryContext::discover(&nested, Some(Path::new("custom.toml"))).unwrap(); + assert_eq!(explicit.config_path, nested.join("custom.toml")); + assert!(RepositoryContext::discover(&nested, Some(Path::new("missing.toml"))).is_err()); + } + assert!(RepositoryContext::discover(&root, None).is_err()); + checked_git(&root, &["init", "--bare", "bare"]); + let error = RepositoryContext::discover(&root.join("bare"), None).unwrap_err(); + assert!(error.to_string().contains("non-bare repository worktree")); + } + + #[test] + #[cfg(unix)] + fn repository_context_preserves_newline_paths() { + use std::os::unix::ffi::OsStringExt; + let temp = tempfile::tempdir().unwrap(); + let root = temp + .path() + .canonicalize() + .unwrap() + .join(std::ffi::OsString::from_vec(b"repo-\n".to_vec())); + std::fs::create_dir(&root).unwrap(); + checked_git(&root, &["init"]); + let context = RepositoryContext::discover(&root, None).unwrap(); + assert_eq!(context.worktree_root, root); + assert_eq!(context.git_dir, root.join(".git")); + assert_eq!(context.common_dir, root.join(".git")); + let child = PathBuf::from(std::ffi::OsString::from_vec(b"child-\xff".to_vec())); + assert_eq!(normalize_submodule_path(&child).unwrap(), child); + assert_eq!( + normalize_submodule_path(Path::new("./vendor/./child")).unwrap(), + Path::new("vendor/child") + ); + } + + #[test] + fn mutation_paths_reject_root_admin_and_parent_components() { + let root = tempfile::tempdir().unwrap(); + for path in [ + "", + ".", + "././", + ".git", + "vendor/.GiT/objects", + "vendor/../child", + "../child", + ] { + assert!( + validate_submodule_path(root.path(), std::path::Path::new(path)).is_err(), + "accepted {path:?}" + ); + } + assert!(validate_submodule_path(root.path(), &root.path().join("child")).is_err()); + assert!( + validate_submodule_path(root.path(), std::path::Path::new("./vendor/./new-child")) + .is_ok() + ); + } + + #[test] + #[cfg(unix)] + fn mutation_paths_reject_all_symlink_components() { + let root = tempfile::tempdir().unwrap(); + std::fs::create_dir(root.path().join("real")).unwrap(); + std::os::unix::fs::symlink("real", root.path().join("alias")).unwrap(); + std::os::unix::fs::symlink("missing", root.path().join("dangling")).unwrap(); + for path in ["alias", "alias/child", "dangling/child"] { + assert!( + validate_submodule_path(root.path(), std::path::Path::new(path)).is_err(), + "accepted {path}" + ); + } + } + #[test] fn test_get_name_valid_name() { assert_eq!( @@ -560,3 +823,62 @@ mod tests { assert_eq!(result, format!("a{}b", std::char::REPLACEMENT_CHARACTER)); } } + +#[cfg(test)] +mod human_output_tests { + use super::safe_human_text; + + #[test] + fn redacts_all_url_userinfo_before_escaping_controls() { + assert_eq!( + safe_human_text( + "clone 'https://USER_A:PASS_A@example.invalid/a', then (file://USER_B:P%40SS_B@localhost/b): failed\r\n" + ), + "clone 'https://[redacted]@example.invalid/a', then (file://[redacted]@localhost/b): failed\\r\\n" + ); + assert_eq!( + safe_human_text("https://u:p@host,https://x:y@other"), + "https://[redacted]@host,https://[redacted]@other" + ); + assert_eq!( + safe_human_text("https://u:p'ass@host/éhttps://x:y@other"), + "https://[redacted]@host/éhttps://[redacted]@other" + ); + assert_eq!( + safe_human_text("ssh://token@host/a https://u:p@ss@host/b"), + "ssh://[redacted]@host/a https://[redacted]@host/b" + ); + assert_eq!( + safe_human_text("https://R26_FAKE_USER:PA\tSS@example.invalid/a"), + "https://[redacted]@example.invalid/a" + ); + assert_eq!( + safe_human_text("https://R26_FAKE_USER:PA\"SS@example.invalid/a"), + "https://[redacted]@example.invalid/a" + ); + assert_eq!( + safe_human_text("https://R26_FAKE_USER:PA = ["lib/nuke-a", "lib/nuke-b"] + .into_iter() + .map(|path| { + ( + path, + harness.git_stdout(&["-C", path, "rev-parse", "--absolute-git-dir"]), + harness.git_stdout(&["-C", path, "rev-parse", "HEAD"]), + ) + }) + .collect(); let config_before = harness.read_config().expect("Failed to read config"); assert!(config_before.contains("[nuke-a]")); assert!(config_before.contains("[nuke-b]")); @@ -279,14 +303,31 @@ mod tests { // --all must nuke every submodule, not just the first one it finds. assert!( - stdout.contains("Nuking submodule 'nuke-a'..."), + stdout.contains( + "nuke-a: changed: removed its checkout, Git registration, and TOML declaration;" + ), "Expected nuke progress for nuke-a; got: {stdout}" ); assert!( - stdout.contains("Nuking submodule 'nuke-b'..."), + stdout.contains( + "nuke-b: changed: removed its checkout, Git registration, and TOML declaration;" + ), "Expected nuke progress for nuke-b; got: {stdout}" ); + assert!(stdout.contains("Nuke summary: 2 changed, 0 unchanged, 0 skipped, 0 failed.")); + for (path, gitdir, pin) in retained { + assert!(!harness.file_exists(&format!("{path}/.git"))); + assert!( + harness + .git_stdout(&["ls-files", "--stage", "--", path]) + .is_empty() + ); + assert_eq!( + harness.git_stdout(&["--git-dir", gitdir.trim(), "rev-parse", "HEAD"]), + pin + ); + } let config_after = harness.read_config().expect("Failed to read config"); assert!( !config_after.contains("[nuke-a]"), @@ -316,6 +357,11 @@ mod tests { .run_submod_success(&["add", &url, "--name", "reinit-lib", "--path", "lib/reinit"]) .expect("Failed to add submodule"); + let pin = harness.git_at(&harness.work_dir.join("lib/reinit"), &["rev-parse", "HEAD"]); + let gitlink = harness.git_stdout(&["ls-files", "--stage", "--", "lib/reinit"]); + let tracked = fs::read(harness.work_dir.join("lib/reinit/src/main.c")) + .expect("Failed to read initial tracked content"); + assert!( harness.file_exists("lib/reinit/.git"), "Submodule should exist after add" @@ -326,15 +372,10 @@ mod tests { .run_submod_success(&["nuke-it-from-orbit", "reinit-lib"]) .expect("Failed to nuke-and-reinit"); - // Without --kill both halves must happen; either one alone is the bug this - // test exists to catch, so assert them separately rather than as a disjunction. - assert!( - stdout.contains("Nuking submodule 'reinit-lib'..."), - "Expected nuke progress; got: {stdout}" - ); + assert!(stdout.contains("Nuke summary: 1 changed, 0 unchanged, 0 skipped, 0 failed.")); assert!( - stdout.contains("Reinitializing submodule 'reinit-lib'..."), - "Expected reinit progress; got: {stdout}" + stdout.contains("Reinitialized submodule 'reinit-lib'."), + "Expected verified reinit completion; got: {stdout}" ); // After reinit, submodule should exist again @@ -349,6 +390,26 @@ mod tests { config.contains("[reinit-lib]"), "Config should retain the submodule entry after reinit" ); + assert_eq!( + harness.git_at(&harness.work_dir.join("lib/reinit"), &["rev-parse", "HEAD"]), + pin + ); + assert_eq!( + fs::read(harness.work_dir.join("lib/reinit/src/main.c")).unwrap(), + tracked + ); + assert_eq!( + harness.git_stdout(&["ls-files", "--stage", "--", "lib/reinit"]), + gitlink + ); + assert!( + harness + .git_at( + &harness.work_dir.join("lib/reinit"), + &["status", "--porcelain=v1"] + ) + .is_empty() + ); } #[test] @@ -558,7 +619,7 @@ mod tests { // ========================================================================= #[test] - fn test_change_path_reclones_at_new_location() { + fn test_change_path_moves_and_preserves_repository() { let harness = TestHarness::new().expect("Failed to create test harness"); harness.init_git_repo().expect("Failed to init git repo"); @@ -579,23 +640,52 @@ mod tests { .expect("Failed to add submodule"); assert!(harness.file_exists("lib/original/.git")); + let gitdir = harness.git_stdout(&["-C", "lib/original", "rev-parse", "--absolute-git-dir"]); + let head = harness.git_stdout(&["-C", "lib/original", "rev-parse", "HEAD"]); + harness.git_stdout(&["-C", "lib/original", "branch", "retained-history"]); + let refs = harness.git_stdout(&["-C", "lib/original", "show-ref"]); let stdout = harness .run_submod_success(&["change", "movable-lib", "--path", "lib/moved"]) .expect("Failed to change submodule path"); - // Should confirm the update, naming the submodule it re-added at the new path. assert!( - stdout.contains("Added submodule movable-lib"), + stdout.contains("Updated submodule 'movable-lib'"), "Expected confirmation of path change; got: {stdout}" ); - // New path should be cloned + // Git-aware relocation keeps the repository and all local refs. assert!( harness.file_exists("lib/moved/.git"), "Submodule should exist at new path" ); + assert!(!harness.dir_exists("lib/original")); + assert_eq!( + harness.git_stdout(&["-C", "lib/moved", "rev-parse", "--absolute-git-dir"]), + gitdir + ); + assert_eq!( + harness.git_stdout(&["-C", "lib/moved", "rev-parse", "HEAD"]), + head + ); + assert_eq!(harness.git_stdout(&["-C", "lib/moved", "show-ref"]), refs); + assert_eq!( + harness.git_stdout(&[ + "config", + "-f", + ".gitmodules", + "--get", + "submodule.movable-lib.path" + ]), + "lib/moved" + ); + assert_eq!( + harness.index_gitlink_mode("lib/moved").as_deref(), + Some("160000") + ); + assert_eq!(harness.index_gitlink_mode("lib/original"), None); + // Config should reflect the new path let config = harness.read_config().expect("Failed to read config"); assert!( @@ -1038,23 +1128,17 @@ mod tests { .create_config("# empty\n") .expect("Failed to create config"); - // Default (non-verbose) check with no submodules produces minimal output - let stdout = harness - .run_submod_success(&["check"]) - .expect("Failed to run check"); - assert!( - !stdout.contains("FAIL"), - "check output should not contain errors; got: {stdout}" - ); - - // Verbose check should show the detailed header - let stdout_verbose = harness - .run_submod_success(&["check", "--verbose"]) - .expect("Failed to run check --verbose"); - assert!( - stdout_verbose.contains("Checking submodule configurations"), - "verbose check should say 'Checking submodule configurations'; got: {stdout_verbose}" - ); + let before = harness.preservation_snapshot(); + for args in [vec!["check"], vec!["check", "--verbose"]] { + let output = harness.run_submod(&args).unwrap(); + assert_eq!(output.status.code(), Some(0)); + assert!(output.stderr.is_empty(), "{output:?}"); + assert_eq!( + String::from_utf8(output.stdout).unwrap(), + "Check complete: all configured submodules match.\n" + ); + assert_eq!(harness.preservation_snapshot(), before); + } } #[test] @@ -1073,21 +1157,39 @@ mod tests { .create_config(&config_content) .expect("Failed to create config"); - // Default init succeeds silently - let _stdout = harness + let pin = harness.git_at(&remote, &["rev-parse", "HEAD"]); + let stdout = harness .run_submod_success(&["init"]) .expect("Failed to run init"); - // Verbose init should mention initialization + assert!(stdout.contains("init-contract at lib/ic: changed:")); + assert!(stdout.contains(&format!("(target {pin})"))); + assert!(stdout.contains( + "Initialization summary: 1 changed, 0 unchanged, 0 skipped, 0 failed, 0 pending." + )); + assert_eq!( + harness.git_at(&harness.work_dir.join("lib/ic"), &["rev-parse", "HEAD"]), + pin + ); + assert_eq!( + harness.index_gitlink_mode("lib/ic").as_deref(), + Some("160000") + ); + + // A repeated initialization reports its observed unchanged state. let stdout_verbose = harness .run_submod_success(&["init", "--verbose"]) .expect("Failed to run init --verbose"); // "Initializing" || "initialized" could not distinguish the two states; the // default init above ran first, so this run must report the already-done case. assert!( - stdout_verbose.contains("init-contract already initialized"), - "verbose init output should name the already-initialized submodule; got: {stdout_verbose}" + stdout_verbose.contains("init-contract at lib/ic: unchanged:"), + "verbose init output should name the unchanged submodule; got: {stdout_verbose}" ); + assert!(stdout_verbose.contains(&format!("(target {pin})"))); + assert!(stdout_verbose.contains( + "Initialization summary: 0 changed, 1 unchanged, 0 skipped, 0 failed, 0 pending." + )); } #[test] @@ -1104,22 +1206,44 @@ mod tests { .run_submod_success(&["add", &url, "--name", "reset-contract", "--path", "lib/rc"]) .expect("Failed to add submodule"); + let pin = harness.git_at(&harness.work_dir.join("lib/rc"), &["rev-parse", "HEAD"]); + let gitlink = harness.git_stdout(&["ls-files", "--stage", "--", "lib/rc"]); + let tracked = fs::read(harness.work_dir.join("lib/rc/src/main.c")) + .expect("Failed to read initial tracked content"); + let stdout = harness .run_submod_success(&["reset", "reset-contract"]) .expect("Failed to run reset"); + assert!(stdout.contains("Reset summary: 1 changed, 0 unchanged, 0 skipped, 0 failed.")); assert!( - stdout.contains("Hard resetting"), - "reset output should say 'Hard resetting'; got: {stdout}" - ); - assert!( - stdout.contains("reset complete"), - "reset output should say 'reset complete'; got: {stdout}" + stdout.contains(&format!("reset-contract reset to {pin}")), + "reset output should report the verified parent pin; got: {stdout}" ); assert!( stdout.contains("reset-contract"), "reset output should name the submodule; got: {stdout}" ); + assert_eq!( + harness.git_at(&harness.work_dir.join("lib/rc"), &["rev-parse", "HEAD"]), + pin + ); + assert_eq!( + fs::read(harness.work_dir.join("lib/rc/src/main.c")).unwrap(), + tracked + ); + assert_eq!( + harness.git_stdout(&["ls-files", "--stage", "--", "lib/rc"]), + gitlink + ); + assert!( + harness + .git_at( + &harness.work_dir.join("lib/rc"), + &["status", "--porcelain=v1"] + ) + .is_empty() + ); } #[test] @@ -1142,22 +1266,41 @@ mod tests { let stdout = harness .run_submod_success(&["sync"]) .expect("Failed to run sync"); + let pin = harness.git_at(&remote, &["rev-parse", "HEAD"]); + assert!(stdout.contains("sync-contract at lib/sc: changed:")); + assert!(stdout.contains(&format!("(target {pin})"))); assert!( - stdout.contains("Syncing submodules:"), - "sync output should say 'Syncing submodules:'; got: {stdout}" - ); - assert!( - stdout.contains("Sync complete"), - "sync output should say 'Sync complete'; got: {stdout}" + stdout + .contains("Sync summary: 1 changed, 0 unchanged, 0 skipped, 0 failed, 0 pending.") ); // Verbose sync shows detailed output let stdout_verbose = harness .run_submod_success(&["sync", "--verbose"]) .expect("Failed to run sync --verbose"); + assert!(stdout_verbose.contains("sync-contract at lib/sc: unchanged:")); + assert!(stdout_verbose.contains(&format!("(target {pin})"))); assert!( - stdout_verbose.contains("Running full sync"), - "verbose sync should say 'Running full sync'; got: {stdout_verbose}" + stdout_verbose + .contains("Sync summary: 0 changed, 1 unchanged, 0 skipped, 0 failed, 0 pending.") + ); + assert!(harness.dir_exists("lib/sc")); + assert!(harness.file_exists("lib/sc/.git")); + assert_eq!( + harness.index_gitlink_mode("lib/sc").as_deref(), + Some("160000") + ); + assert_eq!( + harness + .git_stdout(&["-C", "lib/sc", "rev-parse", "HEAD"]) + .trim(), + harness.git_at(&remote, &["rev-parse", "HEAD"]) + ); + assert_eq!( + std::fs::read_to_string(harness.work_dir.join("lib/sc/LICENSE")) + .unwrap() + .trim(), + harness.git_at(&remote, &["show", "HEAD:LICENSE"]) ); } @@ -1307,7 +1450,12 @@ mod tests { assert!(config.contains("[full-opts]"), "section header missing"); assert!(config.contains("path = \"lib/full\""), "path missing"); assert!(config.contains(&format!("url = \"{url}\"")), "url missing"); - assert!(config.contains("active = true"), "active missing"); + let parsed = submod::Config::parse(&config).expect("Failed to parse config"); + assert_eq!(parsed.get_submodule("full-opts").unwrap().active, None); + assert_eq!( + parsed.effective_entry("full-opts").unwrap().active, + Some(true) + ); assert!( config.contains("\"src\"") && config.contains("\"docs\""), "sparse_paths missing" @@ -1392,3 +1540,313 @@ mod tests { assert!(!config.contains("[remove-b]"), "remove-b should be gone"); } } + +#[cfg(test)] +mod phase3_acceptance_commands { + use super::*; + + fn fixture() -> TestHarness { + let h = TestHarness::new().unwrap(); + h.init_git_repo().unwrap(); + h.create_config("[defaults]\nignore = \"dirty\"\nuse_git_default_sparse_checkout = true\n[lib]\nurl = \"./remote.git\"\nactive = false\nshallow = true\nignore = \"none\"\nuse_git_default_sparse_checkout = true\nsparse_paths = [\"src/\"]\n").unwrap(); + h + } + + fn document(h: &TestHarness) -> toml::Value { + toml::from_str(&h.read_config().unwrap()).unwrap() + } + + #[test] + fn r07_omitted_shallow_preserves_true() { + let h = fixture(); + h.run_submod_success(&["change", "lib", "--ignore", "all"]) + .unwrap(); + assert_eq!(document(&h)["lib"]["shallow"].as_bool(), Some(true)); + assert_eq!(document(&h)["lib"]["active"].as_bool(), Some(false)); + h.run_submod_success(&["list"]).unwrap(); + } + + #[test] + fn r07_explicit_shallow_false_round_trips() { + let h = fixture(); + h.run_submod_success(&["change", "lib", "--shallow", "false"]) + .unwrap(); + assert_eq!(document(&h)["lib"]["shallow"].as_bool(), Some(false)); + h.run_submod_success(&["change", "lib", "--ignore", "all"]) + .unwrap(); + assert_eq!(document(&h)["lib"]["shallow"].as_bool(), Some(false)); + } + + #[test] + fn r07_sparse_mode_booleans_at_both_scopes_round_trip() { + let h = fixture(); + for value in ["false", "true"] { + h.run_submod_success(&["change-global", "--use-git-default-sparse-checkout", value]) + .unwrap(); + h.run_submod_success(&["change", "lib", "--use-git-default-sparse-checkout", value]) + .unwrap(); + let config = document(&h); + for scope in ["defaults", "lib"] { + assert_eq!( + config[scope]["use_git_default_sparse_checkout"].as_bool(), + Some(value == "true"), + "scope {scope}" + ); + } + h.run_submod_success(&["list"]).unwrap(); + } + } + + #[test] + fn r18_unset_override_restores_inheritance() { + let h = fixture(); + h.run_submod_success(&["change", "lib", "--unset", "ignore"]) + .unwrap(); + let config = document(&h); + assert!(config["lib"].get("ignore").is_none()); + assert_eq!(config["defaults"]["ignore"].as_str(), Some("dirty")); + assert_eq!(config["lib"]["shallow"].as_bool(), Some(true)); + h.run_submod_success(&["list"]).unwrap(); + } + + #[test] + fn r18_clear_sparse_paths_removes_patterns() { + let h = fixture(); + h.run_submod_success(&["change", "lib", "--clear-sparse-paths"]) + .unwrap(); + let config = document(&h); + assert!( + config["lib"] + .get("sparse_paths") + .is_none_or(|v| v.as_array().is_some_and(Vec::is_empty)) + ); + assert_eq!(config["lib"]["shallow"].as_bool(), Some(true)); + h.run_submod_success(&["list"]).unwrap(); + } + + macro_rules! rejected_args { + ($name:ident, $args:expr) => { + #[test] + fn $name() { + let h = fixture(); + let before = h.preservation_snapshot(); + let out = h.run_submod($args).unwrap(); + assert_eq!( + h.preservation_snapshot(), + before, + "argument rejection mutated state" + ); + assert_eq!( + out.status.code(), + Some(2), + "expected argument-validation exit 2: {out:?}" + ); + let error = String::from_utf8_lossy(&out.stderr); + assert!( + !error.contains("unexpected argument '--clear-sparse-paths'") + && !error.contains("unexpected argument '--unset'"), + "missing option is not conflict validation: {error}" + ); + } + }; + } + rejected_args!(r18_change_without_settings_rejected, &["change", "lib"]); + rejected_args!(r18_global_without_settings_rejected, &["change-global"]); + rejected_args!( + r18_clear_and_replace_conflict, + &[ + "change", + "lib", + "--clear-sparse-paths", + "--sparse-paths", + "docs/" + ] + ); + rejected_args!( + r18_clear_and_append_conflict, + &[ + "change", + "lib", + "--clear-sparse-paths", + "--sparse-paths", + "docs/", + "--append", + "true" + ] + ); + rejected_args!( + r18_unset_and_set_conflict, + &["change", "lib", "--unset", "ignore", "--ignore", "all"] + ); + rejected_args!( + r18_append_without_patterns_rejected, + &["change", "lib", "--append", "true"] + ); + rejected_args!( + r18_all_and_names_rejected, + &["nuke-it-from-orbit", "--all", "lib", "--kill"] + ); +} + +#[test] +fn phase3_acceptance_r07_boolean_tristate_survives_fresh_edit() { + let h = TestHarness::new().unwrap(); + h.init_git_repo().unwrap(); + h.create_config("[defaults]\nignore='dirty'\n[lib]\nurl='./remote.git'\nactive=false\n") + .unwrap(); + h.run_submod_success(&["change", "lib", "--ignore", "all"]) + .unwrap(); + let raw: toml::Value = toml::from_str(&h.read_config().unwrap()).unwrap(); + assert!(raw["lib"].get("shallow").is_none()); + for scope in ["defaults", "lib"] { + assert!(raw[scope].get("use_git_default_sparse_checkout").is_none()); + } + for value in ["true", "false"] { + h.run_submod_success(&[ + "change", + "lib", + "--shallow", + value, + "--active", + value, + "--use-git-default-sparse-checkout", + value, + ]) + .unwrap(); + h.run_submod_success(&["change-global", "--use-git-default-sparse-checkout", value]) + .unwrap(); + h.run_submod_success(&["change", "lib", "--ignore", "dirty"]) + .unwrap(); + h.run_submod_success(&["change-global", "--ignore", "all"]) + .unwrap(); + let raw: toml::Value = toml::from_str(&h.read_config().unwrap()).unwrap(); + assert_eq!(raw["lib"]["shallow"].as_bool(), Some(value == "true")); + assert_eq!(raw["lib"]["active"].as_bool(), Some(value == "true")); + for scope in ["defaults", "lib"] { + assert_eq!( + raw[scope]["use_git_default_sparse_checkout"].as_bool(), + Some(value == "true") + ); + } + h.run_submod_success(&["list"]).unwrap(); + } +} + +#[test] +fn phase3_acceptance_r18_unset_all_supported_optional_fields() { + let h = TestHarness::new().unwrap(); + h.init_git_repo().unwrap(); + h.create_config("[defaults]\nignore='dirty'\nfetchRecurse='always'\nupdate='checkout'\nuse_git_default_sparse_checkout=true\n[lib]\nurl='./remote.git'\nactive=false\nbranch='main'\nignore='none'\nfetchRecurse='never'\nupdate='none'\nshallow=false\nuse_git_default_sparse_checkout=false\n").unwrap(); + for (option, field) in [ + ("branch", "branch"), + ("ignore", "ignore"), + ("fetch", "fetchRecurse"), + ("update", "update"), + ("shallow", "shallow"), + ("active", "active"), + ( + "use-git-default-sparse-checkout", + "use_git_default_sparse_checkout", + ), + ] { + h.run_submod_success(&["change", "lib", "--unset", option]) + .unwrap(); + let raw: toml::Value = toml::from_str(&h.read_config().unwrap()).unwrap(); + assert!( + raw["lib"].get(field).is_none(), + "override remained: {field}" + ); + h.run_submod_success(&["list"]).unwrap(); + } + for (option, field) in [ + ("ignore", "ignore"), + ("fetch", "fetchRecurse"), + ("update", "update"), + ( + "use-git-default-sparse-checkout", + "use_git_default_sparse_checkout", + ), + ] { + h.run_submod_success(&["change-global", "--unset", option]) + .unwrap(); + let raw: toml::Value = toml::from_str(&h.read_config().unwrap()).unwrap(); + assert!(raw.get("defaults").and_then(|v| v.get(field)).is_none()); + h.run_submod_success(&["list"]).unwrap(); + } +} + +#[test] +fn phase3_acceptance_r18_all_set_unset_conflicts_preserve_state() { + let h = TestHarness::new().unwrap(); + h.init_git_repo().unwrap(); + h.create_config("[lib]\nurl='./remote.git'\nactive=false\n") + .unwrap(); + for (setting, flag, value) in [ + ("branch", "--branch", "main"), + ("ignore", "--ignore", "all"), + ("fetch", "--fetch", "never"), + ("update", "--update", "none"), + ("shallow", "--shallow", "false"), + ("active", "--active", "false"), + ( + "use-git-default-sparse-checkout", + "--use-git-default-sparse-checkout", + "false", + ), + ] { + let before = h.preservation_snapshot(); + let out = h + .run_submod(&["change", "lib", "--unset", setting, flag, value]) + .unwrap(); + assert_eq!(out.status.code(), Some(2), "conflicting {setting}: {out:?}"); + assert_eq!(h.preservation_snapshot(), before); + } + for args in [ + vec!["change", "lib", "--unset", "ignore,ignore"], + vec!["change-global", "--unset", "branch", "--branch", "main"], + vec!["change-global", "--unset", "branch,branch"], + vec!["change-global", "--unset", "ignore", "--ignore", "all"], + ] { + let before = h.preservation_snapshot(); + let out = h.run_submod(&args).unwrap(); + assert_eq!( + out.status.code(), + Some(2), + "invalid unset accepted: {out:?}" + ); + assert_eq!(h.preservation_snapshot(), before); + } +} + +#[test] +fn phase6_global_branch_set_unset_preserves_inheritance() { + let h = TestHarness::new().unwrap(); + h.init_git_repo().unwrap(); + h.create_config("[lib]\nurl='./remote.git'\nactive=false\n[explicit]\nurl='./other.git'\nactive=false\nbranch='HEAD'\n").unwrap(); + for branch in [Some("main"), None] { + let args = match branch { + Some(value) => vec!["change-global", "--branch", value], + None => vec!["change-global", "--unset", "branch"], + }; + h.run_submod_success(&args).unwrap(); + let source = h.read_config().unwrap(); + let raw: toml::Value = toml::from_str(&source).unwrap(); + assert_eq!( + raw.get("defaults") + .and_then(|v| v.get("branch")) + .and_then(|v| v.as_str()), + branch + ); + assert!(raw["lib"].get("branch").is_none()); + assert_eq!(raw["explicit"]["branch"].as_str(), Some("HEAD")); + let config = submod::config::Config::parse(&source).unwrap(); + assert_eq!( + config.effective_entry("lib").unwrap().branch, + config.defaults.branch + ); + assert_eq!( + config.effective_entry("explicit").unwrap().branch, + config.get_submodule("explicit").unwrap().branch + ); + } +} diff --git a/tests/common/mod.rs b/tests/common/mod.rs index 3d45c13a..8cea111f 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -7,6 +7,24 @@ use std::fs; use std::path::PathBuf; use std::process::Command; + +trait CheckedOutput { + fn checked_output(&mut self) -> std::io::Result; +} +impl CheckedOutput for Command { + fn checked_output(&mut self) -> std::io::Result { + let output = self.output()?; + if !output.status.success() { + return Err(std::io::Error::other(format!( + "{self:?} exited {}\nstdout: {}\nstderr: {}", + output.status, + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ))); + } + Ok(output) + } +} use tempfile::TempDir; /// Helper struct for test paths that formats display paths with forward slashes on Windows @@ -37,11 +55,7 @@ pub struct TestPathDisplay<'a>(pub &'a std::path::Path); impl std::fmt::Display for TestPathDisplay<'_> { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { let s = self.0.to_string_lossy().replace('\\', "/"); - if s.starts_with('/') { - write!(f, "{s}") - } else { - write!(f, "/{s}") - } + write!(f, "{s}") } } @@ -76,7 +90,7 @@ impl TestHarness { let git_config_global = temp_dir.path().join("gitconfig"); fs::write( &git_config_global, - "[protocol \"file\"]\n\tallow = always\n[core]\n\tautocrlf = false\n\tfilemode = false\n[user]\n\tname = Test User\n\temail = test@example.com\n", + "[protocol \"file\"]\n\tallow = always\n[core]\n\tautocrlf = false\n\tfilemode = false\n[commit]\n\tgpgsign = false\n[tag]\n\tgpgsign = false\n[user]\n\tname = Test User\n\temail = test@example.com\n", )?; Ok(Self { @@ -89,34 +103,22 @@ impl TestHarness { /// Return a `Command` for git with per-test config isolation. /// - /// Sets `GIT_CONFIG_GLOBAL` to a test-local file and `GIT_CONFIG_SYSTEM` to - /// `/dev/null` so that tests never read or write the real user/system config. - fn git_cmd(&self) -> Command { + /// Uses fixture-local identity, signing and transport settings, with system config disabled. + pub fn git_cmd(&self) -> Command { let mut cmd = Command::new("git"); cmd.env("GIT_CONFIG_GLOBAL", &self.git_config_global); - cmd.env("GIT_CONFIG_SYSTEM", "/dev/null"); + cmd.env("GIT_CONFIG_NOSYSTEM", "1"); + cmd.env("GIT_TERMINAL_PROMPT", "0"); cmd } /// Initialize a git repository in the working directory pub fn init_git_repo(&self) -> Result<(), Box> { - // Use git commands for cleanup instead of direct filesystem operations - let _ = self - .git_cmd() - .args(["submodule", "deinit", "--all", "-f"]) - .current_dir(&self.work_dir) - .output(); - - let _ = self - .git_cmd() - .args(["clean", "-fdx"]) - .current_dir(&self.work_dir) - .output(); let output = self .git_cmd() .args(["init"]) .current_dir(&self.work_dir) - .output()?; + .checked_output()?; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); @@ -127,25 +129,25 @@ impl TestHarness { self.git_cmd() .args(["checkout", "-b", "main"]) .current_dir(&self.work_dir) - .output()?; + .checked_output()?; // Configure git user for tests self.git_cmd() .args(["config", "user.name", "Test User"]) .current_dir(&self.work_dir) - .output()?; + .checked_output()?; self.git_cmd() .args(["config", "user.email", "test@example.com"]) .current_dir(&self.work_dir) - .output()?; + .checked_output()?; // protocol.file.allow=always is set in the per-test global gitconfig // (created in TestHarness::new), so no --global write needed here. self.git_cmd() .args(["config", "protocol.file.allow", "always"]) .current_dir(&self.work_dir) - .output()?; + .checked_output()?; // Create initial commit fs::write(self.work_dir.join("README.md"), "# Test Repository\n")?; @@ -153,12 +155,12 @@ impl TestHarness { self.git_cmd() .args(["add", "README.md"]) .current_dir(&self.work_dir) - .output()?; + .checked_output()?; self.git_cmd() .args(["commit", "-m", "Initial commit"]) .current_dir(&self.work_dir) - .output()?; + .checked_output()?; Ok(()) } @@ -171,29 +173,32 @@ impl TestHarness { self.git_cmd() .args(["init", "--bare"]) .arg(&remote_dir) - .output()?; + .checked_output()?; // Set the default branch to main for the bare repository self.git_cmd() .args(["symbolic-ref", "HEAD", "refs/heads/main"]) .current_dir(&remote_dir) - .output()?; + .checked_output()?; // Create a working copy to add content let work_copy = self.temp_dir.path().join(format!("{name}_work")); - self.git_cmd().args(["init"]).arg(&work_copy).output()?; + self.git_cmd() + .args(["init"]) + .arg(&work_copy) + .checked_output()?; // Set the default branch to main for the working copy self.git_cmd() .args(["checkout", "-b", "main"]) .current_dir(&work_copy) - .output()?; + .checked_output()?; // Set up remote self.git_cmd() .args(["remote", "add", "origin", remote_dir.to_str().unwrap()]) .current_dir(&work_copy) - .output()?; + .checked_output()?; // Add some content fs::create_dir_all(work_copy.join("src"))?; @@ -219,28 +224,40 @@ impl TestHarness { self.git_cmd() .args(["config", "user.name", "Test User"]) .current_dir(&work_copy) - .output()?; + .checked_output()?; self.git_cmd() .args(["config", "user.email", "test@example.com"]) .current_dir(&work_copy) - .output()?; + .checked_output()?; self.git_cmd() .args(["add", "."]) .current_dir(&work_copy) - .output()?; + .checked_output()?; self.git_cmd() .args(["commit", "-m", "Add test content"]) .current_dir(&work_copy) - .output()?; + .checked_output()?; + + self.git_at( + &work_copy, + &["commit", "--allow-empty", "-m", "Second main commit"], + ); + self.git_at(&work_copy, &["checkout", "-b", "feature"]); + self.git_at( + &work_copy, + &["commit", "--allow-empty", "-m", "Distinct feature commit"], + ); + self.git_at(&work_copy, &["push", "origin", "feature"]); + self.git_at(&work_copy, &["checkout", "main"]); let push_output = self .git_cmd() .args(["push", "--no-verify", "origin", "main"]) .current_dir(&work_copy) - .output()?; + .checked_output()?; // Check if push was successful if !push_output.status.success() { @@ -259,17 +276,17 @@ impl TestHarness { self.git_cmd() .args(["add", "."]) .current_dir(&work_copy) - .output()?; + .checked_output()?; self.git_cmd() .args(["commit", "-m", "Advance remote"]) .current_dir(&work_copy) - .output()?; + .checked_output()?; let push_output = self .git_cmd() .args(["push", "--no-verify", "origin", "main"]) .current_dir(&work_copy) - .output()?; + .checked_output()?; if !push_output.status.success() { let stderr = String::from_utf8_lossy(&push_output.stderr); return Err(format!("Failed to push advance to remote: {stderr}").into()); @@ -279,7 +296,7 @@ impl TestHarness { .git_cmd() .args(["rev-parse", "HEAD"]) .current_dir(&work_copy) - .output()?; + .checked_output()?; Ok(String::from_utf8_lossy(&rev.stdout).trim().to_string()) } @@ -287,6 +304,15 @@ impl TestHarness { pub fn run_submod( &self, args: &[&str], + ) -> Result> { + self.run_submod_at(&self.work_dir, args) + } + + #[allow(dead_code)] + pub fn run_submod_at( + &self, + cwd: &std::path::Path, + args: &[&str], ) -> Result> { // NOTE: arguments containing an interior NUL byte cannot be passed to a // process at all — std's Command rejects them before spawn, so `.output()` @@ -294,9 +320,10 @@ impl TestHarness { // here; tests assert the real process-boundary rejection. let output = Command::new(&self.submod_bin) .args(args) - .current_dir(&self.work_dir) + .current_dir(cwd) .env("GIT_CONFIG_GLOBAL", &self.git_config_global) - .env("GIT_CONFIG_SYSTEM", "/dev/null") + .env("GIT_CONFIG_NOSYSTEM", "1") + .env("GIT_TERMINAL_PROMPT", "0") .output()?; Ok(output) @@ -346,7 +373,7 @@ impl TestHarness { } /// Run a git command in the work directory (with per-test config isolation) and - /// return its stdout, trimmed. Panics if git cannot be spawned. Intended for + /// return its stdout, trimmed. Panics on spawn or exit failure. Intended for /// asserting on real git state rather than on printed output or `submod.toml` text. #[allow(dead_code)] // Used by integration tests; required for test harness pub fn git_stdout(&self, args: &[&str]) -> String { @@ -354,11 +381,62 @@ impl TestHarness { .git_cmd() .args(args) .current_dir(&self.work_dir) - .output() + .checked_output() .expect("failed to run git command"); String::from_utf8_lossy(&output.stdout).trim().to_string() } + fn git_config_matches(&self, args: &[&str]) -> String { + let output = self + .git_cmd() + .args(args) + .current_dir(&self.work_dir) + .output() + .expect("git config"); + assert!( + output.status.success() + || (output.status.code() == Some(1) + && output.stdout.is_empty() + && output.stderr.is_empty()), + "git config failed: {output:?}" + ); + String::from_utf8(output.stdout) + .expect("UTF-8 config") + .trim() + .to_owned() + } + + #[allow(dead_code)] + pub fn git_at(&self, path: &std::path::Path, args: &[&str]) -> String { + let output = self + .git_cmd() + .args(args) + .current_dir(path) + .checked_output() + .expect("fixture git command"); + String::from_utf8(output.stdout) + .expect("UTF-8 git output") + .trim() + .to_owned() + } + + /// Metadata and exact index modes/OIDs for preservation checks. + #[allow(dead_code)] + pub fn preservation_snapshot(&self) -> (String, String, Vec>>) { + ( + self.git_stdout(&["ls-files", "--stage"]), + self.git_stdout(&["show-ref"]), + ["submod.toml", ".gitmodules", ".git/config"] + .iter() + .map(|path| match fs::read(self.work_dir.join(path)) { + Ok(bytes) => Some(bytes), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => None, + Err(error) => panic!("snapshot {path}: {error}"), + }) + .collect(), + ) + } + /// Return the index gitlink mode for `path` (e.g. `"160000"`), or `None` if the /// path is not staged. A registered submodule is staged as mode `160000`. #[allow(dead_code)] // Used by integration tests; required for test harness @@ -374,7 +452,7 @@ impl TestHarness { /// (`git config --get-regexp ^submodule\.`), or an empty string if there are none. #[allow(dead_code)] // Used by integration tests; required for test harness pub fn submodule_config_entries(&self) -> String { - self.git_stdout(&["config", "--get-regexp", r"^submodule\."]) + self.git_config_matches(&["config", "--get-regexp", r"^submodule\."]) } /// Return all `submodule.*` entries from the `.gitmodules` file, or an empty string @@ -385,7 +463,7 @@ impl TestHarness { if !self.work_dir.join(".gitmodules").exists() { return String::new(); } - self.git_stdout(&[ + self.git_config_matches(&[ "config", "--file", ".gitmodules", @@ -454,39 +532,42 @@ impl TestHarness { self.git_cmd() .args(["init", "--bare"]) .arg(&remote_dir) - .output()?; + .checked_output()?; // Set the default branch to main for the bare repository self.git_cmd() .args(["symbolic-ref", "HEAD", "refs/heads/main"]) .current_dir(&remote_dir) - .output()?; + .checked_output()?; // Create a working copy to add content let work_copy = self.temp_dir.path().join(format!("{name}_work")); - self.git_cmd().args(["init"]).arg(&work_copy).output()?; + self.git_cmd() + .args(["init"]) + .arg(&work_copy) + .checked_output()?; // Set up the main branch and remote self.git_cmd() .args(["checkout", "-b", "main"]) .current_dir(&work_copy) - .output()?; + .checked_output()?; self.git_cmd() .args(["remote", "add", "origin", remote_dir.to_str().unwrap()]) .current_dir(&work_copy) - .output()?; + .checked_output()?; // Configure git self.git_cmd() .args(["config", "user.name", "Test User"]) .current_dir(&work_copy) - .output()?; + .checked_output()?; self.git_cmd() .args(["config", "user.email", "test@example.com"]) .current_dir(&work_copy) - .output()?; + .checked_output()?; // Create main branch content fs::create_dir_all(work_copy.join("src"))?; @@ -520,18 +601,18 @@ impl TestHarness { self.git_cmd() .args(["add", "."]) .current_dir(&work_copy) - .output()?; + .checked_output()?; self.git_cmd() .args(["commit", "-m", "Initial commit"]) .current_dir(&work_copy) - .output()?; + .checked_output()?; // Create a development branch self.git_cmd() .args(["checkout", "-b", "develop"]) .current_dir(&work_copy) - .output()?; + .checked_output()?; fs::write( work_copy.join("src").join("dev.rs"), @@ -541,25 +622,25 @@ impl TestHarness { self.git_cmd() .args(["add", "."]) .current_dir(&work_copy) - .output()?; + .checked_output()?; self.git_cmd() .args(["commit", "-m", "Add dev features"]) .current_dir(&work_copy) - .output()?; + .checked_output()?; // Create a tag self.git_cmd() .args(["tag", "v0.1.0"]) .current_dir(&work_copy) - .output()?; + .checked_output()?; // Push everything with error checking let push_main = self .git_cmd() .args(["push", "origin", "main"]) .current_dir(&work_copy) - .output()?; + .checked_output()?; if !push_main.status.success() { let stderr = String::from_utf8_lossy(&push_main.stderr); @@ -570,7 +651,7 @@ impl TestHarness { .git_cmd() .args(["push", "origin", "develop"]) .current_dir(&work_copy) - .output()?; + .checked_output()?; if !push_develop.status.success() { let stderr = String::from_utf8_lossy(&push_develop.stderr); @@ -581,7 +662,7 @@ impl TestHarness { .git_cmd() .args(["push", "origin", "--tags"]) .current_dir(&work_copy) - .output()?; + .checked_output()?; if !push_tags.status.success() { let stderr = String::from_utf8_lossy(&push_tags.stderr); diff --git a/tests/config_tests.rs b/tests/config_tests.rs index 4af949f0..ee483e2a 100644 --- a/tests/config_tests.rs +++ b/tests/config_tests.rs @@ -14,91 +14,6 @@ use common::TestHarness; mod tests { use super::*; - #[test] - fn test_config_serialization_roundtrip() { - let harness = TestHarness::new().expect("Failed to create test harness"); - harness.init_git_repo().expect("Failed to init git repo"); - - let original_config = r#"# Submodule configuration for gitoxide-based submodule manager -# Each section [name] defines a submodule - -[defaults] -ignore = "dirty" -update = "checkout" -branch = "." -fetchRecurse = "on-demand" - -[vendor-utils] -path = "vendor/utils" -url = "https://github.com/example/utils.git" -active = true -sparse_paths = ["src/", "include/", "*.md"] -ignore = "all" -update = "rebase" - -[my-library] -path = "lib/my-library" -url = "https://github.com/example/my-library.git" -active = false -sparse_paths = ["src/", "docs/"] -"#; - - // Create config and verify it can be parsed - harness - .create_config(original_config) - .expect("Failed to create config"); - - // Run a command that loads and potentially saves the config - let stdout = harness - .run_submod_success(&["check", "--verbose"]) - .expect("Failed to run check"); - assert!(stdout.contains("Checking submodule configurations")); - - // Verify config content is preserved - let config_content = harness.read_config().expect("Failed to read config"); - assert!(config_content.contains("[defaults]")); - assert!(config_content.contains("ignore = \"dirty\"")); - assert!(config_content.contains("[vendor-utils]")); - assert!(config_content.contains("active = true")); - assert!(config_content.contains("[my-library]")); - assert!(config_content.contains("active = false")); - } - - #[test] - fn test_defaults_inheritance() { - let harness = TestHarness::new().expect("Failed to create test harness"); - harness.init_git_repo().expect("Failed to init git repo"); - - let config_with_defaults = r#"[defaults] -ignore = "dirty" -update = "rebase" -fetchRecurse = "always" - -[submodule-with-override] -path = "lib/override" -url = "https://github.com/example/override.git" -active = true -ignore = "all" # Override default - -[submodule-inherits-defaults] -path = "lib/inherits" -url = "https://github.com/example/inherits.git" -active = true -"#; - - harness - .create_config(config_with_defaults) - .expect("Failed to create config"); - - // Run check to see effective settings - let stdout = harness - .run_submod_success(&["check", "--verbose"]) - .expect("Failed to run check"); - - assert!(stdout.contains("Checking submodule configurations")); - // Check should show that one submodule overrides defaults while another inherits them - } - #[test] fn test_invalid_config_handling() { let harness = TestHarness::new().expect("Failed to create test harness"); @@ -114,14 +29,19 @@ url = "https://github.com/example/test.git" .create_config(invalid_toml) .expect("Failed to create invalid config"); + let before = harness.preservation_snapshot(); // Should fail gracefully with a meaningful error let output = harness .run_submod(&["check", "--verbose"]) .expect("Failed to run submod"); - assert!(!output.status.success()); - + assert_eq!(output.status.code(), Some(2), "{output:?}"); + assert_eq!(harness.preservation_snapshot(), before); let stderr = String::from_utf8_lossy(&output.stderr); - assert!(stderr.contains("Failed to create manager") || stderr.contains("Failed to parse")); + assert!(stderr.contains("Cannot prepare check"), "{stderr}"); + assert!( + stderr.contains("submod.toml") && stderr.contains("TOML parse error"), + "{stderr}" + ); } #[test] @@ -150,14 +70,55 @@ fetchRecurse = "always" .create_config(comprehensive_config) .expect("Failed to create config"); - let stdout = harness - .run_submod_success(&["check", "--verbose"]) + let before = harness.preservation_snapshot(); + let output = harness + .run_submod(&["check", "--verbose"]) .expect("Failed to run check"); - assert!(stdout.contains("Checking submodule configurations")); + assert_eq!(output.status.code(), Some(1), "{output:?}"); + assert_eq!(harness.preservation_snapshot(), before); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + stdout.contains("comprehensive-submodule: drift: checkout is missing"), + "{stdout}" + ); + let config = submod::Config::default() + .load_from_file(Some(&harness.config_path())) + .unwrap(); + assert_eq!( + config + .effective_entry("comprehensive-submodule") + .unwrap() + .path + .as_deref(), + Some("lib/comprehensive") + ); // Verify config was parsed correctly let config_content = harness.read_config().expect("Failed to read config"); - assert!(config_content.contains("comprehensive-submodule")); + assert_eq!(config_content, comprehensive_config); + let raw: toml::Value = toml::from_str(&config_content).unwrap(); + assert_eq!(raw["defaults"]["branch"].as_str(), Some("main")); + let effective = config.effective_entry("comprehensive-submodule").unwrap(); + assert_eq!( + effective.ignore, + Some(submod::options::SerializableIgnore::Dirty) + ); + assert_eq!( + effective.update, + Some(submod::options::SerializableUpdate::Merge) + ); + assert_eq!( + effective.fetch_recurse, + Some(submod::options::SerializableFetchRecurse::Always) + ); + assert_eq!( + effective.branch, + config + .submodules + .get("comprehensive-submodule") + .unwrap() + .branch + ); assert!(config_content.contains("ignore = \"dirty\"")); assert!(config_content.contains("update = \"merge\"")); assert!(config_content.contains("branch = \"develop\"")); @@ -237,10 +198,28 @@ active = true .create_config(config_with_empty_defaults) .expect("Failed to create config"); - let stdout = harness - .run_submod_success(&["check", "--verbose"]) + let before = harness.preservation_snapshot(); + let output = harness + .run_submod(&["check", "--verbose"]) .expect("Failed to run check"); - assert!(stdout.contains("Checking submodule configurations")); + assert_eq!(output.status.code(), Some(1), "{output:?}"); + assert_eq!(harness.preservation_snapshot(), before); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + stdout.contains("test-submodule: drift: checkout is missing"), + "{stdout}" + ); + let config = submod::Config::default() + .load_from_file(Some(&harness.config_path())) + .unwrap(); + assert_eq!( + config + .effective_entry("test-submodule") + .unwrap() + .path + .as_deref(), + Some("lib/test") + ); } #[test] @@ -283,10 +262,29 @@ active = false # Not active by default .create_config(formatted_config) .expect("Failed to create config"); - let stdout = harness - .run_submod_success(&["check", "--verbose"]) + let before = harness.preservation_snapshot(); + let output = harness + .run_submod(&["check", "--verbose"]) .expect("Failed to run check"); - assert!(stdout.contains("Checking submodule configurations")); + assert_eq!(output.status.code(), Some(1), "{output:?}"); + assert_eq!(harness.preservation_snapshot(), before); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + stdout.contains("dev-tools: skipped-disabled at tools/dev (not materialized)"), + "{stdout}" + ); + assert!(!stdout.contains("dev-tools: drift:"), "{stdout}"); + assert!( + stdout.contains("utils: drift: checkout is missing"), + "{stdout}" + ); + let config = submod::Config::default() + .load_from_file(Some(&harness.config_path())) + .unwrap(); + assert_eq!( + config.effective_entry("utils").unwrap().path.as_deref(), + Some("vendor/utils") + ); // Verify comments and formatting are preserved let config_content = harness.read_config().expect("Failed to read config"); @@ -294,29 +292,6 @@ active = false # Not active by default assert!(config_content.contains("# Main utility library")); } - #[test] - fn test_config_validation_missing_required_fields() { - let harness = TestHarness::new().expect("Failed to create test harness"); - harness.init_git_repo().expect("Failed to init git repo"); - - // Config with missing required fields - let incomplete_config = r"[incomplete-submodule] -# Missing path and url -active = true -"; - - harness - .create_config(incomplete_config) - .expect("Failed to create config"); - - // Should handle missing fields gracefully - let stdout = harness - .run_submod_success(&["check", "--verbose"]) - .expect("Failed to run check"); - assert!(stdout.contains("Checking submodule configurations")); - // The check should report issues with incomplete configuration - } - #[test] fn test_config_with_special_characters_in_paths() { let harness = TestHarness::new().expect("Failed to create test harness"); @@ -333,10 +308,28 @@ sparse_paths = ["src/**", "docs/*", "*.{md,txt,rst}"] .create_config(special_config) .expect("Failed to create config"); - let stdout = harness - .run_submod_success(&["check", "--verbose"]) + let before = harness.preservation_snapshot(); + let output = harness + .run_submod(&["check", "--verbose"]) .expect("Failed to run check"); - assert!(stdout.contains("Checking submodule configurations")); + assert_eq!(output.status.code(), Some(1), "{output:?}"); + assert_eq!(harness.preservation_snapshot(), before); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + stdout.contains("special-chars: drift: checkout is missing"), + "{stdout}" + ); + let config = submod::Config::default() + .load_from_file(Some(&harness.config_path())) + .unwrap(); + assert_eq!( + config + .effective_entry("special-chars") + .unwrap() + .path + .as_deref(), + Some("lib/special-chars_123") + ); } #[test] @@ -346,7 +339,6 @@ sparse_paths = ["src/**", "docs/*", "*.{md,txt,rst}"] OtherSubmoduleSettings, SubmoduleEntries, SubmoduleEntry, SubmoduleGitOptions, SubmoduleUpdateOptions, }; - use submod::git_ops::Git2Operations; use submod::options::{ SerializableBranch, SerializableFetchRecurse, SerializableIgnore, SerializableUpdate, }; @@ -370,8 +362,12 @@ sparse_paths = ["src/**", "docs/*", "*.{md,txt,rst}"] }; let from_opts = SubmoduleUpdateOptions::from_options(git_opts.clone()); assert_eq!(from_opts.strategy, SerializableUpdate::Merge); - assert!(from_opts.recursive); + assert!( + !from_opts.recursive, + "fetch policy must not opt into recursive materialization" + ); assert!(!from_opts.force); + assert!(!from_opts.remote); // 2. Test SubmoduleEntry constructors and updater methods let entry = SubmoduleEntry::new( @@ -455,7 +451,7 @@ sparse_paths = ["src/**", "docs/*", "*.{md,txt,rst}"] .0; assert_eq!(stored_entry_empty.sparse_paths, None); - // 4. Test Config loading and sync methods using a real test harness + // 4. Test raw Config loading using a real test harness let harness = TestHarness::new().expect("Failed to create test harness"); harness.init_git_repo().expect("Failed to init git repo"); @@ -478,27 +474,350 @@ active = true .expect("Failed to load_from_file"); assert_eq!(config.defaults.ignore, Some(SerializableIgnore::Dirty)); - // Test sync_with_git_config and load_with_git_sync - let mut git_ops = - Git2Operations::new(Some(&harness.work_dir)).expect("Failed to open git_ops"); + assert_eq!(config.get_submodule("test-sub").unwrap().ignore, None); + assert_eq!( + config.effective_entry("test-sub").unwrap().ignore, + Some(SerializableIgnore::Dirty) + ); + assert!(!harness.work_dir.join(".gitmodules").exists()); + } +} + +/// Phase 1 contracts intentionally assert the repaired behavior against the audited CLI. +#[cfg(test)] +mod phase3_acceptance_config { + use super::*; + + fn fixture(config: &str) -> TestHarness { + let h = TestHarness::new().unwrap(); + h.init_git_repo().unwrap(); + h.create_config(config).unwrap(); + h + } - // Initially, config has a submodule but gitmodules has nothing. - // We sync config with git config, which should write the submodule to .gitmodules - config - .sync_with_git_config(&mut git_ops) - .expect("Failed to sync_with_git_config"); + fn document(h: &TestHarness) -> toml::Value { + toml::from_str(&h.read_config().unwrap()).expect("edited document must remain valid TOML") + } - // Verify .gitmodules was written - let gitmodules_content = harness.gitmodules_entries(); - assert!(gitmodules_content.contains("submodule.test-sub.path")); + #[test] + fn r06_defaults_do_not_become_explicit_overrides() { + let h = fixture( + "[defaults]\nignore = \"dirty\"\n[inherits]\nurl = \"./remote.git\"\nactive = false\n[explicit]\nurl = \"./other.git\"\nactive = false\nignore = \"none\"\n", + ); + h.run_submod_success(&["change-global", "--ignore", "all"]) + .unwrap(); + h.run_submod_success(&["change", "inherits", "--update", "none"]) + .unwrap(); + let value = document(&h); + assert!( + value["inherits"].get("ignore").is_none(), + "inherited setting was pinned: {value}" + ); + assert_eq!(value["defaults"]["ignore"].as_str(), Some("all")); + assert_eq!(value["explicit"]["ignore"].as_str(), Some("none")); + h.run_submod_success(&["list"]) + .expect("second process reload"); + } + + #[test] + fn r08_checked_in_sample_loads_without_rewrite() { + let sample = include_str!("../sample_config/submod.toml"); + let h = fixture(sample); + h.run_submod_success(&["list"]) + .expect("exact shipped sample must load"); + assert_eq!(h.read_config().unwrap(), sample); + } - // Now test load_with_git_sync - let loaded_sync_config = submod::Config::default() - .load_with_git_sync(&config_path, &mut git_ops, submod::Config::default()) - .expect("Failed to load_with_git_sync"); + #[test] + fn r08_generated_template_loads_without_rewrite() { + let h = fixture(""); + h.run_submod_success(&["generate-config", "--template", "--force"]) + .unwrap(); + let before = h.read_config().unwrap(); + let _: toml::Value = toml::from_str(&before).unwrap(); + h.run_submod_success(&["list"]) + .expect("own generated template must load"); + assert_eq!(h.read_config().unwrap(), before); + } + + macro_rules! supported_version { + ($name:ident, $prefix:expr) => { + #[test] + fn $name() { + let config = format!("{}[lib]\nurl = \"./remote.git\"\nactive = false\n", $prefix); + let h = fixture(&config); + h.run_submod_success(&["list"]).unwrap(); + assert_eq!(h.read_config().unwrap(), config); + } + }; + } + supported_version!(r08_absent_schema_version_loads, ""); + supported_version!(r08_schema_1_0_loads, "schema_version = \"1.0.0\"\n"); + supported_version!(r08_schema_1_1_loads, "schema_version = \"1.1.0\"\n"); + + macro_rules! rejected_config { + ($name:ident, $config:expr, $context:expr) => { + #[test] + fn $name() { + let h = fixture($config); + let before = h.preservation_snapshot(); + let out = h.run_submod(&["change-global", "--ignore", "all"]).unwrap(); + assert_eq!( + h.preservation_snapshot(), + before, + "validation failure must not mutate config or Git" + ); + assert!(!out.status.success(), "invalid config accepted: {out:?}"); + let error = String::from_utf8_lossy(&out.stderr); + assert!(error.contains($context), "missing field context: {error}"); + } + }; + } + rejected_config!( + r08_future_schema_rejected, + "schema_version = \"99.0.0\"\n", + "schema_version" + ); + rejected_config!( + r08_unknown_module_key_rejected, + "[lib]\nurl = \"./remote.git\"\nactive = false\nignroe = \"dirty\"\n", + "ignroe" + ); + rejected_config!( + r08_unknown_default_key_rejected, + "[defaults]\nignroe = \"dirty\"\n", + "ignroe" + ); + rejected_config!( + r08_conflicting_fetch_alias_rejected, + "[lib]\nurl = \"./remote.git\"\nfetch = \"always\"\nfetchRecurse = \"never\"\n", + "fetch" + ); + rejected_config!( + r08_conflicting_snake_alias_rejected, + "[lib]\nurl = \"./remote.git\"\nfetch_recurse = \"always\"\nfetchRecurse = \"never\"\n", + "fetch" + ); + + macro_rules! legacy_config { + ($name:ident, $key:expr, $value:expr, $canonical:expr) => { + #[test] + fn $name() { + let config = format!("[lib]\nurl = \"./remote.git\"\nactive = false\nbranch = \"HEAD\"\n{} = \"{}\"\n", $key, $value); + let h = fixture(&config); + let out = h.run_submod(&["list"]).unwrap(); + assert!(out.status.success(), "legacy generated config rejected: {out:?}"); + assert_eq!(h.read_config().unwrap(), config); + let message = format!("{}{}", String::from_utf8_lossy(&out.stdout), String::from_utf8_lossy(&out.stderr)).to_lowercase(); + assert!(message.contains("warn") && (message.contains("legacy") || message.contains("migrat")), "migration warning missing: {message}"); + assert!(message.contains("lib") && message.contains(&$key.to_lowercase()), "migration warning lacks module/field context: {message}"); + h.run_submod_success(&["change", "lib", "--ignore", "all"]).unwrap(); + assert_eq!(document(&h)["lib"]["branch"].as_str(), Some("HEAD")); + assert_eq!(document(&h)["lib"][$key].as_str(), Some($value)); + h.run_submod_success(&["change", "lib", "--fetch", $canonical]).unwrap(); + assert_eq!(document(&h)["lib"]["fetchRecurse"].as_str(), Some($canonical)); + assert!(document(&h)["lib"].get("fetch").is_none()); + assert!(document(&h)["lib"].get("fetch_recurse").is_none()); + h.run_submod_success(&["list"]).unwrap(); + } + }; + } + legacy_config!(r08_legacy_fetch_true_and_head, "fetch", "true", "always"); + legacy_config!( + r08_legacy_fetch_false_and_head, + "fetch_recurse", + "false", + "never" + ); + + legacy_config!(r08_legacy_canonical_true, "fetchRecurse", "true", "always"); + legacy_config!(r08_legacy_canonical_false, "fetchRecurse", "false", "never"); + legacy_config!(r08_legacy_fetch_alias, "fetch", "on-demand", "on-demand"); + legacy_config!(r08_legacy_snake_alias, "fetch_recurse", "always", "always"); + + macro_rules! editor_case { + ($name:ident, $header:expr, $nickname:expr) => { + #[test] + fn $name() { + let untouched = "# retain this section verbatim\n[other]\nurl = './other.git' # untouched\nactive = false\n"; + let config = format!("{}\nurl = '''./remote.git'''\nactive = false\nsparse_paths = [\n \"src/\", # keep pattern comment\n \"docs/\",\n]\nignore = \"dirty\" # retain explanation\n\n{untouched}", $header); + let h = fixture(&config); + h.run_submod_success(&["change", $nickname, "--ignore", "all"]).unwrap(); + let value = document(&h); + assert_eq!(value[$nickname]["ignore"].as_str(), Some("all")); + assert_eq!(value[$nickname]["sparse_paths"].as_array().unwrap().len(), 2); + let after = h.read_config().unwrap(); + assert!(after.contains(untouched), "unrelated section changed: {after}"); + assert!(after.contains("# keep pattern comment") && after.contains("# retain explanation")); + h.run_submod_success(&["list"]).unwrap(); + } + }; + } + editor_case!( + r09_multiline_array_commented_header, + "[lib] # module comment", + "lib" + ); + editor_case!( + r09_quoted_dotted_name, + "[\"lib.with.dots\"]", + "lib.with.dots" + ); + editor_case!(r09_literal_unicode_name, "['bibliothèque']", "bibliothèque"); + editor_case!( + r09_escaped_quoted_name, + "[\"lib\\\"quoted\"]", + "lib\"quoted" + ); + + #[test] + fn r10_identical_edit_keeps_exact_bytes() { + let config = "# preserve spacing\n[lib]\nurl='./remote.git'\nactive=false\nignore = 'all' # same value\n"; + let h = fixture(config); + let modified = std::fs::metadata(h.config_path()) + .unwrap() + .modified() + .unwrap(); + h.run_submod_success(&["change", "lib", "--ignore", "all"]) + .unwrap(); + assert_eq!(h.read_config().unwrap(), config); assert_eq!( - loaded_sync_config.defaults.ignore, - Some(SerializableIgnore::Dirty) + std::fs::metadata(h.config_path()) + .unwrap() + .modified() + .unwrap(), + modified + ); + h.run_submod_success(&["list"]).unwrap(); + } + + #[cfg(unix)] + #[test] + fn r10_symlink_config_output_is_refused() { + let h = fixture("[defaults]\nignore = \"dirty\"\n"); + let target = h.work_dir.join("real.toml"); + std::fs::rename(h.config_path(), &target).unwrap(); + std::os::unix::fs::symlink(&target, h.config_path()).unwrap(); + let before = std::fs::read(&target).unwrap(); + let out = h.run_submod(&["change-global", "--ignore", "all"]).unwrap(); + assert_eq!( + std::fs::read(&target).unwrap(), + before, + "symlink target modified" + ); + assert!( + std::fs::symlink_metadata(h.config_path()) + .unwrap() + .file_type() + .is_symlink() + ); + assert!(!out.status.success(), "symlink output accepted"); + } + + #[test] + fn r10_failed_template_write_preserves_existing_config() { + let h = fixture("# original\n[defaults]\nignore = \"dirty\"\n"); + let before = h.preservation_snapshot(); + std::fs::write(h.work_dir.join("blocked"), "occupied ancestor").unwrap(); + let out = h + .run_submod(&[ + "generate-config", + "--template", + "--force", + "--output", + "blocked/config.toml", + ]) + .unwrap(); + assert!(!out.status.success(), "write beneath a file must fail"); + assert_eq!(h.preservation_snapshot(), before); + assert_eq!( + std::fs::read_to_string(h.work_dir.join("blocked")).unwrap(), + "occupied ancestor" ); + h.run_submod_success(&["change-global", "--ignore", "all"]) + .expect("normal error releases any locks"); } } + +#[test] +fn phase3_acceptance_r06_effective_defaults_applied_after_fresh_load() { + let h = TestHarness::new().unwrap(); + h.init_git_repo().unwrap(); + let remote = h.create_test_remote("inheritance").unwrap(); + h.create_config(&format!("[defaults]\nignore='dirty'\n[inherits]\nurl='file://{}'\n[explicit]\nurl='file://{}'\nignore='none'\n", remote.display(), remote.display())).unwrap(); + h.run_submod_success(&["init"]).unwrap(); + h.run_submod_success(&["change-global", "--ignore", "all"]) + .unwrap(); + h.run_submod_success(&["change", "inherits", "--update", "none"]) + .unwrap(); + h.run_submod_success(&["sync"]).unwrap(); + let value: toml::Value = toml::from_str(&h.read_config().unwrap()).unwrap(); + assert!(value["inherits"].get("ignore").is_none()); + assert_eq!(value["explicit"]["ignore"].as_str(), Some("none")); + assert_eq!( + h.git_stdout(&[ + "config", + "--file", + ".gitmodules", + "--get", + "submodule.inherits.ignore" + ]) + .trim(), + "all" + ); + assert_eq!( + h.git_stdout(&[ + "config", + "--file", + ".gitmodules", + "--get", + "submodule.explicit.ignore" + ]) + .trim(), + "none" + ); +} + +#[test] +fn phase3_acceptance_r09_multiline_strings_and_escaped_values() { + let h = TestHarness::new().unwrap(); + h.init_git_repo().unwrap(); + let untouched = "url = \"\"\"\n./remote.git\"\"\" # multiline URL\nactive = false\nsparse_paths = [\"src/\\\"quoted\\\"\", 'docs/é'] # escaped value\n"; + h.create_config(&format!("['bibliothèque.with.dots'] # literal dotted name\n{untouched}ignore = 'dirty' # retain inline\n")).unwrap(); + let before: toml::Value = toml::from_str(&h.read_config().unwrap()).unwrap(); + h.run_submod_success(&["change", "bibliothèque.with.dots", "--ignore", "all"]) + .unwrap(); + let after = h.read_config().unwrap(); + let value: toml::Value = toml::from_str(&after).unwrap(); + assert_eq!( + value["bibliothèque.with.dots"]["ignore"].as_str(), + Some("all") + ); + for field in ["url", "active", "sparse_paths"] { + assert_eq!( + value["bibliothèque.with.dots"][field], + before["bibliothèque.with.dots"][field] + ); + } + assert!( + after.contains(untouched), + "untouched values reformatted: {after}" + ); + assert!(after.contains("# retain inline")); + h.run_submod_success(&["list"]).unwrap(); +} + +#[test] +fn phase3_acceptance_r09_dotted_keys_edit_without_duplicate_table() { + let h = TestHarness::new().unwrap(); + h.init_git_repo().unwrap(); + h.create_config("# dotted field spelling\nlib.url = './remote.git'\nlib.active = false\nlib.ignore = 'dirty' # policy\n").unwrap(); + h.run_submod_success(&["change", "lib", "--ignore", "all"]) + .unwrap(); + let raw: toml::Value = toml::from_str(&h.read_config().unwrap()).unwrap(); + assert_eq!(raw["lib"]["ignore"].as_str(), Some("all")); + assert_eq!(raw["lib"]["url"].as_str(), Some("./remote.git")); + assert_eq!(raw["lib"]["active"].as_bool(), Some(false)); + assert!(h.read_config().unwrap().contains("# policy")); + h.run_submod_success(&["list"]).unwrap(); +} diff --git a/tests/error_handling_tests.rs b/tests/error_handling_tests.rs index e0ed21c3..1c56d69a 100644 --- a/tests/error_handling_tests.rs +++ b/tests/error_handling_tests.rs @@ -30,9 +30,13 @@ mod tests { assert_eq!(output.status.code(), Some(1)); let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.contains("Cannot prepare check"), "{stderr}"); assert!( - stderr.contains("Repository not found") || stderr.contains("Failed to create manager") + stderr.contains("Failed to discover") && stderr.contains("not a git repository"), + "{stderr}" ); + assert!(!harness.config_path().exists()); + assert!(!harness.work_dir.join(".git").exists()); } #[test] @@ -41,14 +45,11 @@ mod tests { harness.init_git_repo().expect("Failed to init git repo"); // Try various invalid URLs - let invalid_urls = vec![ - "not-a-url", - "http://nonexistent.domain.invalid/repo.git", - "file:///nonexistent/path.git", - "git@invalid-host:user/repo.git", - ]; + let missing = harness.temp_dir.path().join("missing-remote.git"); + let invalid_urls = [missing.to_str().expect("local path")]; for invalid_url in invalid_urls { + let before = harness.preservation_snapshot(); let output = harness .run_submod(&[ "add", @@ -63,7 +64,17 @@ mod tests { assert!(!output.status.success()); assert_eq!(output.status.code(), Some(1)); let stderr = String::from_utf8_lossy(&output.stderr); - assert!(stderr.contains("Failed to add submodule") || stderr.contains("clone failed")); + assert!( + stderr.contains("Add failed") && stderr.contains("missing-remote.git"), + "{stderr}" + ); + assert!( + stderr.contains("does not exist") + || stderr.contains("does not appear to be a git repository"), + "{stderr}" + ); + assert_eq!(harness.preservation_snapshot(), before); + assert!(!harness.work_dir.join("lib/invalid").exists()); } } @@ -72,17 +83,25 @@ mod tests { let harness = TestHarness::new().expect("Failed to create test harness"); harness.init_git_repo().expect("Failed to init git repo"); - // A non-existent config path is handled gracefully: `check` falls back to - // defaults and exits 0 (there are no submodules to report on). + let before = harness.preservation_snapshot(); let missing = harness - .run_submod(&["--config", "/nonexistent/path/config.toml", "check"]) + .run_submod(&["--config", "missing-config.toml", "check"]) .expect("Failed to run submod"); + let stderr = String::from_utf8_lossy(&missing.stderr); + assert!( + !missing.status.success(), + "explicit missing config must fail" + ); assert!( - missing.status.success(), - "a missing config file should be handled gracefully, exit was {:?}; stderr: {}", - missing.status.code(), - String::from_utf8_lossy(&missing.stderr) + stderr.contains("missing-config.toml"), + "missing filename context: {stderr}" ); + assert!( + stderr.to_lowercase().contains("not found") + || stderr.to_lowercase().contains("no such file"), + "missing-file diagnostic: {stderr}" + ); + assert_eq!(harness.preservation_snapshot(), before); // A config file that exists but is malformed must NOT be swallowed: it // fails with a specific parse diagnostic, not a generic catch-all. @@ -123,6 +142,7 @@ mod tests { let path_as_file = harness.work_dir.join("readonly"); fs::write(&path_as_file, "not a directory").expect("Failed to create file"); + let before = harness.preservation_snapshot(); let output = harness .run_submod(&[ "add", @@ -138,17 +158,19 @@ mod tests { assert_eq!(output.status.code(), Some(1)); let stderr = String::from_utf8_lossy(&output.stderr); assert!( - stderr.contains("Failed to add submodule") - || stderr.contains("Not a directory") - || stderr.contains("not a directory"), + stderr.contains("Not a directory") || stderr.contains("not a directory"), "Expected directory collision/IO failure message, got: {stderr}" ); + assert_eq!(harness.preservation_snapshot(), before); + assert_eq!(fs::read_to_string(path_as_file).unwrap(), "not a directory"); } else { // Create a directory we can't write to let readonly_dir = harness.work_dir.join("readonly"); fs::create_dir_all(&readonly_dir).expect("Failed to create readonly dir"); + let before = harness.preservation_snapshot(); // Make directory read-only + let original_perms = fs::metadata(&readonly_dir).unwrap().permissions(); let mut perms = fs::metadata(&readonly_dir).unwrap().permissions(); perms.set_mode(0o444); fs::set_permissions(&readonly_dir, perms).expect("Failed to set permissions"); @@ -165,18 +187,13 @@ mod tests { ]) .expect("Failed to run submod"); - assert!(!output.status.success()); - assert_eq!(output.status.code(), Some(1)); + fs::set_permissions(&readonly_dir, original_perms) + .expect("Failed to restore permissions"); + assert_eq!(output.status.code(), Some(1), "{output:?}"); let stderr = String::from_utf8_lossy(&output.stderr); - assert!( - stderr.contains("Permission denied") || stderr.contains("Failed to add submodule"), - "Expected permission denied/add failure message, got: {stderr}" - ); - - // Restore permissions for cleanup - let mut perms = fs::metadata(&readonly_dir).unwrap().permissions(); - perms.set_mode(0o755); - fs::set_permissions(&readonly_dir, perms).expect("Failed to restore permissions"); + assert!(stderr.contains("Permission denied"), "{stderr}"); + assert_eq!(harness.preservation_snapshot(), before); + assert!(!readonly_dir.join("submodule").exists()); } } @@ -198,16 +215,20 @@ mod tests { .create_config(corrupted_config) .expect("Failed to create corrupted config"); + let before = harness.preservation_snapshot(); let output = harness .run_submod(&["check"]) .expect("Failed to run submod"); assert!(!output.status.success()); - assert_eq!(output.status.code(), Some(1)); + assert_eq!(output.status.code(), Some(2)); let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.contains("Cannot prepare check"), "{stderr}"); assert!( - stderr.contains("Failed to create manager") || stderr.contains("Failed to parse") + stderr.contains("submod.toml") && stderr.contains("TOML parse error"), + "{stderr}" ); + assert_eq!(harness.preservation_snapshot(), before); } } @@ -216,6 +237,9 @@ mod tests { let harness = TestHarness::new().expect("Failed to create test harness"); harness.init_git_repo().expect("Failed to init git repo"); + harness.create_config("").unwrap(); + let before = harness.preservation_snapshot(); + // Try to operate on non-existent submodule let operations = vec![ vec!["reset", "nonexistent-submodule"], @@ -230,9 +254,12 @@ mod tests { match operation[0] { "reset" => { assert!(!output.status.success()); - assert_eq!(output.status.code(), Some(1)); + assert_eq!(output.status.code(), Some(2)); let stderr = String::from_utf8_lossy(&output.stderr); - assert!(stderr.contains("not found") || stderr.contains("Failed to reset")); + assert!( + stderr.contains("nonexistent-submodule") && stderr.contains("not found"), + "{stderr}" + ); } "update" => { // Update should succeed but do nothing @@ -240,6 +267,7 @@ mod tests { } _ => {} } + assert_eq!(harness.preservation_snapshot(), before); } } @@ -322,7 +350,7 @@ mod tests { } #[test] - fn test_concurrent_operations() { + fn test_external_config_edit_is_observed() { let harness = TestHarness::new().expect("Failed to create test harness"); harness.init_git_repo().expect("Failed to init git repo"); @@ -343,7 +371,7 @@ mod tests { ]) .expect("Failed to add submodule"); - // Simulate concurrent access by modifying config externally + // A later invocation observes an external configuration edit. let config_content = format!( r#"[concurrent-test] path = "lib/concurrent" @@ -361,48 +389,37 @@ active = true .expect("Failed to modify config"); // Run check (verbose) to see if it handles the externally modified config - let stdout = harness - .run_submod_success(&["check", "--verbose"]) + let before = harness.preservation_snapshot(); + let child_head = harness.git_stdout(&["-C", "lib/concurrent", "rev-parse", "HEAD"]); + let child_index = harness.git_stdout(&["-C", "lib/concurrent", "ls-files", "--stage"]); + let child_file = std::fs::read(harness.work_dir.join("lib/concurrent/LICENSE")).unwrap(); + let output = harness + .run_submod(&["check", "--verbose"]) .expect("Failed to run check"); + assert_eq!(output.status.code(), Some(1), "{output:?}"); + assert_eq!(harness.preservation_snapshot(), before); + assert_eq!( + harness.git_stdout(&["-C", "lib/concurrent", "rev-parse", "HEAD"]), + child_head + ); + assert_eq!( + harness.git_stdout(&["-C", "lib/concurrent", "ls-files", "--stage"]), + child_index + ); + assert_eq!( + std::fs::read(harness.work_dir.join("lib/concurrent/LICENSE")).unwrap(), + child_file + ); + let stdout = String::from_utf8_lossy(&output.stdout); assert!(stdout.contains("concurrent-test")); assert!(stdout.contains("external-addition")); - } - - #[test] - fn test_disk_space_exhaustion_simulation() { - let harness = TestHarness::new().expect("Failed to create test harness"); - harness.init_git_repo().expect("Failed to init git repo"); - - // Create a very large remote repository to potentially trigger space issues - // This is more of a stress test than a true disk space test - let remote_repo = harness - .create_test_remote("large_repo") - .expect("Failed to create remote"); - let remote_url = format!("file://{}", remote_repo.display()); - - // Add submodule - should handle any space issues gracefully - let output = harness - .run_submod(&[ - "add", - &remote_url, - "--name", - "large-repo", - "--path", - "lib/large", - ]) - .expect("Failed to run submod"); - - // Should either succeed or fail with a meaningful error - if !output.status.success() { - assert_eq!(output.status.code(), Some(1)); - let stderr = String::from_utf8_lossy(&output.stderr); - assert!( - stderr.contains("Failed to add submodule") - || stderr.contains("space") - || stderr.contains("disk"), - "Expected failure message containing space/disk details, got: {stderr}" - ); - } + assert!(stdout.contains("external-addition: drift: checkout is missing")); + assert!(!stdout.contains("concurrent-test: drift: checkout is missing")); + assert!(harness.file_exists("lib/concurrent/.git")); + assert_eq!( + harness.index_gitlink_mode("lib/concurrent").as_deref(), + Some("160000") + ); } #[test] @@ -432,12 +449,12 @@ active = true } #[test] - fn test_network_timeout_simulation() { + fn test_unavailable_local_remote() { let harness = TestHarness::new().expect("Failed to create test harness"); harness.init_git_repo().expect("Failed to init git repo"); - // Use a URL that should fail quickly (invalid domain) - let timeout_url = "http://nonexistent.invalid.domain.test/repo.git"; + let missing = harness.temp_dir.path().join("unavailable.git"); + let timeout_url = missing.to_str().unwrap(); let output = harness .run_submod(&[ @@ -478,6 +495,7 @@ active = true let fake_url = format!("file://{}", fake_remote.display()); + let before = harness.preservation_snapshot(); let output = harness .run_submod(&[ "add", @@ -493,7 +511,18 @@ active = true assert_eq!(output.status.code(), Some(1)); let stderr = String::from_utf8_lossy(&output.stderr); assert!( - stderr.contains("Failed to add submodule") || stderr.contains("not a git repository") + stderr.contains("Add failed") && stderr.contains("fake_remote"), + "{stderr}" + ); + assert!( + stderr.contains("does not appear to be a git repository"), + "{stderr}" + ); + assert_eq!(harness.preservation_snapshot(), before); + assert!(!harness.work_dir.join("lib/fake").exists()); + assert_eq!( + fs::read_to_string(fake_remote.join("not_a_git_repo.txt")).unwrap(), + "This is not a git repository" ); } @@ -526,6 +555,8 @@ active = true // For root, make the config file a directory. Writing to it will fail with EISDIR. fs::remove_file(&config_path).expect("Failed to remove config file"); fs::create_dir(&config_path).expect("Failed to create config directory"); + let index_before = harness.git_stdout(&["ls-files", "--stage"]); + let git_config_before = fs::read(harness.work_dir.join(".git/config")).unwrap(); let output = harness .run_submod(&[ @@ -547,11 +578,19 @@ active = true || stderr.contains("is a directory"), "Expected directory write/config save failure message, got: {stderr}" ); + assert!(config_path.is_dir()); + assert_eq!(harness.git_stdout(&["ls-files", "--stage"]), index_before); + assert_eq!( + fs::read(harness.work_dir.join(".git/config")).unwrap(), + git_config_before + ); + assert!(!harness.file_exists(".gitmodules")); + assert!(!harness.dir_exists("lib/locked")); // Cleanup fs::remove_dir(&config_path).expect("Failed to remove config directory"); } else { - // Make config file read-only to simulate lock + // Atomic sibling replacement can update a read-only destination. let mut perms = fs::metadata(&config_path).unwrap().permissions(); perms.set_mode(0o444); fs::set_permissions(&config_path, perms).expect("Failed to set permissions"); @@ -568,12 +607,21 @@ active = true ]) .expect("Failed to run submod"); - assert!(!output.status.success()); - assert_eq!(output.status.code(), Some(1)); - let stderr = String::from_utf8_lossy(&output.stderr); assert!( - stderr.contains("Permission denied") || stderr.contains("Failed to save config"), - "Expected permission denied/config save failure message, got: {stderr}" + output.status.success(), + "Expected atomic replacement to succeed: {}", + String::from_utf8_lossy(&output.stderr) + ); + let config = submod::Config::parse(&harness.read_config().unwrap()) + .expect("Replacement config must be valid"); + assert!(config.get_submodule("test").is_some()); + let added = config.get_submodule("locked-test").unwrap(); + assert_eq!(added.path.as_deref(), Some("lib/locked")); + assert_eq!(added.url.as_deref(), Some(remote_url.as_str())); + assert_eq!( + fs::metadata(&config_path).unwrap().permissions().mode() & 0o777, + 0o444, + "Atomic replacement must preserve the original read-only mode" ); // Restore permissions for cleanup @@ -582,42 +630,991 @@ active = true fs::set_permissions(&config_path, perms).expect("Failed to restore permissions"); } } +} - #[test] - fn test_recovery_from_partial_operations() { - let harness = TestHarness::new().expect("Failed to create test harness"); - harness.init_git_repo().expect("Failed to init git repo"); +#[test] +fn regression_r02_failed_add_preserves_occupied_directory() { + let h = common::TestHarness::new().unwrap(); + h.init_git_repo().unwrap(); + h.create_config("[defaults]\n").unwrap(); + std::fs::create_dir(h.work_dir.join("occupied")).unwrap(); + let sentinel = h.work_dir.join("occupied/sentinel"); + std::fs::write(&sentinel, b"irreplaceable local bytes").unwrap(); + let before = h.preservation_snapshot(); + let missing = h.temp_dir.path().join("missing.git"); + let output = h + .run_submod(&[ + "add", + missing.to_str().unwrap(), + "--name", + "m", + "--path", + "occupied", + ]) + .unwrap(); + assert!(!output.status.success(), "{output:?}"); + assert_eq!( + std::fs::read(&sentinel).ok().as_deref(), + Some(b"irreplaceable local bytes".as_slice()) + ); + assert_eq!(h.preservation_snapshot(), before); +} - let remote_repo = harness - .create_test_remote("partial_recovery") - .expect("Failed to create remote"); - let remote_url = format!("file://{}", remote_repo.display()); +#[test] +fn regression_r03_disable_preserves_local_commit_and_stash() { + let h = common::TestHarness::new().unwrap(); + h.init_git_repo().unwrap(); + let remote = h.create_test_remote("history").unwrap(); + h.git_stdout(&[ + "submodule", + "add", + "--name", + "module", + remote.to_str().unwrap(), + "module", + ]); + h.create_config(&format!( + "[m]\npath = \"module\"\nurl = {:?}\n", + remote.to_str().unwrap() + )) + .unwrap(); + let child = h.work_dir.join("module"); + std::fs::write(child.join("local-only"), b"local history").unwrap(); + h.git_at(&child, &["add", "local-only"]); + h.git_at(&child, &["commit", "-m", "local only"]); + let commit = h.git_at(&child, &["rev-parse", "HEAD"]); + std::fs::write(child.join("LICENSE"), b"recover me").unwrap(); + h.git_at(&child, &["stash", "push", "-m", "preserved"]); + let stash = h.git_at(&child, &["rev-parse", "refs/stash"]); + let gitdir = h.git_at(&child, &["rev-parse", "--absolute-git-dir"]); + let index = h.git_stdout(&["ls-files", "--stage"]); + h.run_submod_success(&["disable", "m"]).unwrap(); + assert!( + std::path::Path::new(&gitdir).join("objects").is_dir(), + "disable deleted object database {gitdir}" + ); + assert_eq!(h.git_at(&child, &["rev-parse", "HEAD"]), commit); + assert_eq!(h.git_at(&child, &["rev-parse", "refs/stash"]), stash); + assert_eq!(h.git_stdout(&["ls-files", "--stage"]), index); + h.git_at(&child, &["stash", "apply", &stash]); + assert_eq!(std::fs::read(child.join("LICENSE")).unwrap(), b"recover me"); +} - // Simulate partial operation by creating directory but not git repo - let partial_dir = harness.work_dir.join("lib/partial"); - fs::create_dir_all(&partial_dir).expect("Failed to create partial dir"); - fs::write(partial_dir.join("partial_file.txt"), "partial content") - .expect("Failed to create partial file"); +#[test] +fn regression_r05_failed_stash_ref_lock_preserves_work() { + let h = common::TestHarness::new().unwrap(); + h.init_git_repo().unwrap(); + let remote = h.create_test_remote("stash").unwrap(); + h.git_stdout(&[ + "submodule", + "add", + "--name", + "m", + remote.to_str().unwrap(), + "module", + ]); + h.create_config(&format!( + "[m]\npath = \"module\"\nurl = {:?}\n", + remote.to_str().unwrap() + )) + .unwrap(); + let child = h.work_dir.join("module"); + std::fs::write(child.join("LICENSE"), b"dirty tracked").unwrap(); + std::fs::write(child.join("untracked"), b"untracked bytes").unwrap(); + let gitdir = std::path::PathBuf::from(h.git_at(&child, &["rev-parse", "--absolute-git-dir"])); + std::fs::write(gitdir.join("info/exclude"), "ignored\n").unwrap(); + std::fs::write(child.join("ignored"), b"ignored bytes").unwrap(); + std::fs::write(gitdir.join("refs/stash.lock"), "held by test\n").unwrap(); + let before = h.preservation_snapshot(); + let head = h.git_at(&child, &["rev-parse", "HEAD"]); + let output = h.run_submod(&["reset", "m"]).unwrap(); + for (file, bytes) in [ + ("LICENSE", b"dirty tracked".as_slice()), + ("untracked", b"untracked bytes".as_slice()), + ("ignored", b"ignored bytes".as_slice()), + ] { + assert_eq!( + std::fs::read(child.join(file)).ok().as_deref(), + Some(bytes), + "reset lost {file}; {output:?}" + ); + } + assert!( + !output.status.success(), + "stash lock must prevent reset: {output:?}" + ); + assert_eq!(h.git_at(&child, &["rev-parse", "HEAD"]), head); + assert_eq!(h.preservation_snapshot(), before); +} - // Try to add submodule to existing directory - let output = harness - .run_submod(&[ - "add", - &remote_url, - "--name", - "partial-test", - "--path", - "lib/partial", +#[test] +fn phase2_r02_failed_add_preserves_independent_repository() { + let h = common::TestHarness::new().unwrap(); + h.init_git_repo().unwrap(); + h.create_config("[defaults]\n").unwrap(); + let occupied = h.work_dir.join("occupied"); + std::fs::create_dir(&occupied).unwrap(); + h.git_at(&occupied, &["init", "-b", "main"]); + std::fs::write(occupied.join("sentinel"), b"committed local\0\xff").unwrap(); + h.git_at(&occupied, &["add", "sentinel"]); + h.git_at(&occupied, &["commit", "-m", "independent history"]); + let refs = h.git_at(&occupied, &["show-ref"]); + let index = h.git_at(&occupied, &["ls-files", "--stage"]); + let config = std::fs::read(occupied.join(".git/config")).unwrap(); + std::fs::write(occupied.join("sentinel"), b"uncommitted local\0\xff").unwrap(); + let before = h.preservation_snapshot(); + let missing = h.temp_dir.path().join("missing.git"); + let output = h + .run_submod(&[ + "add", + missing.to_str().unwrap(), + "--name", + "m", + "--path", + "occupied", + ]) + .unwrap(); + assert_eq!( + std::fs::read(occupied.join("sentinel")).ok().as_deref(), + Some(b"uncommitted local\0\xff".as_slice()), + "{output:?}" + ); + assert_eq!(h.git_at(&occupied, &["show-ref"]), refs); + assert_eq!(h.git_at(&occupied, &["ls-files", "--stage"]), index); + assert_eq!(std::fs::read(occupied.join(".git/config")).unwrap(), config); + assert_eq!(h.preservation_snapshot(), before); + assert!(!output.status.success(), "{output:?}"); +} + +#[test] +fn phase2_r02_failed_add_preserves_dirty_registered_module() { + let h = common::TestHarness::new().unwrap(); + h.init_git_repo().unwrap(); + let remote = h.create_test_remote("occupied-module").unwrap(); + h.git_stdout(&[ + "submodule", + "add", + "--name", + "logical", + remote.to_str().unwrap(), + "child", + ]); + h.create_config(&format!( + "[alias]\npath = \"child\"\nurl = {:?}\n", + remote.to_str().unwrap() + )) + .unwrap(); + let child = h.work_dir.join("child"); + std::fs::write(child.join("LICENSE"), b"staged change\0\xff").unwrap(); + h.git_at(&child, &["add", "LICENSE"]); + std::fs::write(child.join("LICENSE"), b"unstaged change\0\xff").unwrap(); + std::fs::write(child.join("untracked"), b"untracked\0\xff").unwrap(); + let refs = h.git_at(&child, &["show-ref"]); + let index = h.git_at(&child, &["ls-files", "--stage"]); + let config = h.git_at(&child, &["config", "--local", "--list"]); + let before = h.preservation_snapshot(); + let missing = h.temp_dir.path().join("missing.git"); + let output = h + .run_submod(&[ + "add", + missing.to_str().unwrap(), + "--name", + "alias", + "--path", + "child", + ]) + .unwrap(); + assert_eq!( + std::fs::read(child.join("LICENSE")).ok().as_deref(), + Some(b"unstaged change\0\xff".as_slice()), + "{output:?}" + ); + assert_eq!( + std::fs::read(child.join("untracked")).unwrap(), + b"untracked\0\xff" + ); + assert_eq!(h.git_at(&child, &["show-ref"]), refs); + assert_eq!(h.git_at(&child, &["ls-files", "--stage"]), index); + assert_eq!(h.git_at(&child, &["config", "--local", "--list"]), config); + assert_eq!(h.preservation_snapshot(), before); + assert!(!output.status.success(), "{output:?}"); +} + +#[test] +fn phase2_r02_failed_add_preserves_occupied_file() { + let h = common::TestHarness::new().unwrap(); + h.init_git_repo().unwrap(); + h.create_config("[defaults]\n").unwrap(); + std::fs::write(h.work_dir.join("occupied"), b"valuable file\0\xff").unwrap(); + let before = h.preservation_snapshot(); + let missing = h.temp_dir.path().join("missing.git"); + let output = h + .run_submod(&[ + "add", + missing.to_str().unwrap(), + "--name", + "m", + "--path", + "occupied", + ]) + .unwrap(); + assert_eq!( + std::fs::read(h.work_dir.join("occupied")).ok().as_deref(), + Some(b"valuable file\0\xff".as_slice()), + "{output:?}" + ); + assert_eq!(h.preservation_snapshot(), before); + assert!(!output.status.success(), "{output:?}"); +} + +#[cfg(unix)] +#[test] +fn phase2_r02_failed_add_preserves_occupied_symlink() { + let h = common::TestHarness::new().unwrap(); + h.init_git_repo().unwrap(); + h.create_config("[defaults]\n").unwrap(); + let outside = h.temp_dir.path().join("outside"); + std::fs::create_dir(&outside).unwrap(); + std::fs::write(outside.join("sentinel"), b"outside bytes\0\xff").unwrap(); + std::os::unix::fs::symlink(&outside, h.work_dir.join("occupied")).unwrap(); + let before = h.preservation_snapshot(); + let missing = h.temp_dir.path().join("missing.git"); + let output = h + .run_submod(&[ + "add", + missing.to_str().unwrap(), + "--name", + "m", + "--path", + "occupied", + ]) + .unwrap(); + assert_eq!( + std::fs::read(outside.join("sentinel")).unwrap(), + b"outside bytes\0\xff" + ); + assert_eq!( + std::fs::read_link(h.work_dir.join("occupied")).unwrap(), + outside + ); + assert_eq!(h.preservation_snapshot(), before); + assert!(!output.status.success(), "{output:?}"); +} + +// Capture all bytes, including an ignored nested repository's administrative files. +fn acceptance_tree_bytes( + root: &std::path::Path, +) -> std::collections::BTreeMap> { + fn visit( + root: &std::path::Path, + path: &std::path::Path, + files: &mut std::collections::BTreeMap>, + ) { + for entry in fs::read_dir(path).unwrap() { + let entry = entry.unwrap(); + if entry.file_type().unwrap().is_dir() { + visit(root, &entry.path(), files); + } else { + files.insert( + entry.path().strip_prefix(root).unwrap().to_owned(), + fs::read(entry.path()).unwrap(), + ); + } + } + } + let mut files = std::collections::BTreeMap::new(); + visit(root, root, &mut files); + files +} + +fn acceptance_traced_reset(h: &TestHarness) -> (std::process::Output, Vec) { + let trace = h.temp_dir.path().join("reset-trace.json"); + let mut command = std::process::Command::new(&h.submod_bin); + // Reuse exactly the fixture's child environment without process-global changes. + for (key, value) in h.git_cmd().get_envs() { + match value { + Some(value) => { + command.env(key, value); + } + None => { + command.env_remove(key); + } + } + } + let output = command + .args(["reset", "nickname"]) + .current_dir(&h.work_dir) + .env("GIT_TRACE2_EVENT", &trace) + .output() + .unwrap(); + let trace = fs::read_to_string(trace).expect("native Git must produce trace evidence"); + let names: Vec = trace + .lines() + .filter(|line| line.contains("\"event\":\"cmd_name\"")) + .map(|line| { + line.split("\"name\":\"") + .nth(1) + .unwrap() + .split('"') + .next() + .unwrap() + .to_owned() + }) + .collect(); + assert!( + !names.is_empty(), + "trace must contain actual native Git commands" + ); + // Git stash -u itself runs stash/clean after saving the untracked objects. + // Reject an application clean command, while retaining native stash behavior. + for line in trace.lines().filter(|line| { + line.contains("\"event\":\"cmd_name\"") && line.contains("\"name\":\"clean\"") + }) { + let hierarchy = line + .split("\"hierarchy\":\"") + .nth(1) + .unwrap() + .split('"') + .next() + .unwrap(); + assert!( + hierarchy.split('/').any(|command| command == "stash"), + "reset must not run a standalone git clean: {line}" + ); + } + (output, names) +} + +fn acceptance_reset_collision(kind: &str) { + let h = TestHarness::new().unwrap(); + h.init_git_repo().unwrap(); + let remote = h.create_test_remote("reset-collision").unwrap(); + h.git_stdout(&[ + "submodule", + "add", + "--name", + "logical", + remote.to_str().unwrap(), + "child", + ]); + h.create_config(&format!( + "[nickname]\npath = \"child\"\nurl = {:?}\n", + remote.to_str().unwrap() + )) + .unwrap(); + let child = h.work_dir.join("child"); + let old_head = h.git_at(&child, &["rev-parse", "HEAD"]); + fs::write(child.join("collision"), b"tracked parent target\n").unwrap(); + h.git_at(&child, &["add", "collision"]); + h.git_at( + &child, + &["commit", "-m", "target introduces tracked collision"], + ); + let target = h.git_at(&child, &["rev-parse", "HEAD"]); + h.git_stdout(&["add", "child", "submod.toml"]); + h.git_stdout(&["commit", "-m", "record reset target"]); + assert_eq!(h.git_stdout(&["rev-parse", "HEAD:child"]), target); + assert!( + h.git_stdout(&["ls-files", "--stage", "--", "child"]) + .starts_with(&format!("160000 {target} ")) + ); + h.git_at(&child, &["checkout", "--detach", &old_head]); + assert_ne!(old_head, target); + fs::write(child.join("LICENSE"), b"prior stash\0\xff").unwrap(); + h.git_at(&child, &["stash", "push", "-m", "prior stash"]); + let stash = h.git_at(&child, &["rev-parse", "refs/stash"]); + let gitdir = std::path::PathBuf::from(h.git_at(&child, &["rev-parse", "--absolute-git-dir"])); + fs::write(gitdir.join("info/exclude"), "/collision\n").unwrap(); + if kind == "file" { + fs::write(child.join("collision"), b"ignored priceless\0\xff").unwrap(); + } else { + let collision = child.join("collision"); + fs::create_dir(&collision).unwrap(); + fs::write( + collision.join("priceless"), + b"ignored nested priceless\0\xff", + ) + .unwrap(); + if kind == "repository" { + h.git_at(&collision, &["init", "-b", "main"]); + h.git_at(&collision, &["add", "priceless"]); + h.git_at(&collision, &["commit", "-m", "nested local history"]); + } + } + assert_eq!( + h.git_at(&child, &["check-ignore", "collision"]), + "collision" + ); + fs::write(child.join("LICENSE"), b"staged bytes\0\xff").unwrap(); + h.git_at(&child, &["add", "LICENSE"]); + fs::write(child.join("LICENSE"), b"unstaged bytes\0\xff").unwrap(); + fs::write(child.join("untracked"), b"untracked bytes\0\xff").unwrap(); + let tree = acceptance_tree_bytes(&child); + let parent = h.preservation_snapshot(); + let parent_index = fs::read(h.work_dir.join(".git/index")).unwrap(); + let index = fs::read(gitdir.join("index")).unwrap(); + let index_entries = h.git_at(&child, &["ls-files", "--stage"]); + let config = fs::read(gitdir.join("config")).unwrap(); + let refs = h.git_at(&child, &["show-ref"]); + let (output, commands) = acceptance_traced_reset(&h); + assert!(!output.status.success(), "{kind}: {output:?}"); + assert!( + !commands + .iter() + .any(|name| ["stash", "reset", "checkout"].contains(&name.as_str())), + "collision must refuse before preservation or checkout: {commands:?}" + ); + assert_eq!(acceptance_tree_bytes(&child), tree, "{kind}: {output:?}"); + assert_eq!(fs::read(gitdir.join("index")).unwrap(), index, "{kind}"); + assert_eq!(h.git_at(&child, &["ls-files", "--stage"]), index_entries); + assert_eq!(fs::read(gitdir.join("config")).unwrap(), config); + assert_eq!(h.git_at(&child, &["rev-parse", "HEAD"]), old_head); + assert_eq!(h.git_at(&child, &["show-ref"]), refs); + assert_eq!(h.git_at(&child, &["rev-parse", "refs/stash"]), stash); + assert_eq!(h.git_at(&child, &["cat-file", "-t", &stash]), "commit"); + assert_eq!(h.preservation_snapshot(), parent); + assert_eq!( + fs::read(h.work_dir.join(".git/index")).unwrap(), + parent_index + ); + assert_eq!(h.git_stdout(&["rev-parse", "HEAD:child"]), target); +} + +#[test] +fn phase2_acceptance_r05_ignored_file_collision_refuses_before_stash() { + acceptance_reset_collision("file"); +} +#[test] +fn phase2_acceptance_r05_ignored_directory_collision_refuses_before_stash() { + acceptance_reset_collision("directory"); +} +#[test] +fn phase2_acceptance_r05_ignored_nested_repository_collision_refuses_before_stash() { + acceptance_reset_collision("repository"); +} + +#[test] +fn phase2_acceptance_r05_successful_reset_reaches_pin_and_recovers_work() { + let h = TestHarness::new().unwrap(); + h.init_git_repo().unwrap(); + let remote = h.create_test_remote("reset-success").unwrap(); + h.git_stdout(&[ + "submodule", + "add", + "--name", + "logical", + remote.to_str().unwrap(), + "child", + ]); + h.create_config(&format!( + "[nickname]\npath = \"child\"\nurl = {:?}\n", + remote.to_str().unwrap() + )) + .unwrap(); + h.git_stdout(&["add", "submod.toml"]); + h.git_stdout(&["commit", "-m", "record parent pin"]); + let target = h.git_stdout(&["rev-parse", "HEAD:child"]); + let child = h.work_dir.join("child"); + assert_eq!(h.git_at(&child, &["rev-parse", "HEAD"]), target); + fs::write(child.join("local-only"), b"local history\0\xff").unwrap(); + h.git_at(&child, &["add", "local-only"]); + h.git_at(&child, &["commit", "-m", "local child commit beyond pin"]); + let old_head = h.git_at(&child, &["rev-parse", "HEAD"]); + assert_ne!(old_head, target); + fs::write(child.join("LICENSE"), b"prior stash\0\xff").unwrap(); + h.git_at(&child, &["stash", "push", "-m", "prior stash"]); + let prior_stash = h.git_at(&child, &["rev-parse", "refs/stash"]); + fs::write(child.join("LICENSE"), b"staged original\0\xff").unwrap(); + h.git_at(&child, &["add", "LICENSE"]); + fs::write(child.join("LICENSE"), b"unstaged original\0\xff").unwrap(); + fs::write(child.join("untracked"), b"untracked original\0\xff").unwrap(); + let original_index = h.git_at(&child, &["ls-files", "--stage"]); + let gitdir = std::path::PathBuf::from(h.git_at(&child, &["rev-parse", "--absolute-git-dir"])); + fs::write(gitdir.join("info/exclude"), "/ignored-precious\n").unwrap(); + fs::write(child.join("ignored-precious"), b"keep ignored\0\xff").unwrap(); + let parent = h.preservation_snapshot(); + let (output, commands) = acceptance_traced_reset(&h); + assert!(output.status.success(), "{output:?}"); + assert!( + commands.iter().any(|name| name == "stash"), + "successful reset must preserve dirty work: {commands:?}" + ); + assert_eq!(h.git_at(&child, &["rev-parse", "HEAD"]), target); + assert!(!child.join("local-only").exists()); + assert_eq!(fs::read(child.join("LICENSE")).unwrap(), b"MIT License\n"); + assert!(!child.join("untracked").exists()); + assert_eq!( + fs::read(child.join("ignored-precious")).unwrap(), + b"keep ignored\0\xff" + ); + assert_eq!(h.preservation_snapshot(), parent); + assert_eq!(h.git_stdout(&["rev-parse", "HEAD:child"]), target); + assert!( + h.git_stdout(&["ls-files", "--stage", "--", "child"]) + .starts_with(&format!("160000 {target} ")) + ); + let stash = h.git_at(&child, &["rev-parse", "refs/stash"]); + assert_ne!(stash, prior_stash); + assert_eq!(h.git_at(&child, &["cat-file", "-t", &stash]), "commit"); + assert_eq!( + h.git_at(&child, &["cat-file", "-t", &prior_stash]), + "commit" + ); + assert_eq!( + h.git_at(&child, &["rev-parse", &format!("{stash}^1")]), + old_head + ); + let recovery = h.temp_dir.path().join("recovery"); + h.git_at( + &child, + &[ + "worktree", + "add", + "--detach", + recovery.to_str().unwrap(), + &old_head, + ], + ); + h.git_at(&recovery, &["stash", "apply", "--index", &stash]); + assert_eq!( + h.git_at(&recovery, &["ls-files", "--stage"]), + original_index + ); + assert_eq!( + fs::read(recovery.join("LICENSE")).unwrap(), + b"unstaged original\0\xff" + ); + assert_eq!( + fs::read(recovery.join("untracked")).unwrap(), + b"untracked original\0\xff" + ); + assert_eq!( + fs::read(recovery.join("local-only")).unwrap(), + b"local history\0\xff" + ); + assert_eq!(h.git_at(&child, &["rev-parse", "HEAD"]), target); + assert_eq!(h.git_at(&child, &["rev-parse", "refs/stash"]), stash); +} + +#[test] +fn phase2_acceptance_custom_portable_update_refused_without_execution() { + let h = TestHarness::new().unwrap(); + h.init_git_repo().unwrap(); + let remote = h.create_test_remote("portable-custom-update").unwrap(); + h.git_stdout(&[ + "submodule", + "add", + "--name", + "logical", + remote.to_str().unwrap(), + "child", + ]); + h.create_config(&format!( + "[nickname]\npath = \"child\"\nurl = {:?}\n", + remote.to_str().unwrap() + )) + .unwrap(); + h.run_submod_success(&["update"]).unwrap(); + h.git_stdout(&[ + "config", + "--file", + ".gitmodules", + "submodule.logical.update", + "!touch custom-update-executed", + ]); + h.git_stdout(&["add", ".gitmodules"]); + let child = h.work_dir.join("child"); + let gitdir = std::path::PathBuf::from(h.git_at(&child, &["rev-parse", "--absolute-git-dir"])); + let parent = h.preservation_snapshot(); + let parent_index = fs::read(h.work_dir.join(".git/index")).unwrap(); + let checkout = acceptance_tree_bytes(&child); + let index = fs::read(gitdir.join("index")).unwrap(); + let config = fs::read(gitdir.join("config")).unwrap(); + let refs = h.git_at(&child, &["show-ref"]); + let head = h.git_at(&child, &["rev-parse", "HEAD"]); + let output = h.run_submod(&["update"]).unwrap(); + assert!(!output.status.success(), "{output:?}"); + assert!(!child.join("custom-update-executed").exists(), "{output:?}"); + assert!( + !h.work_dir.join("custom-update-executed").exists(), + "{output:?}" + ); + assert_eq!(acceptance_tree_bytes(&child), checkout); + assert_eq!(h.preservation_snapshot(), parent); + assert_eq!( + fs::read(h.work_dir.join(".git/index")).unwrap(), + parent_index + ); + assert_eq!(fs::read(gitdir.join("index")).unwrap(), index); + assert_eq!(fs::read(gitdir.join("config")).unwrap(), config); + assert_eq!(h.git_at(&child, &["show-ref"]), refs); + assert_eq!(h.git_at(&child, &["rev-parse", "HEAD"]), head); +} + +#[cfg(test)] +mod phase3_acceptance_persistence { + use super::*; + use std::path::Path; + use std::process::{Child, Command, Stdio}; + use std::time::{Duration, Instant}; + + fn spawn(h: &TestHarness, cwd: &Path, config: &Path, field: &str, value: &str) -> Child { + Command::new(&h.submod_bin) + .current_dir(cwd) + .env("GIT_CONFIG_GLOBAL", h.temp_dir.path().join("gitconfig")) + .env("GIT_CONFIG_NOSYSTEM", "1") + .env("GIT_TERMINAL_PROMPT", "0") + .args([ + "--config", + config.to_str().unwrap(), + "change-global", + field, + value, ]) - .expect("Failed to run submod"); + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap() + } - // Should handle existing directory appropriately - if !output.status.success() { - assert_eq!(output.status.code(), Some(1)); - let stderr = String::from_utf8_lossy(&output.stderr); + fn finish(mut child: Child) { + let deadline = Instant::now() + Duration::from_secs(20); + loop { + if child.try_wait().unwrap().is_some() { + let output = child.wait_with_output().unwrap(); + assert!(output.status.success(), "writer failed: {output:?}"); + return; + } + if Instant::now() > deadline { + child.kill().unwrap(); + let output = child.wait_with_output().unwrap(); + panic!("writer deadlocked: {output:?}"); + } + std::thread::sleep(Duration::from_millis(10)); + } + } + + fn config_lock(config: &Path) -> std::path::PathBuf { + config + .parent() + .unwrap() + .canonicalize() + .unwrap() + .join(format!( + "{}.submod.lock", + config.file_name().unwrap().to_str().unwrap() + )) + } + + fn common_lock(h: &TestHarness, cwd: &Path) -> std::path::PathBuf { + let common = h.git_at( + cwd, + &["rev-parse", "--path-format=absolute", "--git-common-dir"], + ); + Path::new(common.trim()) + .canonicalize() + .unwrap() + .join("submod.lock") + } + + fn release_after_arrival( + children: &mut [Child], + arrivals: &[std::path::PathBuf], + gates: &[std::path::PathBuf], + before_release: impl FnOnce(), + ) { + let deadline = Instant::now() + Duration::from_secs(5); + let problem = loop { + if children + .iter_mut() + .any(|child| child.try_wait().unwrap().is_some()) + { + break Some("writer exited before lock arrival"); + } + if arrivals.iter().all(|path| path.is_file()) { + break None; + } + if Instant::now() >= deadline { + break Some("lock-arrival barrier timed out"); + } + std::thread::sleep(Duration::from_millis(10)); + }; + if problem.is_none() { + before_release(); + } + for gate in gates { + fs::remove_file(gate).unwrap(); + } + if let Some(problem) = problem { + for child in children { + let _ = child.kill(); + let status = child.wait().unwrap(); + let mut stderr = String::new(); + std::io::Read::read_to_string(&mut child.stderr.take().unwrap(), &mut stderr) + .unwrap(); + eprintln!("writer {status}: {stderr}"); + } + panic!("{problem}; expected lock paths: {arrivals:?}"); + } + } + + fn writers(layout: &str) { + let h = TestHarness::new().unwrap(); + h.init_git_repo().unwrap(); + let linked = h.temp_dir.path().join("linked"); + let other = h.temp_dir.path().join("repo-b"); + if layout == "linked" { + h.git_stdout(&[ + "worktree", + "add", + "-b", + "acceptance-linked", + linked.to_str().unwrap(), + ]); + } + if layout == "shared" { + fs::create_dir(&other).unwrap(); + h.git_at(&other, &["init"]); + } + let second_cwd = match layout { + "linked" => linked.as_path(), + "shared" => other.as_path(), + _ => h.work_dir.as_path(), + }; + // Keep output locks lexically after both canonical common-dir locks. + let configs = h.temp_dir.path().join("z-configs"); + fs::create_dir(&configs).unwrap(); + let first_config = configs.join("a.toml"); + fs::write(&first_config, "# retain\n[defaults]\nignore='dirty'\n").unwrap(); + let second_config = if layout == "different" || layout == "linked" { + let path = configs.join("b.toml"); + fs::write(&path, "# retain\n[defaults]\nignore='dirty'\n").unwrap(); + path + } else { + first_config.clone() + }; + let first_common = common_lock(&h, &h.work_dir); + let second_common = common_lock(&h, second_cwd); + let first_output = config_lock(&first_config); + let second_output = config_lock(&second_config); + assert!(first_common < first_output); + assert!(second_common < second_output); + if layout == "shared" { + assert_ne!(first_common, second_common); + assert_eq!(first_output, second_output); + } else { + assert_eq!(first_common, second_common); + } + let mut gates = vec![first_output.clone(), second_output.clone()]; + gates.sort(); + gates.dedup(); + for gate in &gates { + fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(gate) + .unwrap(); + } + let mut children = vec![ + spawn(&h, &h.work_dir, &first_config, "--ignore", "all"), + spawn(&h, second_cwd, &second_config, "--fetch", "never"), + ]; + // Distinct repositories must BOTH acquire their common lock before release. + // Shared-common-dir writers require that common lock plus both live children. + release_after_arrival( + &mut children, + &[first_common.clone(), second_common.clone()], + &gates, + || {}, + ); + for child in children { + finish(child); + } + let first_doc: toml::Value = + toml::from_str(&fs::read_to_string(&first_config).unwrap()).unwrap(); + let second_doc: toml::Value = + toml::from_str(&fs::read_to_string(&second_config).unwrap()).unwrap(); + assert_eq!(first_doc["defaults"]["ignore"].as_str(), Some("all")); + assert_eq!( + second_doc["defaults"]["fetchRecurse"].as_str(), + Some("never") + ); + for (cwd, config) in [ + (&h.work_dir as &Path, &first_config), + (second_cwd, &second_config), + ] { + assert!(fs::read_to_string(config).unwrap().contains("# retain")); + let output = h + .run_submod_at(cwd, &["--config", config.to_str().unwrap(), "list"]) + .unwrap(); assert!( - stderr.contains("already exists") || stderr.contains("Failed to add submodule") + output.status.success(), + "fresh process rejected config: {output:?}" ); + assert!(!config_lock(config).exists()); } + assert!(!first_common.exists()); + assert!(!second_common.exists()); + if layout == "linked" { + let gitdir = h.git_at(second_cwd, &["rev-parse", "--absolute-git-dir"]); + assert!(!Path::new(gitdir.trim()).join("submod.lock").exists()); + } + } + + #[test] + fn r10_simultaneous_same_config() { + writers("same"); + } + #[test] + fn r10_simultaneous_different_configs() { + writers("different"); + } + #[test] + fn r10_simultaneous_linked_worktrees() { + writers("linked"); + } + #[test] + fn r10_simultaneous_shared_config_across_repositories() { + writers("shared"); + } + + fn from_setup_relative_output(relative: Option<&str>) { + let h = TestHarness::new().unwrap(); + h.init_git_repo().unwrap(); + let destination = h.work_dir.join(relative.unwrap_or("submod.toml")); + fs::create_dir_all(destination.parent().unwrap()).unwrap(); + let common = common_lock(&h, &h.work_dir); + let output_lock = config_lock(&destination); + let mut args = vec!["generate-config", "--from-setup"]; + if let Some(relative) = relative { + args.extend(["--output", relative]); + } + h.run_submod_success(&args).unwrap(); + let _: toml::Value = toml::from_str(&fs::read_to_string(&destination).unwrap()).unwrap(); + h.run_submod_success(&["--config", destination.to_str().unwrap(), "list"]) + .unwrap(); + assert!(!common.exists(), "common lock leaked"); + assert!(!output_lock.exists(), "output lock leaked"); + if relative.is_some() { + assert!( + !h.config_path().exists(), + "explicit relative output also wrote default output" + ); + } + } + + #[test] + fn r10_from_setup_default_relative_output() { + from_setup_relative_output(None); + } + + #[test] + fn r10_from_setup_relative_subdirectory_output() { + from_setup_relative_output(Some("nested/generated.toml")); + } + + #[test] + fn r10_from_setup_acquires_common_lock_before_output_lock() { + let h = TestHarness::new().unwrap(); + h.init_git_repo().unwrap(); + let output = h.work_dir.join("generated.toml"); + let common = common_lock(&h, &h.work_dir); + let gate = config_lock(&output); + assert!(common < gate, "fixture must hold the later-sorting lock"); + assert!(!common.exists()); + fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(&gate) + .unwrap(); + let child = Command::new(&h.submod_bin) + .current_dir(&h.work_dir) + .env("GIT_CONFIG_GLOBAL", h.temp_dir.path().join("gitconfig")) + .env("GIT_CONFIG_NOSYSTEM", "1") + .env("GIT_TERMINAL_PROMPT", "0") + .args(["generate-config", "--from-setup", "--output"]) + .arg(&output) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap(); + let mut children = vec![child]; + release_after_arrival( + &mut children, + std::slice::from_ref(&common), + std::slice::from_ref(&gate), + || { + assert!( + !output.exists(), + "output written before acquiring output lock" + ); + }, + ); + finish(children.pop().unwrap()); + let _: toml::Value = toml::from_str(&fs::read_to_string(&output).unwrap()).unwrap(); + h.run_submod_success(&["--config", output.to_str().unwrap(), "list"]) + .unwrap(); + assert!(!common.exists()); + assert!(!gate.exists()); + } + + #[test] + fn r10_reloads_config_changed_after_preload_before_lock() { + let h = TestHarness::new().unwrap(); + h.init_git_repo().unwrap(); + h.create_config("[defaults]\nignore='dirty'\n").unwrap(); + let config = h.config_path(); + let common = common_lock(&h, &h.work_dir); + let output = config_lock(&config); + assert!(common < output); + fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(&output) + .unwrap(); + let mut children = vec![spawn(&h, &h.work_dir, &config, "--ignore", "all")]; + // Observing the first acquired lock proves construction/preload finished, + // while the fixture-owned second lock prevents the locked reload. + release_after_arrival( + &mut children, + std::slice::from_ref(&common), + std::slice::from_ref(&output), + || { + fs::write(&config, "# external edit after preload\n[defaults]\nignore='dirty'\nfetchRecurse='never'\n").unwrap(); + }, + ); + finish(children.pop().unwrap()); + let after = fs::read_to_string(&config).unwrap(); + let raw: toml::Value = toml::from_str(&after).unwrap(); + assert_eq!(raw["defaults"]["ignore"].as_str(), Some("all")); + assert_eq!(raw["defaults"]["fetchRecurse"].as_str(), Some("never")); + assert!(after.contains("# external edit after preload")); + assert!(!common.exists()); + assert!(!output.exists()); + h.run_submod_success(&["list"]).unwrap(); + } + + #[cfg(unix)] + #[test] + fn r10_unwritable_parent_preserves_bytes_and_releases_locks() { + let h = TestHarness::new().unwrap(); + h.init_git_repo().unwrap(); + h.create_config("[defaults]\nignore='dirty'\n").unwrap(); + let before = fs::read(h.config_path()).unwrap(); + let mode = fs::metadata(&h.work_dir).unwrap().permissions(); + fs::set_permissions(&h.work_dir, fs::Permissions::from_mode(0o555)).unwrap(); + let output = h.run_submod(&["change-global", "--ignore", "all"]).unwrap(); + fs::set_permissions(&h.work_dir, mode).unwrap(); + assert!( + !output.status.success(), + "unwritable parent accepted: {output:?}" + ); + assert_eq!(fs::read(h.config_path()).unwrap(), before); + let _: toml::Value = toml::from_str(&h.read_config().unwrap()).unwrap(); + assert!(!h.work_dir.join(".git/submod.lock").exists()); + assert!(!h.work_dir.join("submod.toml.submod.lock").exists()); + h.run_submod_success(&["change-global", "--ignore", "all"]) + .unwrap(); + h.run_submod_success(&["list"]).unwrap(); } } diff --git a/tests/fallback_tests.rs b/tests/fallback_tests.rs index 71f80316..69d1a8f5 100644 --- a/tests/fallback_tests.rs +++ b/tests/fallback_tests.rs @@ -2,20 +2,13 @@ // // SPDX-License-Identifier: LicenseRef-PlainMIT OR MIT -//! Tests for the gix→git2 fallback architecture. -//! -//! The core design of the `git_ops` layer is "gix first, git2 fallback, CLI last resort". -//! These tests verify that: -//! - When gix fails, the error propagates and git2 is invoked -//! - The fallback produces correct results (not silent failures) -//! - Operations that gix intentionally doesn't implement correctly fall through -//! - The `GitOpsManager` with only git2 (no gix) works for all operations +//! Read-backend compatibility and native CLI mutation state tests. mod common; use common::TestHarness; use std::collections::HashMap; -use submod::config::{SubmoduleAddOptions, SubmoduleEntries, SubmoduleEntry}; +use submod::config::{SubmoduleEntries, SubmoduleEntry}; use submod::git_ops::{Git2Operations, GitConfig, GitOperations, GitOpsManager, GixOperations}; use submod::options::ConfigLevel; @@ -27,51 +20,37 @@ use submod::options::ConfigLevel; mod fallback_behavior_tests { use super::*; - /// Verify that gix returns errors for operations it explicitly doesn't support, - /// confirming the fallback will be needed. + /// The gix backend is read-only: it serves the read contract the manager's + /// inspection fallback relies on (gitmodules, config, list). Mutations go + /// through the manager's native Git path instead. #[test] - fn gix_returns_error_for_unimplemented_operations() { + fn gix_serves_manager_read_contract() { let harness = TestHarness::new().expect("harness"); harness.init_git_repo().expect("init repo"); let gix = GixOperations::new(Some(&harness.work_dir)).expect("gix should init"); - // These operations all return explicit "not implemented/supported" errors in gix - assert!( - gix.reset_submodule("any", true).is_err(), - "gix.reset_submodule should return error" - ); - assert!( - gix.clean_submodule("any", true, true).is_err(), - "gix.clean_submodule should return error" - ); - assert!( - gix.stash_submodule("any", true).is_err(), - "gix.stash_submodule should return error" - ); - assert!( - gix.enable_sparse_checkout("any").is_err(), - "gix.enable_sparse_checkout should return error" - ); - assert!( - gix.set_sparse_patterns("any", &["src".to_string()]) - .is_err(), - "gix.set_sparse_patterns should return error" - ); - assert!( - gix.get_sparse_patterns("any").is_err(), - "gix.get_sparse_patterns should return error" + let entries = gix.read_gitmodules().expect("gix read_gitmodules"); + assert_eq!( + entries.submodule_iter().count(), + 0, + "fresh repo has no submodule entries" ); + let subs = gix.list_submodules().expect("gix list_submodules"); + assert!(subs.is_empty(), "fresh repo lists no submodules"); + let config = gix + .read_git_config(ConfigLevel::Local) + .expect("gix read_git_config"); assert!( - gix.get_submodule_status("any").is_err(), - "gix.get_submodule_status should return error" + !config.entries.is_empty(), + "init_git_repo seeds local config entries" ); } - /// When gix can't handle an operation, the manager should succeed via git2 fallback - /// for operations where git2 has a real implementation. + /// The manager writes natively; the write must be visible through the + /// retained backend read APIs and real Git state. #[test] - fn manager_write_gitmodules_succeeds_despite_gix_limitations() { + fn manager_write_gitmodules_visible_to_backend_readers() { let harness = TestHarness::new().expect("harness"); harness.init_git_repo().expect("init repo"); @@ -105,10 +84,10 @@ mod fallback_behavior_tests { ); } - /// The 2-part config key test: gix rejects these, git2 handles them. - /// This tests the actual fallback path where gix fails and git2 succeeds. + /// Native config writes accept 2-part keys; the value must be visible + /// through the retained git2 read API and real Git state. #[test] - fn config_write_falls_back_to_git2_for_two_part_keys() { + fn config_write_accepts_two_part_keys() { let harness = TestHarness::new().expect("harness"); harness.init_git_repo().expect("init repo"); @@ -119,9 +98,9 @@ mod fallback_behavior_tests { entries.insert("submod.testkey".to_string(), "testval".to_string()); let config = GitConfig { entries }; - // Manager should succeed via git2 fallback + // Manager writes natively; the value must land in the local config mgr.write_git_config(&config, ConfigLevel::Local) - .expect("write_git_config should succeed via git2 fallback"); + .expect("write_git_config should succeed"); // Verify the value was actually written let git2_ops = Git2Operations::new(Some(&harness.work_dir)).expect("git2"); @@ -131,21 +110,21 @@ mod fallback_behavior_tests { assert_eq!( read_back.entries.get("submod.testkey").map(String::as_str), Some("testval"), - "value written via fallback should be readable" + "natively written value should be readable" ); } - /// Verify `set_config_value` also uses fallback for 2-part keys. + /// `set_config_value` persists 2-part keys through the native write path. #[test] - fn set_config_value_falls_back_for_two_part_keys() { + fn set_config_value_persists_two_part_keys() { let harness = TestHarness::new().expect("harness"); harness.init_git_repo().expect("init repo"); let mgr = GitOpsManager::new(Some(&harness.work_dir), true).expect("mgr"); - // 2-part key triggers gix failure → git2 fallback + // 2-part keys are written natively and readable via the git2 reader mgr.set_config_value("submod.fallbackkey", "fallbackval", ConfigLevel::Local) - .expect("set_config_value should succeed via fallback"); + .expect("set_config_value should succeed"); // Verify let git2_ops = Git2Operations::new(Some(&harness.work_dir)).expect("git2"); @@ -187,32 +166,27 @@ mod fallback_submodule_tests { Ok(remote_url) } - /// `add_submodule`: gix explicitly doesn't implement this, so it must fall through - /// to git2, and if that fails, to CLI. Verify the result is correct. + /// Native CLI additions remain visible through the manager read API. #[test] - fn add_submodule_works_through_fallback() { + fn native_add_is_visible_to_manager() { let harness = TestHarness::new().expect("harness"); harness.init_git_repo().expect("init repo"); let remote = harness.create_test_remote("add_fb").expect("create remote"); let remote_url = format!("file://{}", remote.display()); - let mut mgr = GitOpsManager::new(Some(&harness.work_dir), true).expect("mgr"); + let mgr = GitOpsManager::new(Some(&harness.work_dir), true).expect("mgr"); - let opts = SubmoduleAddOptions { - url: remote_url, - path: std::path::PathBuf::from("lib/added"), - name: "added-sub".to_string(), - branch: None, - ignore: None, - update: None, - fetch_recurse: None, - shallow: false, - no_init: false, - }; - - mgr.add_submodule(&opts) - .expect("add_submodule should succeed via fallback"); + harness + .run_submod_success(&[ + "add", + &remote_url, + "--name", + "added-sub", + "--path", + "lib/added", + ]) + .expect("native add"); // Verify it was actually added let subs = mgr.list_submodules().expect("list_submodules"); @@ -252,44 +226,6 @@ mod fallback_submodule_tests { assert!(!subs.is_empty(), "should list the added submodule"); } - /// `apply_sparse_checkout` has a triple fallback (gix → git2 → CLI). - /// Both gix and git2 fail for this, so it must reach the CLI fallback. - /// With a valid submodule path, the CLI fallback should succeed. - #[test] - fn apply_sparse_checkout_reaches_cli_fallback() { - let harness = TestHarness::new().expect("harness"); - let _url = setup_repo_with_submodule(&harness).expect("setup"); - - let mgr = GitOpsManager::new(Some(&harness.work_dir), true).expect("mgr"); - - let submodule_path = harness.work_dir.join("lib/fallback"); - - // Enable sparse checkout first using git commands directly - let _ = std::process::Command::new("git") - .args([ - "-C", - submodule_path.to_str().unwrap(), - "config", - "core.sparseCheckout", - "true", - ]) - .output(); - - // apply_sparse_checkout goes through gix (fail) → git2 (fail) → CLI - // With a valid path, the CLI git read-tree should succeed - let result = mgr.apply_sparse_checkout(submodule_path.to_str().unwrap()); - // This may or may not succeed depending on the state, but it should - // NOT silently succeed without doing anything — it should either - // actually run git read-tree or return a clear error. - if let Err(e) = &result { - let msg = format!("{e:?}"); - assert!( - msg.contains("git read-tree") || msg.contains("read-tree"), - "CLI fallback error should mention git read-tree, got: {msg}" - ); - } - } - /// Nonexistent path should fail through all three layers with a clear error. #[test] fn apply_sparse_checkout_fails_cleanly_for_bad_path() { @@ -376,9 +312,10 @@ mod backend_consistency_tests { ); } - /// Write with one backend, read with the other — the roundtrip should preserve data. + /// Native manager write must land in real Git state: the `.gitmodules` + /// file on disk must contain the written path and url. #[test] - fn write_gix_read_git2_roundtrip() { + fn native_write_visible_in_git_state_and_git2_reader() { let harness = TestHarness::new().expect("harness"); harness.init_git_repo().expect("init repo"); @@ -397,25 +334,31 @@ mod backend_consistency_tests { map.insert("roundtrip-sub".to_string(), entry); let entries = SubmoduleEntries::new(Some(map), None); - // Write with gix - let mut gix = GixOperations::new(Some(&harness.work_dir)).expect("gix"); - gix.write_gitmodules(&entries) - .expect("gix write_gitmodules"); + // Write via the native manager path + let mut mgr = GitOpsManager::new(Some(&harness.work_dir), false).expect("mgr"); + mgr.write_gitmodules(&entries) + .expect("manager write_gitmodules"); + + // Real Git state must contain the written registration + assert!( + harness.gitmodules_entries().contains("lib/roundtrip"), + "native write must persist lib/roundtrip in .gitmodules" + ); - // Read back with git2 + // Read back with the retained git2 reader let git2 = Git2Operations::new(Some(&harness.work_dir)).expect("git2"); let read_back = git2.read_gitmodules().expect("git2 read_gitmodules"); assert_eq!( read_back.submodule_iter().count(), 1, - "git2 should read what gix wrote" + "git2 should read what the manager wrote" ); } /// Manager write → both backends can read. - /// Uses the manager (which writes via gix or git2 fallback) and verifies - /// both backends can read the result. + /// Uses the manager (which writes natively) and verifies both retained + /// backend readers can read the result. #[test] fn manager_write_both_backends_read() { let harness = TestHarness::new().expect("harness"); @@ -514,36 +457,6 @@ mod error_propagation_tests { ); } - /// gix `fetch_submodule` on an invalid path should return Err. - #[test] - fn gix_fetch_submodule_propagates_error() { - let harness = TestHarness::new().expect("harness"); - harness.init_git_repo().expect("init repo"); - - let gix = GixOperations::new(Some(&harness.work_dir)).expect("gix"); - - let result = gix.fetch_submodule("nonexistent/path"); - assert!( - result.is_err(), - "gix.fetch_submodule should propagate error for invalid path, not swallow it" - ); - } - - /// git2 `fetch_submodule` on an invalid path should return Err. - #[test] - fn git2_fetch_submodule_propagates_error() { - let harness = TestHarness::new().expect("harness"); - harness.init_git_repo().expect("init repo"); - - let git2 = Git2Operations::new(Some(&harness.work_dir)).expect("git2"); - - let result = git2.fetch_submodule("nonexistent/path"); - assert!( - result.is_err(), - "git2.fetch_submodule should propagate error for invalid path" - ); - } - /// Manager operations on nonexistent submodules must error, not silently pass. #[test] fn manager_operations_error_on_invalid_submodule() { @@ -637,28 +550,47 @@ mod reopen_tests { mgr.reopen().expect("second reopen"); mgr.reopen().expect("third reopen"); } + + /// Reopen preserves the backend policy: a gix-less manager must stay + /// gix-less, so git2-only test routing cannot silently gain a gix backend. + #[test] + fn reopen_preserves_without_gix_policy() { + let harness = TestHarness::new().expect("harness"); + harness.init_git_repo().expect("init repo"); + + let mut mgr = GitOpsManager::without_gix(Some(&harness.work_dir), false).expect("mgr"); + assert!(!mgr.gix_enabled(), "without_gix starts gix-less"); + + mgr.reopen().expect("reopen should succeed"); + assert!( + !mgr.gix_enabled(), + "reopen must not enable gix on a without_gix manager" + ); + + // The git2 read path still works after the refresh. + let subs = mgr.list_submodules().expect("list after reopen"); + assert!(subs.is_empty(), "fresh repo lists no submodules"); + } } // ============================================================ -// Verbose mode: verify fallback logging +// Verbose mode: flag affects logging only, never operation outcome // ============================================================ #[cfg(test)] mod verbose_fallback_tests { use super::*; - /// With verbose=true, the manager should still succeed for operations - /// that fall back to git2, just with logging. + /// With verbose=true, native writes still succeed, just with logging. #[test] - fn verbose_mode_does_not_affect_fallback_success() { + fn verbose_mode_does_not_affect_operation_success() { let harness = TestHarness::new().expect("harness"); harness.init_git_repo().expect("init repo"); // verbose=true let mgr = GitOpsManager::new(Some(&harness.work_dir), true).expect("mgr"); - // 2-part key triggers gix failure → git2 fallback - // With verbose=true, this will log to stderr but should still succeed + // Native write path; verbose mode only affects logging, not success let mut entries = HashMap::new(); entries.insert("submod.verbosetest".to_string(), "val".to_string()); let config = GitConfig { entries }; @@ -669,7 +601,7 @@ mod verbose_fallback_tests { /// With verbose=false, the manager should still succeed identically. #[test] - fn non_verbose_mode_fallback_success() { + fn non_verbose_mode_operation_success() { let harness = TestHarness::new().expect("harness"); harness.init_git_repo().expect("init repo"); @@ -689,15 +621,15 @@ mod verbose_fallback_tests { // Failure-injection seam: force git2 by disabling gix (P0-1) // // `GitOpsManager::without_gix` builds a manager with no gix backend, so every -// `try_with_fallback` call goes straight to git2. This is the only way to -// exercise git2's implementation of operations gix *does* implement -// (read/write_gitmodules, add/delete, list) for *correct results* rather than -// just "didn't panic". Without this seam, gix always wins those ops and git2's -// code is dead from the suite's perspective. +// `try_with_fallback` read goes straight to git2. This exercises git2's +// implementation of the retained reads (git config, detailed status, list) +// for *correct results* rather than just "didn't panic". Without this seam, +// gix always wins those reads and git2's code is dead from the suite's +// perspective. Mutations are native in both constructors. // ============================================================ #[cfg(test)] -mod git2_fallback_injection_tests { +mod git2_read_path_tests { use super::*; /// Set up a repo with one real submodule (name `inj-sub`, path `lib/inj`). @@ -740,9 +672,9 @@ mod git2_fallback_injection_tests { } /// git2's `read_gitmodules` must parse the *correct* path and url, not just - /// return a non-empty count. + /// return a non-empty count. Uses the retained git2 reader directly. #[test] - fn git2_fallback_reads_gitmodules_correctly() { + fn git2_reader_parses_gitmodules_fields() { let harness = TestHarness::new().expect("harness"); harness.init_git_repo().expect("init repo"); @@ -751,8 +683,8 @@ mod git2_fallback_injection_tests { std::fs::write(harness.work_dir.join(".gitmodules"), gitmodules) .expect("write .gitmodules"); - let mgr = GitOpsManager::without_gix(Some(&harness.work_dir), false).expect("mgr"); - let entries = mgr.read_gitmodules().expect("git2 read_gitmodules"); + let git2 = Git2Operations::new(Some(&harness.work_dir)).expect("git2"); + let entries = git2.read_gitmodules().expect("git2 read_gitmodules"); let entry = entries .submodule_iter() @@ -771,11 +703,10 @@ mod git2_fallback_injection_tests { ); } - /// git2's config write must persist a correct value. Routed through the - /// git2-only seam, `write_git_config` + `read_git_config` must round-trip - /// the exact value (this is the write path the manager relies on git2 for). + /// Native config writes must persist a correct value: `write_git_config` + /// plus the retained git2 `read_git_config` must round-trip the exact value. #[test] - fn git2_fallback_writes_git_config_correctly() { + fn native_write_roundtrips_through_git2_reader() { let harness = TestHarness::new().expect("harness"); harness.init_git_repo().expect("init repo"); @@ -798,11 +729,9 @@ mod git2_fallback_injection_tests { ); } - /// git2's `add_submodule` must produce *correct git state*: an index gitlink - /// at mode 160000, a `.gitmodules` entry, a `submodule.*` config section, and - /// the path must appear in `list_submodules`. + /// A native addition is registered correctly and visible to the git2 reader. #[test] - fn git2_fallback_add_produces_real_git_state() { + fn native_add_is_visible_to_git2_reader() { let harness = TestHarness::new().expect("harness"); harness.init_git_repo().expect("init repo"); let remote = harness @@ -810,34 +739,33 @@ mod git2_fallback_injection_tests { .expect("create remote"); let remote_url = format!("file://{}", remote.display()); - let mut mgr = GitOpsManager::without_gix(Some(&harness.work_dir), false).expect("mgr"); + let mgr = GitOpsManager::without_gix(Some(&harness.work_dir), false).expect("mgr"); - let opts = SubmoduleAddOptions { - url: remote_url, - path: std::path::PathBuf::from("lib/addinj"), - name: "addinj-sub".to_string(), - branch: None, - ignore: None, - update: None, - fetch_recurse: None, - shallow: false, - no_init: false, - }; - mgr.add_submodule(&opts) - .expect("git2 add_submodule should succeed"); + harness + .run_submod_success(&[ + "add", + &remote_url, + "--name", + "addinj-sub", + "--path", + "lib/addinj", + ]) + .expect("native add"); assert_eq!( harness.index_gitlink_mode("lib/addinj").as_deref(), Some("160000"), - "git2 add must stage a gitlink at mode 160000" + "native add must stage a gitlink at mode 160000" ); assert!( harness.gitmodules_entries().contains("lib/addinj"), - "git2 add must write the .gitmodules entry" + "native add must write the .gitmodules entry" ); assert!( - harness.submodule_config_entries().contains("lib/addinj"), - "git2 add must write the submodule.* config section" + harness + .submodule_config_entries() + .contains("submodule.addinj-sub.url"), + "native add must write the submodule.* config section" ); let subs = mgr.list_submodules().expect("git2 list_submodules"); assert!( @@ -846,45 +774,33 @@ mod git2_fallback_injection_tests { ); } - /// git2's `deinit_submodule(force)` removes the worktree and the - /// `submodule.*` config section. git2's `delete_submodule` deliberately - /// leaves `.gitmodules` untouched ("left to higher-level logic"), so the - /// entry persists — which is exactly why `GitManager` performs additional - /// cleanup. This characterizes that partial git2 contract. #[test] - fn git2_fallback_deinit_clears_worktree_and_config() { + fn native_delete_clears_registration_and_retains_history() { let harness = TestHarness::new().expect("harness"); setup_repo_with_submodule(&harness).expect("setup"); - - let mut mgr = GitOpsManager::without_gix(Some(&harness.work_dir), false).expect("mgr"); - - // Guards: present before delete (so the post-delete checks can't pass vacuously). + let gitdir = harness.git_stdout(&["-C", "lib/inj", "rev-parse", "--absolute-git-dir"]); + let oid = harness.git_stdout(&["-C", "lib/inj", "rev-parse", "HEAD"]); assert!( - harness.work_dir.join("lib/inj").exists(), - "submodule worktree should exist before delete" - ); - assert!( - harness.submodule_config_entries().contains("lib/inj"), - "submodule.* config should exist before delete" - ); - - mgr.deinit_submodule("lib/inj", true) - .expect("git2 deinit_submodule"); - mgr.delete_submodule("lib/inj") - .expect("git2 delete_submodule"); - - assert!( - !harness.work_dir.join("lib/inj").exists(), - "git2 deinit(force) must remove the submodule worktree" + harness + .submodule_config_entries() + .contains("submodule.inj-sub.url") ); + harness + .run_submod_success(&["delete", "inj-sub"]) + .expect("native delete"); + assert!(!harness.work_dir.join("lib/inj").exists()); assert!( - !harness.submodule_config_entries().contains("lib/inj"), - "git2 deinit must remove the submodule.* config section" + !harness + .submodule_config_entries() + .contains("submodule.inj-sub.") ); - // git2 leaves .gitmodules alone — documents why higher-level cleanup exists. - assert!( - harness.gitmodules_entries().contains("lib/inj"), - "git2 delete_submodule must NOT touch .gitmodules (higher-level logic handles it)" + assert!(!harness.gitmodules_entries().contains("lib/inj")); + assert_eq!(harness.index_gitlink_mode("lib/inj"), None); + assert_eq!( + harness + .git_stdout(&["--git-dir", gitdir.trim(), "cat-file", "-t", oid.trim()]) + .trim(), + "commit" ); } @@ -913,113 +829,44 @@ mod git2_fallback_injection_tests { ); } - /// `reopen()` hazard (P0-1): in a single process, add → delete → reopen → - /// re-add the same name+path must succeed and re-stage a gitlink. This - /// exercises `GitOpsManager::reopen()` in-process, which refreshes the - /// cached git2 repository so the re-add sees the post-delete state. #[test] - fn reopen_after_delete_allows_readd_same_path() { + fn reopen_after_delete_preserves_retained_history() { let harness = TestHarness::new().expect("harness"); - harness.init_git_repo().expect("init repo"); - let remote = harness.create_test_remote("inj_reopen").expect("remote"); - let remote_url = format!("file://{}", remote.display()); - + setup_repo_with_submodule(&harness).expect("setup"); let mut mgr = GitOpsManager::new(Some(&harness.work_dir), false).expect("mgr"); - - let opts = SubmoduleAddOptions { - url: remote_url, - path: std::path::PathBuf::from("lib/reopen"), - name: "reopen-sub".to_string(), - branch: None, - ignore: None, - update: None, - fetch_recurse: None, - shallow: false, - no_init: false, - }; - - mgr.add_submodule(&opts).expect("initial add"); - assert_eq!( - harness.index_gitlink_mode("lib/reopen").as_deref(), - Some("160000"), - "gitlink should be staged after the first add" - ); - - // Full delete: deinit + git-layer delete, then strip the config/state that - // git2's delete deliberately leaves behind, mirroring the high-level cleanup. - mgr.deinit_submodule("lib/reopen", true).expect("deinit"); - mgr.delete_submodule("lib/reopen").expect("delete"); - let _ = std::fs::remove_file(harness.work_dir.join(".gitmodules")); - let _ = harness.git_stdout(&[ - "rm", - "--cached", - "-r", - "--ignore-unmatch", - "--", - "lib/reopen", - ]); - let _ = harness.git_stdout(&["config", "--remove-section", "submodule.lib/reopen"]); - let _ = std::fs::remove_dir_all(harness.work_dir.join(".git/modules/lib/reopen")); - - // Refresh cached git2 state after the destructive sequence. + let gitdir = harness.git_stdout(&["-C", "lib/inj", "rev-parse", "--absolute-git-dir"]); + let oid = harness.git_stdout(&["-C", "lib/inj", "rev-parse", "HEAD"]); + harness + .run_submod_success(&["delete", "inj-sub"]) + .expect("delete"); mgr.reopen().expect("reopen after delete"); - - // Re-add the same name+path in the same process. - mgr.add_submodule(&opts) - .expect("re-add after reopen should succeed"); + assert!( + !mgr.list_submodules() + .expect("list") + .iter() + .any(|p| p == "lib/inj") + ); + assert_eq!(harness.index_gitlink_mode("lib/inj"), None); assert_eq!( - harness.index_gitlink_mode("lib/reopen").as_deref(), - Some("160000"), - "gitlink should be re-staged after reopen + re-add" + harness + .git_stdout(&["--git-dir", gitdir.trim(), "cat-file", "-t", oid.trim()]) + .trim(), + "commit" ); } } // ============================================================ -// CLI last-resort path of add_submodule +// Native CLI mutation state and preservation // ============================================================ -// -// `add_submodule`'s `.or_else(...)` CLI branch only runs when *both* in-process -// backends (gix and git2) fail. That condition cannot be reproduced offline with -// real inputs — git2 and the git CLI clone from the same URL, so anything that -// breaks git2 breaks the CLI too. `GitOpsManager::forcing_cli_add` is a fault- -// injection seam that bypasses both in-process backends so the otherwise- -// unreachable CLI last resort runs *unmodified* and can be checked for correct -// results, including its cleanup of partial state left by a failed git2 attempt. #[cfg(test)] -mod cli_last_resort_tests { +mod native_mutation_tests { use super::*; - /// The seam itself: `forcing_cli_add` must flag the manager to route - /// `add_submodule` through the CLI last resort, while the normal constructor - /// does not. This anchors non-vacuousness for the whole module — when the - /// flag is set, any resulting git state must have come from the CLI branch - /// (both in-process backends are bypassed). - #[test] - fn forcing_cli_add_enables_cli_seam() { - let harness = TestHarness::new().expect("harness"); - harness.init_git_repo().expect("init repo"); - - let normal = GitOpsManager::new(Some(&harness.work_dir), false).expect("mgr"); - assert!( - !normal.forces_cli_add(), - "GitOpsManager::new must not force the CLI last resort" - ); - - let forced = GitOpsManager::forcing_cli_add(Some(&harness.work_dir), false).expect("mgr"); - assert!( - forced.forces_cli_add(), - "GitOpsManager::forcing_cli_add must force the CLI last resort" - ); - } - - /// The CLI last resort must produce *correct git state*: an index gitlink at - /// mode 160000, a `.gitmodules` entry, a `submodule.*` config section, and the - /// path must appear in `list_submodules`. Both in-process backends are - /// bypassed, so this state can only have come from the CLI branch. + /// Native add stages a real gitlink and registers the logical identity. #[test] - fn cli_last_resort_add_produces_real_git_state() { + fn native_add_produces_real_git_state() { let harness = TestHarness::new().expect("harness"); harness.init_git_repo().expect("init repo"); let remote = harness @@ -1027,21 +874,18 @@ mod cli_last_resort_tests { .expect("create remote"); let remote_url = format!("file://{}", remote.display()); - let mut mgr = GitOpsManager::forcing_cli_add(Some(&harness.work_dir), false).expect("mgr"); - - let opts = SubmoduleAddOptions { - url: remote_url, - path: std::path::PathBuf::from("lib/cliadd"), - name: "cliadd-sub".to_string(), - branch: None, - ignore: None, - update: None, - fetch_recurse: None, - shallow: false, - no_init: false, - }; - mgr.add_submodule(&opts) - .expect("CLI last-resort add_submodule should succeed"); + let mgr = GitOpsManager::new(Some(&harness.work_dir), false).expect("mgr"); + + harness + .run_submod_success(&[ + "add", + &remote_url, + "--name", + "cliadd-sub", + "--path", + "lib/cliadd", + ]) + .expect("native add"); assert_eq!( harness.index_gitlink_mode("lib/cliadd").as_deref(), @@ -1065,133 +909,41 @@ mod cli_last_resort_tests { ); } - /// The CLI branch's first job is to clean up partial state a failed git2 - /// attempt may have left behind (a stale `.gitmodules` section, a config - /// section, an internal `.git/modules/` dir, a staged index entry) and - /// then re-add cleanly. Seed exactly that leftover state for the target name, - /// then run the forced CLI add and assert it both succeeds and ends with the - /// *real* url — not the stale seed — proving the cleanup ran. #[test] - fn cli_last_resort_cleans_up_partial_state_and_succeeds() { + fn native_add_refuses_partial_state_without_cleanup() { let harness = TestHarness::new().expect("harness"); harness.init_git_repo().expect("init repo"); - let remote = harness - .create_test_remote("cli_reinit") - .expect("create remote"); + let remote = harness.create_test_remote("partial").expect("remote"); let remote_url = format!("file://{}", remote.display()); - - // Simulate the debris a half-finished git2 add_submodule leaves behind: - // a stale .gitmodules section (with a bogus url), a stale config section, - // and an internal modules directory — all keyed by the submodule name. - let stale_gitmodules = "[submodule \"cleanup-sub\"]\n\tpath = lib/cleanup\n\turl = https://example.com/STALE.git\n"; - std::fs::write(harness.work_dir.join(".gitmodules"), stale_gitmodules) - .expect("seed stale .gitmodules"); + let stale = "[submodule \"cleanup-sub\"]\n\tpath = lib/cleanup\n\turl = https://example.com/STALE.git\n"; + std::fs::write(harness.work_dir.join(".gitmodules"), stale).expect("seed declaration"); harness.git_stdout(&[ "config", "submodule.cleanup-sub.url", "https://example.com/STALE.git", ]); - std::fs::create_dir_all(harness.work_dir.join(".git/modules/cleanup-sub")) - .expect("seed stale internal modules dir"); - - let mut mgr = GitOpsManager::forcing_cli_add(Some(&harness.work_dir), false).expect("mgr"); - - let opts = SubmoduleAddOptions { - url: remote_url.clone(), - path: std::path::PathBuf::from("lib/cleanup"), - name: "cleanup-sub".to_string(), - branch: None, - ignore: None, - update: None, - fetch_recurse: None, - shallow: false, - no_init: false, - }; - mgr.add_submodule(&opts) - .expect("CLI last-resort add must clean up partial state and succeed"); - - assert_eq!( - harness.index_gitlink_mode("lib/cleanup").as_deref(), - Some("160000"), - "CLI add must stage the gitlink after cleaning up partial state" - ); - let gitmodules = harness.gitmodules_entries(); - assert!( - gitmodules.contains(&remote_url), - "the real url must replace the stale seed, got: {gitmodules}" - ); - assert!( - !gitmodules.contains("STALE.git"), - "the stale .gitmodules section must have been cleaned up, got: {gitmodules}" - ); - } - - /// The audit flagged that fallback warning logs are never asserted. gix's - /// `add_submodule` always errors, so a verbose run of the real binary must - /// emit the gix→git2 fallback warning to stderr. - #[test] - fn fallback_warning_is_logged_in_verbose_mode() { - let harness = TestHarness::new().expect("harness"); - harness.init_git_repo().expect("init repo"); - let remote = harness - .create_test_remote("warn_add") - .expect("create remote"); - let remote_url = format!("file://{}", remote.display()); - + let retained = harness.work_dir.join(".git/modules/cleanup-sub"); + std::fs::create_dir_all(&retained).expect("seed partial gitdir"); + std::fs::write(retained.join("sentinel"), b"retain partial bytes").expect("sentinel"); + let before = harness.preservation_snapshot(); let output = harness .run_submod(&[ - "--verbose", "add", &remote_url, "--name", - "warn-sub", + "cleanup-sub", "--path", - "lib/warn", + "lib/cleanup", ]) - .expect("run submod --verbose add"); + .expect("run add"); assert!( - output.status.success(), - "verbose add should still succeed via fallback; stderr: {}", - String::from_utf8_lossy(&output.stderr) + !output.status.success(), + "partial state must require explicit recovery" ); - let stderr = String::from_utf8_lossy(&output.stderr); - assert!( - stderr.contains("falling back to git2"), - "verbose mode must log the gix→git2 fallback warning, got stderr: {stderr}" - ); - } - - /// Non-vacuousness for the warning assertion above: without `--verbose`, the - /// same fallback occurs silently. This proves the assertion discriminates on - /// the verbose flag rather than matching unconditional output. - #[test] - fn fallback_warning_is_silent_without_verbose() { - let harness = TestHarness::new().expect("harness"); - harness.init_git_repo().expect("init repo"); - let remote = harness - .create_test_remote("quiet_add") - .expect("create remote"); - let remote_url = format!("file://{}", remote.display()); - - let output = harness - .run_submod(&[ - "add", - &remote_url, - "--name", - "quiet-sub", - "--path", - "lib/quiet", - ]) - .expect("run submod add"); - assert!( - output.status.success(), - "non-verbose add should succeed via fallback; stderr: {}", - String::from_utf8_lossy(&output.stderr) - ); - let stderr = String::from_utf8_lossy(&output.stderr); - assert!( - !stderr.contains("falling back to git2"), - "non-verbose mode must not log the fallback warning, got stderr: {stderr}" + assert_eq!(harness.preservation_snapshot(), before); + assert_eq!( + std::fs::read(retained.join("sentinel")).expect("retained sentinel"), + b"retain partial bytes" ); } } diff --git a/tests/git_ops_tests.rs b/tests/git_ops_tests.rs index af6300e5..23d3ed23 100644 --- a/tests/git_ops_tests.rs +++ b/tests/git_ops_tests.rs @@ -100,16 +100,18 @@ mod git2_ops_tests { fn test_write_and_read_git_config_local() { let harness = TestHarness::new().expect("harness"); harness.init_git_repo().expect("init repo"); - let ops = Git2Operations::new(Some(&harness.work_dir)).expect("ops"); + let mgr = GitOpsManager::new(Some(&harness.work_dir), false).expect("mgr"); let mut entries = HashMap::new(); entries.insert("submod.testkey".to_string(), "testvalue123".to_string()); let config = GitConfig { entries }; - ops.write_git_config(&config, ConfigLevel::Local) + mgr.write_git_config(&config, ConfigLevel::Local) .expect("write config should succeed"); - let read_back = ops + // The retained git2 reader must see the natively written value. + let git2_ops = Git2Operations::new(Some(&harness.work_dir)).expect("git2"); + let read_back = git2_ops .read_git_config(ConfigLevel::Local) .expect("read after write should succeed"); assert_eq!( @@ -123,47 +125,44 @@ mod git2_ops_tests { fn test_set_config_value_local() { let harness = TestHarness::new().expect("harness"); harness.init_git_repo().expect("init repo"); - let ops = Git2Operations::new(Some(&harness.work_dir)).expect("ops"); + let mgr = GitOpsManager::new(Some(&harness.work_dir), false).expect("mgr"); - ops.set_config_value("submod.singlekey", "singlevalue", ConfigLevel::Local) + mgr.set_config_value("submod.singlekey", "singlevalue", ConfigLevel::Local) .expect("set_config_value should succeed"); - let config = ops - .read_git_config(ConfigLevel::Local) - .expect("read config"); assert_eq!( - config.entries.get("submod.singlekey").map(String::as_str), - Some("singlevalue"), + harness + .git_stdout(&["config", "--local", "--get", "submod.singlekey"]) + .trim(), + "singlevalue", + "set value must land in real local Git config" ); } #[test] - fn test_write_gitmodules_empty_entries() { + fn test_write_gitmodules_persists_unregistered_entry() { let harness = TestHarness::new().expect("harness"); harness.init_git_repo().expect("init repo"); - let mut ops = Git2Operations::new(Some(&harness.work_dir)).expect("ops"); - // Writing empty entries should silently succeed (nothing to do). - ops.write_gitmodules(&SubmoduleEntries::default()) - .expect("writing empty entries should succeed"); - } - - #[test] - fn test_write_gitmodules_skips_unknown_submodule() { - let harness = TestHarness::new().expect("harness"); - harness.init_git_repo().expect("init repo"); - let mut ops = Git2Operations::new(Some(&harness.work_dir)).expect("ops"); - // Entries that reference a submodule not yet added are silently skipped. - ops.write_gitmodules(&one_entry_entries()) - .expect("writing unknown submodule entry should not error"); + let mut mgr = GitOpsManager::new(Some(&harness.work_dir), false).expect("mgr"); + // Native writes persist declarations; completion of the registration + // is the reconciliation layer's job, not the write path's. + mgr.write_gitmodules(&one_entry_entries()) + .expect("writing an unregistered entry should not error"); + assert!( + harness.gitmodules_entries().contains("lib/test"), + "native write must persist lib/test in real .gitmodules state" + ); } // ---- Error paths (submodule not found) -------------------------------- + // Mutations run natively through the manager; the retained git2 backend + // keeps only its read methods (status, sparse patterns) below. #[test] fn test_init_submodule_not_found() { let harness = TestHarness::new().expect("harness"); harness.init_git_repo().expect("init repo"); - let mut ops = Git2Operations::new(Some(&harness.work_dir)).expect("ops"); + let mut ops = GitOpsManager::new(Some(&harness.work_dir), false).expect("mgr"); assert!(ops.init_submodule("nonexistent").is_err()); } @@ -171,7 +170,7 @@ mod git2_ops_tests { fn test_deinit_submodule_not_found() { let harness = TestHarness::new().expect("harness"); harness.init_git_repo().expect("init repo"); - let mut ops = Git2Operations::new(Some(&harness.work_dir)).expect("ops"); + let mut ops = GitOpsManager::new(Some(&harness.work_dir), false).expect("mgr"); assert!(ops.deinit_submodule("nonexistent", true).is_err()); } @@ -179,7 +178,7 @@ mod git2_ops_tests { fn test_update_submodule_not_found() { let harness = TestHarness::new().expect("harness"); harness.init_git_repo().expect("init repo"); - let mut ops = Git2Operations::new(Some(&harness.work_dir)).expect("ops"); + let mut ops = GitOpsManager::new(Some(&harness.work_dir), false).expect("mgr"); assert!( ops.update_submodule("nonexistent", &SubmoduleUpdateOptions::default()) .is_err() @@ -190,8 +189,8 @@ mod git2_ops_tests { fn test_delete_submodule_not_found() { let harness = TestHarness::new().expect("harness"); harness.init_git_repo().expect("init repo"); - let mut ops = Git2Operations::new(Some(&harness.work_dir)).expect("ops"); - assert!(ops.delete_submodule("nonexistent").is_err()); + let mut ops = GitOpsManager::new(Some(&harness.work_dir), false).expect("mgr"); + assert!(ops.delete_submodule("nonexistent", false).is_err()); } #[test] @@ -206,7 +205,7 @@ mod git2_ops_tests { fn test_fetch_submodule_not_found() { let harness = TestHarness::new().expect("harness"); harness.init_git_repo().expect("init repo"); - let ops = Git2Operations::new(Some(&harness.work_dir)).expect("ops"); + let ops = GitOpsManager::new(Some(&harness.work_dir), false).expect("mgr"); assert!(ops.fetch_submodule("nonexistent").is_err()); } @@ -214,7 +213,7 @@ mod git2_ops_tests { fn test_reset_submodule_not_found() { let harness = TestHarness::new().expect("harness"); harness.init_git_repo().expect("init repo"); - let ops = Git2Operations::new(Some(&harness.work_dir)).expect("ops"); + let ops = GitOpsManager::new(Some(&harness.work_dir), false).expect("mgr"); assert!(ops.reset_submodule("nonexistent", true).is_err()); } @@ -222,7 +221,7 @@ mod git2_ops_tests { fn test_clean_submodule_not_found() { let harness = TestHarness::new().expect("harness"); harness.init_git_repo().expect("init repo"); - let ops = Git2Operations::new(Some(&harness.work_dir)).expect("ops"); + let ops = GitOpsManager::new(Some(&harness.work_dir), false).expect("mgr"); assert!(ops.clean_submodule("nonexistent", true, true).is_err()); } @@ -230,7 +229,7 @@ mod git2_ops_tests { fn test_stash_submodule_not_found() { let harness = TestHarness::new().expect("harness"); harness.init_git_repo().expect("init repo"); - let ops = Git2Operations::new(Some(&harness.work_dir)).expect("ops"); + let ops = GitOpsManager::new(Some(&harness.work_dir), false).expect("mgr"); assert!(ops.stash_submodule("nonexistent", false).is_err()); } @@ -238,7 +237,7 @@ mod git2_ops_tests { fn test_enable_sparse_checkout_not_found() { let harness = TestHarness::new().expect("harness"); harness.init_git_repo().expect("init repo"); - let ops = Git2Operations::new(Some(&harness.work_dir)).expect("ops"); + let ops = GitOpsManager::new(Some(&harness.work_dir), false).expect("mgr"); assert!(ops.enable_sparse_checkout("nonexistent").is_err()); } @@ -246,7 +245,7 @@ mod git2_ops_tests { fn test_set_sparse_patterns_not_found() { let harness = TestHarness::new().expect("harness"); harness.init_git_repo().expect("init repo"); - let ops = Git2Operations::new(Some(&harness.work_dir)).expect("ops"); + let ops = GitOpsManager::new(Some(&harness.work_dir), false).expect("mgr"); assert!( ops.set_sparse_patterns("nonexistent", &["src/".to_string()]) .is_err() @@ -261,15 +260,6 @@ mod git2_ops_tests { assert!(ops.get_sparse_patterns("nonexistent").is_err()); } - #[test] - fn test_apply_sparse_checkout_not_supported() { - let harness = TestHarness::new().expect("harness"); - harness.init_git_repo().expect("init repo"); - let ops = Git2Operations::new(Some(&harness.work_dir)).expect("ops"); - // git2 apply_sparse_checkout is always an error. - assert!(ops.apply_sparse_checkout("any").is_err()); - } - // ---- Tests with a real submodule (set up via CLI) ---------------------- #[test] @@ -405,7 +395,7 @@ mod git2_ops_tests { ]) .expect("add submodule"); - let ops = Git2Operations::new(Some(&harness.work_dir)).expect("ops"); + let ops = GitOpsManager::new(Some(&harness.work_dir), false).expect("mgr"); ops.enable_sparse_checkout("lib/sparsesub") .expect("enable_sparse_checkout"); @@ -421,7 +411,7 @@ mod git2_ops_tests { } #[test] - fn test_with_submodule_write_gitmodules_updates_existing() { + fn test_native_metadata_update_preserves_checkout() { let harness = TestHarness::new().expect("harness"); harness.init_git_repo().expect("init repo"); let remote = harness.create_test_remote("g2_write_sub").expect("remote"); @@ -438,38 +428,29 @@ mod git2_ops_tests { ]) .expect("add submodule"); - let mut ops = Git2Operations::new(Some(&harness.work_dir)).expect("ops"); - let mut entries = ops.read_gitmodules().expect("read_gitmodules"); - // write_gitmodules with the same entries should succeed without error - ops.write_gitmodules(&entries).expect("write_gitmodules"); - - // Verify that updating `active` sets it in the git configuration - // In .gitmodules the git2 fallback might name the submodule by its path 'lib/writesub' - let name = if entries.get("write-sub").is_some() { - "write-sub" - } else { - "lib/writesub" - }; - - if let Some(mut entry) = entries.get(name).cloned() { - entry.active = Some(false); - entries.update_entry(name.to_string(), entry); - } - ops.write_gitmodules(&entries) - .expect("write_gitmodules active false"); - - // Check git2 config manually or via read_gitmodules? Actually read_gitmodules in git2 - // doesn't read active from .git/config, but wait, it is set in `.git/config`! - let config_path = harness.work_dir.join(".git").join("config"); - let config_content = std::fs::read_to_string(&config_path).expect("read git config"); - assert!( - config_content.contains("active = false"), - "submodule should be inactive in config" + let before_head = harness.git_stdout(&["-C", "lib/writesub", "rev-parse", "HEAD"]); + let before_index = harness.git_stdout(&["ls-files", "--stage", "--", "lib/writesub"]); + harness + .run_submod_success(&["change", "write-sub", "--active", "false"]) + .expect("native metadata update"); + assert_eq!( + harness.git_stdout(&["config", "--get", "submodule.write-sub.active"]), + "false" + ); + assert!(!harness.gitmodules_entries().contains(".active")); + assert!(harness.read_config().unwrap().contains("active = false")); + assert_eq!( + harness.git_stdout(&["-C", "lib/writesub", "rev-parse", "HEAD"]), + before_head + ); + assert_eq!( + harness.git_stdout(&["ls-files", "--stage", "--", "lib/writesub"]), + before_index ); } #[test] - fn test_with_submodule_write_gitmodules_active_none() { + fn test_native_metadata_roundtrip_preserves_unspecified_activation() { let harness = TestHarness::new().expect("harness"); harness.init_git_repo().expect("init repo"); let remote = harness @@ -488,27 +469,30 @@ mod git2_ops_tests { ]) .expect("add submodule"); - let mut ops = Git2Operations::new(Some(&harness.work_dir)).expect("ops"); - let mut entries = ops.read_gitmodules().expect("read_gitmodules"); - - let name = if entries.get("write-sub-none").is_some() { - "write-sub-none" - } else { - "lib/writesubnone" - }; - - if let Some(mut entry) = entries.get(name).cloned() { - entry.active = None; - entries.update_entry(name.to_string(), entry); - } - ops.write_gitmodules(&entries) - .expect("write_gitmodules active none"); - - let config_path = harness.work_dir.join(".git").join("config"); - let config_content = std::fs::read_to_string(&config_path).expect("read git config"); - assert!( - !config_content.contains("active ="), - "submodule active should be untouched" + harness.git_stdout(&["config", "submodule.write-sub-none.custom", "retained"]); + let before_head = harness.git_stdout(&["-C", "lib/writesubnone", "rev-parse", "HEAD"]); + let before_index = harness.git_stdout(&["ls-files", "--stage", "--", "lib/writesubnone"]); + let before_active = + harness.git_stdout(&["config", "--get", "submodule.write-sub-none.active"]); + harness + .run_submod_success(&["change", "write-sub-none", "--url", &remote_url]) + .expect("native metadata round-trip without activation option"); + assert_eq!( + harness.git_stdout(&["config", "--get", "submodule.write-sub-none.active"]), + before_active + ); + assert_eq!( + harness.git_stdout(&["config", "--get", "submodule.write-sub-none.custom"]), + "retained" + ); + assert!(!harness.gitmodules_entries().contains(".active")); + assert_eq!( + harness.git_stdout(&["-C", "lib/writesubnone", "rev-parse", "HEAD"]), + before_head + ); + assert_eq!( + harness.git_stdout(&["ls-files", "--stage", "--", "lib/writesubnone"]), + before_index ); } } @@ -520,7 +504,6 @@ mod git2_ops_tests { #[cfg(test)] mod gix_ops_tests { use super::*; - use submod::config::SubmoduleAddOptions; #[test] fn test_new_from_valid_path() { @@ -575,10 +558,8 @@ mod gix_ops_tests { fn test_write_git_config_local() { let harness = TestHarness::new().expect("harness"); harness.init_git_repo().expect("init repo"); - let ops = GixOperations::new(Some(&harness.work_dir)).expect("ops"); + let mgr = GitOpsManager::new(Some(&harness.work_dir), false).expect("mgr"); - // gix write_git_config requires 3-part keys (section.subsection.name). - // 2-part keys produce an empty "name" segment which gix rejects. let mut entries = HashMap::new(); entries.insert( "remote.testremote.url".to_string(), @@ -586,11 +567,14 @@ mod gix_ops_tests { ); let config = GitConfig { entries }; - let result = ops.write_git_config(&config, ConfigLevel::Local); - assert!( - result.is_ok(), - "writing a 3-part key to local config should succeed: {:?}", - result.err() + mgr.write_git_config(&config, ConfigLevel::Local) + .expect("writing a 3-part key to local config should succeed"); + assert_eq!( + harness + .git_stdout(&["config", "--local", "--get", "remote.testremote.url"]) + .trim(), + "https://example.com", + "written value must land in real local Git config" ); } @@ -598,54 +582,36 @@ mod gix_ops_tests { fn test_write_git_config_global_level_fails() { let harness = TestHarness::new().expect("harness"); harness.init_git_repo().expect("init repo"); - let ops = GixOperations::new(Some(&harness.work_dir)).expect("ops"); + let mgr = GitOpsManager::new(Some(&harness.work_dir), false).expect("mgr"); let config = GitConfig { entries: HashMap::new(), }; - let result = ops.write_git_config(&config, ConfigLevel::Global); + let result = mgr.write_git_config(&config, ConfigLevel::Global); assert!( result.is_err(), - "gix only supports local config writing; global should fail" + "the manager only supports local config writing; global should fail" ); } #[test] - fn test_write_git_config_two_part_key_fails() { + fn test_write_git_config_two_part_key_succeeds() { let harness = TestHarness::new().expect("harness"); harness.init_git_repo().expect("init repo"); - let ops = GixOperations::new(Some(&harness.work_dir)).expect("ops"); - // gix write_git_config splits on '.' with splitn(3, '.'), mapping - // "section.name" → section="section", subsection=Some("name"), name="". - // An empty name is invalid, so 2-part keys are rejected. + let mgr = GitOpsManager::new(Some(&harness.work_dir), false).expect("mgr"); + // Native `git config --local` accepts 2-part keys; the old gix + // restriction no longer applies. let mut entries = HashMap::new(); entries.insert("submod.gixkey".to_string(), "gixvalue".to_string()); let config = GitConfig { entries }; - let result = ops.write_git_config(&config, ConfigLevel::Local); - assert!( - result.is_err(), - "gix write_git_config rejects 2-part keys (name part is empty)" - ); - } - - #[test] - fn test_set_config_value_local() { - let harness = TestHarness::new().expect("harness"); - harness.init_git_repo().expect("init repo"); - let ops = GixOperations::new(Some(&harness.work_dir)).expect("ops"); - // set_config_value reads the existing config then calls write_git_config. - // The existing local config contains 2-part keys (e.g. user.name) that - // gix cannot round-trip through write_git_config, so this always fails. - // The test exists to exercise the set_config_value → read → merge → write - // code path for coverage. - let result = ops.set_config_value( - "remote.gixremote.url", - "https://gix.example.com", - ConfigLevel::Local, - ); - assert!( - result.is_err(), - "expected failure: existing 2-part config keys cannot be round-tripped by gix" + mgr.write_git_config(&config, ConfigLevel::Local) + .expect("native write accepts 2-part keys"); + assert_eq!( + harness + .git_stdout(&["config", "--local", "--get", "submod.gixkey"]) + .trim(), + "gixvalue", + "written 2-part value must land in real local Git config" ); } @@ -653,9 +619,9 @@ mod gix_ops_tests { fn test_write_gitmodules_creates_file() { let harness = TestHarness::new().expect("harness"); harness.init_git_repo().expect("init repo"); - let mut ops = GixOperations::new(Some(&harness.work_dir)).expect("ops"); + let mut mgr = GitOpsManager::new(Some(&harness.work_dir), false).expect("mgr"); - ops.write_gitmodules(&one_entry_entries()) + mgr.write_gitmodules(&one_entry_entries()) .expect("write_gitmodules should succeed"); // The .gitmodules file should be created. @@ -672,168 +638,32 @@ mod gix_ops_tests { } #[test] - fn test_write_gitmodules_active_false() { + fn test_write_gitmodules_keeps_active_app_only() { let harness = TestHarness::new().expect("harness"); harness.init_git_repo().expect("init repo"); - let mut ops = GixOperations::new(Some(&harness.work_dir)).expect("ops"); - - let mut entries = one_entry_entries(); - if let Some(mut entry) = entries.get("test-lib").cloned() { - entry.active = Some(false); - entries.update_entry("test-lib".to_string(), entry); - } - - ops.write_gitmodules(&entries) - .expect("write_gitmodules should succeed"); - - let content = std::fs::read_to_string(harness.work_dir.join(".gitmodules")) - .expect("read .gitmodules"); - assert!( - content.contains("active = false"), - ".gitmodules should contain active = false" - ); - } - - #[test] - fn test_write_gitmodules_active_none() { - let harness = TestHarness::new().expect("harness"); - harness.init_git_repo().expect("init repo"); - let mut ops = GixOperations::new(Some(&harness.work_dir)).expect("ops"); + let mut mgr = GitOpsManager::new(Some(&harness.work_dir), false).expect("mgr"); - let mut entries = one_entry_entries(); - if let Some(mut entry) = entries.get("test-lib").cloned() { - entry.active = None; - entries.update_entry("test-lib".to_string(), entry); + for active in [Some(false), None] { + let mut entries = one_entry_entries(); + if let Some(mut entry) = entries.get("test-lib").cloned() { + entry.active = active; + entries.update_entry("test-lib".to_string(), entry); + } + + mgr.write_gitmodules(&entries) + .expect("write_gitmodules should succeed"); + + let content = std::fs::read_to_string(harness.work_dir.join(".gitmodules")) + .expect("read .gitmodules"); + assert!( + !content.contains("active"), + "active stays app-only and must not leak into .gitmodules (active={active:?})" + ); + assert!( + content.contains("lib/test"), + ".gitmodules should still contain the path we wrote" + ); } - - ops.write_gitmodules(&entries).expect("write_gitmodules"); - let content = std::fs::read_to_string(harness.work_dir.join(".gitmodules")).expect("read"); - assert!(!content.contains("active =")); - } - - #[test] - fn test_write_gitmodules_empty_entries() { - let harness = TestHarness::new().expect("harness"); - harness.init_git_repo().expect("init repo"); - let mut ops = GixOperations::new(Some(&harness.work_dir)).expect("ops"); - // Writing empty entries should succeed and create an empty .gitmodules file. - ops.write_gitmodules(&SubmoduleEntries::default()) - .expect("write empty gitmodules should succeed"); - } - - // ---- Stubs that always return errors ---------------------------------- - - #[test] - fn test_add_submodule_not_implemented() { - let harness = TestHarness::new().expect("harness"); - harness.init_git_repo().expect("init repo"); - let mut ops = GixOperations::new(Some(&harness.work_dir)).expect("ops"); - let add_opts = SubmoduleAddOptions { - name: "stub-sub".to_string(), - path: std::path::PathBuf::from("lib/stub"), - url: "https://example.com/repo.git".to_string(), - branch: None, - ignore: None, - update: None, - fetch_recurse: None, - shallow: false, - no_init: false, - }; - let result = ops.add_submodule(&add_opts); - assert!( - result.is_err(), - "gix add_submodule should be not implemented" - ); - let msg = result.unwrap_err().to_string(); - assert!( - msg.contains("gix add_submodule not implemented"), - "unexpected error: {msg}" - ); - } - - #[test] - fn test_get_submodule_status_not_implemented() { - let harness = TestHarness::new().expect("harness"); - harness.init_git_repo().expect("init repo"); - let ops = GixOperations::new(Some(&harness.work_dir)).expect("ops"); - assert!(ops.get_submodule_status("any").is_err()); - } - - #[test] - fn test_reset_submodule_not_supported() { - let harness = TestHarness::new().expect("harness"); - harness.init_git_repo().expect("init repo"); - let ops = GixOperations::new(Some(&harness.work_dir)).expect("ops"); - let err = ops.reset_submodule("any", true).unwrap_err().to_string(); - assert!( - err.contains("not yet supported"), - "unexpected message: {err}" - ); - } - - #[test] - fn test_clean_submodule_not_supported() { - let harness = TestHarness::new().expect("harness"); - harness.init_git_repo().expect("init repo"); - let ops = GixOperations::new(Some(&harness.work_dir)).expect("ops"); - let err = ops - .clean_submodule("any", true, true) - .unwrap_err() - .to_string(); - assert!( - err.contains("not yet supported"), - "unexpected message: {err}" - ); - } - - #[test] - fn test_stash_submodule_not_supported() { - let harness = TestHarness::new().expect("harness"); - harness.init_git_repo().expect("init repo"); - let ops = GixOperations::new(Some(&harness.work_dir)).expect("ops"); - let err = ops.stash_submodule("any", false).unwrap_err().to_string(); - assert!( - err.contains("not yet supported"), - "unexpected message: {err}" - ); - } - - #[test] - fn test_enable_sparse_checkout_deferred() { - let harness = TestHarness::new().expect("harness"); - harness.init_git_repo().expect("init repo"); - let ops = GixOperations::new(Some(&harness.work_dir)).expect("ops"); - // Deferred to git2 — always returns an error from gix. - assert!(ops.enable_sparse_checkout("any").is_err()); - } - - #[test] - fn test_set_sparse_patterns_deferred() { - let harness = TestHarness::new().expect("harness"); - harness.init_git_repo().expect("init repo"); - let ops = GixOperations::new(Some(&harness.work_dir)).expect("ops"); - assert!( - ops.set_sparse_patterns("any", &["src/".to_string()]) - .is_err() - ); - } - - #[test] - fn test_get_sparse_patterns_deferred() { - let harness = TestHarness::new().expect("harness"); - harness.init_git_repo().expect("init repo"); - let ops = GixOperations::new(Some(&harness.work_dir)).expect("ops"); - assert!(ops.get_sparse_patterns("any").is_err()); - } - - #[test] - fn test_apply_sparse_checkout_deferred() { - let harness = TestHarness::new().expect("harness"); - harness.init_git_repo().expect("init repo"); - let ops = GixOperations::new(Some(&harness.work_dir)).expect("ops"); - // apply_sparse_checkout calls get_sparse_patterns internally, which - // also defers to git2 and errors immediately. - assert!(ops.apply_sparse_checkout("any").is_err()); } // ---- Tests with a real submodule (set up via CLI) -------------------- @@ -872,7 +702,7 @@ mod gix_ops_tests { fn test_gix_deinit_submodule_not_found() { let harness = TestHarness::new().expect("harness"); harness.init_git_repo().expect("init repo"); - let mut ops = GixOperations::new(Some(&harness.work_dir)).expect("ops"); + let mut ops = GitOpsManager::new(Some(&harness.work_dir), false).expect("mgr"); // deinit reads .gitmodules first; if the path isn't there it should error. let result = ops.deinit_submodule("nonexistent", true); assert!(result.is_err(), "should fail for nonexistent submodule"); @@ -882,8 +712,8 @@ mod gix_ops_tests { fn test_gix_delete_submodule_not_found() { let harness = TestHarness::new().expect("harness"); harness.init_git_repo().expect("init repo"); - let mut ops = GixOperations::new(Some(&harness.work_dir)).expect("ops"); - let result = ops.delete_submodule("nonexistent"); + let mut ops = GitOpsManager::new(Some(&harness.work_dir), false).expect("mgr"); + let result = ops.delete_submodule("nonexistent", false); assert!(result.is_err(), "should fail for nonexistent submodule"); } } @@ -973,11 +803,11 @@ mod git_ops_manager_tests { entries.insert("submod.mgrkey".to_string(), "mgrvalue".to_string()); let config = GitConfig { entries }; - // The manager falls back to git2 for this 2-part key (gix rejects it). + // The manager writes natively; 2-part keys are accepted. mgr.write_git_config(&config, ConfigLevel::Local) - .expect("write_git_config should succeed via git2 fallback"); + .expect("write_git_config should succeed"); - // Read back using git2 directly to avoid gix snapshot-caching issues. + // Read back through the retained git2 reader. let git2_ops = Git2Operations::new(Some(&harness.work_dir)).expect("git2"); let read_back = git2_ops .read_git_config(ConfigLevel::Local) @@ -1003,11 +833,11 @@ mod git_ops_manager_tests { } #[test] - fn test_apply_sparse_checkout_fallback_chain() { + fn test_apply_sparse_checkout_rejects_unknown_path() { let harness = TestHarness::new().expect("harness"); harness.init_git_repo().expect("init repo"); let mgr = GitOpsManager::new(Some(&harness.work_dir), false).expect("mgr"); - // gix → git2 → CLI fallback; will ultimately fail since no submodule/path exists. + // Native sparse application; fails since no submodule/path exists. let result = mgr.apply_sparse_checkout("nonexistent_path_xyz"); assert!( result.is_err(), @@ -1135,3 +965,903 @@ mod data_types_tests { assert!(config.entries.is_empty()); } } + +#[test] +fn regression_r04_delete_preserves_prefix_sibling_index_entries() { + let h = TestHarness::new().unwrap(); + h.init_git_repo().unwrap(); + let remote = h.create_test_remote("prefix").unwrap(); + std::fs::write(h.work_dir.join("library.txt"), b"sibling").unwrap(); + std::fs::create_dir(h.work_dir.join("lib-extra")).unwrap(); + std::fs::write(h.work_dir.join("lib-extra/file"), b"other sibling").unwrap(); + h.git_stdout(&["add", "library.txt", "lib-extra/file"]); + h.git_stdout(&[ + "submodule", + "add", + "--name", + "lib", + remote.to_str().unwrap(), + "lib", + ]); + h.create_config(&format!( + "[m]\npath = \"lib\"\nurl = {:?}\n", + remote.to_str().unwrap() + )) + .unwrap(); + let before = h.git_stdout(&["ls-files", "--stage", "--", "library.txt", "lib-extra/file"]); + assert_eq!(h.index_gitlink_mode("lib").as_deref(), Some("160000")); + h.run_submod_success(&["delete", "m"]).unwrap(); + assert_eq!( + h.git_stdout(&["ls-files", "--stage", "--", "library.txt", "lib-extra/file"]), + before + ); + assert_eq!( + std::fs::read(h.work_dir.join("library.txt")).unwrap(), + b"sibling" + ); + assert_eq!( + std::fs::read(h.work_dir.join("lib-extra/file")).unwrap(), + b"other sibling" + ); + assert_eq!(h.index_gitlink_mode("lib"), None); +} + +#[test] +fn regression_r04_delete_refuses_held_index_lock() { + let h = TestHarness::new().unwrap(); + h.init_git_repo().unwrap(); + let remote = h.create_test_remote("locked").unwrap(); + h.git_stdout(&[ + "submodule", + "add", + "--name", + "m", + remote.to_str().unwrap(), + "lib", + ]); + h.create_config(&format!( + "[m]\npath = \"lib\"\nurl = {:?}\n", + remote.to_str().unwrap() + )) + .unwrap(); + let before = h.preservation_snapshot(); + let contents = std::fs::read(h.work_dir.join("lib/LICENSE")).unwrap(); + std::fs::write(h.work_dir.join(".git/index.lock"), "held by test\n").unwrap(); + let output = h.run_submod(&["delete", "m"]).unwrap(); + assert_eq!( + h.preservation_snapshot(), + before, + "locked index must prevent all mutation: {output:?}" + ); + assert_eq!( + std::fs::read(h.work_dir.join("lib/LICENSE")).ok(), + Some(contents) + ); + assert!(!output.status.success(), "{output:?}"); +} + +#[test] +fn regression_r16_duplicate_paths_rejected_before_init() { + let h = TestHarness::new().unwrap(); + h.init_git_repo().unwrap(); + let remote = h.create_test_remote("duplicate").unwrap(); + h.create_config(&format!( + "[alias]\npath = \"lib\"\nurl = {0:?}\n[other]\npath = \"lib\"\nurl = {0:?}\n", + remote.to_str().unwrap() + )) + .unwrap(); + let before = h.preservation_snapshot(); + let output = h.run_submod(&["init"]).unwrap(); + assert_eq!( + h.preservation_snapshot(), + before, + "duplicate paths mutated state: {output:?}" + ); + assert!(!h.work_dir.join("lib").exists()); + assert!(!output.status.success(), "{output:?}"); +} + +#[test] +fn test_fixture_path_display_preserves_drive_and_relative_paths() { + for spelling in [ + "C:/fixtures/remote.git", + "relative/remote.git", + "/tmp/remote.git", + ] { + assert_eq!( + common::TestPath(std::path::PathBuf::from(spelling)) + .display() + .to_string(), + spelling + ); + } +} + +#[test] +fn phase2_r16_overlapping_paths_rejected_before_init() { + let h = TestHarness::new().unwrap(); + h.init_git_repo().unwrap(); + let remote = h.create_test_remote("overlap").unwrap(); + h.create_config(&format!( + "[parent]\npath = \"lib\"\nurl = {0:?}\n[child]\npath = \"lib/nested\"\nurl = {0:?}\n", + remote.to_str().unwrap() + )) + .unwrap(); + let before = h.preservation_snapshot(); + let output = h.run_submod(&["init"]).unwrap(); + assert_eq!(h.preservation_snapshot(), before, "{output:?}"); + assert!(!h.work_dir.join("lib").exists(), "{output:?}"); + assert!(!output.status.success(), "{output:?}"); +} + +#[test] +fn phase2_r16_formatted_logical_name_reused_by_exact_path() { + let h = TestHarness::new().unwrap(); + h.init_git_repo().unwrap(); + let remote = h.create_test_remote("logical").unwrap(); + h.git_stdout(&[ + "submodule", + "add", + "--name", + "logical.name", + remote.to_str().unwrap(), + "vendor/child", + ]); + h.create_config(&format!( + "[alias]\npath = \"vendor/child\"\nurl = {:?}\n", + remote.to_str().unwrap() + )) + .unwrap(); + // Git accepts quoted values and compact assignment; substring matching does not. + std::fs::write(h.work_dir.join(".gitmodules"), format!("# preserve this comment\n[submodule \"logical.name\"]\n\tpath=\"vendor/child\"\n\turl={:?}\n", remote.to_str().unwrap())).unwrap(); + h.git_stdout(&["add", ".gitmodules", "submod.toml"]); + h.git_stdout(&["commit", "-m", "registered logical identity"]); + let child = h.work_dir.join("vendor/child"); + let head = h.git_at(&child, &["rev-parse", "HEAD"]); + let gitdir = h.git_at(&child, &["rev-parse", "--absolute-git-dir"]); + let index = h.git_stdout(&["ls-files", "--stage"]); + let output = h.run_submod(&["init"]).unwrap(); + assert!(output.status.success(), "{output:?}"); + assert_eq!( + h.git_stdout(&[ + "config", + "--file", + ".gitmodules", + "--get-regexp", + r"^submodule\..*\.path$" + ]), + "submodule.logical.name.path vendor/child" + ); + assert_eq!(h.git_at(&child, &["rev-parse", "HEAD"]), head); + assert_eq!( + h.git_at(&child, &["rev-parse", "--absolute-git-dir"]), + gitdir + ); + assert_eq!(h.git_stdout(&["ls-files", "--stage"]), index); + assert!(!h.work_dir.join("alias").exists()); +} + +#[test] +fn phase2_r16_prefix_registration_does_not_match_new_path() { + let h = TestHarness::new().unwrap(); + h.init_git_repo().unwrap(); + let remote = h.create_test_remote("prefix-match").unwrap(); + h.git_stdout(&[ + "submodule", + "add", + "--name", + "existing", + remote.to_str().unwrap(), + "library", + ]); + h.create_config(&format!( + "[alias]\npath = \"lib\"\nurl = {:?}\n", + remote.to_str().unwrap() + )) + .unwrap(); + let existing_head = h.git_at(&h.work_dir.join("library"), &["rev-parse", "HEAD"]); + let existing_index = h.git_stdout(&["ls-files", "--stage", "--", "library"]); + let output = h.run_submod(&["init"]).unwrap(); + assert!(output.status.success(), "{output:?}"); + assert_eq!( + h.git_stdout(&[ + "config", + "--file", + ".gitmodules", + "--get", + "submodule.alias.path" + ]), + "lib" + ); + assert_eq!(h.index_gitlink_mode("lib").as_deref(), Some("160000")); + assert!(h.work_dir.join("lib/LICENSE").is_file()); + assert_eq!( + h.git_at(&h.work_dir.join("library"), &["rev-parse", "HEAD"]), + existing_head + ); + assert_eq!( + h.git_stdout(&["ls-files", "--stage", "--", "library"]), + existing_index + ); +} + +#[test] +fn phase2_context_nested_cwd_add_uses_repository_root() { + let h = TestHarness::new().unwrap(); + h.init_git_repo().unwrap(); + h.create_config("[defaults]\n").unwrap(); + let remote = h.create_test_remote("nested-context").unwrap(); + let nested = h.work_dir.join("nested/deeper"); + std::fs::create_dir_all(&nested).unwrap(); + let refs = h.git_stdout(&["show-ref"]); + let root_index = h.git_stdout(&["ls-files", "--stage", "--", "README.md"]); + let output = h + .run_submod_at( + &nested, + &[ + "add", + remote.to_str().unwrap(), + "--name", + "alias", + "--path", + "vendor/child", + ], + ) + .unwrap(); + assert!(output.status.success(), "{output:?}"); + assert_eq!( + h.index_gitlink_mode("vendor/child").as_deref(), + Some("160000") + ); + assert!(h.work_dir.join("vendor/child/LICENSE").is_file()); + assert!(h.read_config().unwrap().contains("[alias]")); + assert!(!nested.join("submod.toml").exists()); + assert!(!nested.join("vendor").exists()); + assert_eq!(h.git_stdout(&["show-ref"]), refs); + assert_eq!( + h.git_stdout(&["ls-files", "--stage", "--", "README.md"]), + root_index + ); +} + +#[test] +fn phase2_context_linked_worktree_mutates_only_selected_checkout() { + let h = TestHarness::new().unwrap(); + h.init_git_repo().unwrap(); + h.create_config("[defaults]\n").unwrap(); + h.git_stdout(&["add", "submod.toml"]); + h.git_stdout(&["commit", "-m", "shared initial configuration"]); + let remote = h.create_test_remote("linked-context").unwrap(); + let linked = h.temp_dir.path().join("linked"); + h.git_stdout(&[ + "worktree", + "add", + "-b", + "linked-branch", + linked.to_str().unwrap(), + ]); + let main_index = h.git_stdout(&["ls-files", "--stage"]); + let main_config = std::fs::read(h.config_path()).unwrap(); + let refs = h.git_stdout(&["show-ref"]); + let nested = linked.join("nested/deeper"); + std::fs::create_dir_all(&nested).unwrap(); + let output = h + .run_submod_at( + &nested, + &[ + "add", + remote.to_str().unwrap(), + "--name", + "logical", + "--path", + "vendor/child", + ], + ) + .unwrap(); + assert!(output.status.success(), "{output:?}"); + assert_eq!( + h.git_at( + &linked, + &[ + "config", + "--file", + ".gitmodules", + "--get", + "submodule.logical.path" + ] + ), + "vendor/child" + ); + assert!( + h.git_at(&linked, &["ls-files", "--stage", "--", "vendor/child"]) + .starts_with("160000 ") + ); + assert!(linked.join("vendor/child/LICENSE").is_file()); + assert!( + std::fs::read_to_string(linked.join("submod.toml")) + .unwrap() + .contains("[logical]") + ); + let child_gitdir = h.git_at( + &linked.join("vendor/child"), + &["rev-parse", "--absolute-git-dir"], + ); + let expected_gitdir = h.git_at( + &linked, + &[ + "rev-parse", + "--path-format=absolute", + "--git-path", + "modules/logical", + ], + ); + assert_eq!( + std::fs::canonicalize(child_gitdir).unwrap(), + std::fs::canonicalize(expected_gitdir).unwrap() + ); + assert!(!h.work_dir.join("vendor").exists()); + assert!(!nested.join("vendor").exists()); + assert!(!nested.join("submod.toml").exists()); + assert_eq!(h.git_stdout(&["ls-files", "--stage"]), main_index); + assert_eq!(std::fs::read(h.config_path()).unwrap(), main_config); + assert_eq!(h.git_stdout(&["show-ref"]), refs); +} + +#[test] +fn phase2_r16_duplicate_git_registrations_rejected_before_mutation() { + let h = TestHarness::new().unwrap(); + h.init_git_repo().unwrap(); + let remote = h.create_test_remote("ambiguous-git").unwrap(); + h.git_stdout(&[ + "submodule", + "add", + "--name", + "logical", + remote.to_str().unwrap(), + "child", + ]); + h.git_stdout(&[ + "config", + "--file", + ".gitmodules", + "submodule.other.path", + "child", + ]); + h.git_stdout(&[ + "config", + "--file", + ".gitmodules", + "submodule.other.url", + remote.to_str().unwrap(), + ]); + h.git_stdout(&["add", ".gitmodules"]); + h.create_config(&format!( + "[alias]\npath = \"child\"\nurl = {:?}\n", + remote.to_str().unwrap() + )) + .unwrap(); + let before = h.preservation_snapshot(); + let child = h.work_dir.join("child"); + let refs = h.git_at(&child, &["show-ref"]); + let contents = std::fs::read(child.join("LICENSE")).unwrap(); + let output = h.run_submod(&["init"]).unwrap(); + assert_eq!(h.preservation_snapshot(), before, "{output:?}"); + assert_eq!(h.git_at(&child, &["show-ref"]), refs); + assert_eq!(std::fs::read(child.join("LICENSE")).unwrap(), contents); + assert!( + !output.status.success(), + "ambiguous registration accepted: {output:?}" + ); +} + +#[test] +fn phase2_r16_case_colliding_paths_rejected_on_insensitive_filesystem() { + let h = TestHarness::new().unwrap(); + h.init_git_repo().unwrap(); + std::fs::write(h.work_dir.join("CaseProbe"), b"probe").unwrap(); + if !h.work_dir.join("caseprobe").exists() { + return; // Case-sensitive filesystems permit these distinct paths. + } + let remote = h.create_test_remote("case-collision").unwrap(); + h.create_config(&format!( + "[first]\npath = \"Lib\"\nurl = {0:?}\n[second]\npath = \"lib\"\nurl = {0:?}\n", + remote.to_str().unwrap() + )) + .unwrap(); + let before = h.preservation_snapshot(); + let output = h.run_submod(&["init"]).unwrap(); + assert_eq!(h.preservation_snapshot(), before, "{output:?}"); + assert!(!h.work_dir.join("Lib").exists(), "{output:?}"); + assert!(!output.status.success(), "{output:?}"); +} + +#[test] +fn phase2_r16_case_normalized_ancestor_overlap_rejected_before_init() { + let h = TestHarness::new().unwrap(); + h.init_git_repo().unwrap(); + let remote = h.create_test_remote("case-ancestor-overlap").unwrap(); + h.create_config(&format!( + "[parent]\npath = \"Lib\"\nurl = {0:?}\n[child]\npath = \"lib/nested\"\nurl = {0:?}\n", + remote.to_str().unwrap() + )) + .unwrap(); + let before = h.preservation_snapshot(); + let output = h.run_submod(&["init"]).unwrap(); + assert_eq!(h.preservation_snapshot(), before, "{output:?}"); + assert!(!h.work_dir.join("Lib").exists(), "{output:?}"); + assert!(!h.work_dir.join("lib").exists(), "{output:?}"); + assert!(!output.status.success(), "{output:?}"); +} + +#[test] +fn phase2_r16_existing_nested_logical_name_is_accepted_without_mutation() { + let h = TestHarness::new().unwrap(); + h.init_git_repo().unwrap(); + let remote = h.create_test_remote("nested-logical-name").unwrap(); + h.git_stdout(&["submodule", "add", remote.to_str().unwrap(), "vendor/lib"]); + h.create_config(&format!( + "[nickname]\npath = \"vendor/lib\"\nurl = {:?}\n", + remote.to_str().unwrap() + )) + .unwrap(); + h.git_stdout(&["add", "submod.toml"]); + h.git_stdout(&["commit", "-m", "native nested logical name"]); + assert_eq!( + h.git_stdout(&[ + "config", + "--file", + ".gitmodules", + "--get-regexp", + r"^submodule\..*\.path$" + ]), + "submodule.vendor/lib.path vendor/lib" + ); + let child = h.work_dir.join("vendor/lib"); + let before = h.preservation_snapshot(); + let head = h.git_at(&child, &["rev-parse", "HEAD"]); + let refs = h.git_at(&child, &["show-ref"]); + let index = h.git_at(&child, &["ls-files", "--stage"]); + let config = h.git_at(&child, &["config", "--local", "--list"]); + let gitdir = h.git_at(&child, &["rev-parse", "--absolute-git-dir"]); + let contents = std::fs::read(child.join("LICENSE")).unwrap(); + let output = h.run_submod(&["init"]).unwrap(); + assert!(output.status.success(), "{output:?}"); + assert_eq!(h.preservation_snapshot(), before, "{output:?}"); + assert_eq!(h.git_at(&child, &["rev-parse", "HEAD"]), head); + assert_eq!(h.git_at(&child, &["show-ref"]), refs); + assert_eq!(h.git_at(&child, &["ls-files", "--stage"]), index); + assert_eq!(h.git_at(&child, &["config", "--local", "--list"]), config); + assert_eq!( + h.git_at(&child, &["rev-parse", "--absolute-git-dir"]), + gitdir + ); + assert_eq!(std::fs::read(child.join("LICENSE")).unwrap(), contents); +} + +#[cfg(unix)] // Windows filenames cannot contain the literal '*' fixture component. +#[test] +fn phase2_r04_delete_literal_glob_preserves_unrelated_submodule() { + let h = TestHarness::new().unwrap(); + h.init_git_repo().unwrap(); + let remote = h.create_test_remote("literal-pathspec").unwrap(); + for (name, path) in [("logical", "lib*"), ("sibling", "lib-extra")] { + h.git_stdout(&[ + "--literal-pathspecs", + "submodule", + "add", + "--name", + name, + remote.to_str().unwrap(), + path, + ]); + } + h.git_stdout(&["commit", "-m", "Record literal and sibling gitlinks"]); + h.create_config(&format!( + "[logical]\npath = \"lib*\"\nurl = {:?}\n", + remote.to_str().unwrap() + )) + .unwrap(); + let exact_stage = + |path: &str| h.git_stdout(&["--literal-pathspecs", "ls-files", "--stage", "--", path]); + assert!(exact_stage("lib*").starts_with("160000 ")); + let sibling_stage = exact_stage("lib-extra"); + assert!(sibling_stage.starts_with("160000 ")); + let sibling_section = || { + h.git_stdout(&[ + "config", + "-f", + ".gitmodules", + "--get-regexp", + "^submodule[.]sibling[.]", + ]) + }; + let section = sibling_section(); + let local_config = h.git_stdout(&[ + "config", + "--local", + "--get-regexp", + "^submodule[.]sibling[.]", + ]); + let child = h.work_dir.join("lib-extra"); + let head = h.git_at(&child, &["rev-parse", "HEAD"]); + let refs = h.git_at(&child, &["show-ref"]); + let child_index = h.git_at(&child, &["ls-files", "--stage"]); + let gitdir = h.git_at(&child, &["rev-parse", "--absolute-git-dir"]); + let config_bytes = std::fs::read(std::path::Path::new(&gitdir).join("config")).unwrap(); + let paths = h.git_at(&child, &["ls-files", "-z"]); + let files: Vec<_> = paths + .split('\0') + .filter(|path| !path.is_empty()) + .chain(std::iter::once(".git")) + .map(|path| (path.to_owned(), std::fs::read(child.join(path)).unwrap())) + .collect(); + + h.run_submod_success(&["delete", "logical"]).unwrap(); + + assert_eq!(exact_stage("lib*"), ""); + assert!(!h.work_dir.join("lib*").exists()); + assert_eq!(exact_stage("lib-extra"), sibling_stage); + assert_eq!(sibling_section(), section); + assert_eq!(h.gitmodules_entries(), section); + assert_eq!( + h.git_stdout(&[ + "config", + "--local", + "--get-regexp", + "^submodule[.]sibling[.]", + ]), + local_config + ); + assert_eq!(h.git_at(&child, &["rev-parse", "HEAD"]), head); + assert_eq!(h.git_at(&child, &["show-ref"]), refs); + assert_eq!(h.git_at(&child, &["ls-files", "--stage"]), child_index); + assert_eq!( + h.git_at(&child, &["rev-parse", "--absolute-git-dir"]), + gitdir + ); + assert_eq!( + std::fs::read(std::path::Path::new(&gitdir).join("config")).unwrap(), + config_bytes + ); + for (path, bytes) in files { + assert_eq!( + std::fs::read(child.join(&path)).unwrap(), + bytes, + "sibling file changed: {path}" + ); + } +} + +#[test] +fn phase2_r01_materialization_refuses_redirected_child_gitfile() { + for command in [vec!["update"], vec!["init"]] { + let h = TestHarness::new().unwrap(); + h.init_git_repo().unwrap(); + let other = TestHarness::new().unwrap(); + other.init_git_repo().unwrap(); + let remote = h.create_test_remote("materialization-redirect").unwrap(); + h.git_stdout(&[ + "submodule", + "add", + "--name", + "logical", + remote.to_str().unwrap(), + "child", + ]); + h.create_config(&format!( + "[logical]\npath = \"child\"\nurl = {:?}\n", + remote.to_str().unwrap() + )) + .unwrap(); + h.git_stdout(&["add", "submod.toml"]); + h.git_stdout(&["commit", "-m", "Register intended child"]); + let child = h.work_dir.join("child"); + let intended_head = h.git_at(&child, &["rev-parse", "HEAD"]); + let child_paths = h.git_at(&child, &["ls-files", "-z"]); + let child_files: Vec<_> = child_paths + .split('\0') + .filter(|path| !path.is_empty()) + .map(|path| (path.to_owned(), std::fs::read(child.join(path)).unwrap())) + .collect(); + std::fs::write( + other.work_dir.join("sentinel"), + b"unrelated committed bytes\0\xff", + ) + .unwrap(); + other.git_stdout(&["add", "sentinel"]); + other.git_stdout(&["commit", "-m", "Distinct unrelated history"]); + other.git_stdout(&["branch", "unrelated-history"]); + other.git_stdout(&["config", "core.worktree", other.work_dir.to_str().unwrap()]); + other.git_stdout(&["config", "submod.sentinel", "unrelated configuration"]); + let other_head = other.git_stdout(&["rev-parse", "HEAD"]); + assert_ne!(other_head, intended_head); + let other_gitdir = other.git_stdout(&["rev-parse", "--absolute-git-dir"]); + let pointer = format!("gitdir: {other_gitdir}\n"); + std::fs::write(child.join(".git"), pointer.as_bytes()).unwrap(); + let before = h.preservation_snapshot(); + let parent_index = std::fs::read(h.work_dir.join(".git/index")).unwrap(); + let other_before = other.preservation_snapshot(); + let other_bytes: Vec<_> = [ + ".git/HEAD", + ".git/index", + ".git/config", + ".git/refs/heads/unrelated-history", + "sentinel", + ] + .into_iter() + .map(|path| (path, std::fs::read(other.work_dir.join(path)).unwrap())) + .collect(); + + let output = h.run_submod(&command).unwrap(); + + assert!( + !output.status.success(), + "{command:?} accepted redirected child: {output:?}" + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + !stderr.contains("unexpected argument"), + "{command:?}: {stderr}" + ); + assert!( + stderr.contains("child") || stderr.contains("worktree") || stderr.contains("gitdir"), + "missing repository-boundary diagnostic: {stderr}" + ); + assert_eq!(h.preservation_snapshot(), before, "{command:?}: {output:?}"); + assert_eq!( + std::fs::read(h.work_dir.join(".git/index")).unwrap(), + parent_index + ); + assert_eq!( + other.preservation_snapshot(), + other_before, + "{command:?}: {output:?}" + ); + assert_eq!(other.git_stdout(&["rev-parse", "HEAD"]), other_head); + assert_eq!( + std::fs::read(child.join(".git")).unwrap(), + pointer.as_bytes() + ); + for (path, bytes) in &child_files { + assert_eq!( + std::fs::read(child.join(path)).unwrap(), + *bytes, + "{command:?} changed child {path}" + ); + } + for (path, bytes) in other_bytes { + assert_eq!( + std::fs::read(other.work_dir.join(path)).unwrap(), + bytes, + "{command:?} changed unrelated {path}" + ); + } + } +} + +#[cfg(unix)] // Windows filenames cannot contain the literal '*' fixture component. +#[test] +fn phase2_r23_move_literal_glob_preserves_unrelated_submodule() { + let h = TestHarness::new().unwrap(); + h.init_git_repo().unwrap(); + let remote = h.create_test_remote("literal-move-pathspec").unwrap(); + for (name, path) in [("logical", "move*"), ("sibling", "move-extra")] { + h.git_stdout(&[ + "--literal-pathspecs", + "submodule", + "add", + "--name", + name, + remote.to_str().unwrap(), + path, + ]); + } + h.git_stdout(&["commit", "-m", "Record literal and sibling gitlinks"]); + h.create_config(&format!( + "[logical]\npath = \"move*\"\nurl = {:?}\n", + remote.to_str().unwrap() + )) + .unwrap(); + let exact_stage = + |path: &str| h.git_stdout(&["--literal-pathspecs", "ls-files", "--stage", "--", path]); + let source_stage = exact_stage("move*"); + assert!(source_stage.starts_with("160000 ")); + let source = h.work_dir.join("move*"); + let source_head = h.git_at(&source, &["rev-parse", "HEAD"]); + let source_refs = h.git_at(&source, &["show-ref"]); + let source_gitdir = h.git_at(&source, &["rev-parse", "--absolute-git-dir"]); + let source_paths = h.git_at(&source, &["ls-files", "-z"]); + let source_files: Vec<_> = source_paths + .split('\0') + .filter(|path| !path.is_empty()) + .map(|path| (path.to_owned(), std::fs::read(source.join(path)).unwrap())) + .collect(); + let sibling_stage = exact_stage("move-extra"); + assert!(sibling_stage.starts_with("160000 ")); + let sibling_section = || { + h.git_stdout(&[ + "config", + "-f", + ".gitmodules", + "--get-regexp", + "^submodule[.]sibling[.]", + ]) + }; + let section = sibling_section(); + let local_config = h.git_stdout(&[ + "config", + "--local", + "--get-regexp", + "^submodule[.]sibling[.]", + ]); + let child = h.work_dir.join("move-extra"); + let head = h.git_at(&child, &["rev-parse", "HEAD"]); + let refs = h.git_at(&child, &["show-ref"]); + let child_index = h.git_at(&child, &["ls-files", "--stage"]); + let gitdir = h.git_at(&child, &["rev-parse", "--absolute-git-dir"]); + let config_bytes = std::fs::read(std::path::Path::new(&gitdir).join("config")).unwrap(); + let paths = h.git_at(&child, &["ls-files", "-z"]); + let files: Vec<_> = paths + .split('\0') + .filter(|path| !path.is_empty()) + .chain(std::iter::once(".git")) + .map(|path| (path.to_owned(), std::fs::read(child.join(path)).unwrap())) + .collect(); + + h.run_submod_success(&["change", "logical", "--path", "moved"]) + .unwrap(); + + assert_eq!(exact_stage("move*"), ""); + assert!(!h.work_dir.join("move*").exists()); + assert_eq!(exact_stage("move-extra"), sibling_stage); + assert_eq!(sibling_section(), section); + assert_eq!( + exact_stage("moved"), + source_stage.replace("\tmove*", "\tmoved") + ); + assert_eq!( + h.git_stdout(&[ + "config", + "-f", + ".gitmodules", + "--get", + "submodule.logical.path" + ]), + "moved" + ); + let moved = h.work_dir.join("moved"); + assert_eq!(h.git_at(&moved, &["rev-parse", "HEAD"]), source_head); + assert_eq!(h.git_at(&moved, &["show-ref"]), source_refs); + assert_eq!( + h.git_at(&moved, &["rev-parse", "--absolute-git-dir"]), + source_gitdir + ); + for (path, bytes) in source_files { + assert_eq!( + std::fs::read(moved.join(&path)).unwrap(), + bytes, + "moved file changed: {path}" + ); + } + assert_eq!( + h.git_stdout(&[ + "config", + "--local", + "--get-regexp", + "^submodule[.]sibling[.]", + ]), + local_config + ); + assert_eq!(h.git_at(&child, &["rev-parse", "HEAD"]), head); + assert_eq!(h.git_at(&child, &["show-ref"]), refs); + assert_eq!(h.git_at(&child, &["ls-files", "--stage"]), child_index); + assert_eq!( + h.git_at(&child, &["rev-parse", "--absolute-git-dir"]), + gitdir + ); + assert_eq!( + std::fs::read(std::path::Path::new(&gitdir).join("config")).unwrap(), + config_bytes + ); + for (path, bytes) in files { + assert_eq!( + std::fs::read(child.join(&path)).unwrap(), + bytes, + "sibling file changed: {path}" + ); + } +} + +#[cfg(unix)] // Windows filenames cannot contain the literal ':' fixture component. +#[test] +fn phase2_r04_delete_literal_magic_preserves_unrelated_submodule() { + let h = TestHarness::new().unwrap(); + h.init_git_repo().unwrap(); + let remote = h.create_test_remote("literal-magic-pathspec").unwrap(); + for (name, path) in [("logical", ":(top)magic"), ("sibling", "lib-extra")] { + h.git_stdout(&[ + "--literal-pathspecs", + "submodule", + "add", + "--name", + name, + remote.to_str().unwrap(), + path, + ]); + } + h.git_stdout(&["commit", "-m", "Record literal and sibling gitlinks"]); + h.create_config(&format!( + "[logical]\npath = \":(top)magic\"\nurl = {:?}\n", + remote.to_str().unwrap() + )) + .unwrap(); + let exact_stage = + |path: &str| h.git_stdout(&["--literal-pathspecs", "ls-files", "--stage", "--", path]); + assert!(exact_stage(":(top)magic").starts_with("160000 ")); + let sibling_stage = exact_stage("lib-extra"); + assert!(sibling_stage.starts_with("160000 ")); + let sibling_section = || { + h.git_stdout(&[ + "config", + "-f", + ".gitmodules", + "--get-regexp", + "^submodule[.]sibling[.]", + ]) + }; + let section = sibling_section(); + let local_config = h.git_stdout(&[ + "config", + "--local", + "--get-regexp", + "^submodule[.]sibling[.]", + ]); + let child = h.work_dir.join("lib-extra"); + let head = h.git_at(&child, &["rev-parse", "HEAD"]); + let refs = h.git_at(&child, &["show-ref"]); + let child_index = h.git_at(&child, &["ls-files", "--stage"]); + let gitdir = h.git_at(&child, &["rev-parse", "--absolute-git-dir"]); + let config_bytes = std::fs::read(std::path::Path::new(&gitdir).join("config")).unwrap(); + let paths = h.git_at(&child, &["ls-files", "-z"]); + let files: Vec<_> = paths + .split('\0') + .filter(|path| !path.is_empty()) + .chain(std::iter::once(".git")) + .map(|path| (path.to_owned(), std::fs::read(child.join(path)).unwrap())) + .collect(); + + h.run_submod_success(&["delete", "logical"]).unwrap(); + + assert_eq!(exact_stage(":(top)magic"), ""); + assert!(!h.work_dir.join(":(top)magic").exists()); + assert_eq!(exact_stage("lib-extra"), sibling_stage); + assert_eq!(sibling_section(), section); + assert_eq!(h.gitmodules_entries(), section); + assert_eq!( + h.git_stdout(&[ + "config", + "--local", + "--get-regexp", + "^submodule[.]sibling[.]", + ]), + local_config + ); + assert_eq!(h.git_at(&child, &["rev-parse", "HEAD"]), head); + assert_eq!(h.git_at(&child, &["show-ref"]), refs); + assert_eq!(h.git_at(&child, &["ls-files", "--stage"]), child_index); + assert_eq!( + h.git_at(&child, &["rev-parse", "--absolute-git-dir"]), + gitdir + ); + assert_eq!( + std::fs::read(std::path::Path::new(&gitdir).join("config")).unwrap(), + config_bytes + ); + for (path, bytes) in files { + assert_eq!( + std::fs::read(child.join(&path)).unwrap(), + bytes, + "sibling file changed: {path}" + ); + } +} diff --git a/tests/integration_tests.rs b/tests/integration_tests.rs index 178e79ba..06731dc7 100644 --- a/tests/integration_tests.rs +++ b/tests/integration_tests.rs @@ -26,10 +26,12 @@ mod tests { .run_submod(&["check", "--verbose"]) .expect("Failed to run submod"); - // Should succeed but show no submodules - assert!(output.status.success()); - let stdout = String::from_utf8_lossy(&output.stdout); - assert!(stdout.contains("Checking submodule configurations")); + assert_eq!(output.status.code(), Some(2)); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.contains("submod.toml") && stderr.contains("was not found")); + assert!(stderr.contains("generate-config --from-setup")); + assert!(!harness.file_exists("submod.toml")); + assert!(!harness.file_exists(".gitmodules")); } #[test] @@ -46,7 +48,8 @@ mod tests { assert!(output.status.success()); let stdout = String::from_utf8_lossy(&output.stdout); - assert!(stdout.contains("Checking submodule configurations")); + assert!(stdout.contains("Check complete: all configured submodules match.")); + assert_eq!(harness.read_config().unwrap(), "# Empty config\n"); } #[test] @@ -78,7 +81,12 @@ mod tests { assert!(config.contains("[test-lib]")); assert!(config.contains("path = \"lib/test\"")); assert!(config.contains(&format!("url = \"{remote_url}\""))); - assert!(config.contains("active = true")); + let parsed = submod::Config::parse(&config).expect("Failed to parse config"); + assert_eq!(parsed.get_submodule("test-lib").unwrap().active, None); + assert_eq!( + parsed.effective_entry("test-lib").unwrap().active, + Some(true) + ); // Verify directory structure was created assert!(harness.dir_exists("lib/test")); @@ -153,8 +161,17 @@ sparse_paths = ["src"] .run_submod_success(&["init", "--verbose"]) .expect("Failed to run init"); - assert!(stdout.contains("Initializing init-lib")); - assert!(stdout.contains("initialized")); + assert!(stdout.contains("init-lib at lib/init: changed:")); + assert!(stdout.contains( + "Initialization summary: 1 changed, 0 unchanged, 0 skipped, 0 failed, 0 pending." + )); + let target = harness.git_at(&remote_repo, &["rev-parse", "HEAD"]); + assert!(stdout.contains(&format!("(target {target})"))); + assert_eq!( + harness.git_stdout(&["-C", "lib/init", "rev-parse", "HEAD"]), + target + ); + assert!(harness.file_exists("lib/init/src/main.c")); // Verify directory was created assert!(harness.dir_exists("lib/init")); @@ -192,14 +209,21 @@ sparse_paths = ["src"] .run_submod_success(&["update"]) .expect("Failed to run update"); - // `update` must name each submodule it touched, then summarize the count. + let target = harness.git_at(&remote_repo, &["rev-parse", "HEAD"]); assert!( - stdout.contains("✅ Updated update-lib"), - "update should name the submodule it updated; got: {stdout}" + stdout.contains("update-lib at lib/update: unchanged:"), + "{stdout}" ); assert!( - stdout.contains("Updated 1 submodule(s)"), - "update should report how many submodules it updated; got: {stdout}" + stdout.contains( + "Update summary: 0 changed, 1 unchanged, 0 skipped, 0 failed, 0 pending." + ), + "{stdout}" + ); + assert!(stdout.contains(&format!("(target {target})")), "{stdout}"); + assert_eq!( + harness.git_stdout(&["-C", "lib/update", "rev-parse", "HEAD"]), + target ); // gitoxide's fetch report is plumbing narration for `gix fetch`, not part @@ -281,14 +305,9 @@ sparse_paths = ["src"] ); } - /// Characterizes `update` against a remote that has moved forward: the fetch - /// genuinely happens (the new commit's object is pulled into the submodule), - /// but HEAD follows the superproject-recorded gitlink — it does NOT jump to - /// the remote tip. That is plain `git submodule update` semantics (only - /// `--remote` would follow the branch tip). Replaces the prior no-op smoke - /// test that updated against a remote which never advanced (#62 P1). + /// Default update retains the parent pin even when the remote advances. #[test] - fn update_against_advanced_remote_fetches_without_moving_head() { + fn update_against_advanced_remote_preserves_parent_pin() { let harness = TestHarness::new().expect("Failed to create test harness"); harness.init_git_repo().expect("Failed to init git repo"); @@ -309,29 +328,26 @@ sparse_paths = ["src"] .advance_test_remote("upd_adv") .expect("Failed to advance remote"); assert_ne!(recorded, advanced, "the remote must have actually moved"); + let unknown = harness + .git_cmd() + .env("LC_ALL", "C") + .args(["-C", "lib/adv", "cat-file", "-t", &advanced]) + .current_dir(&harness.work_dir) + .output() + .expect("probe unknown object"); assert!( - harness - .git_stdout(&["-C", "lib/adv", "cat-file", "-t", &advanced]) - .is_empty(), - "precondition: the advanced commit must be unknown before update" + unknown.status.code() == Some(128) + && unknown.stdout.is_empty() + && String::from_utf8_lossy(&unknown.stderr).contains("could not get object info"), + "precondition: the advanced commit must be unknown before update: {unknown:?}" ); harness .run_submod_success(&["update"]) .expect("Failed to run update"); - // The fetch really ran: the advanced commit's object is now present in - // the submodule's object store. (This is the non-vacuous part — it is - // false unless update actually fetched, since the object was absent in - // the precondition above.) - assert_eq!( - harness.git_stdout(&["-C", "lib/adv", "cat-file", "-t", &advanced]), - "commit", - "update must fetch the advanced remote commit into the submodule" - ); - - // But HEAD stayed at the recorded gitlink — update tracks the recorded - // commit, not the remote tip. + assert!(!harness.file_exists("lib/adv/ADVANCE.txt")); + // Default update materializes the recorded commit. assert_eq!( harness.git_stdout(&["-C", "lib/adv", "rev-parse", "HEAD"]), recorded, @@ -374,7 +390,7 @@ sparse_paths = ["src"] .run_submod_success(&["check", "--verbose"]) .expect("Failed to run check on clean submodule"); assert!( - clean_out.contains("Working tree is clean"), + clean_out.contains("dirty-lib: unchanged (matches configured state)"), "precondition: a freshly-added submodule must report a clean worktree, got:\n{clean_out}" ); @@ -393,15 +409,44 @@ sparse_paths = ["src"] "precondition: the submodule worktree must be dirty per git" ); - let dirty_out = harness - .run_submod_success(&["check", "--verbose"]) + let before = harness.preservation_snapshot(); + let child_before = harness.git_stdout(&["-C", "lib/dirty", "status", "--porcelain=v1"]); + let child_index_before = harness.git_stdout(&["-C", "lib/dirty", "ls-files", "--stage"]); + let child_head_before = harness.git_stdout(&["-C", "lib/dirty", "rev-parse", "HEAD"]); + let child_config_before = + harness.git_stdout(&["-C", "lib/dirty", "config", "--local", "--list"]); + let output = harness + .run_submod(&["check", "--verbose"]) .expect("Failed to run check on dirty submodule"); + assert_eq!(output.status.code(), Some(1), "{output:?}"); + assert_eq!(harness.preservation_snapshot(), before); + assert_eq!( + harness.git_stdout(&["-C", "lib/dirty", "status", "--porcelain=v1"]), + child_before + ); + assert_eq!( + harness.git_stdout(&["-C", "lib/dirty", "ls-files", "--stage"]), + child_index_before + ); + assert_eq!( + harness.git_stdout(&["-C", "lib/dirty", "rev-parse", "HEAD"]), + child_head_before + ); + assert_eq!( + harness.git_stdout(&["-C", "lib/dirty", "config", "--local", "--list"]), + child_config_before + ); + let dirty_out = String::from_utf8_lossy(&output.stdout); + assert_eq!( + fs::read_to_string(harness.work_dir.join("lib/dirty/LICENSE")).unwrap(), + "MIT License\nlocal edit\n" + ); assert!( - dirty_out.contains("Working tree has changes"), + dirty_out.contains("dirty-lib: drift: working tree has changes"), "check must report the modified submodule worktree as dirty, got:\n{dirty_out}" ); assert!( - !dirty_out.contains("Working tree is clean"), + !dirty_out.contains("dirty-lib: unchanged"), "check must not report the modified submodule worktree as clean, got:\n{dirty_out}" ); } @@ -428,6 +473,10 @@ sparse_paths = ["src"] ]) .expect("Failed to add submodule"); + let pin = harness.git_at(&harness.work_dir.join("lib/reset"), &["rev-parse", "HEAD"]); + let parent_index = + fs::read(harness.work_dir.join(".git/index")).expect("Failed to snapshot parent index"); + // Make some changes in the submodule fs::write( harness.work_dir.join("lib/reset/test_file.txt"), @@ -440,11 +489,50 @@ sparse_paths = ["src"] .run_submod_success(&["reset", "reset-lib"]) .expect("Failed to run reset"); - assert!(stdout.contains("Hard resetting")); - assert!(stdout.contains("reset complete")); + assert!(stdout.contains("Reset summary: 1 changed, 0 unchanged, 0 skipped, 0 failed.")); + assert!(stdout.contains(&format!("reset-lib reset to {pin}"))); // Verify test file was removed assert!(!harness.file_exists("lib/reset/test_file.txt")); + assert_eq!( + harness.git_at(&harness.work_dir.join("lib/reset"), &["rev-parse", "HEAD"]), + pin + ); + assert_eq!( + fs::read(harness.work_dir.join(".git/index")).unwrap(), + parent_index + ); + let stash = harness.git_at( + &harness.work_dir.join("lib/reset"), + &["rev-parse", "refs/stash"], + ); + assert!(stdout.contains(&format!("Preserved local work in stash {stash}"))); + let short = &stash[..stash.len().min(12)]; + assert!(stdout.contains(&format!("git stash branch submod-recovery-{short} {stash}"))); + assert_eq!( + harness.git_at( + &harness.work_dir.join("lib/reset"), + &["show", &format!("{stash}^3:test_file.txt")], + ), + "This is a test change" + ); + harness.git_at( + &harness.work_dir.join("lib/reset"), + &[ + "stash", + "branch", + &format!("submod-recovery-{short}"), + &stash, + ], + ); + assert_eq!( + fs::read(harness.work_dir.join("lib/reset/test_file.txt")).unwrap(), + b"This is a test change" + ); + assert_eq!( + fs::read(harness.work_dir.join(".git/index")).unwrap(), + parent_index + ); } #[test] @@ -484,6 +572,11 @@ sparse_paths = ["src"] ]) .expect("Failed to add submodule 2"); + let pins: Vec<_> = ["lib/reset1", "lib/reset2"] + .iter() + .map(|path| harness.git_stdout(&["-C", path, "rev-parse", "HEAD"])) + .collect(); + let parent_index = fs::read(harness.work_dir.join(".git/index")).unwrap(); // Make changes in both submodules fs::write(harness.work_dir.join("lib/reset1/test1.txt"), "change1") .expect("Failed to create test file"); @@ -495,7 +588,27 @@ sparse_paths = ["src"] .run_submod_success(&["reset", "--all"]) .expect("Failed to run reset all"); - assert!(stdout.contains("Hard resetting")); + assert!(stdout.contains("Reset summary: 2 changed, 0 unchanged, 0 skipped, 0 failed.")); + for (i, path) in ["lib/reset1", "lib/reset2"].iter().enumerate() { + assert!(stdout.contains(&format!("reset-lib{} reset to {}", i + 1, pins[i]))); + assert_eq!( + harness.git_stdout(&["-C", path, "rev-parse", "HEAD"]), + pins[i] + ); + assert_eq!( + harness.git_stdout(&[ + "-C", + path, + "show", + &format!("refs/stash^3:test{}.txt", i + 1) + ]), + format!("change{}", i + 1) + ); + } + assert_eq!( + fs::read(harness.work_dir.join(".git/index")).unwrap(), + parent_index + ); // Verify both test files were removed assert!(!harness.file_exists("lib/reset1/test1.txt")); @@ -529,14 +642,32 @@ active = true .run_submod_success(&["sync", "--verbose"]) .expect("Failed to run sync"); - assert!(stdout.contains("Running full sync")); - assert!(stdout.contains("Checking submodule configurations")); - assert!(stdout.contains("Initializing")); - assert!(stdout.contains("Sync complete")); + assert!(stdout.contains("sync-lib at lib/sync: changed:")); + assert!( + stdout + .contains("Sync summary: 1 changed, 0 unchanged, 0 skipped, 0 failed, 0 pending.") + ); + assert!(!stdout.contains("Reconciling configured submodules")); // Verify submodule was initialized assert!(harness.dir_exists("lib/sync")); assert!(harness.file_exists("lib/sync/.git")); + assert_eq!( + harness.index_gitlink_mode("lib/sync").as_deref(), + Some("160000") + ); + assert_eq!( + harness + .git_stdout(&["-C", "lib/sync", "rev-parse", "HEAD"]) + .trim(), + harness.git_at(&remote_repo, &["rev-parse", "HEAD"]) + ); + assert_eq!( + std::fs::read_to_string(harness.work_dir.join("lib/sync/LICENSE")) + .unwrap() + .trim(), + harness.git_at(&remote_repo, &["show", "HEAD:LICENSE"]) + ); } #[test] @@ -564,13 +695,42 @@ active = true .create_config(&config_content) .expect("Failed to create config"); + harness + .run_submod_success(&["init"]) + .expect("Failed to initialize inherited settings"); + let raw: toml::Value = toml::from_str(&harness.read_config().unwrap()).unwrap(); + assert!(raw["defaults-lib"].get("ignore").is_none()); + assert_eq!(raw["defaults"]["ignore"].as_str(), Some("dirty")); + assert_eq!( + harness + .git_stdout(&[ + "config", + "--local", + "--get", + "submodule.defaults-lib.ignore" + ]) + .trim(), + "dirty" + ); + assert_eq!( + harness + .git_stdout(&[ + "config", + "--file", + ".gitmodules", + "--get", + "submodule.defaults-lib.ignore" + ]) + .trim(), + "dirty" + ); + // Run check to see if defaults are applied let stdout = harness .run_submod_success(&["check", "--verbose"]) .expect("Failed to run check"); - assert!(stdout.contains("Checking submodule configurations")); - // The output should show effective settings including defaults + assert!(stdout.contains("defaults-lib: unchanged (matches configured state)")); } #[test] @@ -586,11 +746,22 @@ active = true .expect("Failed to create custom config"); // Run with custom config file (verbose to verify output) - let stdout = harness - .run_submod_success(&["--config", "custom.toml", "check", "--verbose"]) + let before = harness.preservation_snapshot(); + let output = harness + .run_submod(&["--config", "custom.toml", "check", "--verbose"]) .expect("Failed to run with custom config"); + assert_eq!(output.status.code(), Some(1), "{output:?}"); + assert_eq!(harness.preservation_snapshot(), before); + let stdout = String::from_utf8_lossy(&output.stdout); - assert!(stdout.contains("Checking submodule configurations")); + assert!(stdout.contains("test-sub: drift: checkout is missing")); + let custom = fs::read_to_string(&custom_config).unwrap(); + let parsed = submod::Config::parse(&custom).unwrap(); + assert_eq!( + parsed.get_submodule("test-sub").unwrap().path.as_deref(), + Some("test") + ); + assert!(!harness.file_exists("submod.toml")); } #[test] @@ -606,12 +777,12 @@ active = true let stderr = String::from_utf8_lossy(&output.stderr); assert!( - stderr.contains("Failed to create manager"), + stderr.contains("Cannot prepare check"), "error should name the operation that failed; got: {stderr}" ); // The underlying cause must survive the wrapping, not be flattened away. assert!( - stderr.contains("Repository not found"), + stderr.contains("not a git repository"), "error should preserve the underlying cause; got: {stderr}" ); } @@ -621,6 +792,7 @@ active = true let harness = TestHarness::new().expect("Failed to create test harness"); harness.init_git_repo().expect("Failed to init git repo"); + let before = harness.preservation_snapshot(); // Try to add submodule with invalid URL let output = harness .run_submod(&[ @@ -634,9 +806,16 @@ active = true .expect("Failed to run submod"); assert_eq!(output.status.code(), Some(1)); + assert_eq!(harness.preservation_snapshot(), before); let stderr = String::from_utf8_lossy(&output.stderr); assert!( - stderr.contains("Failed to add submodule"), + stderr.contains("repo URL") + && stderr.contains("not-a-valid-url") + && stderr.contains("must be absolute or begin"), + "{stderr}" + ); + assert!( + stderr.contains("Add failed"), "error should name the operation that failed; got: {stderr}" ); } @@ -670,11 +849,42 @@ active = true fs::write(&sparse_file, "include\nLICENSE\n").expect("Failed to modify sparse file"); // Run check command - let stdout = harness - .run_submod_success(&["check", "--verbose"]) + let before = harness.preservation_snapshot(); + let child_before = harness.git_stdout(&["-C", "lib/mismatch", "status", "--porcelain=v1"]); + let child_index_before = harness.git_stdout(&["-C", "lib/mismatch", "ls-files", "--stage"]); + let child_head_before = harness.git_stdout(&["-C", "lib/mismatch", "rev-parse", "HEAD"]); + let child_config_before = + harness.git_stdout(&["-C", "lib/mismatch", "config", "--local", "--list"]); + let output = harness + .run_submod(&["check", "--verbose"]) .expect("Failed to run check"); + assert_eq!(output.status.code(), Some(1), "{output:?}"); + assert_eq!(harness.preservation_snapshot(), before); + assert_eq!( + harness.git_stdout(&["-C", "lib/mismatch", "status", "--porcelain=v1"]), + child_before + ); + assert_eq!( + harness.git_stdout(&["-C", "lib/mismatch", "ls-files", "--stage"]), + child_index_before + ); + assert_eq!( + harness.git_stdout(&["-C", "lib/mismatch", "rev-parse", "HEAD"]), + child_head_before + ); + assert_eq!( + harness.git_stdout(&["-C", "lib/mismatch", "config", "--local", "--list"]), + child_config_before + ); + let stdout = String::from_utf8_lossy(&output.stdout); - assert!(stdout.contains("Sparse checkout mismatch")); + assert!(stdout.contains("mismatch-lib: drift: sparse patterns differ")); + assert_eq!( + fs::read_to_string(&sparse_file).unwrap(), + "include\nLICENSE\n" + ); + assert!(stdout.contains("expected [\"!/*\", \"src\", \"docs\"]")); + assert!(stdout.contains(r#"current ["include", "LICENSE"]"#)); } #[test] @@ -768,6 +978,10 @@ active = true ]) .expect("Failed to add submodule"); + let head = harness.git_stdout(&["-C", "lib/disable", "rev-parse", "HEAD"]); + let refs = harness.git_stdout(&["-C", "lib/disable", "show-ref"]); + let content = std::fs::read(harness.work_dir.join("lib/disable/docs/README.md")).unwrap(); + let stdout = harness .run_submod_success(&["disable", "disable-lib"]) .expect("Failed to disable submodule"); @@ -778,12 +992,20 @@ active = true let config = harness.read_config().expect("Failed to read config"); assert!(config.contains("active = false")); - // .gitmodules should show active = false - let gitmodules_path = harness.work_dir.join(".gitmodules"); - let gitmodules_content = - std::fs::read_to_string(&gitmodules_path).expect("Failed to read .gitmodules"); - println!("GITMODULES CONTENT:\n{gitmodules_content}"); - assert!(gitmodules_content.contains("active = false")); + assert_eq!( + harness.git_stdout(&["config", "--local", "--get", "submodule.disable-lib.active"]), + "false" + ); + assert!(!harness.gitmodules_entries().contains(".active")); + assert_eq!( + harness.git_stdout(&["-C", "lib/disable", "rev-parse", "HEAD"]), + head + ); + assert_eq!( + std::fs::read(harness.work_dir.join("lib/disable/docs/README.md")).unwrap(), + content + ); + assert_eq!(harness.git_stdout(&["-C", "lib/disable", "show-ref"]), refs); } #[test] @@ -818,7 +1040,12 @@ active = true let gitmodules_updated = std::fs::read_to_string(harness.work_dir.join(".gitmodules")) .expect("Failed to read .gitmodules"); - assert!(gitmodules_updated.contains("active = false")); + assert_eq!(gitmodules_updated, gitmodules_content); + assert!(harness.read_config().unwrap().contains("active = false")); + assert_eq!( + harness.git_stdout(&["config", "--local", "--get", "submodule.my-lib.active"]), + "false" + ); } #[test] @@ -1067,7 +1294,8 @@ active = true ]) .expect("Failed to run generate-config"); - assert_eq!(output.status.code(), Some(1)); + assert_eq!(output.status.code(), Some(2)); + assert_eq!(fs::read(&output_path).unwrap(), b"# existing\n"); let stderr = String::from_utf8_lossy(&output.stderr); // Both the refusal and the way out of it must be in the message. assert!( @@ -1097,16 +1325,29 @@ active = true ]) .expect("Failed to add submodule"); + let gitdir = harness.git_stdout(&["-C", "lib/nuke", "rev-parse", "--absolute-git-dir"]); + let head = harness.git_stdout(&["-C", "lib/nuke", "rev-parse", "HEAD"]); // Nuke with --kill (does not reinit) let stdout = harness .run_submod_success(&["nuke-it-from-orbit", "nuke-lib", "--kill"]) .expect("Failed to nuke submodule"); assert!( - stdout.contains("💥 Nuking submodule 'nuke-lib'..."), + stdout.contains( + "nuke-lib: changed: removed its checkout, Git registration, and TOML declaration" + ), "nuke should report the submodule it is nuking; got: {stdout}" ); + assert!(stdout.contains("Nuke summary: 1 changed, 0 unchanged, 0 skipped, 0 failed.")); + assert!(!harness.dir_exists("lib/nuke")); + assert_eq!(harness.index_gitlink_mode("lib/nuke"), None); + assert!(!harness.gitmodules_entries().contains("nuke-lib")); + assert!(!harness.submodule_config_entries().contains("nuke-lib")); + assert_eq!( + harness.git_stdout(&["--git-dir", &gitdir, "rev-parse", "HEAD"]), + head + ); // Config should not contain the submodule anymore let config = harness.read_config().expect("Failed to read config"); assert!(!config.contains("[nuke-lib]")); @@ -1236,15 +1477,22 @@ active = true // `.git/config` must carry a `submodule.*` section for the submodule. let config_entries = harness.submodule_config_entries(); assert!( - config_entries.contains("lib/state"), - "expected a submodule.* config section for lib/state, got:\n{config_entries}" + config_entries.contains("submodule.state-lib.url"), + "expected a logical-name config section for state-lib, got:\n{config_entries}" ); - // The per-submodule git directory must exist. - assert!( - harness.git_modules_dir_exists("lib/state"), - ".git/modules/lib/state should exist after add" - ); + assert_eq!( + harness.git_stdout(&[ + "config", + "--file", + ".gitmodules", + "--get", + "submodule.state-lib.path" + ]), + "lib/state" + ); + let gitdir = harness.git_stdout(&["-C", "lib/state", "rev-parse", "--absolute-git-dir"]); + assert!(std::path::Path::new(&gitdir).is_dir()); // The worktree must be checked out at exactly the gitlinked commit. let head = harness.git_stdout(&["-C", "lib/state", "rev-parse", "HEAD"]); @@ -1254,9 +1502,7 @@ active = true ); } - /// `delete` must remove all git state, not just the `submod.toml` section: - /// the worktree, the index gitlink, the `.gitmodules` entry, the `submodule.*` - /// config section, and the `.git/modules/` directory. + /// Delete removes registration and checkout while retaining repository history. #[test] fn test_delete_cleans_up_git_state() { let harness = TestHarness::new().expect("Failed to create test harness"); @@ -1293,10 +1539,10 @@ active = true !harness.submodule_config_entries().is_empty(), "precondition: submodule.* config should exist before delete" ); - assert!( - harness.git_modules_dir_exists("lib/delstate"), - "precondition: .git/modules/lib/delstate should exist before delete" - ); + let gitdir = harness.git_stdout(&["-C", "lib/delstate", "rev-parse", "--absolute-git-dir"]); + let head = harness.git_stdout(&["-C", "lib/delstate", "rev-parse", "HEAD"]); + let refs = harness.git_stdout(&["-C", "lib/delstate", "show-ref"]); + assert!(std::path::Path::new(&gitdir).is_dir()); assert!(harness.dir_exists("lib/delstate")); harness @@ -1323,8 +1569,16 @@ active = true harness.submodule_config_entries() ); assert!( - !harness.git_modules_dir_exists("lib/delstate"), - ".git/modules/lib/delstate should be removed" + std::path::Path::new(&gitdir).is_dir(), + "retained history must survive removal" + ); + assert_eq!( + harness.git_stdout(&["--git-dir", &gitdir, "show-ref"]), + refs + ); + assert_eq!( + harness.git_stdout(&["--git-dir", &gitdir, "cat-file", "-t", &head]), + "commit" ); } @@ -1356,7 +1610,11 @@ active = true Some("160000"), "precondition: gitlink should exist before nuke" ); - assert!(harness.git_modules_dir_exists("lib/nukestate")); + let gitdir = + harness.git_stdout(&["-C", "lib/nukestate", "rev-parse", "--absolute-git-dir"]); + let head = harness.git_stdout(&["-C", "lib/nukestate", "rev-parse", "HEAD"]); + let refs = harness.git_stdout(&["-C", "lib/nukestate", "show-ref"]); + assert!(std::path::Path::new(&gitdir).is_dir()); harness .run_submod_success(&["nuke-it-from-orbit", "nuke-state", "--kill"]) @@ -1382,8 +1640,16 @@ active = true harness.submodule_config_entries() ); assert!( - !harness.git_modules_dir_exists("lib/nukestate"), - ".git/modules/lib/nukestate should be removed after nuke --kill" + std::path::Path::new(&gitdir).is_dir(), + "retained history must survive removal" + ); + assert_eq!( + harness.git_stdout(&["--git-dir", &gitdir, "show-ref"]), + refs + ); + assert_eq!( + harness.git_stdout(&["--git-dir", &gitdir, "cat-file", "-t", &head]), + "commit" ); } @@ -1427,10 +1693,91 @@ active = true ); } - /// Adding the same submodule (same name + path) a second time is idempotent: - /// it succeeds without creating duplicate `.gitmodules` or config entries. + /// Committed gitlinks use Git's removal path and retain a reusable repository. + #[test] + fn test_delete_committed_gitlink_retains_history_and_readds() { + let harness = TestHarness::new().unwrap(); + harness.init_git_repo().unwrap(); + let remote = harness.create_test_remote("committed-readd").unwrap(); + let url = format!("file://{}", remote.display()); + let args = ["add", &url, "--name", "retained", "--path", "lib/retained"]; + harness.run_submod_success(&args).unwrap(); + harness.git_stdout(&["commit", "-m", "Record submodule"]); + let stage = harness.git_stdout(&["ls-files", "--stage", "--", "lib/retained"]); + assert!(stage.starts_with("160000 ")); + let head = harness.git_stdout(&["-C", "lib/retained", "rev-parse", "HEAD"]); + let gitdir = harness.git_stdout(&["-C", "lib/retained", "rev-parse", "--absolute-git-dir"]); + let refs = harness.git_stdout(&["-C", "lib/retained", "show-ref"]); + harness.run_submod_success(&["delete", "retained"]).unwrap(); + assert!(!harness.dir_exists("lib/retained")); + assert_eq!(harness.index_gitlink_mode("lib/retained"), None); + assert!(harness.gitmodules_entries().is_empty()); + assert!( + harness.submodule_config_entries().is_empty(), + "local registration remains: {}", + harness.submodule_config_entries() + ); + assert!(std::path::Path::new(&gitdir).is_dir()); + assert_eq!( + harness.git_stdout(&[ + "--git-dir", + &gitdir, + "--work-tree", + harness.work_dir.to_str().unwrap(), + "show-ref" + ]), + refs + ); + assert_eq!( + harness.git_stdout(&[ + "--git-dir", + &gitdir, + "--work-tree", + harness.work_dir.to_str().unwrap(), + "cat-file", + "-t", + &head + ]), + "commit" + ); + harness.run_submod_success(&args).unwrap(); + assert_eq!( + harness.git_stdout(&["ls-files", "--stage", "--", "lib/retained"]), + stage + ); + assert_eq!( + harness.git_stdout(&["-C", "lib/retained", "rev-parse", "HEAD"]), + head + ); + assert_eq!( + harness.git_stdout(&["-C", "lib/retained", "rev-parse", "--absolute-git-dir"]), + gitdir + ); + assert_eq!( + harness.git_stdout(&[ + "config", + "--file", + ".gitmodules", + "--get", + "submodule.retained.path" + ]), + "lib/retained" + ); + assert_eq!( + harness.git_stdout(&[ + "config", + "--file", + ".gitmodules", + "--get", + "submodule.retained.url" + ]), + url + ); + } + + /// Repeated add refuses an existing declaration without changing its state. #[test] - fn test_add_same_submodule_twice_is_idempotent() { + fn test_add_same_submodule_twice_refuses_without_changes() { let harness = TestHarness::new().expect("Failed to create test harness"); harness.init_git_repo().expect("Failed to init git repo"); @@ -1442,21 +1789,37 @@ active = true harness .run_submod_success(&["add", &url, "--name", "idem", "--path", "lib/idem"]) .expect("first add should succeed"); - harness - .run_submod_success(&["add", &url, "--name", "idem", "--path", "lib/idem"]) - .expect("re-adding the same submodule should be a graceful no-op"); + let before = harness.preservation_snapshot(); + let refs = harness.git_stdout(&["-C", "lib/idem", "show-ref"]); + let head = harness.git_stdout(&["-C", "lib/idem", "rev-parse", "HEAD"]); + let content = std::fs::read(harness.work_dir.join("lib/idem/docs/README.md")).unwrap(); + let output = harness + .run_submod(&["add", &url, "--name", "idem", "--path", "lib/idem"]) + .unwrap(); + assert!(!output.status.success()); + assert!(String::from_utf8_lossy(&output.stderr).contains("already declared")); + assert_eq!(harness.preservation_snapshot(), before); + assert_eq!(harness.git_stdout(&["-C", "lib/idem", "show-ref"]), refs); + assert_eq!( + harness.git_stdout(&["-C", "lib/idem", "rev-parse", "HEAD"]), + head + ); + assert_eq!( + std::fs::read(harness.work_dir.join("lib/idem/docs/README.md")).unwrap(), + content + ); // Exactly one entry must exist in .gitmodules, git config, and submod.toml. let gm_raw = std::fs::read_to_string(harness.work_dir.join(".gitmodules")) .expect("read .gitmodules"); assert_eq!( - gm_raw.matches("[submodule \"lib/idem\"]").count(), + gm_raw.matches("[submodule \"idem\"]").count(), 1, ".gitmodules must hold exactly one section for the re-added submodule, got:\n{gm_raw}" ); let cfg = harness.submodule_config_entries(); assert_eq!( - cfg.matches("submodule.lib/idem.url").count(), + cfg.matches("submodule.idem.url").count(), 1, "git config must hold exactly one entry for the submodule, got:\n{cfg}" ); @@ -1475,13 +1838,13 @@ active = true let harness = TestHarness::new().expect("Failed to create test harness"); harness.init_git_repo().expect("Failed to init git repo"); + harness.create_config("# empty\n").unwrap(); + let before = harness.preservation_snapshot(); let output = harness .run_submod(&["delete", "ghost"]) .expect("Failed to run submod"); - assert!( - !output.status.success(), - "deleting a nonexistent submodule must exit non-zero" - ); + assert_eq!(output.status.code(), Some(2)); + assert_eq!(harness.preservation_snapshot(), before); let stderr = String::from_utf8_lossy(&output.stderr); assert!( stderr.contains("ghost") && stderr.contains("not found"), @@ -1551,4 +1914,406 @@ active = true "cleanup of the failed add must not remove the existing submodule:\n{gm}" ); } + + /// R11: a declaration without `path` uses its validated TOML nickname and + /// initialization must leave a real gitlink, not just a cloned directory. + #[test] + fn r11_toml_only_init_defaults_path_and_registers_gitlink() { + let harness = TestHarness::new().expect("harness"); + harness.init_git_repo().expect("parent repo"); + let remote = harness.create_test_remote("r11-remote").expect("remote"); + let url = remote.as_ref().to_string_lossy(); + harness + .create_config(&format!("[library]\nurl = {url:?}\n")) + .expect("config"); + + let output = harness.run_submod(&["init"]).expect("run init"); + assert!( + output.status.success(), + "init must accept an omitted path; stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + assert_eq!( + harness.index_gitlink_mode("library").as_deref(), + Some("160000") + ); + assert!(harness.work_dir.join("library/.git").is_file()); + assert!(harness.work_dir.join("library/src/main.c").is_file()); + } + + /// R12: onboarding a non-recursive clone must materialize the commit pinned + /// by the parent even when the remote has advanced. + #[test] + fn r12_fresh_clone_init_materializes_parent_pin() { + let harness = TestHarness::new().expect("harness"); + harness.init_git_repo().expect("parent repo"); + let remote = harness.create_test_remote("r12-remote").expect("remote"); + + let add = harness + .git_cmd() + .args(["submodule", "add", "--name", "logical"]) + .arg(remote.as_ref()) + .arg("deps/library") + .current_dir(&harness.work_dir) + .output() + .expect("native add"); + assert!( + add.status.success(), + "native add: {}", + String::from_utf8_lossy(&add.stderr) + ); + let pinned = harness.git_at( + &harness.work_dir.join("deps/library"), + &["rev-parse", "HEAD"], + ); + harness + .create_config(&format!( + "[library]\npath = \"deps/library\"\nurl = {:?}\n", + remote.as_ref().to_string_lossy() + )) + .expect("config"); + let commit = harness + .git_cmd() + .args(["add", ".gitmodules", "submod.toml", "deps/library"]) + .current_dir(&harness.work_dir) + .output() + .expect("stage parent"); + assert!(commit.status.success()); + let commit = harness + .git_cmd() + .args(["commit", "-m", "pin submodule"]) + .current_dir(&harness.work_dir) + .output() + .expect("commit parent"); + assert!( + commit.status.success(), + "parent commit: {}", + String::from_utf8_lossy(&commit.stderr) + ); + let advanced = harness + .advance_test_remote("r12-remote") + .expect("advance remote"); + assert_ne!(advanced, pinned); + + let fresh = harness.temp_dir.path().join("fresh-parent"); + let clone = harness + .git_cmd() + .arg("clone") + .arg(&harness.work_dir) + .arg(&fresh) + .output() + .expect("clone parent"); + assert!( + clone.status.success(), + "clone parent: {}", + String::from_utf8_lossy(&clone.stderr) + ); + + let output = harness + .run_submod_at(&fresh, &["init"]) + .expect("fresh init"); + assert!( + output.status.success(), + "fresh init: {}", + String::from_utf8_lossy(&output.stderr) + ); + assert!( + fresh.join("deps/library/.git").is_file(), + "successful init must create a usable checkout" + ); + assert_eq!( + harness.git_at(&fresh.join("deps/library"), &["rev-parse", "HEAD"]), + pinned + ); + assert!(fresh.join("deps/library/src/main.c").is_file()); + } + + /// R13: the requested branch controls the initial checkout and is recorded + /// for ordinary Git consumers. + #[test] + fn r13_add_branch_selects_and_records_named_branch() { + let harness = TestHarness::new().expect("harness"); + harness.init_git_repo().expect("parent repo"); + let remote = harness.create_complex_remote("r13-remote").expect("remote"); + let url = format!("file://{}", remote.display()); + + let output = harness + .run_submod(&[ + "add", + &url, + "--name", + "library", + "--path", + "deps/library", + "--branch", + "develop", + ]) + .expect("branch add"); + assert!( + output.status.success(), + "branch add: {}", + String::from_utf8_lossy(&output.stderr) + ); + assert!(harness.work_dir.join("deps/library/src/dev.rs").is_file()); + let remote_tip = harness.git_at(remote.as_ref(), &["rev-parse", "refs/heads/develop"]); + let checkout = harness.git_at( + &harness.work_dir.join("deps/library"), + &["rev-parse", "HEAD"], + ); + assert_eq!(checkout, remote_tip); + assert!( + harness.gitmodules_entries().lines().any(|line| { + line.ends_with(".branch=develop") || line.ends_with(".branch develop") + }), + "branch missing from .gitmodules: {}", + harness.gitmodules_entries() + ); + } + + /// R13: `update=none` governs later automatic work; an explicit add still + /// requests the initial checkout. + #[test] + fn r13_explicit_add_with_update_none_creates_initial_checkout() { + let harness = TestHarness::new().expect("harness"); + harness.init_git_repo().expect("parent repo"); + let remote = harness.create_test_remote("r13-none").expect("remote"); + let url = format!("file://{}", remote.display()); + + let output = harness + .run_submod(&[ + "add", + &url, + "--name", + "library", + "--path", + "deps/library", + "--update", + "none", + ]) + .expect("add update none"); + assert!( + output.status.success(), + "add: {}", + String::from_utf8_lossy(&output.stderr) + ); + assert_eq!( + harness.index_gitlink_mode("deps/library").as_deref(), + Some("160000") + ); + assert!(harness.work_dir.join("deps/library/src/main.c").is_file()); + } + + /// R14: an explicit managed URL edit followed by sync owns the portable, + /// parent-local, and initialized-child URL while preserving unrelated config. + #[test] + fn r14_sync_reconciles_managed_url_everywhere() { + let harness = TestHarness::new().expect("harness"); + harness.init_git_repo().expect("parent repo"); + let first = harness + .create_test_remote("r14-first") + .expect("first remote"); + let second = harness + .create_test_remote("r14-second") + .expect("second remote"); + let first_url = first.as_ref().to_string_lossy().into_owned(); + let second_url = second.as_ref().to_string_lossy().into_owned(); + harness + .run_submod_success(&[ + "add", + &first_url, + "--name", + "library", + "--path", + "deps/library", + ]) + .expect("initial add"); + let set = harness + .git_cmd() + .args(["config", "submod.unrelated", "keep-me"]) + .current_dir(&harness.work_dir) + .output() + .expect("set unrelated config"); + assert!(set.status.success()); + + harness + .run_submod_success(&["change", "library", "--url", &second_url]) + .expect("change URL"); + let sync = harness.run_submod(&["sync"]).expect("sync"); + assert!( + sync.status.success(), + "sync: {}", + String::from_utf8_lossy(&sync.stderr) + ); + + assert!(harness.gitmodules_entries().contains(&second_url)); + assert!(harness.submodule_config_entries().contains(&second_url)); + assert_eq!( + harness.git_at( + &harness.work_dir.join("deps/library"), + &["remote", "get-url", "origin"] + ), + second_url + ); + assert_eq!( + harness.git_stdout(&["config", "--get", "submod.unrelated"]), + "keep-me" + ); + } +} + +#[test] +fn phase2_acceptance_r03_disable_enable_init_preserves_local_history() { + let h = TestHarness::new().unwrap(); + h.init_git_repo().unwrap(); + let remote = h.create_test_remote("activation-history").unwrap(); + h.git_stdout(&[ + "submodule", + "add", + "--name", + "logical", + remote.to_str().unwrap(), + "vendor/child", + ]); + h.create_config(&format!( + "[nickname]\npath = \"vendor/child\"\nurl = {:?}\nactive = true\n", + remote.to_str().unwrap() + )) + .unwrap(); + h.git_stdout(&["config", "submodule.logical.active", "true"]); + h.git_stdout(&["add", "submod.toml"]); + h.git_stdout(&["commit", "-m", "record child pin"]); + let pin = h.git_stdout(&["rev-parse", "HEAD:vendor/child"]); + let child = h.work_dir.join("vendor/child"); + fs::write(child.join("local-only"), b"local-only commit\0\xff").unwrap(); + h.git_at(&child, &["add", "local-only"]); + h.git_at(&child, &["commit", "-m", "local-only child history"]); + let local_commit = h.git_at(&child, &["rev-parse", "HEAD"]); + h.git_at(&child, &["branch", "saved-local", &local_commit]); + assert_ne!(local_commit, pin); + fs::write(child.join("LICENSE"), b"existing stash bytes\0\xff").unwrap(); + h.git_at(&child, &["stash", "push", "-m", "existing recovery point"]); + let stash = h.git_at(&child, &["rev-parse", "refs/stash"]); + assert!( + h.git_at(&child, &["--no-optional-locks", "status", "--porcelain"]) + .is_empty() + ); + let gitdir = std::path::PathBuf::from(h.git_at(&child, &["rev-parse", "--absolute-git-dir"])); + let files: Vec<_> = h + .git_at(&child, &["ls-files", "-z"]) + .split('\0') + .filter(|path| !path.is_empty()) + .map(|path| (path.to_owned(), fs::read(child.join(path)).unwrap())) + .collect(); + let pointer = fs::read(child.join(".git")).unwrap(); + let index = fs::read(gitdir.join("index")).unwrap(); + let index_entries = h.git_at(&child, &["ls-files", "--stage"]); + let config = fs::read(gitdir.join("config")).unwrap(); + let refs = h.git_at(&child, &["show-ref"]); + let parent_index = h.git_stdout(&["ls-files", "--stage"]); + let parent_refs = h.git_stdout(&["show-ref"]); + let gitmodules = fs::read(h.work_dir.join(".gitmodules")).unwrap(); + + for (args, active) in [ + (&["disable", "nickname"][..], "false"), + (&["change", "nickname", "--active", "true"][..], "true"), + ] { + let output = h.run_submod(args).unwrap(); + assert!(output.status.success(), "{args:?}: {output:?}"); + assert_eq!( + h.git_stdout(&["config", "--local", "--get", "submodule.logical.active"]), + active, + "{args:?}" + ); + assert!( + h.read_config() + .unwrap() + .contains(&format!("active = {active}")), + "{args:?}" + ); + for (path, bytes) in &files { + assert_eq!( + fs::read(child.join(path)).unwrap(), + *bytes, + "{args:?}: {path}" + ); + } + assert_eq!(fs::read(child.join(".git")).unwrap(), pointer, "{args:?}"); + assert_eq!(fs::read(gitdir.join("index")).unwrap(), index, "{args:?}"); + assert_eq!(fs::read(gitdir.join("config")).unwrap(), config, "{args:?}"); + assert_eq!( + h.git_at(&child, &["rev-parse", "HEAD"]), + local_commit, + "{args:?}" + ); + assert_eq!(h.git_at(&child, &["show-ref"]), refs, "{args:?}"); + assert_eq!( + h.git_at(&child, &["cat-file", "-t", &local_commit]), + "commit" + ); + assert_eq!( + h.git_at(&child, &["rev-parse", "refs/stash"]), + stash, + "{args:?}" + ); + assert_eq!(h.git_at(&child, &["cat-file", "-t", &stash]), "commit"); + assert_eq!( + h.git_stdout(&["ls-files", "--stage"]), + parent_index, + "{args:?}" + ); + assert_eq!(h.git_stdout(&["show-ref"]), parent_refs, "{args:?}"); + assert_eq!(h.git_stdout(&["rev-parse", "HEAD:vendor/child"]), pin); + assert_eq!( + fs::read(h.work_dir.join(".gitmodules")).unwrap(), + gitmodules, + "{args:?}" + ); + } + + // Re-enabling preserves the checkout. Explicit init then applies the + // selected checkout strategy to the parent pin while retaining every + // recoverable ref, object, and pre-existing stash. + let init = h.run_submod(&["init"]).unwrap(); + assert!(init.status.success(), "init: {init:?}"); + assert_eq!( + h.git_stdout(&["config", "--local", "--get", "submodule.logical.active"]), + "true" + ); + assert_eq!(fs::read(child.join(".git")).unwrap(), pointer); + assert_eq!(fs::read(gitdir.join("config")).unwrap(), config); + assert_eq!(h.git_at(&child, &["rev-parse", "HEAD"]), pin); + assert_eq!(h.git_at(&child, &["show-ref"]), refs); + assert_eq!( + h.git_at(&child, &["rev-parse", "refs/heads/saved-local"]), + local_commit + ); + assert_eq!( + h.git_at(&child, &["cat-file", "-t", &local_commit]), + "commit" + ); + assert_eq!(h.git_at(&child, &["rev-parse", "refs/stash"]), stash); + assert_eq!(h.git_at(&child, &["cat-file", "-t", &stash]), "commit"); + assert_eq!(h.git_stdout(&["ls-files", "--stage"]), parent_index); + assert_eq!(h.git_stdout(&["show-ref"]), parent_refs); + assert_eq!(h.git_stdout(&["rev-parse", "HEAD:vendor/child"]), pin); + assert_eq!( + fs::read(h.work_dir.join(".gitmodules")).unwrap(), + gitmodules + ); + assert!(!child.join("local-only").exists()); + h.git_at(&child, &["checkout", "saved-local"]); + assert_eq!(h.git_at(&child, &["rev-parse", "HEAD"]), local_commit); + assert_eq!(h.git_at(&child, &["ls-files", "--stage"]), index_entries); + for (path, bytes) in &files { + assert_eq!( + fs::read(child.join(path)).unwrap(), + *bytes, + "recovery: {path}" + ); + } + h.git_at(&child, &["stash", "apply", &stash]); + assert_eq!( + fs::read(child.join("LICENSE")).unwrap(), + b"existing stash bytes\0\xff" + ); } diff --git a/tests/performance_tests.rs b/tests/performance_tests.rs index eb950136..bbdf1f46 100644 --- a/tests/performance_tests.rs +++ b/tests/performance_tests.rs @@ -178,12 +178,32 @@ ignore = "all" // Test parsing performance let parse_start = Instant::now(); - harness - .run_submod_success(&["check", "--verbose"]) + let output = harness + .run_submod(&["check", "--verbose"]) .expect("Failed to run check"); let parse_duration = parse_start.elapsed(); println!("Large config parse time: {parse_duration:?}"); + assert_eq!(output.status.code(), Some(1)); + let stdout = String::from_utf8_lossy(&output.stdout); + let config = submod::Config::default() + .load_from_file(Some(&harness.config_path())) + .expect("Failed to load large config"); + for i in 0..100 { + let name = format!("large-submodule-{i}"); + let path = format!("lib/large{i}"); + let diagnostic = format!("{name}: drift: checkout is missing"); + assert!( + stdout.lines().any(|line| line == diagnostic), + "Missing diagnostic: {diagnostic}\n{stdout}" + ); + assert_eq!( + config.get_submodule(&name).unwrap().path.as_deref(), + Some(path.as_str()) + ); + } + assert_eq!(harness.read_config().unwrap(), large_config); + // Performance assertions assert!( config_create_duration.as_millis() < 1000, @@ -399,6 +419,26 @@ ignore = "all" ); } + /// Restores the process working directory on drop so a mid-test panic + /// cannot pollute sibling tests sharing this process. + struct CwdGuard { + previous: std::path::PathBuf, + } + + impl CwdGuard { + fn enter(dir: &std::path::Path) -> Self { + let previous = std::env::current_dir().expect("Failed to get current directory"); + std::env::set_current_dir(dir).expect("Failed to set CWD"); + Self { previous } + } + } + + impl Drop for CwdGuard { + fn drop(&mut self) { + std::env::set_current_dir(&self.previous).ok(); + } + } + #[test] fn test_memory_usage_with_large_operations() { let harness = TestHarness::new().expect("Failed to create test harness"); @@ -425,9 +465,9 @@ ignore = "all" .expect("Failed to add submodule"); } - // Switch directory to the test workspace to run in-process - let orig_dir = std::env::current_dir().expect("Failed to get current directory"); - std::env::set_current_dir(&harness.work_dir).expect("Failed to set CWD"); + // Switch directory to the test workspace to run in-process. + // The guard restores it even if an operation below panics. + let _cwd = CwdGuard::enter(&harness.work_dir); reset_peak_memory(); let mem_start = get_current_memory(); @@ -460,19 +500,19 @@ ignore = "all" let peak_mem = get_peak_memory(); let net_peak = peak_mem.saturating_sub(mem_start); - // Restore working directory - std::env::set_current_dir(orig_dir).ok(); - println!("Large operations in-process time: {duration:?}"); println!( - "Peak memory usage during large operations: {} KB", + "Rust allocator high-water delta during large operations: {} KB \ + (test-process Rust allocations only; excludes Git child processes, \ + native C allocations, stacks, and total process RSS)", net_peak / 1024 ); - // Assert memory usage is within reasonable bounds (e.g. less than 20 MB) + // Assert Rust allocation growth stays within reasonable bounds + // (e.g. less than 20 MB). This is not a process peak-memory limit. assert!( net_peak < 20 * 1024 * 1024, - "Peak memory usage too high: {net_peak} bytes" + "Rust allocation growth too high: {net_peak} bytes" ); // If we reach here without OOM or crashes, the test passes @@ -556,13 +596,34 @@ active = true .create_config(unicode_config) .expect("Failed to create unicode config"); - let stdout = harness - .run_submod_success(&["check", "--verbose"]) + let output = harness + .run_submod(&["check", "--verbose"]) .expect("Failed to run check"); let duration = start_time.elapsed(); println!("Unicode config processing time: {duration:?}"); - assert!(stdout.contains("Checking submodule configurations")); + assert_eq!(output.status.code(), Some(1)); + let stdout = String::from_utf8_lossy(&output.stdout); + let config = submod::Config::default() + .load_from_file(Some(&harness.config_path())) + .expect("Failed to load unicode config"); + for (name, path) in [ + ("测试-submodule", "lib/测试"), + ("émoji-test-🚀", "lib/émoji🚀"), + ("special-chars-!@#$%", "lib/special"), + ] { + let diagnostic = format!("{name}: drift: checkout is missing"); + assert!( + stdout.lines().any(|line| line == diagnostic), + "Missing diagnostic: {diagnostic}\n{stdout}" + ); + assert_eq!( + config.get_submodule(name).unwrap().path.as_deref(), + Some(path) + ); + assert!(!harness.dir_exists(path)); + } + assert_eq!(harness.read_config().unwrap(), unicode_config); // Performance assertion assert!( diff --git a/tests/phase5_checkout_recovery_tests.rs b/tests/phase5_checkout_recovery_tests.rs new file mode 100644 index 00000000..ade0e5e2 --- /dev/null +++ b/tests/phase5_checkout_recovery_tests.rs @@ -0,0 +1,383 @@ +// SPDX-License-Identifier: LicenseRef-PlainMIT OR MIT +//! Checkout and recovery checks against local native Git fixtures. +mod common; + +use common::TestHarness; +use std::{ + fs, + path::Path, + process::{Command, Output}, +}; + +fn success(output: Output) -> String { + let text = format!( + "{}{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + assert!(output.status.success(), "{text}"); + text +} + +fn fixture() -> TestHarness { + let h = TestHarness::new().unwrap(); + h.init_git_repo().unwrap(); + h +} + +fn snapshot(root: &Path) -> Vec<(String, Vec)> { + fn visit(root: &Path, dir: &Path, result: &mut Vec<(String, Vec)>) { + let mut entries: Vec<_> = fs::read_dir(dir) + .unwrap() + .map(|e| e.unwrap().path()) + .collect(); + entries.sort(); + for path in entries { + let name = path + .strip_prefix(root) + .unwrap() + .to_string_lossy() + .into_owned(); + if path.is_dir() { + result.push((format!("{name}/"), Vec::new())); + visit(root, &path, result); + } else { + result.push((name, fs::read(path).unwrap())); + } + } + } + let mut result = Vec::new(); + visit(root, root, &mut result); + result +} + +fn unchanged(before: Vec<(String, Vec)>, root: &Path) { + let after = snapshot(root); + assert_eq!(before.len(), after.len(), "filesystem entry count changed"); + for ((path, bytes), (new_path, new_bytes)) in before.into_iter().zip(after) { + assert_eq!(path, new_path); + assert!(bytes == new_bytes, "bytes changed: {path}"); + } +} + +fn clone_parent(h: &TestHarness, source: &Path, destination: &Path) { + success( + h.git_cmd() + .arg("clone") + .arg(source) + .arg(destination) + .output() + .unwrap(), + ); +} + +fn traced_sync(h: &TestHarness, cwd: &Path) { + let trace = h.temp_dir.path().join("repeat-trace.jsonl"); + success( + Command::new(&h.submod_bin) + .arg("sync") + .current_dir(cwd) + .env("GIT_CONFIG_GLOBAL", h.temp_dir.path().join("gitconfig")) + .env("GIT_CONFIG_NOSYSTEM", "1") + .env("GIT_TERMINAL_PROMPT", "0") + .env("GIT_TRACE2_EVENT", &trace) + .output() + .unwrap(), + ); + let events = fs::read_to_string(trace).unwrap(); + assert!(!events.is_empty(), "Git trace must be enabled"); + assert!( + !events.contains("\"fetch\""), + "unchanged sync fetched: {events}" + ); +} + +#[test] +fn r20_phase5_relative_url_fresh_clone_preserves_pin_and_native_url_resolution() { + let h = fixture(); + let remote = h.create_test_remote("relative-child").unwrap(); + let parent_remote = h.temp_dir.path().join("parent.git"); + h.git_stdout(&["remote", "add", "origin", parent_remote.to_str().unwrap()]); + h.git_stdout(&[ + "submodule", + "add", + "--name", + "logical", + "../relative-child.git", + "library", + ]); + let pin = h.git_at(&h.work_dir.join("library"), &["rev-parse", "HEAD"]); + h.create_config("[library]\nurl = \"../relative-child.git\"\n") + .unwrap(); + h.git_stdout(&["add", ".gitmodules", "library", "submod.toml"]); + h.git_stdout(&["commit", "-m", "Record relative submodule pin"]); + success( + h.git_cmd() + .args(["clone", "--bare"]) + .arg(&h.work_dir) + .arg(&parent_remote) + .output() + .unwrap(), + ); + assert_ne!(h.advance_test_remote("relative-child").unwrap(), pin); + let fresh = h.temp_dir.path().join("fresh"); + let oracle = h.temp_dir.path().join("native-oracle"); + clone_parent(&h, &parent_remote, &fresh); + clone_parent(&h, &parent_remote, &oracle); + h.git_at(&oracle, &["submodule", "update", "--init", "--", "library"]); + let native_parent_url = h.git_at(&oracle, &["config", "--local", "submodule.logical.url"]); + let native_child_url = h.git_at(&oracle.join("library"), &["remote", "get-url", "origin"]); + assert_eq!( + Path::new(&native_parent_url).canonicalize().unwrap(), + remote.canonicalize().unwrap() + ); + let portable_before = fs::read(fresh.join(".gitmodules")).unwrap(); + let gitlinks_before = h.git_at(&fresh, &["ls-files", "--stage", "library"]); + success(h.run_submod_at(&fresh, &["sync"]).unwrap()); + assert_eq!( + h.git_at(&fresh.join("library"), &["rev-parse", "HEAD"]), + pin + ); + assert!(fresh.join("library/src/main.c").is_file()); + assert!(!fresh.join("library/ADVANCE.txt").exists()); + assert_eq!( + h.git_at(&fresh, &["config", "--local", "submodule.logical.url"]), + native_parent_url + ); + assert_eq!( + h.git_at(&fresh.join("library"), &["remote", "get-url", "origin"]), + native_child_url + ); + assert_eq!( + fs::read(fresh.join(".gitmodules")).unwrap(), + portable_before + ); + assert_eq!( + h.git_at(&fresh, &["ls-files", "--stage", "library"]), + gitlinks_before + ); + let before = snapshot(&fresh); + traced_sync(&h, &fresh); + unchanged(before, &fresh); +} + +fn shallow_pin(unavailable: bool) { + let h = fixture(); + let remote = h.create_test_remote("shallow-child").unwrap(); + let url = format!("file://{}", remote.display()); + h.git_stdout(&["submodule", "add", "--name", "library", &url, "library"]); + let pin = if unavailable { + "1111111111111111111111111111111111111111".to_owned() + } else { + h.git_at(&h.work_dir.join("library"), &["rev-parse", "HEAD~1"]) + }; + if unavailable { + let missing = h + .git_cmd() + .args(["cat-file", "-e", &pin]) + .current_dir(remote.as_ref()) + .output() + .unwrap(); + assert!(!missing.status.success(), "fixture pin unexpectedly exists"); + } + h.git_stdout(&[ + "update-index", + "--cacheinfo", + &format!("160000,{pin},library"), + ]); + h.git_stdout(&[ + "config", + "-f", + ".gitmodules", + "submodule.library.shallow", + "true", + ]); + h.create_config(&format!("[library]\nurl = {url:?}\nshallow = true\n")) + .unwrap(); + h.git_stdout(&["add", ".gitmodules", "submod.toml"]); + h.git_stdout(&["commit", "-m", "Record shallow checkout pin"]); + let tip = h.advance_test_remote("shallow-child").unwrap(); + assert_ne!(tip, pin); + let fresh = h.temp_dir.path().join("fresh-shallow"); + clone_parent(&h, &h.work_dir, &fresh); + fs::write(fresh.join("KEEP.txt"), "pre-existing user bytes\n").unwrap(); + let preserved_paths = ["submod.toml", ".gitmodules", ".git/index", "KEEP.txt"]; + let preserved: Vec<_> = preserved_paths + .iter() + .map(|path| fs::read(fresh.join(path)).unwrap()) + .collect(); + let parent_refs = h.git_at(&fresh, &["show-ref"]); + let index_before = h.git_at(&fresh, &["ls-files", "--stage", "library"]); + assert_eq!(index_before, format!("160000 {pin} 0\tlibrary")); + let output = h.run_submod_at(&fresh, &["init"]).unwrap(); + if !unavailable { + assert!( + output.status.success(), + "available old pin must initialize on the first attempt: {}{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + } + if output.status.success() { + assert!(!unavailable, "init accepted a nonexistent recorded commit"); + assert_eq!( + h.git_at(&fresh.join("library"), &["rev-parse", "HEAD"]), + pin + ); + assert_eq!( + h.git_at( + &fresh.join("library"), + &["rev-parse", "--is-shallow-repository"] + ), + "true" + ); + assert!(fresh.join("library/src/main.c").is_file()); + assert!(!fresh.join("library/ADVANCE.txt").exists()); + assert_eq!( + h.git_at(&fresh, &["ls-files", "--stage", "library"]), + index_before + ); + } else { + let text = format!( + "{}{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + assert!( + text.contains(&pin), + "refusal must identify missing recorded commit {pin}: {text}" + ); + assert!( + text.to_lowercase().contains("commit") || text.to_lowercase().contains("reference"), + "refusal must explain commit failure: {text}" + ); + for (path, before) in preserved_paths.iter().zip(&preserved) { + assert_eq!( + &fs::read(fresh.join(path)).unwrap(), + before, + "changed prior state: {path}" + ); + } + assert_eq!(h.git_at(&fresh, &["show-ref"]), parent_refs); + assert!(!text.to_lowercase().contains("initialized successfully")); + assert!( + !fresh.join("library/ADVANCE.txt").exists(), + "remote-tip content must not stand in for the missing pin" + ); + let gitdir = fresh.join(".git/modules/library"); + if gitdir.exists() { + assert!( + gitdir + .canonicalize() + .unwrap() + .starts_with(fresh.join(".git").canonicalize().unwrap()) + ); + h.git_at(&gitdir, &["rev-parse", "--git-dir"]); + let missing = h + .git_cmd() + .args(["cat-file", "-e", &pin]) + .current_dir(&gitdir) + .output() + .unwrap(); + assert!( + !missing.status.success(), + "missing target unexpectedly became available" + ); + let worktree = h.git_at(&gitdir, &["config", "--local", "core.worktree"]); + assert_eq!( + gitdir.join(worktree).canonicalize().unwrap(), + fresh.join("library").canonicalize().unwrap() + ); + } + // Correcting the unavailable declaration's gitlink must allow the same + // retained repository to finish initialization without manual cleanup. + let repair_pin = h.git_at(remote.as_ref(), &["rev-parse", "HEAD~2"]); + h.git_at( + &fresh, + &[ + "update-index", + "--cacheinfo", + &format!("160000,{repair_pin},library"), + ], + ); + success(h.run_submod_at(&fresh, &["init"]).unwrap()); + assert_eq!( + h.git_at(&fresh.join("library"), &["rev-parse", "HEAD"]), + repair_pin + ); + assert!(fresh.join("library/src/main.c").is_file()); + assert!(!fresh.join("library/ADVANCE.txt").exists()); + assert_eq!(h.git_at(&fresh, &["show-ref"]), parent_refs); + assert_eq!( + fs::read_to_string(fresh.join("KEEP.txt")).unwrap(), + "pre-existing user bytes\n" + ); + } +} + +#[test] +fn r20_phase5_shallow_older_recorded_pin_never_substitutes_remote_tip() { + shallow_pin(false); +} + +#[test] +fn r20_phase5_shallow_unavailable_pin_refuses_with_exact_oid_and_preserves_state() { + shallow_pin(true); +} + +fn broken_gitfile(contents: &str) { + let h = fixture(); + let remote = h.create_test_remote("broken-pointer").unwrap(); + h.git_stdout(&[ + "submodule", + "add", + "--name", + "library", + remote.to_str().unwrap(), + "library", + ]); + h.create_config(&format!( + "[library]\nurl = {:?}\n", + remote.to_str().unwrap() + )) + .unwrap(); + h.git_stdout(&["add", ".gitmodules", "library", "submod.toml"]); + h.git_stdout(&["commit", "-m", "Record valid module"]); + let child = h.work_dir.join("library"); + h.git_at(&child, &["branch", "precious-local"]); + fs::write(child.join("LOCAL.txt"), "preserve user content\n").unwrap(); + fs::write(child.join(".git"), contents).unwrap(); + let before = snapshot(&h.work_dir); + for command in ["init", "update", "sync"] { + let output = h.run_submod(&[command]).unwrap(); + let text = format!( + "{}{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ) + .to_lowercase(); + assert!( + !output.status.success(), + "{command} accepted broken gitfile: {text}" + ); + unchanged(before.clone(), &h.work_dir); + assert!( + text.contains("library") + && (text.contains("gitfile") + || text.contains("gitdir") + || text.contains("pointer") + || text.contains(".git")), + "{command} lacks targeted pointer diagnosis: {text}" + ); + } +} + +#[test] +fn r21_phase5_malformed_child_gitfile_refuses_without_mutation() { + broken_gitfile("this is not a Git pointer\n"); +} + +#[test] +fn r21_phase5_nonexistent_child_gitfile_target_refuses_without_mutation() { + broken_gitfile("gitdir: ../.git/modules/nonexistent\n"); +} diff --git a/tests/phase5_delete_move_tests.rs b/tests/phase5_delete_move_tests.rs new file mode 100644 index 00000000..4e4f687a --- /dev/null +++ b/tests/phase5_delete_move_tests.rs @@ -0,0 +1,1312 @@ +// SPDX-FileCopyrightText: 2025 Adam Poulemanos <89049923+bashandbone@users.noreply.github.com> +// SPDX-License-Identifier: LicenseRef-PlainMIT OR MIT + +mod common; +use common::TestHarness; +use std::{fs, path::PathBuf}; + +fn fixture() -> TestHarness { + let h = TestHarness::new().unwrap(); + h.init_git_repo().unwrap(); + h +} + +fn add_child(h: &TestHarness) -> PathBuf { + let remote = h.create_test_remote("phase5").unwrap(); + h.run_submod_success(&[ + "add", + &format!("file://{}", remote.display()), + "--name", + "child", + "--path", + "lib/child", + ]) + .unwrap(); + h.git_stdout(&["add", "submod.toml", ".gitmodules", "lib/child"]); + h.git_stdout(&["commit", "-m", "register child"]); + PathBuf::from(h.git_stdout(&["-C", "lib/child", "rev-parse", "--absolute-git-dir"])) +} + +fn local_history(h: &TestHarness) -> (String, String) { + h.git_stdout(&[ + "-C", + "lib/child", + "commit", + "--allow-empty", + "-m", + "local only", + ]); + h.git_stdout(&["-C", "lib/child", "branch", "local-only"]); + let head = h.git_stdout(&["-C", "lib/child", "rev-parse", "HEAD"]); + fs::write(h.work_dir.join("lib/child/stash-only"), "retained stash\n").unwrap(); + h.git_stdout(&[ + "-C", + "lib/child", + "stash", + "push", + "--include-untracked", + "-m", + "retain", + ]); + let stash = h.git_stdout(&["-C", "lib/child", "rev-parse", "refs/stash"]); + h.git_stdout(&["add", "lib/child"]); + h.git_stdout(&["commit", "-m", "record local child"]); + (head, stash) +} + +fn assert_history(h: &TestHarness, storage: &std::path::Path, head: &str, stash: &str) { + let dir = format!("--git-dir={}", storage.display()); + let worktree = format!("--work-tree={}", h.work_dir.display()); + assert_eq!( + h.git_stdout(&[&dir, &worktree, "rev-parse", "refs/heads/local-only"]), + head + ); + assert_eq!( + h.git_stdout(&[&dir, &worktree, "rev-parse", "refs/stash"]), + stash + ); + assert_eq!( + h.git_stdout(&[&dir, &worktree, "cat-file", "-t", head]), + "commit" + ); + assert_eq!( + h.git_stdout(&[&dir, &worktree, "show", &format!("{stash}^3:stash-only")]), + "retained stash" + ); +} + +#[test] +fn r22_config_only_delete_preserves_unrelated_state() { + for occupied in [false, true] { + let h = fixture(); + h.create_config("[child]\npath = 'lib/child'\nurl = 'https://invalid.invalid/absent.git'\nactive = true\n").unwrap(); + if occupied { + fs::create_dir_all(h.work_dir.join("lib/child")).unwrap(); + fs::write(h.work_dir.join("lib/child/sentinel"), "unrelated\n").unwrap(); + } + let before = h.preservation_snapshot(); + let index = fs::read(h.work_dir.join(".git/index")).unwrap(); + h.run_submod_success(&["delete", "child"]).unwrap(); + let config: toml::Value = toml::from_str(&h.read_config().unwrap()).unwrap(); + assert!(config.get("child").is_none()); + let after = h.preservation_snapshot(); + assert_eq!(before.0, after.0); + assert_eq!(before.1, after.1); + assert_eq!(before.2[1..], after.2[1..]); + assert_eq!(fs::read(h.work_dir.join(".git/index")).unwrap(), index); + if occupied { + assert_eq!( + fs::read_to_string(h.work_dir.join("lib/child/sentinel")).unwrap(), + "unrelated\n" + ); + } else { + assert!(!h.work_dir.join("lib/child").exists()); + } + assert!(!h.work_dir.join(".git/modules").exists()); + } +} + +#[test] +fn r22_delete_legacy_embedded_gitdir_retains_objects() { + let h = fixture(); + let storage = add_child(&h); + let (head, stash) = local_history(&h); + fs::remove_file(h.work_dir.join("lib/child/.git")).unwrap(); + fs::rename(&storage, h.work_dir.join("lib/child/.git")).unwrap(); + h.git_stdout(&["-C", "lib/child", "config", "--unset", "core.worktree"]); + assert_eq!( + h.git_stdout(&["-C", "lib/child", "rev-parse", "HEAD"]), + head + ); + assert_eq!(h.index_gitlink_mode("lib/child").as_deref(), Some("160000")); + h.run_submod_success(&["delete", "child"]).unwrap(); + assert!(!h.work_dir.join("lib/child").exists()); + assert_eq!(h.index_gitlink_mode("lib/child"), None); + let config: toml::Value = toml::from_str(&h.read_config().unwrap()).unwrap(); + assert!(config.get("child").is_none()); + assert_history(&h, &storage, &head, &stash); +} + +#[test] +fn r23_rejected_moves_preserve_exact_state() { + for case in ["tracked", "untracked", "occupied"] { + let h = fixture(); + let storage = add_child(&h); + let (head, stash) = local_history(&h); + let dirty_path = match case { + "tracked" => { + let tracked = h.git_stdout(&["-C", "lib/child", "ls-files"]); + h.work_dir + .join("lib/child") + .join(tracked.lines().next().unwrap()) + } + "untracked" => h.work_dir.join("lib/child/untracked"), + _ => { + fs::create_dir_all(h.work_dir.join("lib/destination")).unwrap(); + h.work_dir.join("lib/destination/sentinel") + } + }; + fs::write(&dirty_path, "must survive\n").unwrap(); + let before = h.preservation_snapshot(); + let paths = [ + h.work_dir.join(".git/index"), + h.work_dir.join("lib/child/.git"), + storage.join("config"), + storage.join("index"), + dirty_path.clone(), + ]; + let bytes: Vec<_> = paths.iter().map(|p| fs::read(p).unwrap()).collect(); + let refs = h.git_stdout(&["-C", "lib/child", "show-ref"]); + let worktree = h.git_stdout(&["-C", "lib/child", "config", "--get", "core.worktree"]); + let output = h + .run_submod(&["change", "child", "--path", "lib/destination"]) + .unwrap(); + assert!(!output.status.success(), "{case}: {output:?}"); + assert_eq!(h.preservation_snapshot(), before, "{case}"); + for (path, expected) in paths.iter().zip(bytes) { + assert_eq!(fs::read(path).unwrap(), expected, "{case}: {path:?}"); + } + assert_eq!( + h.git_stdout(&["-C", "lib/child", "rev-parse", "HEAD"]), + head + ); + assert_eq!(h.git_stdout(&["-C", "lib/child", "show-ref"]), refs); + assert_eq!( + h.git_stdout(&["-C", "lib/child", "config", "--get", "core.worktree"]), + worktree + ); + assert_history(&h, &storage, &head, &stash); + if case != "occupied" { + assert!(!h.work_dir.join("lib/destination").exists()); + } + } +} + +#[test] +fn r23_inactive_path_change_does_not_materialize() { + let h = fixture(); + h.create_config( + "[child]\npath = 'lib/child'\nurl = 'https://invalid.invalid/absent.git'\nactive = false\n", + ) + .unwrap(); + let before = h.preservation_snapshot(); + let index = fs::read(h.work_dir.join(".git/index")).unwrap(); + let trace = h.temp_dir.path().join("inactive-git-trace"); + let output = std::process::Command::new(&h.submod_bin) + .args(["change", "child", "--path", "lib/destination"]) + .current_dir(&h.work_dir) + .env("GIT_CONFIG_GLOBAL", h.temp_dir.path().join("gitconfig")) + .env("GIT_CONFIG_NOSYSTEM", "1") + .env("GIT_TERMINAL_PROMPT", "0") + .env("GIT_TRACE", &trace) + .output() + .unwrap(); + assert!(output.status.success(), "{output:?}"); + let trace = fs::read_to_string(trace).unwrap(); + assert!( + trace.contains("built-in: git"), + "missing Git trace evidence" + ); + for forbidden in [ + "git clone", + "git fetch", + "git-remote-", + "git remote-http", + "git submodule add", + ] { + assert!( + !trace.contains(forbidden), + "unexpected network-capable command: {trace}" + ); + } + let config: toml::Value = toml::from_str(&h.read_config().unwrap()).unwrap(); + assert_eq!(config["child"]["path"].as_str(), Some("lib/destination")); + assert_eq!(config["child"]["active"].as_bool(), Some(false)); + assert_eq!( + config["child"]["url"].as_str(), + Some("https://invalid.invalid/absent.git") + ); + assert!(!h.work_dir.join("lib/child").exists()); + assert!(!h.work_dir.join("lib/destination").exists()); + assert!(!h.work_dir.join(".git/modules").exists()); + assert_eq!(fs::read(h.work_dir.join(".git/index")).unwrap(), index); + let after = h.preservation_snapshot(); + assert_eq!(before.0, after.0); + assert_eq!(before.1, after.1); + assert_eq!(before.2[1..], after.2[1..]); +} + +#[cfg(unix)] +#[test] +fn r22_failed_nuke_rebuild_retains_history_and_retry_converges() { + use std::os::unix::fs::PermissionsExt; + let h = fixture(); + let storage = add_child(&h); + let (head, stash) = local_history(&h); + for scope in [["--file", ".gitmodules"], ["--blob", ":.gitmodules"]] { + assert_eq!( + h.git_stdout(&[ + "config", + scope[0], + scope[1], + "--get", + "submodule.child.shallow" + ]), + "false" + ); + } + let tracked = h.git_stdout(&["-C", "lib/child", "ls-files", "-z"]); + let contents: Vec<_> = tracked + .split('\0') + .filter(|path| !path.is_empty()) + .map(|path| { + ( + path.to_owned(), + fs::read(h.work_dir.join("lib/child").join(path)).unwrap(), + ) + }) + .collect(); + assert!(!contents.is_empty()); + fs::write(h.work_dir.join("sibling-staged"), "keep staged\n").unwrap(); + h.git_stdout(&["add", "sibling-staged"]); + let sibling = h.git_stdout(&["ls-files", "--stage", "--", "sibling-staged"]); + let gitlink = h.git_stdout(&["ls-files", "--stage", "--", "lib/child"]); + let mut declaration: toml::Value = toml::from_str(&h.read_config().unwrap()).unwrap(); + let child = declaration.as_table_mut().unwrap().remove("child").unwrap(); + declaration + .as_table_mut() + .unwrap() + .insert("nickname".to_owned(), child); + h.create_config(&toml::to_string(&declaration).unwrap()) + .unwrap(); + let config = h.read_config().unwrap(); + let gitmodules = fs::read(h.work_dir.join(".gitmodules")).unwrap(); + assert_eq!( + h.git_stdout(&[ + "config", + "--file", + ".gitmodules", + "--get", + "submodule.child.path" + ]), + "lib/child" + ); + let wrappers = h.temp_dir.path().join("wrappers"); + fs::create_dir(&wrappers).unwrap(); + let real_git = std::process::Command::new("sh") + .args(["-c", "command -v git"]) + .output() + .unwrap(); + assert!(real_git.status.success()); + let real_git = String::from_utf8(real_git.stdout).unwrap(); + fs::write( + wrappers.join("git"), + r#"#!/bin/sh +case " $* " in + *" submodule deinit "*) + "$PHASE5_REAL_GIT" "$@" + status=$? + if [ "$status" -eq 0 ]; then printf deinitialized > "$PHASE5_DEINIT"; fi + exit "$status" + ;; + *" submodule update "*) + if [ -f "$PHASE5_DEINIT" ]; then + printf held > "$PHASE5_STORAGE/index.lock" + printf injected > "$PHASE5_MARKER" + fi + ;; +esac +exec "$PHASE5_REAL_GIT" "$@" +"#, + ) + .unwrap(); + fs::set_permissions(wrappers.join("git"), fs::Permissions::from_mode(0o755)).unwrap(); + let marker = h.temp_dir.path().join("injected"); + let deinit_marker = h.temp_dir.path().join("deinitialized"); + let output = std::process::Command::new(&h.submod_bin) + .args(["nuke-it-from-orbit", "nickname"]) + .current_dir(&h.work_dir) + .env( + "PATH", + format!("{}:{}", wrappers.display(), std::env::var("PATH").unwrap()), + ) + .env("PHASE5_REAL_GIT", real_git.trim()) + .env("PHASE5_STORAGE", &storage) + .env("PHASE5_MARKER", &marker) + .env("PHASE5_DEINIT", &deinit_marker) + .env("GIT_CONFIG_GLOBAL", h.temp_dir.path().join("gitconfig")) + .env("GIT_CONFIG_NOSYSTEM", "1") + .env("GIT_TERMINAL_PROMPT", "0") + .output() + .unwrap(); + assert!( + marker.exists(), + "rebuild injection never reached: {output:?}" + ); + assert_eq!(fs::read_to_string(&deinit_marker).unwrap(), "deinitialized"); + assert_eq!(fs::read_to_string(&marker).unwrap(), "injected"); + assert_eq!( + fs::read_to_string(storage.join("index.lock")).unwrap(), + "held" + ); + assert!(!output.status.success(), "{output:?}"); + let diagnostic = format!( + "{}{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + assert!(diagnostic.contains("partial"), "{diagnostic}"); + assert_eq!(h.read_config().unwrap(), config); + assert_eq!( + fs::read(h.work_dir.join(".gitmodules")).unwrap(), + gitmodules + ); + assert_eq!( + h.git_stdout(&["ls-files", "--stage", "--", "lib/child"]), + gitlink + ); + assert_eq!( + h.git_stdout(&["ls-files", "--stage", "--", "sibling-staged"]), + sibling + ); + assert_history(&h, &storage, &head, &stash); + fs::remove_file(storage.join("index.lock")).unwrap(); + let partial_metadata = h.preservation_snapshot(); + let partial_paths = [ + h.work_dir.join(".git/index"), + h.work_dir.join("lib/child/.git"), + storage.join("config"), + storage.join("index"), + storage.join("HEAD"), + ]; + let read_partial = || { + partial_paths + .iter() + .map(|path| match fs::read(path) { + Ok(bytes) => Some(bytes), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => None, + Err(error) => panic!("{path:?}: {error}"), + }) + .collect::>() + }; + let partial_bytes = read_partial(); + let partial_status = h.git_stdout(&[ + "-C", + "lib/child", + "status", + "--porcelain=v1", + "--untracked-files=all", + ]); + assert!(!partial_status.is_empty()); + let partial_refs = h.git_stdout(&["-C", "lib/child", "show-ref"]); + let retry = h.run_submod(&["nuke-it-from-orbit", "nickname"]).unwrap(); + assert!( + !retry.status.success(), + "ordinary retry discarded local deletion state: {retry:?}" + ); + let guidance = format!( + "{}{}", + String::from_utf8_lossy(&retry.stdout), + String::from_utf8_lossy(&retry.stderr) + ); + assert!(guidance.contains("--force"), "{guidance}"); + assert_eq!(h.preservation_snapshot(), partial_metadata); + assert_eq!(read_partial(), partial_bytes); + assert_eq!( + h.git_stdout(&[ + "-C", + "lib/child", + "status", + "--porcelain=v1", + "--untracked-files=all" + ]), + partial_status + ); + assert_eq!(h.git_stdout(&["-C", "lib/child", "show-ref"]), partial_refs); + for (path, _) in &contents { + assert!( + !h.work_dir.join("lib/child").join(path).exists(), + "ordinary retry restored {path}" + ); + } + assert_history(&h, &storage, &head, &stash); + h.run_submod_success(&["nuke-it-from-orbit", "nickname", "--force"]) + .unwrap(); + for (path, expected) in contents { + assert_eq!( + fs::read(h.work_dir.join("lib/child").join(&path)).unwrap(), + expected, + "{path}" + ); + } + assert!( + h.git_stdout(&[ + "-C", + "lib/child", + "status", + "--porcelain=v1", + "--untracked-files=all" + ]) + .is_empty() + ); + h.run_submod_success(&["check"]).unwrap(); + assert_eq!(h.read_config().unwrap(), config); + assert_eq!( + fs::read(h.work_dir.join(".gitmodules")).unwrap(), + gitmodules + ); + assert_eq!( + PathBuf::from(h.git_stdout(&["-C", "lib/child", "rev-parse", "--absolute-git-dir"])), + storage + ); + assert_eq!( + h.git_stdout(&["ls-files", "--stage", "--", "lib/child"]), + gitlink + ); + + assert_history(&h, &storage, &head, &stash); + assert_eq!( + h.git_stdout(&["ls-files", "--stage", "--", "sibling-staged"]), + sibling + ); + assert_eq!( + h.git_stdout(&["-C", "lib/child", "rev-parse", "HEAD"]), + head + ); +} + +#[test] +fn r22_force_delete_only_discards_selected_checkout_changes() { + for case in ["tracked", "untracked", "ignored"] { + let h = fixture(); + let storage = add_child(&h); + let (head, stash) = local_history(&h); + fs::create_dir_all(h.work_dir.join("lib/child-sibling")).unwrap(); + let sibling_path = h.work_dir.join("lib/child-sibling/sentinel"); + fs::write(&sibling_path, "prefix sibling\n").unwrap(); + h.git_stdout(&["add", "lib/child-sibling/sentinel"]); + h.git_stdout(&["config", "phase5.sibling", "preserve-me"]); + let outside_path = h.temp_dir.path().join("outside-sentinel"); + fs::write(&outside_path, "outside checkout\n").unwrap(); + let sibling_index = h.git_stdout(&["ls-files", "--stage", "--", "lib/child-sibling"]); + let parent_refs = h.git_stdout(&["show-ref"]); + let child_refs = h.git_stdout(&["-C", "lib/child", "show-ref"]); + let dirty_path = match case { + "tracked" => { + let tracked = h.git_stdout(&["-C", "lib/child", "ls-files"]); + h.work_dir + .join("lib/child") + .join(tracked.lines().next().unwrap()) + } + "ignored" => { + fs::write(storage.join("info/exclude"), "/ignored-local\n").unwrap(); + h.work_dir.join("lib/child/ignored-local") + } + _ => h.work_dir.join("lib/child/untracked-local"), + }; + fs::write(&dirty_path, "selected local changes\n").unwrap(); + if case == "ignored" { + assert_eq!( + h.git_stdout(&["-C", "lib/child", "check-ignore", "ignored-local"]), + "ignored-local" + ); + } + let before = h.preservation_snapshot(); + let paths = [ + h.work_dir.join(".git/index"), + h.work_dir.join("lib/child/.git"), + storage.join("config"), + storage.join("index"), + dirty_path.clone(), + ]; + let bytes: Vec<_> = paths.iter().map(|path| fs::read(path).unwrap()).collect(); + let output = h.run_submod(&["delete", "child"]).unwrap(); + assert!( + !output.status.success(), + "default deletion accepted {case} changes: {output:?}" + ); + assert_eq!(h.preservation_snapshot(), before, "{case}"); + for (path, expected) in paths.iter().zip(bytes) { + assert_eq!(fs::read(path).unwrap(), expected, "{case}: {path:?}"); + } + assert_eq!(h.git_stdout(&["-C", "lib/child", "show-ref"]), child_refs); + assert_eq!( + h.git_stdout(&["-C", "lib/child", "rev-parse", "HEAD"]), + head + ); + assert_history(&h, &storage, &head, &stash); + + h.run_submod_success(&["delete", "child", "--force"]) + .unwrap(); + assert!(!h.work_dir.join("lib/child").exists()); + assert_eq!(h.index_gitlink_mode("lib/child"), None); + let config: toml::Value = toml::from_str(&h.read_config().unwrap()).unwrap(); + assert!(config.get("child").is_none()); + for args in [ + vec![ + "config", + "--file", + ".gitmodules", + "--get-regexp", + "^submodule\\.child\\.", + ], + vec!["config", "--local", "--get-regexp", "^submodule\\.child\\."], + ] { + let result = h + .git_cmd() + .args(args) + .current_dir(&h.work_dir) + .output() + .unwrap(); + assert_eq!(result.status.code(), Some(1), "{result:?}"); + assert!(result.stdout.is_empty()); + assert!(result.stderr.is_empty()); + } + assert_history(&h, &storage, &head, &stash); + assert_eq!( + h.git_stdout(&["ls-files", "--stage", "--", "lib/child-sibling"]), + sibling_index + ); + assert_eq!(h.git_stdout(&["show-ref"]), parent_refs); + assert_eq!( + h.git_stdout(&["config", "--get", "phase5.sibling"]), + "preserve-me" + ); + assert_eq!( + fs::read_to_string(sibling_path).unwrap(), + "prefix sibling\n" + ); + assert_eq!( + fs::read_to_string(outside_path).unwrap(), + "outside checkout\n" + ); + } +} + +#[test] +fn r22_nuke_refuses_deleted_worktree_without_force() { + let h = fixture(); + let storage = add_child(&h); + let (head, stash) = local_history(&h); + let tracked = h.git_stdout(&["-C", "lib/child", "ls-files", "-z"]); + assert!(!tracked.is_empty()); + for path in tracked.split('\0').filter(|path| !path.is_empty()) { + let file = h.work_dir.join("lib/child").join(path); + fs::remove_file(&file).unwrap(); + let mut parent = file.parent().unwrap(); + while parent != h.work_dir.join("lib/child") { + if fs::read_dir(parent).unwrap().next().is_some() { + break; + } + fs::remove_dir(parent).unwrap(); + parent = parent.parent().unwrap(); + } + } + let mut remaining: Vec<_> = fs::read_dir(h.work_dir.join("lib/child")) + .unwrap() + .map(|entry| entry.unwrap().file_name()) + .collect(); + remaining.sort(); + assert_eq!(remaining, vec![std::ffi::OsString::from(".git")]); + let read_status = || { + let output = h + .git_cmd() + .args(["-C", "lib/child", "status", "--porcelain=v1", "-z"]) + .current_dir(&h.work_dir) + .output() + .unwrap(); + assert!(output.status.success(), "{output:?}"); + String::from_utf8(output.stdout).unwrap() + }; + let status = read_status(); + assert!(!status.is_empty()); + assert!( + status + .split('\0') + .filter(|line| !line.is_empty()) + .all(|line| line.starts_with(" D ")) + ); + let before = h.preservation_snapshot(); + let paths = [ + h.work_dir.join(".git/index"), + h.work_dir.join("lib/child/.git"), + storage.join("config"), + storage.join("index"), + ]; + let bytes: Vec<_> = paths.iter().map(|path| fs::read(path).unwrap()).collect(); + let refs = h.git_stdout(&["-C", "lib/child", "show-ref"]); + let output = h.run_submod(&["nuke-it-from-orbit", "child"]).unwrap(); + assert!( + !output.status.success(), + "unmarked user deletions were discarded: {output:?}" + ); + assert_eq!(h.preservation_snapshot(), before); + for (path, expected) in paths.iter().zip(bytes) { + assert_eq!(fs::read(path).unwrap(), expected, "{path:?}"); + } + assert_eq!(read_status(), status); + assert_eq!(h.git_stdout(&["-C", "lib/child", "show-ref"]), refs); + assert_eq!( + h.git_stdout(&["-C", "lib/child", "rev-parse", "HEAD"]), + head + ); + assert_history(&h, &storage, &head, &stash); + let after: Vec<_> = fs::read_dir(h.work_dir.join("lib/child")) + .unwrap() + .map(|entry| entry.unwrap().file_name()) + .collect(); + assert_eq!(after, remaining); +} + +#[test] +fn r22_nuke_managed_inactive_update_none_preserves_ordered_unmanaged_settings() { + let h = fixture(); + let storage = add_child(&h); + let (head, stash) = local_history(&h); + let content = fs::read(h.work_dir.join("lib/child/src/main.c")).unwrap(); + let url = h.git_stdout(&[ + "config", + "--file", + ".gitmodules", + "--get", + "submodule.child.url", + ]); + h.create_config(&format!("[nickname]\npath = 'lib/child'\nurl = '{url}'\nactive = false\nupdate = 'none'\nignore = 'dirty'\nbranch = 'main'\nfetchRecurse = 'always'\n")).unwrap(); + for (key, value) in [ + ("update", "none"), + ("ignore", "dirty"), + ("branch", "main"), + ("fetchRecurseSubmodules", "true"), + ] { + h.git_stdout(&[ + "config", + "--file", + ".gitmodules", + &format!("submodule.child.{key}"), + value, + ]); + } + h.git_stdout(&["add", ".gitmodules", "submod.toml"]); + h.git_stdout(&["commit", "-m", "inactive desired settings"]); + h.git_stdout(&["config", "extensions.worktreeConfig", "true"]); + h.git_stdout(&["config", "--local", "submodule.child.active", "false"]); + h.git_stdout(&["config", "--local", "submodule.child.update", "none"]); + h.git_stdout(&["config", "--local", "submodule.child.ignore", "all"]); + h.git_stdout(&["config", "--local", "submodule.child.branch", "stale"]); + h.git_stdout(&[ + "config", + "--local", + "submodule.child.fetchRecurseSubmodules", + "false", + ]); + for scope in ["--local", "--worktree"] { + for key in ["submodule.child.custom", "submodule.unrelated.custom"] { + for value in ["first", "second", "first"] { + h.git_stdout(&["config", scope, "--add", key, value]); + } + } + } + let before = h.preservation_snapshot(); + let index = fs::read(h.work_dir.join(".git/index")).unwrap(); + let staged_gitmodules = h.git_stdout(&["show", ":.gitmodules"]); + let expected_gitmodules_path = h.temp_dir.path().join("expected.gitmodules"); + fs::write(&expected_gitmodules_path, before.2[1].as_ref().unwrap()).unwrap(); + h.git_stdout(&[ + "config", + "--file", + expected_gitmodules_path.to_str().unwrap(), + "--unset-all", + "submodule.child.shallow", + ]); + let expected_gitmodules = fs::read(expected_gitmodules_path).unwrap(); + h.run_submod_success(&["nuke-it-from-orbit", "nickname"]) + .unwrap(); + assert_eq!( + h.git_stdout(&["-C", "lib/child", "rev-parse", "HEAD"]), + head + ); + assert_eq!( + fs::read(h.work_dir.join("lib/child/src/main.c")).unwrap(), + content + ); + assert!( + h.git_stdout(&["-C", "lib/child", "status", "--porcelain=v1"]) + .is_empty() + ); + assert_eq!(fs::read(h.work_dir.join(".git/index")).unwrap(), index); + let after = h.preservation_snapshot(); + assert_eq!(before.0, after.0); + assert_eq!(before.1, after.1); + assert_eq!(before.2[0], after.2[0]); + assert_eq!(after.2[1].as_ref().unwrap(), &expected_gitmodules); + assert_eq!(h.git_stdout(&["show", ":.gitmodules"]), staged_gitmodules); + for scope in [ + vec!["--file", ".gitmodules"], + vec!["--local"], + vec!["--worktree"], + ] { + let mut args = vec!["config"]; + args.extend(scope); + args.extend(["--get-all", "submodule.child.shallow"]); + let output = h + .git_cmd() + .args(args) + .current_dir(&h.work_dir) + .output() + .unwrap(); + assert_eq!(output.status.code(), Some(1), "{output:?}"); + assert!(output.stdout.is_empty()); + assert!(output.stderr.is_empty()); + } + assert_eq!( + PathBuf::from(h.git_stdout(&["-C", "lib/child", "rev-parse", "--absolute-git-dir"])), + storage + ); + assert_history(&h, &storage, &head, &stash); + for (key, value) in [ + ("active", "false"), + ("update", "none"), + ("ignore", "dirty"), + ("branch", "main"), + ("fetchRecurseSubmodules", "true"), + ] { + assert_eq!( + h.git_stdout(&[ + "config", + "--local", + "--get-all", + &format!("submodule.child.{key}") + ]), + value + ); + } + for scope in ["--local", "--worktree"] { + for key in ["submodule.child.custom", "submodule.unrelated.custom"] { + assert_eq!( + h.git_stdout(&["config", scope, "--get-all", key]), + "first\nsecond\nfirst", + "{scope} {key}" + ); + } + } +} + +#[test] +fn r22_nuke_changed_relative_url_fetches_missing_pin_from_selected_remote() { + let h = fixture(); + let storage = add_child(&h); + let (old_head, stash) = local_history(&h); + let new_remote = h.create_test_remote("reachable").unwrap(); + let new_work = h.temp_dir.path().join("reachable_work"); + fs::write( + new_work.join("required-only-new.txt"), + "required parent pin from new remote\n", + ) + .unwrap(); + h.git_at(&new_work, &["add", "required-only-new.txt"]); + h.git_at(&new_work, &["commit", "-m", "pin only in new remote"]); + h.git_at(&new_work, &["push", "origin", "main"]); + let pin = h.git_at(&new_work, &["rev-parse", "HEAD"]); + let missing = h + .git_cmd() + .args(["-C", "lib/child", "cat-file", "-e", &pin]) + .current_dir(&h.work_dir) + .output() + .unwrap(); + assert_eq!( + missing.status.code(), + Some(1), + "pin unexpectedly present or Git inspection failed: {missing:?}" + ); + let expected_url = format!("file://{}", new_remote.display()); + let parent_url = format!("file://{}", h.temp_dir.path().join("super.git").display()); + h.git_stdout(&["remote", "add", "upstream", &parent_url]); + h.git_stdout(&["config", "branch.main.remote", "upstream"]); + h.git_stdout(&["config", "branch.main.merge", "refs/heads/main"]); + h.git_stdout(&["-C", "lib/child", "remote", "rename", "origin", "selected"]); + h.git_stdout(&[ + "-C", + "lib/child", + "config", + "branch.main.remote", + "selected", + ]); + assert_eq!( + h.git_stdout(&["-C", "lib/child", "symbolic-ref", "--short", "HEAD"]), + "main" + ); + h.create_config("[nickname]\npath = 'lib/child'\nurl = '../reachable.git'\nactive = true\nupdate = 'checkout'\n").unwrap(); + let stale_url = h.git_stdout(&[ + "config", + "--file", + ".gitmodules", + "--get", + "submodule.child.url", + ]); + assert_ne!(stale_url, expected_url); + assert_ne!(stale_url, "../reachable.git"); + assert_eq!( + h.git_stdout(&["config", "--local", "--get", "submodule.child.url"]), + stale_url + ); + assert_eq!( + h.git_stdout(&["-C", "lib/child", "config", "--get", "remote.selected.url"]), + stale_url + ); + h.git_stdout(&[ + "config", + "--file", + ".gitmodules", + "submodule.child.update", + "checkout", + ]); + h.git_stdout(&[ + "update-index", + "--cacheinfo", + &format!("160000,{pin},lib/child"), + ]); + h.git_stdout(&["add", ".gitmodules", "submod.toml"]); + h.git_stdout(&["commit", "-m", "record pin and TOML-only URL change"]); + let selected_fetch = h.git_stdout(&[ + "-C", + "lib/child", + "config", + "--get-all", + "remote.selected.fetch", + ]); + let before = h.preservation_snapshot(); + let index = fs::read(h.work_dir.join(".git/index")).unwrap(); + let staged_gitmodules = h.git_stdout(&["show", ":.gitmodules"]); + let expected_gitmodules_path = h.temp_dir.path().join("expected.gitmodules"); + fs::write(&expected_gitmodules_path, before.2[1].as_ref().unwrap()).unwrap(); + h.git_stdout(&[ + "config", + "--file", + expected_gitmodules_path.to_str().unwrap(), + "submodule.child.url", + "../reachable.git", + ]); + h.git_stdout(&[ + "config", + "--file", + expected_gitmodules_path.to_str().unwrap(), + "--unset-all", + "submodule.child.shallow", + ]); + let expected_gitmodules = fs::read(expected_gitmodules_path).unwrap(); + h.run_submod_success(&["nuke-it-from-orbit", "nickname", "--force"]) + .unwrap(); + assert_eq!(h.git_stdout(&["-C", "lib/child", "rev-parse", "HEAD"]), pin); + assert_eq!( + fs::read_to_string(h.work_dir.join("lib/child/required-only-new.txt")).unwrap(), + "required parent pin from new remote\n" + ); + assert!( + h.git_stdout(&["-C", "lib/child", "status", "--porcelain=v1"]) + .is_empty() + ); + assert_eq!( + h.git_stdout(&["config", "--local", "--get", "submodule.child.url"]), + expected_url + ); + assert_eq!( + h.git_stdout(&["-C", "lib/child", "config", "--get", "remote.selected.url"]), + expected_url + ); + assert_eq!( + h.git_stdout(&["-C", "lib/child", "config", "--get", "branch.main.remote"]), + "selected" + ); + assert_eq!( + h.git_stdout(&[ + "-C", + "lib/child", + "config", + "--get-all", + "remote.selected.fetch" + ]), + selected_fetch + ); + assert_eq!( + PathBuf::from(h.git_stdout(&["-C", "lib/child", "rev-parse", "--absolute-git-dir"])), + storage + ); + assert_eq!(fs::read(h.work_dir.join(".git/index")).unwrap(), index); + let after = h.preservation_snapshot(); + assert_eq!(before.0, after.0); + assert_eq!(before.1, after.1); + assert_eq!(before.2[0], after.2[0]); + assert_eq!(after.2[1].as_ref().unwrap(), &expected_gitmodules); + assert_eq!(h.git_stdout(&["show", ":.gitmodules"]), staged_gitmodules); + assert_eq!( + h.git_stdout(&[ + "config", + "--file", + ".gitmodules", + "--get", + "submodule.child.url" + ]), + "../reachable.git" + ); + assert_history(&h, &storage, &old_head, &stash); +} + +#[test] +fn r22_nuke_refuses_nested_ignored_content_without_mutation() { + let h = fixture(); + add_child(&h); + let remote = h.create_test_remote("nested").unwrap(); + h.git_stdout(&[ + "-C", + "lib/child", + "submodule", + "add", + "--name", + "inner", + &format!("file://{}", remote.display()), + "nested/inner", + ]); + h.git_stdout(&["-C", "lib/child", "commit", "-am", "register inner"]); + h.git_stdout(&["-C", "lib/child", "rm", ".gitmodules"]); + h.git_stdout(&[ + "-C", + "lib/child", + "commit", + "-m", + "retain inner gitlink without declarations", + ]); + assert!(!h.work_dir.join("lib/child/.gitmodules").exists()); + assert!( + h.git_stdout(&[ + "-C", + "lib/child", + "ls-files", + "--stage", + "--", + "nested/inner" + ]) + .starts_with("160000 ") + ); + h.git_stdout(&["add", "lib/child"]); + h.git_stdout(&["commit", "-m", "record nested checkout"]); + for path in [".", "lib/child", "lib/child/nested/inner"] { + assert!( + h.git_stdout(&["-C", path, "status", "--porcelain=v1"]) + .is_empty(), + "{path} must start clean" + ); + } + // Without .gitmodules there is no name lookup for submodule.inner.ignore. + h.git_stdout(&[ + "-C", + "lib/child", + "config", + "--local", + "diff.ignoreSubmodules", + "all", + ]); + h.git_stdout(&[ + "-C", + "lib/child", + "config", + "--local", + "submodule.inner.ignore", + "all", + ]); + let inner_storage = PathBuf::from(h.git_stdout(&[ + "-C", + "lib/child/nested/inner", + "rev-parse", + "--absolute-git-dir", + ])); + fs::write(inner_storage.join("info/exclude"), "/precious-ignored\n").unwrap(); + fs::write( + h.work_dir.join("lib/child/nested/inner/precious-ignored"), + "irreplaceable ignored bytes\n", + ) + .unwrap(); + fs::write( + h.work_dir.join("lib/child/nested/inner/src/main.c"), + "precious tracked edits\n", + ) + .unwrap(); + assert_eq!( + h.git_stdout(&[ + "-C", + "lib/child/nested/inner", + "check-ignore", + "precious-ignored" + ]), + "precious-ignored" + ); + assert!( + h.git_stdout(&["-C", "lib/child", "status", "--porcelain=v1"]) + .is_empty(), + "outer status should hide inner changes" + ); + assert!( + !h.git_stdout(&["-C", "lib/child/nested/inner", "status", "--porcelain=v1"]) + .is_empty() + ); + + fn snapshot( + path: &std::path::Path, + entries: &mut std::collections::BTreeMap>>, + ) { + if path.is_dir() { + entries.insert(path.to_path_buf(), None); + for entry in fs::read_dir(path).unwrap() { + snapshot(&entry.unwrap().path(), entries); + } + } else { + entries.insert(path.to_path_buf(), Some(fs::read(path).unwrap())); + } + } + let mut before = std::collections::BTreeMap::new(); + snapshot(&h.work_dir, &mut before); + let output = h.run_submod(&["nuke-it-from-orbit", "child"]).unwrap(); + assert!( + !output.status.success(), + "nested content was discarded: {output:?}" + ); + let guidance = format!( + "{}{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + assert!(guidance.contains("--force"), "{guidance}"); + let mut after = std::collections::BTreeMap::new(); + snapshot(&h.work_dir, &mut after); + assert_eq!( + after.keys().collect::>(), + before.keys().collect::>(), + "fixture paths changed" + ); + for (path, expected) in before { + assert_eq!(after.get(&path).unwrap(), &expected, "changed {path:?}"); + } +} + +#[test] +fn r22_config_only_nuke_materializes_inactive_then_rebuilds_pinned_sibling() { + let h = fixture(); + let storage = add_child(&h); + let (existing_pin, stash) = local_history(&h); + let existing_content = fs::read(h.work_dir.join("lib/child/src/main.c")).unwrap(); + let old_remote_work = h.temp_dir.path().join("phase5_work"); + fs::write( + old_remote_work.join("remote-tip-only"), + "must not replace parent pin\n", + ) + .unwrap(); + h.git_at(&old_remote_work, &["add", "remote-tip-only"]); + h.git_at( + &old_remote_work, + &["commit", "-m", "advance remote past recorded pin"], + ); + h.git_at(&old_remote_work, &["push", "origin", "main"]); + assert_ne!( + h.git_at(&old_remote_work, &["rev-parse", "HEAD"]), + existing_pin + ); + + let remote = h.create_test_remote("config_only_nuke").unwrap(); + let remote_work = h.temp_dir.path().join("config_only_nuke_work"); + let new_pin = h.git_at(&remote_work, &["rev-parse", "HEAD"]); + let new_content = fs::read(remote_work.join("src/main.c")).unwrap(); + let second_remote = h.create_test_remote("second_config_only_nuke").unwrap(); + let second_work = h.temp_dir.path().join("second_config_only_nuke_work"); + fs::write( + second_work.join("src/main.c"), + "distinct second config-only content\n", + ) + .unwrap(); + h.git_at(&second_work, &["add", "src/main.c"]); + h.git_at(&second_work, &["commit", "-m", "distinct second pin"]); + h.git_at(&second_work, &["push", "origin", "main"]); + let second_pin = h.git_at(&second_work, &["rev-parse", "HEAD"]); + assert_ne!(second_pin, new_pin); + let second_content = fs::read(second_work.join("src/main.c")).unwrap(); + let mut config: toml::Value = toml::from_str(&h.read_config().unwrap()).unwrap(); + config + .get_mut("child") + .unwrap() + .as_table_mut() + .unwrap() + .insert("ignore".to_owned(), toml::Value::String("dirty".to_owned())); + let new_entry: toml::Value = toml::from_str(&format!( + "path = 'lib/new'\nurl = 'file://{}'\nactive = false\nupdate = 'none'\nshallow = false\n", + remote.display() + )) + .unwrap(); + config + .as_table_mut() + .unwrap() + .insert("new".to_owned(), new_entry); + let second_entry: toml::Value = toml::from_str(&format!("path = 'lib/second'\nurl = 'file://{}'\nactive = false\nupdate = 'none'\nshallow = false\n", second_remote.display())).unwrap(); + config + .as_table_mut() + .unwrap() + .insert("second".to_owned(), second_entry); + h.create_config(&toml::to_string(&config).unwrap()).unwrap(); + for (key, value) in [("ignore", "dirty"), ("shallow", "false")] { + h.git_stdout(&[ + "config", + "--file", + ".gitmodules", + &format!("submodule.child.{key}"), + value, + ]); + } + h.git_stdout(&["add", ".gitmodules"]); + let raw = h.read_config().unwrap(); + assert!(!h.work_dir.join("lib/new").exists()); + assert!(!h.work_dir.join(".git/modules/new").exists()); + assert_eq!(h.index_gitlink_mode("lib/new"), None); + assert!(!h.work_dir.join("lib/second").exists()); + assert!(!h.work_dir.join(".git/modules/second").exists()); + assert_eq!(h.index_gitlink_mode("lib/second"), None); + fs::write(h.work_dir.join("unrelated-staged"), "keep staged\n").unwrap(); + h.git_stdout(&["add", "unrelated-staged"]); + fs::write( + h.work_dir.join("unrelated-unstaged"), + "keep outside checkout\n", + ) + .unwrap(); + h.git_stdout(&["config", "--add", "phase5.unrelated", "first"]); + h.git_stdout(&["config", "--add", "phase5.unrelated", "second"]); + let parent_refs = h.git_stdout(&["show-ref"]); + let before_index = h.git_stdout(&["ls-files", "--stage"]); + + h.run_submod_success(&["nuke-it-from-orbit", "new", "second", "child"]) + .unwrap(); + assert_eq!(h.read_config().unwrap(), raw); + assert_eq!( + h.git_stdout(&["-C", "lib/new", "rev-parse", "HEAD"]), + new_pin + ); + assert_eq!( + h.git_stdout(&["ls-files", "--stage", "--", "lib/new"]), + format!("160000 {new_pin} 0\tlib/new") + ); + assert_eq!( + fs::read(h.work_dir.join("lib/new/src/main.c")).unwrap(), + new_content + ); + assert!( + h.git_stdout(&["-C", "lib/new", "status", "--porcelain=v1"]) + .is_empty() + ); + assert_eq!( + h.git_stdout(&[ + "config", + "--file", + ".gitmodules", + "--get", + "submodule.new.path" + ]), + "lib/new" + ); + assert_eq!( + h.git_stdout(&["config", "--local", "--get", "submodule.new.active"]), + "false" + ); + for scope in [vec!["--local"], vec!["--file", ".gitmodules"]] { + let mut args = vec!["config"]; + args.extend(scope); + args.extend(["--get", "submodule.new.update"]); + assert_eq!(h.git_stdout(&args), "none"); + } + assert_eq!( + h.git_stdout(&["-C", "lib/second", "rev-parse", "HEAD"]), + second_pin + ); + assert_eq!( + h.git_stdout(&["ls-files", "--stage", "--", "lib/second"]), + format!("160000 {second_pin} 0\tlib/second") + ); + assert_eq!( + fs::read(h.work_dir.join("lib/second/src/main.c")).unwrap(), + second_content + ); + assert!( + h.git_stdout(&["-C", "lib/second", "status", "--porcelain=v1"]) + .is_empty() + ); + assert_eq!( + h.git_stdout(&["config", "--local", "--get", "submodule.second.active"]), + "false" + ); + assert_eq!( + h.git_stdout(&["config", "--local", "--get", "submodule.second.update"]), + "none" + ); + for (name, path) in [("new", "lib/new"), ("second", "lib/second")] { + for (key, value) in [("path", path), ("update", "none"), ("shallow", "false")] { + assert_eq!( + h.git_stdout(&[ + "config", + "--blob", + ":.gitmodules", + "--get", + &format!("submodule.{name}.{key}") + ]), + value + ); + } + } + assert!(h.git_stdout(&["diff", "--", ".gitmodules"]).is_empty()); + assert_eq!( + h.git_stdout(&["-C", "lib/child", "rev-parse", "HEAD"]), + existing_pin + ); + assert_eq!( + fs::read(h.work_dir.join("lib/child/src/main.c")).unwrap(), + existing_content + ); + assert!(!h.work_dir.join("lib/child/remote-tip-only").exists()); + assert_eq!( + h.git_stdout(&["config", "--local", "--get", "submodule.child.ignore"]), + "dirty" + ); + assert_eq!( + h.git_stdout(&[ + "config", + "--file", + ".gitmodules", + "--get", + "submodule.child.ignore" + ]), + "dirty" + ); + assert_history(&h, &storage, &existing_pin, &stash); + assert_eq!(h.git_stdout(&["show-ref"]), parent_refs); + let after_index = h.git_stdout(&["ls-files", "--stage"]); + let untouched_entries = |index: &str| { + index + .lines() + .filter(|line| { + !line.ends_with("\t.gitmodules") + && !line.ends_with("\tlib/new") + && !line.ends_with("\tlib/second") + }) + .map(str::to_owned) + .collect::>() + }; + assert_eq!( + untouched_entries(&after_index), + untouched_entries(&before_index) + ); + assert_eq!( + h.git_stdout(&["config", "--get-all", "phase5.unrelated"]), + "first\nsecond" + ); + assert_eq!( + fs::read_to_string(h.work_dir.join("unrelated-staged")).unwrap(), + "keep staged\n" + ); + assert_eq!( + fs::read_to_string(h.work_dir.join("unrelated-unstaged")).unwrap(), + "keep outside checkout\n" + ); +} diff --git a/tests/phase5_reset_batch_tests.rs b/tests/phase5_reset_batch_tests.rs new file mode 100644 index 00000000..1a580433 --- /dev/null +++ b/tests/phase5_reset_batch_tests.rs @@ -0,0 +1,740 @@ +// SPDX-FileCopyrightText: 2026 Adam Poulemanos and contributors +// SPDX-License-Identifier: LicenseRef-PlainMIT OR MIT + +mod common; +use common::TestHarness; +use std::{ + collections::BTreeMap, + fs, + path::{Path, PathBuf}, + process::{Command, Output}, +}; + +fn bytes(root: &Path) -> BTreeMap> { + fn walk(root: &Path, at: &Path, result: &mut BTreeMap>) { + for entry in fs::read_dir(at).unwrap() { + let entry = entry.unwrap(); + if entry.file_type().unwrap().is_dir() { + walk(root, &entry.path(), result); + } else { + result.insert( + entry.path().strip_prefix(root).unwrap().to_owned(), + fs::read(entry.path()).unwrap(), + ); + } + } + } + let mut result = BTreeMap::new(); + walk(root, root, &mut result); + result +} + +fn assert_tree_eq( + actual: BTreeMap>, + expected: &BTreeMap>, + context: &str, +) { + let changed: std::collections::BTreeSet<_> = actual + .keys() + .chain(expected.keys()) + .filter(|path| actual.get(*path) != expected.get(*path)) + .collect(); + assert!(changed.is_empty(), "{context}; changed paths: {changed:?}"); +} + +fn fixture(initialized: bool) -> TestHarness { + let h = TestHarness::new().unwrap(); + h.init_git_repo().unwrap(); + let remote = h.create_test_remote("batch").unwrap(); + let mut config = String::new(); + // Deliberately declare out of order: application order must be sorted. + for name in ["gamma", "beta", "alpha"] { + config.push_str(&format!( + "[{name}]\npath = {name:?}\nurl = {:?}\n", + remote.to_str().unwrap() + )); + if initialized { + h.git_stdout(&[ + "submodule", + "add", + "--name", + name, + remote.to_str().unwrap(), + name, + ]); + } + } + h.create_config(&config).unwrap(); + h.git_stdout(&["add", "."]); + h.git_stdout(&["commit", "-m", "batch declarations and pins"]); + h +} + +fn command(h: &TestHarness, args: &[&str]) -> Command { + let mut cmd = Command::new(&h.submod_bin); + for (key, value) in h.git_cmd().get_envs() { + if let Some(value) = value { + cmd.env(key, value); + } else { + cmd.env_remove(key); + } + } + cmd.current_dir(&h.work_dir).args(args); + cmd +} + +fn text(output: &Output) -> String { + format!( + "{}\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ) +} + +fn dirty(h: &TestHarness, name: &str) { + let child = h.work_dir.join(name); + fs::write(child.join("LICENSE"), b"staged\0\xff").unwrap(); + h.git_at(&child, &["add", "LICENSE"]); + fs::write(child.join("LICENSE"), b"unstaged\0\xff").unwrap(); + fs::write(child.join("untracked"), b"untracked\0\xff").unwrap(); +} + +fn execute_advertised_recovery(h: &TestHarness, child: &Path, output: &Output, stash: &str) { + let printed = text(output); + assert!( + printed.contains(child.to_str().unwrap()), + "display the recovery directory: {printed}" + ); + let line = printed + .lines() + .find(|line| line.contains("git stash ")) + .expect("advertised recovery command"); + let start = line.find("git stash ").unwrap(); + let args: Vec<&str> = line[start..].split_whitespace().collect(); + assert_eq!( + args.len(), + 5, + "command must be path-free and require no shell quoting: {line}" + ); + assert_eq!(args[0], "git"); + assert_eq!(args[1], "stash"); + assert_eq!(args[4], stash, "recover the exact created stash object"); + match args[2] { + "branch" => { + let branch = args[3]; + assert!( + branch + .as_bytes() + .first() + .is_some_and(u8::is_ascii_alphanumeric) + && branch + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_'), + "generated recovery branch must be shell-safe: {branch:?}" + ); + h.git_at(child, &["check-ref-format", "--branch", branch]); + } + "apply" => assert_eq!(args[3], "--index", "recovery must restore staged changes"), + other => panic!("unrecognized advertised recovery command: {other}"), + } + // Execute only the actual advertised argument tokens, directly in the pinned + // child. No fixture checkout/worktree or hidden base restoration is allowed. + h.git_at(child, &args[1..]); + if args[2] == "branch" { + assert_eq!( + h.git_at(child, &["symbolic-ref", "--short", "HEAD"]), + args[3] + ); + } + let staged = h + .git_cmd() + .args(["show", ":LICENSE"]) + .current_dir(child) + .output() + .unwrap(); + assert!(staged.status.success(), "{}", text(&staged)); + assert_eq!(staged.stdout, b"staged\0\xff"); +} + +#[test] +fn reset_reports_exact_stash_oid_and_usable_recovery_command() { + let h = fixture(true); + let child = h.work_dir.join("alpha"); + let pin = h.git_stdout(&["rev-parse", "HEAD:alpha"]); + fs::write(child.join("local-only"), b"local history").unwrap(); + fs::write(child.join("LICENSE"), b"different committed base\n").unwrap(); + h.git_at(&child, &["add", "local-only", "LICENSE"]); + h.git_at(&child, &["commit", "-m", "ahead of parent pin"]); + let original_head = h.git_at(&child, &["rev-parse", "HEAD"]); + assert_ne!(original_head, pin); + dirty(&h, "alpha"); + let original_index = h.git_at(&child, &["ls-files", "--stage"]); + let parent = h.preservation_snapshot(); + let output = h.run_submod(&["reset", "alpha"]).unwrap(); + assert!(output.status.success(), "{}", text(&output)); + let stash = h.git_at(&child, &["rev-parse", "refs/stash"]); + assert_eq!(h.git_at(&child, &["cat-file", "-t", &stash]), "commit"); + assert_eq!(h.git_at(&child, &["rev-parse", "HEAD"]), pin); + assert_eq!(h.preservation_snapshot(), parent); + assert_eq!( + h.git_at(&child, &["rev-parse", &format!("{stash}^1")]), + original_head + ); + assert_eq!( + h.git_at(&child, &["rev-parse", &format!("{original_head}^1")]), + pin + ); + execute_advertised_recovery(&h, &child, &output, &stash); + assert_eq!(h.git_at(&child, &["rev-parse", "HEAD"]), original_head); + assert_eq!(h.git_at(&child, &["ls-files", "--stage"]), original_index); + assert_eq!(fs::read(child.join("LICENSE")).unwrap(), b"unstaged\0\xff"); + assert_eq!( + fs::read(child.join("untracked")).unwrap(), + b"untracked\0\xff" + ); + assert_eq!( + fs::read(child.join("local-only")).unwrap(), + b"local history" + ); + assert_eq!(h.git_at(&child, &["cat-file", "-t", &stash]), "commit"); + assert_eq!(h.preservation_snapshot(), parent); +} + +fn reset_batch_refuses_later(kind: &str) { + let h = fixture(true); + dirty(&h, "alpha"); + let later = h.work_dir.join("beta"); + let gitdir = PathBuf::from(h.git_at(&later, &["rev-parse", "--absolute-git-dir"])); + if kind == "lock" { + fs::write(gitdir.join("refs/stash.lock"), b"held fixture lock").unwrap(); + } else { + let old = h.git_at(&later, &["rev-parse", "HEAD"]); + fs::write(later.join("collision"), b"target tracked file").unwrap(); + h.git_at(&later, &["add", "collision"]); + h.git_at(&later, &["commit", "-m", "new parent target"]); + let target = h.git_at(&later, &["rev-parse", "HEAD"]); + h.git_stdout(&["add", "beta"]); + h.git_stdout(&["commit", "-m", "record later pin"]); + assert_eq!(h.git_stdout(&["rev-parse", "HEAD:beta"]), target); + h.git_at(&later, &["checkout", "--detach", &old]); + fs::write(gitdir.join("info/exclude"), "/collision\n").unwrap(); + if kind == "ignored" { + fs::write(later.join("collision"), b"priceless\0\xff").unwrap(); + } else { + let nested = later.join("collision"); + fs::create_dir(&nested).unwrap(); + h.git_at(&nested, &["init", "-b", "main"]); + fs::write(nested.join("sentinel"), b"nested history\0\xff").unwrap(); + h.git_at(&nested, &["add", "."]); + h.git_at(&nested, &["commit", "-m", "nested history"]); + } + } + let before = bytes(&h.work_dir); + let index = h.git_stdout(&["ls-files", "--stage"]); + let output = h.run_submod(&["reset", "alpha", "beta", "gamma"]).unwrap(); + assert!(!output.status.success(), "{}", text(&output)); + assert_tree_eq( + bytes(&h.work_dir), + &before, + &format!( + "later {kind} must prevent first stash/reset: {}", + text(&output) + ), + ); + assert_eq!(h.git_stdout(&["ls-files", "--stage"]), index); +} +#[test] +fn reset_batch_preflights_later_ignored_collision() { + reset_batch_refuses_later("ignored"); +} +#[test] +fn reset_batch_preflights_later_nested_repository_collision() { + reset_batch_refuses_later("nested"); +} +#[test] +fn reset_batch_preflights_later_stash_lock() { + reset_batch_refuses_later("lock"); +} + +#[test] +fn nuke_all_preflights_later_dirty_target_before_any_removal() { + let h = fixture(true); + dirty(&h, "gamma"); + let before = bytes(&h.work_dir); + let parent = h.preservation_snapshot(); + let output = h.run_submod(&["nuke-it-from-orbit", "--all"]).unwrap(); + assert!(!output.status.success(), "{}", text(&output)); + assert_tree_eq( + bytes(&h.work_dir), + &before, + &format!( + "nuke preflight must preserve every target: {}", + text(&output) + ), + ); + assert_eq!(h.preservation_snapshot(), parent); +} + +#[cfg(unix)] +fn wrapper(h: &TestHarness, mode: &str, args: &[&str]) -> Command { + use std::os::unix::fs::PermissionsExt; + let found = Command::new("which").arg("git").output().unwrap(); + assert!(found.status.success()); + let real = String::from_utf8(found.stdout).unwrap().trim().to_owned(); + let bin = h.temp_dir.path().join("wrapper"); + fs::create_dir_all(&bin).unwrap(); + fs::write( + bin.join("git"), + r#"#!/usr/bin/env python3 +import os,sys,subprocess,time +args=sys.argv[1:] +root=os.environ['FIXTURE_CONTROL'] +mode=os.environ['FIXTURE_MODE'] +is_add='submodule' in args and 'add' in args +is_beta=any(x in ('beta', ':(literal)beta') for x in args) +is_deinit='submodule' in args and 'deinit' in args +is_update='submodule' in args and 'update' in args +if mode=='fail' and is_deinit: + with open(root+'/deinitialized','a') as log: log.write(args[-1]+'\n') +if mode=='fail' and is_update and is_beta: + sys.stderr.write('fixture local rebuild failure\n'); sys.exit(71) +result=subprocess.run([os.environ['FIXTURE_REAL_GIT']]+args) +if mode=='pause' and is_add and is_beta and result.returncode==0: + parent=os.getppid() + with open(root+'/paused','w') as marker: marker.write(str(os.getpid())) + deadline=time.monotonic()+30 + while not os.path.exists(root+'/release') and time.monotonic()>(), + ["alpha", "beta"], + "sorted deterministic order" + ); + assert_eq!(fs::read(h.config_path()).unwrap(), declarations); + assert_eq!( + h.git_at(&h.work_dir.join("alpha"), &["rev-parse", "HEAD"]), + alpha_pin + ); + assert_eq!(bytes(&h.work_dir.join("gamma")), gamma); + assert_eq!( + h.git_stdout(&["ls-files", "--stage", "--", "gamma"]), + gamma_index + ); + assert_tree_eq( + bytes(&h.work_dir.join("alpha")), + &alpha_bytes, + "completed alpha must be rebuilt at its original pin", + ); + assert_eq!( + h.git_stdout(&["ls-files", "--stage"]), + parent_index, + "rebuild must retain original gitlinks" + ); + assert_eq!( + fs::read(h.work_dir.join(".gitmodules")).unwrap(), + gitmodules + ); + assert!( + !h.work_dir.join("beta/LICENSE").exists(), + "failure occurs after beta deinit, before its update" + ); + let retained = h.git_stdout(&[ + "rev-parse", + "--path-format=absolute", + "--git-path", + "modules/beta", + ]); + assert_eq!( + h.git_stdout(&[ + "--git-dir", + &retained, + "--work-tree", + h.work_dir.to_str().unwrap(), + "cat-file", + "-t", + &beta_pin + ]), + "commit" + ); + assert_eq!( + h.git_stdout(&[ + "--git-dir", + &retained, + "--work-tree", + h.work_dir.to_str().unwrap(), + "show-ref" + ]), + beta_refs + ); + let printed = text(&output); + for expected in ["completed: alpha", "failed: beta", "pending: gamma"] { + assert!(printed.contains(expected), "missing {expected}: {printed}"); + } +} + +#[cfg(unix)] +#[test] +fn nuke_all_sorted_order_reports_completed_failed_pending() { + nuke_runtime_failure(&["nuke-it-from-orbit", "--all"]); +} + +#[cfg(unix)] +#[test] +fn nuke_runtime_failure_preserves_state_and_reports_outcome() { + nuke_runtime_failure(&["nuke-it-from-orbit", "alpha", "beta", "gamma"]); +} + +#[cfg(unix)] +#[test] +fn interrupted_init_retains_partial_state_and_owned_lock_recovery_converges() { + use std::{ + thread, + time::{Duration, Instant}, + }; + let h = fixture(false); + let declarations = fs::read(h.config_path()).unwrap(); + let common = + PathBuf::from(h.git_stdout(&["rev-parse", "--path-format=absolute", "--git-common-dir"])); + let locks = [ + common.join("submod.lock"), + h.work_dir.join("submod.toml.submod.lock"), + ]; + assert!( + locks.iter().all(|path| !path.exists()), + "fixture owns no pre-existing locks" + ); + let log = fs::File::create(h.temp_dir.path().join("interrupted.log")).unwrap(); + let mut cmd = wrapper(&h, "pause", &["init"]); + cmd.stdout(log.try_clone().unwrap()).stderr(log); + let mut child = cmd.spawn().unwrap(); + let paused = h.temp_dir.path().join("paused"); + let deadline = Instant::now() + Duration::from_secs(25); + while !paused.exists() && Instant::now() < deadline { + if let Some(status) = child.try_wait().unwrap() { + panic!( + "CLI exited before deterministic pause: {status}; {}", + fs::read_to_string(h.temp_dir.path().join("interrupted.log")).unwrap() + ); + } + thread::sleep(Duration::from_millis(20)); + } + if !paused.exists() { + child.kill().unwrap(); + child.wait().unwrap(); + panic!("wrapper never reached pause"); + } + child.kill().unwrap(); + assert!(!child.wait().unwrap().success()); + fs::write( + h.temp_dir.path().join("release"), + b"release orphan wrapper without further Git", + ) + .unwrap(); + let deadline = Instant::now() + Duration::from_secs(3); + while !h.temp_dir.path().join("wrapper-finished").exists() && Instant::now() < deadline { + thread::sleep(Duration::from_millis(20)); + } + assert!(h.temp_dir.path().join("wrapper-finished").exists()); + assert_eq!(fs::read(h.config_path()).unwrap(), declarations); + let alpha = h.git_at(&h.work_dir.join("alpha"), &["rev-parse", "HEAD"]); + let beta = h.git_at(&h.work_dir.join("beta"), &["rev-parse", "HEAD"]); + assert_eq!(h.index_gitlink_mode("alpha").as_deref(), Some("160000")); + assert_eq!(h.index_gitlink_mode("beta").as_deref(), Some("160000")); + assert!(!h.work_dir.join("gamma/LICENSE").exists()); + let owned: Vec<_> = locks + .iter() + .map(|path| { + ( + path.clone(), + fs::read(path).expect("interrupted operation retains its lock"), + ) + }) + .collect(); + let alpha_refs = h.git_at(&h.work_dir.join("alpha"), &["show-ref"]); + let beta_refs = h.git_at(&h.work_dir.join("beta"), &["show-ref"]); + let completed_index = h.git_stdout(&["ls-files", "--stage", "--", "alpha", "beta"]); + let partial = bytes(&h.work_dir); + let blocked = h.run_submod(&["init"]).unwrap(); + assert!(!blocked.status.success()); + assert_tree_eq( + bytes(&h.work_dir), + &partial, + "blocked retry must preserve partial state", + ); + assert!(text(&blocked).to_lowercase().contains("lock")); + // The only removed files are known locks absent before our now-dead process. + for (path, expected) in owned { + assert_eq!(fs::read(&path).unwrap(), expected); + fs::remove_file(path).unwrap(); + } + h.run_submod_success(&["init"]).unwrap(); + assert_eq!(fs::read(h.config_path()).unwrap(), declarations); + assert_eq!( + h.git_at(&h.work_dir.join("alpha"), &["rev-parse", "HEAD"]), + alpha + ); + assert_eq!( + h.git_at(&h.work_dir.join("beta"), &["rev-parse", "HEAD"]), + beta + ); + for name in ["alpha", "beta", "gamma"] { + assert!(h.work_dir.join(name).join("LICENSE").is_file()); + assert_eq!(h.index_gitlink_mode(name).as_deref(), Some("160000")); + } + assert_eq!( + h.git_at(&h.work_dir.join("alpha"), &["show-ref"]), + alpha_refs + ); + assert_eq!(h.git_at(&h.work_dir.join("beta"), &["show-ref"]), beta_refs); + assert_eq!( + h.git_stdout(&["ls-files", "--stage", "--", "alpha", "beta"]), + completed_index + ); + let converged = h.preservation_snapshot(); + h.run_submod_success(&["init"]).unwrap(); + assert_eq!(h.preservation_snapshot(), converged); + assert!(locks.iter().all(|path| !path.exists())); +} + +#[test] +fn duplicate_reset_names_refuse_before_cli_or_manager_mutation() { + if std::env::var_os("SUBMOD_DUPLICATE_RESET_CHILD").is_some() { + let mut manager = + submod::git_manager::GitManager::new(PathBuf::from("submod.toml")).unwrap(); + let result = manager.reset_submodules(false, vec!["alpha".to_owned(), "alpha".to_owned()]); + assert!( + result.is_err(), + "shared manager boundary must reject duplicate reset names" + ); + return; + } + let h = fixture(true); + dirty(&h, "alpha"); + let before = bytes(&h.work_dir); + let output = h.run_submod(&["reset", "alpha", "alpha"]).unwrap(); + assert!(!output.status.success(), "{}", text(&output)); + assert_tree_eq( + bytes(&h.work_dir), + &before, + "duplicate CLI names must not mutate", + ); + + // Exercise the public manager in a fixture-rooted subprocess, bypassing clap + // without changing this test process's cwd or environment. + let mut child = Command::new(std::env::current_exe().unwrap()); + for (key, value) in h.git_cmd().get_envs() { + if let Some(value) = value { + child.env(key, value); + } else { + child.env_remove(key); + } + } + let output = child + .current_dir(&h.work_dir) + .env("SUBMOD_DUPLICATE_RESET_CHILD", "1") + .args([ + "--exact", + "duplicate_reset_names_refuse_before_cli_or_manager_mutation", + "--nocapture", + ]) + .output() + .unwrap(); + assert!( + output.status.success(), + "manager child assertion failed: {}", + text(&output) + ); + assert_tree_eq( + bytes(&h.work_dir), + &before, + "duplicate manager names must not mutate", + ); +} + +#[test] +fn spaced_module_path_reports_executable_path_free_recovery_command() { + let h = TestHarness::new().unwrap(); + h.init_git_repo().unwrap(); + let remote = h.create_test_remote("spaced-recovery").unwrap(); + let path = "vendor/module with spaces"; + h.git_stdout(&[ + "submodule", + "add", + "--name", + "spaced", + remote.to_str().unwrap(), + path, + ]); + h.create_config(&format!( + "[spaced]\npath = {path:?}\nurl = {:?}\n", + remote.to_str().unwrap() + )) + .unwrap(); + h.git_stdout(&["add", "submod.toml"]); + h.git_stdout(&["commit", "-m", "record spaced module pin"]); + let child = h.work_dir.join(path); + let pin = h.git_stdout(&["rev-parse", &format!("HEAD:{path}")]); + fs::write(child.join("local-only"), b"spaced local history").unwrap(); + fs::write(child.join("LICENSE"), b"different spaced committed base\n").unwrap(); + h.git_at(&child, &["add", "local-only", "LICENSE"]); + h.git_at(&child, &["commit", "-m", "spaced module ahead of pin"]); + let original_head = h.git_at(&child, &["rev-parse", "HEAD"]); + assert_ne!(original_head, pin); + dirty(&h, path); + let original_index = h.git_at(&child, &["ls-files", "--stage"]); + let parent = h.preservation_snapshot(); + let output = h.run_submod(&["reset", "spaced"]).unwrap(); + assert!(output.status.success(), "{}", text(&output)); + assert_eq!(h.git_at(&child, &["rev-parse", "HEAD"]), pin); + assert_eq!(h.preservation_snapshot(), parent); + let stash = h.git_at(&child, &["rev-parse", "refs/stash"]); + assert_eq!(h.git_at(&child, &["cat-file", "-t", &stash]), "commit"); + assert_eq!( + h.git_at(&child, &["rev-parse", &format!("{stash}^1")]), + original_head + ); + assert_eq!( + h.git_at(&child, &["rev-parse", &format!("{original_head}^1")]), + pin + ); + execute_advertised_recovery(&h, &child, &output, &stash); + assert_eq!(h.git_at(&child, &["rev-parse", "HEAD"]), original_head); + assert_eq!(h.git_at(&child, &["ls-files", "--stage"]), original_index); + assert_eq!(fs::read(child.join("LICENSE")).unwrap(), b"unstaged\0\xff"); + assert_eq!( + fs::read(child.join("untracked")).unwrap(), + b"untracked\0\xff" + ); + assert_eq!( + fs::read(child.join("local-only")).unwrap(), + b"spaced local history" + ); + assert_eq!(h.git_at(&child, &["cat-file", "-t", &stash]), "commit"); + assert_eq!(h.preservation_snapshot(), parent); +} + +#[test] +fn reset_unmerged_gitlink_refuses_before_stashing_dirty_child() { + use std::io::Write; + use std::process::Stdio; + let h = fixture(true); + let child = h.work_dir.join("alpha"); + fs::write(child.join("LICENSE"), b"existing stash\0\xff").unwrap(); + h.git_at(&child, &["stash", "push", "-m", "preserve existing stash"]); + let stash = h.git_at(&child, &["rev-parse", "refs/stash"]); + let base = h.git_at(&child, &["rev-parse", "HEAD~1"]); + let ours = h.git_at(&child, &["rev-parse", "HEAD"]); + let theirs = h.git_at(&child, &["rev-parse", "origin/feature"]); + assert_ne!(ours, theirs); + dirty(&h, "alpha"); + let records = format!( + "0 {}\talpha\n160000 {base} 1\talpha\n160000 {ours} 2\talpha\n160000 {theirs} 3\talpha\n", + "0".repeat(ours.len()) + ); + let mut git = h + .git_cmd() + .args(["update-index", "--index-info"]) + .current_dir(&h.work_dir) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap(); + git.stdin + .take() + .unwrap() + .write_all(records.as_bytes()) + .unwrap(); + let setup = git.wait_with_output().unwrap(); + assert!( + setup.status.success(), + "index setup failed: {}", + text(&setup) + ); + let entries = h.git_stdout(&["ls-files", "--stage", "--", "alpha"]); + assert_eq!( + entries + .lines() + .map(|line| line.split_whitespace().nth(2).unwrap()) + .collect::>(), + ["1", "2", "3"] + ); + let before = bytes(&h.work_dir); + let parent = h.preservation_snapshot(); + let child_index = h.git_at(&child, &["ls-files", "--stage"]); + let refs = h.git_at(&child, &["show-ref"]); + let trace_path = h.temp_dir.path().join("unmerged-reset-trace.json"); + let output = command(&h, &["reset", "alpha"]) + .env("GIT_TRACE2_EVENT", &trace_path) + .output() + .unwrap(); + assert!( + !output.status.success(), + "unmerged gitlink must refuse reset: {}", + text(&output) + ); + assert_tree_eq( + bytes(&h.work_dir), + &before, + "unmerged reset must preserve raw parent/child index, config, files, refs, and stash", + ); + assert_eq!(h.preservation_snapshot(), parent); + assert_eq!( + h.git_stdout(&["ls-files", "--stage", "--", "alpha"]), + entries + ); + assert_eq!(h.git_at(&child, &["ls-files", "--stage"]), child_index); + assert_eq!(h.git_at(&child, &["rev-parse", "HEAD"]), ours); + assert_eq!(h.git_at(&child, &["show-ref"]), refs); + assert_eq!(h.git_at(&child, &["rev-parse", "refs/stash"]), stash); + let trace = fs::read_to_string(trace_path).unwrap(); + assert!( + trace.contains("\"event\":\"cmd_name\""), + "require actual Git trace evidence" + ); + for line in trace + .lines() + .filter(|line| line.contains("\"event\":\"cmd_name\"")) + { + for prohibited in ["stash", "reset", "checkout", "clean"] { + assert!( + !line.contains(&format!("\"name\":\"{prohibited}\"")), + "unmerged gitlink must refuse before mutation: {line}" + ); + } + } +} diff --git a/tests/phase6_cli_contract_tests.rs b/tests/phase6_cli_contract_tests.rs new file mode 100644 index 00000000..0578fab6 --- /dev/null +++ b/tests/phase6_cli_contract_tests.rs @@ -0,0 +1,590 @@ +// SPDX-License-Identifier: LicenseRef-PlainMIT OR MIT +//! Read-only inspection, config discovery, and dry-run CLI contracts. +mod common; + +use common::TestHarness; +use std::{ + fs, + path::{Path, PathBuf}, + process::{Command, Output}, +}; + +fn text(output: &Output) -> String { + format!( + "{}{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ) +} +fn success(output: Output) -> String { + assert!(output.status.success(), "{}", text(&output)); + text(&output) +} +fn snapshot(root: &Path) -> Vec<(PathBuf, Vec)> { + fn visit(root: &Path, dir: &Path, entries: &mut Vec<(PathBuf, Vec)>) { + let mut paths: Vec<_> = fs::read_dir(dir) + .unwrap() + .map(|e| e.unwrap().path()) + .collect(); + paths.sort(); + for path in paths { + if path.is_dir() { + entries.push(( + path.strip_prefix(root).unwrap().join(".directory-snapshot"), + vec![], + )); + visit(root, &path, entries); + } else { + entries.push(( + path.strip_prefix(root).unwrap().to_owned(), + fs::read(path).unwrap(), + )); + } + } + } + let mut entries = vec![]; + visit(root, root, &mut entries); + entries +} +fn unchanged(before: Vec<(PathBuf, Vec)>, root: &Path) { + let after = snapshot(root); + assert_eq!(before.len(), after.len(), "filesystem entries changed"); + for ((path, bytes), (next, new_bytes)) in before.into_iter().zip(after) { + assert_eq!(path, next); + assert!(bytes == new_bytes, "bytes changed: {}", path.display()); + } +} +fn fixture() -> TestHarness { + let h = TestHarness::new().unwrap(); + h.init_git_repo().unwrap(); + h +} +fn managed() -> TestHarness { + let h = fixture(); + let remote = h.create_test_remote("library-remote").unwrap(); + success( + h.run_submod(&["add", remote.to_str().unwrap(), "--name", "library"]) + .unwrap(), + ); + success(h.run_submod(&["sync"]).unwrap()); + h +} +fn locks(h: &TestHarness) -> [PathBuf; 2] { + let common = h.git_stdout(&["rev-parse", "--git-common-dir"]); + let paths = [ + h.work_dir.join(common).join("submod.lock"), + h.work_dir.join("submod.toml.submod.lock"), + ]; + for path in &paths { + fs::write(path, "owned by another process\n").unwrap(); + } + paths +} +fn traced(h: &TestHarness, cwd: &Path, args: &[&str]) -> Output { + let trace = h.temp_dir.path().join("inspection-trace.jsonl"); + fs::write(&trace, "").unwrap(); + let output = Command::new(&h.submod_bin) + .args(args) + .current_dir(cwd) + .env("GIT_CONFIG_GLOBAL", h.temp_dir.path().join("gitconfig")) + .env("GIT_CONFIG_NOSYSTEM", "1") + .env("GIT_TERMINAL_PROMPT", "0") + .env("GIT_TRACE2_EVENT", &trace) + .output() + .unwrap(); + let events = fs::read_to_string(trace).unwrap(); + for verb in [ + "fetch", + "clone", + "checkout", + "reset", + "update-index", + "read-tree", + "write-tree", + "stash", + "commit", + "add", + ] { + assert!( + !events.contains(&format!("\"{verb}\"")), + "inspection ran mutating Git command {verb}: {events}" + ); + } + output +} + +#[test] +fn r17_phase6_missing_implicit_config_explains_import_but_add_can_create_it() { + let h = fixture(); + for command in ["check", "list", "init"] { + let before = snapshot(&h.work_dir); + let output = h.run_submod(&[command]).unwrap(); + unchanged(before, &h.work_dir); + assert!( + !output.status.success(), + "{command} silently accepted missing config" + ); + let message = text(&output).to_lowercase(); + assert!( + message.contains("submod.toml") + && (message.contains("generate-config") || message.contains("import")), + "{message}" + ); + } + let remote = h.create_test_remote("new-default").unwrap(); + success( + h.run_submod(&[ + "add", + remote.to_str().unwrap(), + "--name", + "library", + "--no-init", + ]) + .unwrap(), + ); + assert!(h.config_path().is_file()); + assert!(!h.work_dir.join("library/.git").exists()); +} + +#[test] +fn r17_phase6_explicit_missing_default_config_is_not_implicit_creation() { + let h = fixture(); + let remote = h.create_test_remote("explicit-missing").unwrap(); + let before = snapshot(&h.work_dir); + let output = h + .run_submod(&[ + "--config", + "submod.toml", + "add", + remote.to_str().unwrap(), + "--name", + "library", + "--no-init", + ]) + .unwrap(); + unchanged(before, &h.work_dir); + assert!(!output.status.success()); + assert!(text(&output).contains("submod.toml")); +} + +#[test] +fn r17_r25_phase6_root_nested_custom_and_linked_inspection_preserve_all_state() { + let h = managed(); + h.git_stdout(&["add", ".gitmodules", "library", "submod.toml"]); + h.git_stdout(&["commit", "-m", "Record managed fixture"]); + let nested = h.work_dir.join("nested"); + fs::create_dir(&nested).unwrap(); + fs::write(nested.join("custom.toml"), h.read_config().unwrap()).unwrap(); + let linked = h.temp_dir.path().join("linked"); + h.git_stdout(&[ + "worktree", + "add", + "-b", + "linked-inspection", + linked.to_str().unwrap(), + ]); + let held = locks(&h); + for (cwd, args) in [ + (h.work_dir.as_path(), vec!["list"]), + (nested.as_path(), vec!["check"]), + (nested.as_path(), vec!["--config", "custom.toml", "list"]), + (linked.as_path(), vec!["list"]), + ] { + let before = snapshot(&h.work_dir); + let linked_before = snapshot(&linked); + let output = traced(&h, cwd, &args); + unchanged(before, &h.work_dir); + unchanged(linked_before, &linked); + let message = success(output); + assert!( + message.contains("library"), + "inspection omitted managed module: {message}" + ); + } + for path in held { + fs::remove_file(path).unwrap(); + } +} + +fn nested_fixture() -> TestHarness { + let h = fixture(); + let leaf = h.create_test_remote("leaf").unwrap(); + let outer = h.create_test_remote("outer").unwrap(); + let work = h.temp_dir.path().join("outer_work"); + h.git_at( + &work, + &[ + "submodule", + "add", + "--name", + "nested-logical", + leaf.to_str().unwrap(), + "deps/leaf", + ], + ); + h.git_at(&work, &["commit", "-am", "Record nested leaf"]); + h.git_at(&work, &["push", "origin", "main"]); + h.create_config(&format!("[library]\nurl = {:?}\n", outer.to_str().unwrap())) + .unwrap(); + success(h.run_submod(&["init", "--recursive"]).unwrap()); + h +} + +#[test] +fn r19_r25_phase6_recursive_list_reports_actual_nested_hierarchy_without_mutation() { + let h = nested_fixture(); + let held = locks(&h); + let before = snapshot(&h.work_dir); + let output = traced(&h, &h.work_dir, &["list", "--recursive"]); + unchanged(before, &h.work_dir); + let message = success(output); + assert!( + message.contains("library") && message.contains("library/deps/leaf"), + "real nested hierarchy missing: {message}" + ); + assert!(h.work_dir.join("library/deps/leaf/src/main.c").is_file()); + for path in held { + fs::remove_file(path).unwrap(); + } +} + +#[test] +fn r19_phase6_recursive_list_propagates_structural_inspection_errors() { + let h = nested_fixture(); + fs::write( + h.work_dir.join("library/.gitmodules"), + "[broken submodule metadata\n", + ) + .unwrap(); + let before = snapshot(&h.work_dir); + let output = traced(&h, &h.work_dir, &["list", "--recursive"]); + unchanged(before, &h.work_dir); + assert!( + !output.status.success(), + "recursive inspection swallowed malformed metadata: {}", + text(&output) + ); + let message = text(&output); + assert!( + message.contains("library") && message.contains(".gitmodules"), + "{message}" + ); +} + +#[test] +fn r19_phase6_recursive_list_marks_uninitialized_checkout_without_guessing() { + let h = managed(); + h.git_stdout(&["submodule", "deinit", "-f", "--", "library"]); + let before = snapshot(&h.work_dir); + let output = traced(&h, &h.work_dir, &["list", "--recursive"]); + unchanged(before, &h.work_dir); + let message = success(output); + assert!(message.contains("library"), "{message}"); + assert!( + message.contains("not inspected") || message.contains("inspection skipped"), + "uninitialized checkout was presented as fully inspected: {message}" + ); +} + +#[test] +fn r19_phase6_recursive_list_refuses_redirected_child_gitfile_without_mutation() { + let h = managed(); + let foreign = h.temp_dir.path().join("foreign-list-target"); + fs::create_dir(&foreign).unwrap(); + h.git_at(&foreign, &["init"]); + h.git_at( + &foreign, + &["config", "core.worktree", foreign.to_str().unwrap()], + ); + fs::write( + h.work_dir.join("library/.git"), + format!("gitdir: {}\n", foreign.join(".git").display()), + ) + .unwrap(); + let before = snapshot(&h.work_dir); + let foreign_before = snapshot(&foreign); + let output = traced(&h, &h.work_dir, &["list", "--recursive"]); + unchanged(before, &h.work_dir); + unchanged(foreign_before, &foreign); + assert_eq!(output.status.code(), Some(1), "{}", text(&output)); + let message = text(&output); + assert!( + message.contains("library") && (message.contains("belong") || message.contains("checkout")), + "{message}" + ); +} + +#[test] +fn r25_phase6_structural_dry_run_preserves_stale_index_stat_cache() { + let h = managed(); + let modules = h.work_dir.join(".gitmodules"); + let modules_bytes = fs::read(&modules).unwrap(); + let original_metadata = fs::metadata(&modules).unwrap(); + let replacement = h.work_dir.join(".gitmodules-replacement"); + fs::write(&replacement, &modules_bytes).unwrap(); + fs::rename(&replacement, &modules).unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::MetadataExt; + assert_ne!( + original_metadata.ino(), + fs::metadata(&modules).unwrap().ino() + ); + } + #[cfg(not(unix))] + let _ = original_metadata; + + let stage_before = h.git_stdout(&["ls-files", "--stage", "--", ".gitmodules"]); + assert!(!stage_before.is_empty()); + let index = h.work_dir.join(".git/index"); + let index_bytes = fs::read(&index).unwrap(); + let index_before = fs::metadata(&index).unwrap(); + let before = snapshot(&h.work_dir); + + success(traced(&h, &h.work_dir, &["delete", "library", "--dry-run"])); + + assert_eq!(index_bytes, fs::read(&index).unwrap()); + let index_after = fs::metadata(&index).unwrap(); + assert_eq!(index_before.len(), index_after.len()); + assert_eq!( + index_before.modified().unwrap(), + index_after.modified().unwrap() + ); + assert_eq!(index_before.permissions(), index_after.permissions()); + #[cfg(unix)] + { + use std::os::unix::fs::MetadataExt; + assert_eq!(index_before.ino(), index_after.ino()); + assert_eq!(index_before.ctime(), index_after.ctime()); + assert_eq!(index_before.ctime_nsec(), index_after.ctime_nsec()); + } + assert_eq!(modules_bytes, fs::read(&modules).unwrap()); + unchanged(before, &h.work_dir); + assert_eq!( + stage_before, + h.git_stdout(&["ls-files", "--stage", "--", ".gitmodules"]) + ); +} + +fn dry_run(case: &str) { + let h = managed(); + let remote = h.create_test_remote("new-remote").unwrap(); + let child = h.work_dir.join("library"); + let pin = h.git_at(&child, &["rev-parse", "HEAD"]); + if case == "reset" { + fs::write(child.join("LICENSE"), "dirty to preserve\n").unwrap(); + } + let args: Vec<&str> = match case { + "add" => vec!["add", remote.to_str().unwrap(), "--name", "new_module"], + "no-init" => vec![ + "add", + remote.to_str().unwrap(), + "--name", + "new_module", + "--no-init", + ], + "change" => vec!["change", "library", "--ignore", "all"], + "change-global" => vec!["change-global", "--branch", "feature"], + "disable" => vec!["disable", "library"], + "delete" => vec!["delete", "library"], + "reset" => vec!["reset", "library"], + "nuke" => vec!["nuke-it-from-orbit", "library"], + "init" => vec!["init"], + "update" => vec!["update"], + "sync" => vec!["sync"], + "generate-config" => vec![ + "generate-config", + "--from-setup", + "--output", + "generated.toml", + ], + _ => unreachable!(), + }; + let held = locks(&h); + let before = snapshot(&h.work_dir); + let mut preview_args = args.clone(); + preview_args.push("--dry-run"); + let output = traced(&h, &h.work_dir, &preview_args); + unchanged(before, &h.work_dir); + let preview = success(output).to_lowercase(); + let target = match case { + "add" | "no-init" => "new_module", + "generate-config" => "generated.toml", + "change-global" => "feature", + _ => "library", + }; + assert!( + preview.contains(target), + "preview omits affected target {target}: {preview}" + ); + for path in held { + fs::remove_file(path).unwrap(); + } + let execution_before = snapshot(&h.work_dir); + let execution = success(h.run_submod(&args).unwrap()).to_lowercase(); + let raw: toml::Value = toml::from_str(&h.read_config().unwrap()).unwrap(); + match case { + "add" => { + assert_eq!( + h.index_gitlink_mode("new_module").as_deref(), + Some("160000") + ); + assert!(h.work_dir.join("new_module/src/main.c").is_file()); + } + "no-init" => { + assert!(raw.get("new_module").is_some()); + assert!(!h.work_dir.join("new_module/.git").exists()); + } + "change" => assert_eq!(raw["library"]["ignore"].as_str(), Some("all")), + "change-global" => { + assert_eq!(raw["defaults"]["branch"].as_str(), Some("feature")); + assert_eq!(h.git_at(&child, &["rev-parse", "HEAD"]), pin); + } + "disable" => { + assert_eq!(raw["library"]["active"].as_bool(), Some(false)); + assert_eq!(h.git_at(&child, &["rev-parse", "HEAD"]), pin); + } + "delete" => { + assert!(raw.get("library").is_none()); + assert!(!child.exists()); + assert_eq!(h.index_gitlink_mode("library"), None); + } + "reset" => { + assert_eq!(h.git_at(&child, &["rev-parse", "HEAD"]), pin); + assert_eq!( + h.git_at(&child, &["show", "refs/stash:LICENSE"]), + "dirty to preserve" + ); + } + "nuke" => { + assert!(raw.get("library").is_some()); + assert_eq!(h.git_at(&child, &["rev-parse", "HEAD"]), pin); + assert!(child.join("src/main.c").is_file()); + } + "generate-config" => { + let generated: toml::Value = + toml::from_str(&fs::read_to_string(h.work_dir.join("generated.toml")).unwrap()) + .unwrap(); + assert!(generated.get("library").is_some()); + } + "init" | "update" | "sync" => { + unchanged(execution_before, &h.work_dir); + assert!(preview.contains("unchanged"), "{case} preview: {preview}"); + assert!( + execution.contains("unchanged"), + "{case} execution: {execution}" + ); + assert_eq!(h.git_at(&child, &["rev-parse", "HEAD"]), pin); + } + _ => unreachable!(), + } +} +macro_rules! dry_run_case { + ($name:ident, $case:literal) => { + #[test] + fn $name() { + dry_run($case); + } + }; +} +dry_run_case!(r25_phase6_add_dry_run_matches_execution, "add"); +dry_run_case!(r25_phase6_no_init_dry_run_matches_execution, "no-init"); +dry_run_case!(r25_phase6_change_dry_run_matches_execution, "change"); +dry_run_case!( + r25_phase6_change_global_dry_run_matches_execution, + "change-global" +); +dry_run_case!(r25_phase6_disable_dry_run_matches_execution, "disable"); +dry_run_case!(r25_phase6_delete_dry_run_matches_execution, "delete"); +dry_run_case!(r25_phase6_reset_dry_run_matches_execution, "reset"); +dry_run_case!(r25_phase6_nuke_dry_run_matches_execution, "nuke"); +dry_run_case!(r25_phase6_init_dry_run_matches_execution, "init"); +dry_run_case!(r25_phase6_update_dry_run_matches_execution, "update"); +dry_run_case!(r25_phase6_sync_dry_run_matches_execution, "sync"); +dry_run_case!( + r25_phase6_generate_config_dry_run_matches_execution, + "generate-config" +); + +#[test] +fn r17_phase6_selection_conflicts_and_duplicates_fail_before_mutation() { + let h = managed(); + for args in [ + vec!["reset", "--all", "library"], + vec!["nuke-it-from-orbit", "--all", "library"], + vec!["reset", "library,library"], + vec!["nuke-it-from-orbit", "library,library"], + ] { + let before = snapshot(&h.work_dir); + let output = h.run_submod(&args).unwrap(); + unchanged(before, &h.work_dir); + assert!( + !output.status.success(), + "invalid selection accepted: {args:?}" + ); + if args.contains(&"--all") { + assert_eq!(output.status.code(), Some(2)); + } + } +} + +#[test] +fn r17_phase6_check_and_sync_report_initialized_update_none_drift() { + let h = managed(); + let mut config = h.read_config().unwrap(); + config.push_str("update = 'none'\nignore = 'all'\n"); + h.create_config(&config).unwrap(); + let child = h.work_dir.join("library"); + fs::write(child.join("LICENSE"), "policy-skipped dirty bytes\n").unwrap(); + + let before = snapshot(&h.work_dir); + let check = h.run_submod(&["check"]).unwrap(); + unchanged(before, &h.work_dir); + assert_eq!(check.status.code(), Some(1), "{}", text(&check)); + let check_text = text(&check).to_lowercase(); + assert!(check_text.contains("managed git metadata"), "{check_text}"); + assert!(check_text.contains("working tree"), "{check_text}"); + + let sync = h.run_submod(&["sync"]).unwrap(); + assert_eq!(sync.status.code(), Some(1), "{}", text(&sync)); + let sync_text = text(&sync).to_lowercase(); + assert!(sync_text.contains("skipped-policy"), "{sync_text}"); + assert!(sync_text.contains("unresolved"), "{sync_text}"); + assert_eq!( + fs::read_to_string(child.join("LICENSE")).unwrap(), + "policy-skipped dirty bytes\n" + ); + assert_eq!( + h.git_stdout(&["config", "--get", "submodule.library.ignore"]), + "all" + ); +} + +#[test] +fn r17_phase6_check_reports_unmanaged_git_submodule_without_removing_it() { + let h = managed(); + let remote = h.create_test_remote("unmanaged-native").unwrap(); + h.git_stdout(&[ + "submodule", + "add", + "--name", + "unmanaged-native", + remote.to_str().unwrap(), + "vendor/unmanaged", + ]); + let pin = h.git_at(&h.work_dir.join("vendor/unmanaged"), &["rev-parse", "HEAD"]); + let before = snapshot(&h.work_dir); + let output = h.run_submod(&["check"]).unwrap(); + unchanged(before, &h.work_dir); + assert!(output.status.success(), "{}", text(&output)); + let message = text(&output); + assert!( + message.contains("Unmanaged Git submodule preserved") + && message.contains("vendor/unmanaged"), + "{message}" + ); + assert_eq!( + h.git_at(&h.work_dir.join("vendor/unmanaged"), &["rev-parse", "HEAD"]), + pin + ); +} diff --git a/tests/phase6_onboarding_tests.rs b/tests/phase6_onboarding_tests.rs new file mode 100644 index 00000000..5f61e05e --- /dev/null +++ b/tests/phase6_onboarding_tests.rs @@ -0,0 +1,189 @@ +// SPDX-License-Identifier: LicenseRef-PlainMIT OR MIT +//! README onboarding commands, exercised against fixture-local repositories. +mod common; + +use common::TestHarness; +use std::{fs, process::Output}; +use submod::Config; + +fn success(output: Output) -> String { + assert!( + output.status.success(), + "stdout: {}\nstderr: {}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + String::from_utf8(output.stdout).unwrap() +} + +#[test] +fn readme_toml_workflow_from_nested_directory_repairs_drift() { + let h = TestHarness::new().unwrap(); + h.init_git_repo().unwrap(); + let remote = h.create_test_remote("readme-library").unwrap(); + h.create_config(&format!( + r#"[defaults] +ignore = "dirty" + +[my-submodule] +path = "vendor/my-lib" +url = "{}" +sparse_paths = ["src/", "include/", "*.md"] +"#, + remote.display() + )) + .unwrap(); + let nested = h.work_dir.join("app/nested"); + fs::create_dir_all(&nested).unwrap(); + success(h.run_submod_at(&nested, &["init"]).unwrap()); + success(h.run_submod_at(&nested, &["check"]).unwrap()); + assert!(h.file_exists("vendor/my-lib/src/main.c")); + assert!(h.file_exists("vendor/my-lib/include/header.h")); + assert!(!h.file_exists("vendor/my-lib/LICENSE")); + assert_eq!( + h.index_gitlink_mode("vendor/my-lib").as_deref(), + Some("160000") + ); + h.git_stdout(&[ + "config", + "-f", + ".gitmodules", + "submodule.my-submodule.ignore", + "all", + ]); + h.git_stdout(&["add", ".gitmodules"]); + assert_eq!( + h.run_submod_at(&nested, &["check"]).unwrap().status.code(), + Some(1) + ); + success(h.run_submod_at(&nested, &["sync"]).unwrap()); + success(h.run_submod_at(&nested, &["check"]).unwrap()); + success(h.run_submod_at(&nested, &["update"]).unwrap()); + assert_eq!( + h.git_stdout(&[ + "config", + "-f", + ".gitmodules", + "submodule.my-submodule.ignore" + ]), + "dirty" + ); +} + +#[test] +fn readme_boolean_from_setup_imports_registration_without_checkout() { + let h = TestHarness::new().unwrap(); + h.init_git_repo().unwrap(); + let remote = h.create_test_remote("existing-library").unwrap(); + h.git_stdout(&[ + "submodule", + "add", + "--name", + "library", + remote.to_str().unwrap(), + "vendor/library", + ]); + h.git_stdout(&["submodule", "deinit", "-f", "--", "vendor/library"]); + success(h.run_submod(&["generate-config", "--from-setup"]).unwrap()); + let config = Config::default() + .load_from_file(Some(h.config_path())) + .unwrap(); + assert!(config.submodules.contains_key("library")); + assert_eq!(h.run_submod(&["check"]).unwrap().status.code(), Some(1)); + success(h.run_submod(&["sync"]).unwrap()); + success(h.run_submod(&["check"]).unwrap()); + assert!(h.file_exists("vendor/library/src/main.c")); + assert_eq!( + h.run_submod(&["generate-config", "--from-setup", "."]) + .unwrap() + .status + .code(), + Some(2) + ); +} + +#[test] +fn readme_template_outside_repository_reloads_and_protects_existing_output() { + let h = TestHarness::new().unwrap(); + let args = [ + "generate-config", + "--template", + "--output", + "my-config.toml", + ]; + success(h.run_submod(&args).unwrap()); + let path = h.work_dir.join("my-config.toml"); + let original = fs::read(&path).unwrap(); + Config::default().load_from_file(Some(&path)).unwrap(); + assert!(!h.work_dir.join(".git").exists()); + assert!(!h.run_submod(&args).unwrap().status.success()); + assert_eq!(fs::read(&path).unwrap(), original); + success( + h.run_submod(&[ + "generate-config", + "--template", + "--output", + "my-config.toml", + "--force", + ]) + .unwrap(), + ); + Config::default().load_from_file(Some(&path)).unwrap(); +} + +#[test] +fn completion_scripts_for_all_supported_shells_and_nu_alias_work_outside_repository() { + let h = TestHarness::new().unwrap(); + for shell in ["bash", "zsh", "fish", "powershell", "elvish", "nushell"] { + let output = h.run_submod(&["completeme", shell]).unwrap(); + assert!(output.stderr.is_empty(), "unexpected stderr for {shell}"); + let script = success(output); + assert!( + script.contains("submod") && script.contains("generate-config"), + "incomplete {shell} script" + ); + } + let canonical = success(h.run_submod(&["completeme", "nushell"]).unwrap()); + assert_eq!( + success(h.run_submod(&["completeme", "nu"]).unwrap()), + canonical + ); + assert_eq!( + success(h.run_submod(&["complete-me", "nu"]).unwrap()), + canonical + ); +} + +#[test] +fn onboarding_help_documents_boolean_import_reset_and_global_branch() { + let h = TestHarness::new().unwrap(); + for command in [ + "init", + "check", + "sync", + "update", + "generate-config", + "completeme", + "disable", + "reset", + "change-global", + ] { + assert!(success(h.run_submod(&[command, "--help"]).unwrap()).contains("Usage:")); + } + let import = success(h.run_submod(&["generate-config", "--help"]).unwrap()); + let flag = import + .lines() + .find(|line| line.contains("--from-setup")) + .unwrap(); + assert!(!flag.contains('<'), "from-setup must be boolean: {flag}"); + let reset = success(h.run_submod(&["reset", "--help"]).unwrap()); + assert!(reset.contains("parent gitlinks"), "{reset}"); + assert!(success(h.run_submod(&["change-global", "--help"]).unwrap()).contains("--branch")); + assert_eq!( + h.run_submod(&["generate-config", "--from-setup", "--template"]) + .unwrap() + .status + .code(), + Some(2) + ); +} diff --git a/tests/phase6_output_tests.rs b/tests/phase6_output_tests.rs new file mode 100644 index 00000000..3bbf783f --- /dev/null +++ b/tests/phase6_output_tests.rs @@ -0,0 +1,617 @@ +// SPDX-FileCopyrightText: 2026 Adam Poulemanos <89049923+bashandbone@users.noreply.github.com> +// SPDX-License-Identifier: LicenseRef-PlainMIT OR MIT + +//! R26: assert display boundaries without modifying Git's machine-readable bytes. +mod common; +use common::TestHarness; +use std::process::Output; + +fn fixture() -> TestHarness { + let h = TestHarness::new().unwrap(); + h.init_git_repo().unwrap(); + h +} + +fn text(bytes: &[u8]) -> String { + String::from_utf8(bytes.to_vec()).expect("human output is UTF-8") +} + +fn safe_human_output(out: &Output) { + for bytes in [&out.stdout, &out.stderr] { + let message = text(bytes); + assert!( + !message + .chars() + .any(|c| c.is_control() && c != '\n' && c != '\t'), + "terminal controls emitted: {message:?}" + ); + for secret in [ + "R26_FAKE_USER", + "R26_FAKE_PASS", + "USER_A", + "PASS_A", + "USER_B", + "P%40SS_B", + ] { + assert!( + !message.contains(secret), + "URL userinfo leaked: {message:?}" + ); + } + for decoration in ["✅", "❌", "🔄", "⚠", "⏭", "ℹ", "📋", "💥"] { + assert!( + !message.contains(decoration), + "decoration in redirected output: {message:?}" + ); + } + } +} + +#[test] +fn r26_list_redacts_multiple_urls_and_preserves_unicode() { + let h = fixture(); + let config = "['bibliothèque']\npath = 'dépendances/été'\nurl = 'https://USER_A:PASS_A@example.invalid/a'\nactive = false\n[other]\nurl = 'https://USER_B:P%40SS_B@example.invalid/b'\nactive = false\n[quoted]\nurl = 'https://R26_FAKE_USER:PA\"SS@example.invalid/quoted'\nactive = false\n"; + h.create_config(config).unwrap(); + let out = h.run_submod(&["list"]).unwrap(); + assert!(out.status.success(), "{out:?}"); + assert_eq!(h.read_config().unwrap(), config); + let stdout = text(&out.stdout); + for expected in [ + "bibliothèque", + "dépendances/été", + "example.invalid/a", + "example.invalid/b", + "example.invalid/quoted", + ] { + assert!( + stdout.contains(expected), + "lost useful display context: {stdout:?}" + ); + } + assert!( + out.stderr.is_empty(), + "ordinary list must not produce progress/errors" + ); + safe_human_output(&out); +} + +#[test] +fn r26_repository_name_and_legacy_warning_escape_controls() { + let h = fixture(); + let config = "[\"module-https://R26_FAKE_USER:R26_FAKE_PASS@example.invalid/warned\\u001b[2J\\r\\nFORGED_NAME\\u0008\\u007f\\u0085\"]\npath = \"lib\"\nurl = 'https://R26_FAKE_USER:R26_FAKE_PASS@example.invalid/repo.git'\nactive = false\nfetch = 'never'\n"; + h.create_config(config).unwrap(); + let out = h.run_submod(&["list"]).unwrap(); + assert!( + !out.status.success(), + "invalid administrative name must be rejected" + ); + assert_eq!(h.read_config().unwrap(), config); + assert!( + out.stdout.is_empty(), + "rejected config must not emit results" + ); + let warning = text(&out.stderr); + let legacy_line = warning + .lines() + .find(|line| line.contains("legacy")) + .expect("legacy warning must be emitted"); + assert!(legacy_line.contains("fetch") && legacy_line.contains("module")); + assert!( + legacy_line.contains("example.invalid/warned"), + "warning nickname context lost: {warning:?}" + ); + assert!( + !text(&out.stdout).contains("warning:"), + "warnings belong to stderr" + ); + for message in [text(&out.stdout), warning] { + assert!( + !message.contains("\nFORGED_"), + "repository field forged an output line: {message:?}" + ); + } + safe_human_output(&out); +} + +#[test] +fn r26_app_config_error_redacts_embedded_url_and_retains_field_cause() { + let h = fixture(); + let config = "[module]\nurl = './remote.git'\nignore = 'https://R26_FAKE_USER:R26_FAKE_PASS@example.invalid/rejected'\n"; + h.create_config(config).unwrap(); + let before = h.preservation_snapshot(); + let out = h.run_submod(&["list"]).unwrap(); + assert!(!out.status.success()); + assert_eq!(h.preservation_snapshot(), before); + assert!( + out.stdout.is_empty(), + "error must not appear as stdout result: {out:?}" + ); + let error = text(&out.stderr); + assert!( + error.contains("module") && error.contains("ignore"), + "field context lost: {error}" + ); + assert!( + error.contains("invalid") || error.contains("expected"), + "validation cause lost: {error}" + ); + safe_human_output(&out); +} + +#[test] +fn r26_clap_error_sanitizes_early_diagnostic() { + let h = fixture(); + let out = h + .run_submod(&[ + "change", + "module", + "--ignore", + "https://R26_FAKE_USER:R26_FAKE_PASS@example.invalid/\u{1b}[2J\r\nFORGED_ARGUMENT", + ]) + .unwrap(); + assert_eq!(out.status.code(), Some(2)); + assert!(out.stdout.is_empty()); + let error = text(&out.stderr); + assert!( + error.contains("--ignore") && error.contains("invalid"), + "argument cause lost: {error:?}" + ); + assert!(!error.contains("\nFORGED_ARGUMENT")); + safe_human_output(&out); +} + +#[test] +fn r26_real_local_git_error_redacts_userinfo_without_false_success() { + let h = fixture(); + // Native submodule add echoes the original file URL in its final failure. + // Prove this in a separate fixture, without transport to any network host. + let missing = format!( + "file://R26_FAKE_USER:R26_FAKE_PASS@localhost{}/missing.git", + h.work_dir.display() + ); + let url = missing.as_str(); + let probe = fixture(); + let git = probe + .git_cmd() + .args(["submodule", "add", "--", url, "probe"]) + .current_dir(&probe.work_dir) + .output() + .unwrap(); + assert!(!git.status.success()); + let raw = text(&git.stderr); + assert!( + raw.contains("R26_FAKE_PASS"), + "fixture must prove Git actually echoed the secret: {raw}" + ); + h.create_config(&format!("[module]\npath = 'lib'\nurl = '{}'\n", url)) + .unwrap(); + let config_before = h.read_config().unwrap(); + let out = h.run_submod(&["init"]).unwrap(); + assert!(!out.status.success(), "missing remote must fail: {out:?}"); + assert_eq!(h.read_config().unwrap(), config_before); + let error = text(&out.stderr); + assert!(error.contains("module"), "module context lost: {error}"); + assert!( + error.to_lowercase().contains("init") || error.to_lowercase().contains("clone"), + "phase lost: {error}" + ); + assert!( + error.contains("does not appear to be a git repository") + || error.contains("does not exist"), + "actionable Git cause lost: {error}" + ); + let stdout = text(&out.stdout).to_lowercase(); + assert!( + !stdout.contains("successfully") + && !stdout.contains("initialized 1") + && !stdout.contains("sync complete"), + "false success: {stdout}" + ); + safe_human_output(&out); +} + +#[test] +fn r26_redirected_init_sends_progress_to_stderr() { + let h = fixture(); + let remote = h.create_test_remote("r26-progress").unwrap(); + h.create_config(&format!( + "[module]\npath = 'lib'\nurl = '{}'\n", + remote.display() + )) + .unwrap(); + let out = h.run_submod(&["init"]).unwrap(); + assert!(out.status.success(), "{out:?}"); + assert!(h.index_gitlink_mode("lib").is_some()); + let stdout = text(&out.stdout); + assert!( + stdout.contains("module"), + "final result must identify module: {stdout}" + ); + assert!( + !stdout.contains("Initializing ") && !stdout.contains("Cloning into"), + "progress on stdout: {stdout}" + ); + safe_human_output(&out); +} + +#[test] +fn r26_completion_stdout_remains_shell_program() { + let h = TestHarness::new().unwrap(); + let out = h.run_submod(&["completeme", "bash"]).unwrap(); + assert!(out.status.success(), "{out:?}"); + assert!( + out.stderr.is_empty(), + "completion stderr contaminated: {out:?}" + ); + let script = text(&out.stdout); + assert!( + script.starts_with("_submod()"), + "unexpected human prefix: {script}" + ); + assert!(script.contains("complete ") && script.contains("COMPREPLY=")); + // Shell parser validates literal newlines, quotes and backslashes survived. + let path = h.work_dir.join("completion.bash"); + std::fs::write(&path, &out.stdout).unwrap(); + let syntax = std::process::Command::new("bash") + .arg("-n") + .arg(path) + .output() + .unwrap(); + assert!(syntax.status.success(), "completion corrupted: {syntax:?}"); + assert!(!out.stdout.contains(&0x1b)); +} + +#[test] +fn r26_invalid_repository_path_reports_safe_validation_error() { + let h = fixture(); + let config = "[module]\nurl = './remote.git'\npath = \"lib\\u001b[2J\\r\\nFORGED_PATH\"\n"; + h.create_config(config).unwrap(); + let before = h.preservation_snapshot(); + let out = h.run_submod(&["list"]).unwrap(); + assert!(!out.status.success()); + assert_eq!(h.preservation_snapshot(), before); + assert!(out.stdout.is_empty()); + let error = text(&out.stderr); + assert!(error.contains("module") && error.contains("path") && error.contains("invalid")); + assert!(!error.contains("\nFORGED_PATH")); + safe_human_output(&out); +} + +#[test] +fn r26_redirected_sync_has_no_decoration_or_stdout_progress() { + let h = fixture(); + h.create_config("[module]\nurl = './remote.git'\nactive = false\n") + .unwrap(); + let out = h.run_submod(&["sync"]).unwrap(); + assert!(out.status.success(), "{out:?}"); + assert!( + !text(&out.stdout).contains("Syncing submodules"), + "progress belongs to stderr: {out:?}" + ); + let stdout = text(&out.stdout).to_lowercase(); + assert!( + stdout.lines().any(|line| line.contains("module") + && line.contains("skipped") + && line.contains("disabled")), + "disabled module needs a skipped result: {stdout}" + ); + for progress in [ + "syncing submodules", + "reconciling configured", + "initializing ", + "cloning into", + ] { + assert!(!stdout.contains(progress), "progress on stdout: {stdout}"); + } + safe_human_output(&out); +} + +#[test] +fn r26_successful_list_escapes_url_suffix_controls() { + let h = fixture(); + let config = r#"[module] +url = "https://example.invalid/repo.git/ESC\u001b[2J/TAB\t/BS\u0008/DEL\u007f/C1\u0085/end" +active = false +"#; + h.create_config(config).unwrap(); + let out = h.run_submod(&["list"]).unwrap(); + assert!(out.status.success(), "{out:?}"); + assert_eq!(h.read_config().unwrap(), config); + assert!( + out.stderr.is_empty(), + "ordinary list must succeed without diagnostics: {out:?}" + ); + let stdout = text(&out.stdout); + let url_line = stdout + .lines() + .find(|line| line.contains("example.invalid/repo.git")) + .expect("URL display retained"); + for escaped in [ + r"ESC\u{1b}[2J", + r"TAB\t", + r"BS\u{8}", + r"DEL\u{7f}", + r"C1\u{85}", + "/end", + ] { + assert!( + url_line.contains(escaped), + "missing recognizable escaped payload {escaped}: {stdout:?}" + ); + } + assert!(!stdout.contains("\nFORGED_URL") && !stdout.contains('\t')); + safe_human_output(&out); +} + +#[test] +fn r26_generate_preview_sanitizes_the_output_path() { + let h = TestHarness::new().unwrap(); + let output = "generated\nFORGED.toml"; + let out = h + .run_submod(&[ + "generate-config", + "--template", + "--output", + output, + "--dry-run", + ]) + .unwrap(); + assert!(out.status.success(), "{out:?}"); + let stdout = text(&out.stdout); + assert!(stdout.contains(r"generated\nFORGED.toml"), "{stdout:?}"); + assert!(!stdout.contains("\nFORGED"), "{stdout:?}"); + safe_human_output(&out); +} + +// Result rows must describe observed Git postconditions, not the pre-fetch plan. +fn result_fixture(names: &[&str]) -> TestHarness { + let h = fixture(); + let mut config = String::new(); + for name in names { + let remote = h.create_test_remote(name).unwrap(); + h.git_stdout(&[ + "submodule", + "add", + "--name", + name, + remote.to_str().unwrap(), + name, + ]); + config.push_str(&format!( + "[{name}]\npath = '{name}'\nurl = '{}'\nbranch = 'main'\nignore = 'none'\n", + remote.display() + )); + } + h.create_config(&config).unwrap(); + h.run_submod_success(&["sync"]).unwrap(); + h.git_stdout(&["add", ".gitmodules"]); + h.git_stdout(&["commit", "-am", "Record result fixture pins"]); + h +} + +fn result_row<'a>(output: &'a str, name: &str, status: &str) -> &'a str { + let prefix = format!("{name} at {name}: {status}:"); + let rows: Vec<_> = output + .lines() + .filter(|line| line.starts_with(&prefix)) + .collect(); + assert_eq!( + rows.len(), + 1, + "expected exactly one final row {prefix}: {output}" + ); + rows[0] +} + +fn assert_summary(output: &str, expected: &str) { + assert_eq!( + output + .lines() + .filter(|line| line.contains(" summary:")) + .collect::>(), + vec![expected] + ); +} + +#[test] +fn r26_result_remote_advance_then_repeat_uses_observed_target() { + let h = result_fixture(&["module"]); + let pin = h.git_stdout(&["-C", "module", "rev-parse", "HEAD"]); + let index = h.git_stdout(&["ls-files", "--stage"]); + let original = std::fs::read(h.work_dir.join("module/src/main.c")).unwrap(); + let target = h.advance_test_remote("module").unwrap(); + assert_ne!(target, pin); + for (status, summary) in [ + ( + "changed", + "Update summary: 1 changed, 0 unchanged, 0 skipped, 0 failed, 0 pending.", + ), + ( + "unchanged", + "Update summary: 0 changed, 1 unchanged, 0 skipped, 0 failed, 0 pending.", + ), + ] { + let out = h.run_submod(&["update", "--remote"]).unwrap(); + assert!(out.status.success(), "{out:?}"); + let stdout = text(&out.stdout); + assert!( + result_row(&stdout, "module", status).ends_with(&format!("(target {target})")), + "wrong selected target: {stdout}" + ); + assert_summary(&stdout, summary); + assert_eq!(h.git_stdout(&["-C", "module", "rev-parse", "HEAD"]), target); + assert_eq!( + h.git_stdout(&["ls-files", "--stage"]), + index, + "remote tracking must preserve parent gitlink pin" + ); + assert_eq!( + std::fs::read(h.work_dir.join("module/src/main.c")).unwrap(), + original + ); + assert_eq!( + std::fs::read_to_string(h.work_dir.join("module/ADVANCE.txt")).unwrap(), + "advanced module\n" + ); + } +} + +#[test] +fn r26_result_recursive_noop_is_unchanged() { + let h = result_fixture(&["module"]); + let nested = h.create_test_remote("nested").unwrap(); + h.git_stdout(&[ + "-C", + "module", + "submodule", + "add", + nested.to_str().unwrap(), + "nested", + ]); + h.git_stdout(&["-C", "module", "commit", "-am", "Record nested pin"]); + h.git_stdout(&["add", "module"]); + h.git_stdout(&["commit", "-m", "Pin parent with nested checkout"]); + let nested_head = h.git_stdout(&["-C", "module/nested", "rev-parse", "HEAD"]); + let nested_index = h.git_stdout(&["-C", "module", "ls-files", "--stage"]); + let nested_content = std::fs::read(h.work_dir.join("module/nested/src/main.c")).unwrap(); + let before = h.preservation_snapshot(); + let target = h.git_stdout(&["-C", "module", "rev-parse", "HEAD"]); + let content = std::fs::read(h.work_dir.join("module/src/main.c")).unwrap(); + let out = h.run_submod(&["update", "--recursive"]).unwrap(); + assert!(out.status.success(), "{out:?}"); + let stdout = text(&out.stdout); + assert!(result_row(&stdout, "module", "unchanged").ends_with(&format!("(target {target})"))); + assert_summary( + &stdout, + "Update summary: 0 changed, 1 unchanged, 0 skipped, 0 failed, 0 pending.", + ); + assert_eq!(h.preservation_snapshot(), before); + assert_eq!(h.git_stdout(&["-C", "module", "rev-parse", "HEAD"]), target); + assert_eq!( + std::fs::read(h.work_dir.join("module/src/main.c")).unwrap(), + content + ); + assert_eq!( + h.git_stdout(&["-C", "module/nested", "rev-parse", "HEAD"]), + nested_head + ); + assert_eq!( + h.git_stdout(&["-C", "module", "ls-files", "--stage"]), + nested_index + ); + assert_eq!( + std::fs::read(h.work_dir.join("module/nested/src/main.c")).unwrap(), + nested_content + ); +} + +fn metadata_skip_result(setting: &str, status: &str) { + let h = result_fixture(&["module"]); + let target = h.git_stdout(&["-C", "module", "rev-parse", "HEAD"]); + let gitlink = h.git_stdout(&["ls-files", "--stage", "--", "module"]); + let content = std::fs::read(h.work_dir.join("module/src/main.c")).unwrap(); + let config = h + .read_config() + .unwrap() + .replace("ignore = 'none'", "ignore = 'all'"); + h.create_config(&format!("{config}{setting}\n")).unwrap(); + let remote_target = h.advance_test_remote("module").unwrap(); + assert_ne!(target, remote_target); + let out = h.run_submod(&["update", "--remote"]).unwrap(); + assert!(out.status.success(), "{out:?}"); + let stdout = text(&out.stdout); + result_row(&stdout, "module", status); + assert_summary( + &stdout, + "Update summary: 1 changed, 0 unchanged, 1 skipped, 0 failed, 0 pending.", + ); + assert_eq!( + h.git_stdout(&[ + "config", + "--file", + ".gitmodules", + "--get", + "submodule.module.ignore" + ]), + "all" + ); + assert_eq!( + h.git_stdout(&["config", "--get", "submodule.module.ignore"]), + "all" + ); + assert_eq!(h.git_stdout(&["-C", "module", "rev-parse", "HEAD"]), target); + assert_eq!( + h.git_stdout(&["ls-files", "--stage", "--", "module"]), + gitlink + ); + assert_eq!( + std::fs::read(h.work_dir.join("module/src/main.c")).unwrap(), + content + ); + assert!(!h.work_dir.join("module/ADVANCE.txt").exists()); +} + +#[test] +fn r26_result_disabled_metadata_repair_counts_changed_and_skipped() { + metadata_skip_result("active = false", "changed/skipped-disabled"); +} + +#[test] +fn r26_result_policy_metadata_repair_counts_changed_and_skipped() { + metadata_skip_result("update = 'none'", "changed/skipped-policy"); +} + +#[test] +fn r26_result_runtime_failure_preserves_observed_completed_and_pending() { + let h = result_fixture(&["alpha", "beta", "gamma"]); + let index = h.git_stdout(&["ls-files", "--stage"]); + let pins: Vec<_> = ["alpha", "beta", "gamma"] + .iter() + .map(|name| h.git_stdout(&["-C", name, "rev-parse", "HEAD"])) + .collect(); + let contents: Vec<_> = ["alpha", "beta", "gamma"] + .iter() + .map(|name| std::fs::read(h.work_dir.join(name).join("src/main.c")).unwrap()) + .collect(); + let target = h.advance_test_remote("alpha").unwrap(); + h.advance_test_remote("gamma").unwrap(); + std::fs::rename( + h.temp_dir.path().join("beta.git"), + h.temp_dir.path().join("beta-offline.git"), + ) + .unwrap(); + let out = h.run_submod(&["update", "--remote"]).unwrap(); + assert!(!out.status.success(), "offline beta must fail: {out:?}"); + let error = text(&out.stderr); + assert!( + result_row(&error, "alpha", "changed").ends_with(&format!("(target {target})")), + "completed row must contain observed target: {error}" + ); + result_row(&error, "beta", "failed"); + result_row(&error, "gamma", "pending"); + assert_summary( + &error, + "Update incomplete summary: 1 changed, 0 unchanged, 0 skipped, 1 failed, 1 pending.", + ); + assert!( + !text(&out.stdout).contains("summary:"), + "failed batch must not emit a success summary" + ); + for (index, name) in ["alpha", "beta", "gamma"].iter().enumerate() { + assert_eq!( + h.git_stdout(&["-C", name, "rev-parse", "HEAD"]), + if index == 0 { &target } else { &pins[index] }.as_str() + ); + assert_eq!( + std::fs::read(h.work_dir.join(name).join("src/main.c")).unwrap(), + contents[index] + ); + } + assert_eq!(h.git_stdout(&["ls-files", "--stage"]), index); + assert_eq!( + std::fs::read_to_string(h.work_dir.join("alpha/ADVANCE.txt")).unwrap(), + "advanced alpha\n" + ); + assert!(!h.work_dir.join("beta/ADVANCE.txt").exists()); + assert!(!h.work_dir.join("gamma/ADVANCE.txt").exists()); +} diff --git a/tests/reconciliation_lifecycle_tests.rs b/tests/reconciliation_lifecycle_tests.rs new file mode 100644 index 00000000..f2d4dc5b --- /dev/null +++ b/tests/reconciliation_lifecycle_tests.rs @@ -0,0 +1,1183 @@ +// SPDX-License-Identifier: LicenseRef-PlainMIT OR MIT +mod common; + +use common::TestHarness; +use std::{fs, path::Path, process::Output}; + +fn success(output: Output) -> String { + let text = format!( + "{}{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + assert!(output.status.success(), "{text}"); + text +} + +fn fixture() -> TestHarness { + let h = TestHarness::new().unwrap(); + h.init_git_repo().unwrap(); + h +} + +fn register(h: &TestHarness, remote: &Path, name: &str) -> String { + h.git_at( + &h.work_dir, + &[ + "submodule", + "add", + "--name", + name, + remote.to_str().unwrap(), + name, + ], + ); + h.git_at(&h.work_dir.join(name), &["rev-parse", "HEAD"]) +} + +fn declaration(name: &str, remote: &Path, extra: &str) -> String { + format!("[{name}]\nurl = {:?}\n{extra}\n", remote.to_string_lossy()) +} + +fn fingerprint(h: &TestHarness, root: &Path, module: &str) -> Vec<(String, Option>)> { + let mut paths = vec![ + ".git/index".to_owned(), + ".git/config".into(), + ".git/HEAD".into(), + ".gitmodules".into(), + "submod.toml".into(), + ]; + let child = root.join(module); + let gitdir = h.git_at(&child, &["rev-parse", "--absolute-git-dir"]); + for file in ["index", "config", "HEAD", "packed-refs", "FETCH_HEAD"] { + paths.push(Path::new(&gitdir).join(file).to_string_lossy().into_owned()); + } + fn files(dir: &Path, paths: &mut Vec) { + let mut entries: Vec<_> = fs::read_dir(dir) + .unwrap() + .map(|e| e.unwrap().path()) + .collect(); + entries.sort(); + for path in entries { + if path.is_dir() { + files(&path, paths); + } else { + paths.push(path.to_string_lossy().into_owned()); + } + } + } + files(&child, &mut paths); + let mut result: Vec<_> = paths + .into_iter() + .map(|p| { + let bytes = fs::read(root.join(&p)).ok(); + (p, bytes) + }) + .collect(); + result.push(( + "child refs".into(), + Some(h.git_at(&child, &["show-ref"]).into_bytes()), + )); + result +} + +fn sync_without_fetch(h: &TestHarness, cwd: &Path) { + let trace = h.temp_dir.path().join("second-sync-trace.jsonl"); + success( + std::process::Command::new(&h.submod_bin) + .arg("sync") + .current_dir(cwd) + .env("GIT_CONFIG_GLOBAL", h.temp_dir.path().join("gitconfig")) + .env("GIT_CONFIG_NOSYSTEM", "1") + .env("GIT_TERMINAL_PROMPT", "0") + .env("GIT_TRACE2_EVENT", &trace) + .output() + .unwrap(), + ); + let events = fs::read_to_string(trace).unwrap(); + assert!(!events.is_empty(), "Git tracing must actually be enabled"); + assert!( + !events.contains("\"fetch\""), + "unchanged sync fetched: {events}" + ); +} + +fn unchanged(before: Vec<(String, Option>)>, after: Vec<(String, Option>)>) { + assert_eq!(before.len(), after.len(), "snapshot file count changed"); + for ((name, old), (new_name, new)) in before.into_iter().zip(after) { + assert_eq!(name, new_name, "snapshot path changed"); + assert!(old == new, "bytes changed: {name}"); + } +} + +#[test] +fn r11_phase4_toml_only_sync_registers_omitted_path_and_is_exact_noop() { + let h = fixture(); + let remote = h.create_test_remote("toml").unwrap(); + h.create_config(&declaration("library", remote.as_ref(), "")) + .unwrap(); + success(h.run_submod(&["sync"]).unwrap()); + assert_eq!(h.index_gitlink_mode("library").as_deref(), Some("160000")); + assert_eq!( + h.git_stdout(&["config", "-f", ".gitmodules", "submodule.library.path"]), + "library" + ); + assert_eq!( + h.git_at(&h.work_dir.join("library"), &["rev-parse", "HEAD"]), + h.git_at(remote.as_ref(), &["rev-parse", "HEAD"]) + ); + assert!(h.work_dir.join("library/src/main.c").is_file()); + let before = fingerprint(&h, &h.work_dir, "library"); + sync_without_fetch(&h, &h.work_dir); + unchanged(before, fingerprint(&h, &h.work_dir, "library")); +} + +#[test] +fn r12_phase4_fresh_clone_sync_materializes_parent_pin() { + let h = fixture(); + let remote = h.create_test_remote("fresh").unwrap(); + let pin = register(&h, remote.as_ref(), "library"); + h.create_config(&declaration("library", remote.as_ref(), "")) + .unwrap(); + h.git_stdout(&["add", ".gitmodules", "library", "submod.toml"]); + h.git_stdout(&["commit", "-m", "Pin library"]); + assert_ne!(h.advance_test_remote("fresh").unwrap(), pin); + let fresh = h.temp_dir.path().join("fresh-parent"); + success( + h.git_cmd() + .arg("clone") + .arg(&h.work_dir) + .arg(&fresh) + .output() + .unwrap(), + ); + success(h.run_submod_at(&fresh, &["sync"]).unwrap()); + assert_eq!( + h.git_at(&fresh.join("library"), &["rev-parse", "HEAD"]), + pin + ); + assert_eq!( + h.git_at(&fresh, &["ls-files", "--stage", "library"]), + format!("160000 {pin} 0\tlibrary") + ); + assert!(fresh.join("library/src/main.c").is_file()); + assert!(!fresh.join("library/ADVANCE.txt").exists()); + let before = fingerprint(&h, &fresh, "library"); + sync_without_fetch(&h, &fresh); + unchanged(before, fingerprint(&h, &fresh, "library")); +} + +#[test] +fn r21_phase4_retained_gitdir_reattaches_and_preserves_refs_and_stash() { + let h = fixture(); + let remote = h.create_test_remote("retained").unwrap(); + let pin = register(&h, remote.as_ref(), "library"); + h.create_config(&declaration("library", remote.as_ref(), "")) + .unwrap(); + let child = h.work_dir.join("library"); + h.git_at(&child, &["branch", "precious-local"]); + fs::write(child.join("LICENSE"), "precious edit\n").unwrap(); + h.git_at(&child, &["stash", "push", "-m", "preserve me"]); + let stash = h.git_at(&child, &["rev-parse", "refs/stash"]); + h.git_stdout(&["add", ".gitmodules", "library"]); + h.git_stdout(&["commit", "-m", "Record library"]); + h.git_stdout(&["submodule", "deinit", "--", "library"]); + assert!(!child.join(".git").exists()); + success(h.run_submod(&["sync"]).unwrap()); + assert_eq!(h.git_at(&child, &["rev-parse", "HEAD"]), pin); + assert_eq!( + h.git_at(&child, &["rev-parse", "refs/heads/precious-local"]), + pin + ); + assert_eq!(h.git_at(&child, &["rev-parse", "refs/stash"]), stash); + assert_eq!( + h.git_at(&child, &["show", "refs/stash:LICENSE"]), + "precious edit" + ); + assert!(child.join("src/main.c").is_file()); +} + +#[test] +fn r19_phase4_disabled_and_update_none_missing_modules_skip_unreachable_urls() { + let h = fixture(); + let unavailable = h.temp_dir.path().join("unavailable.git"); + h.create_config( + &(declaration("disabled", &unavailable, "active = false") + + &declaration("none", &unavailable, "update = \"none\"")), + ) + .unwrap(); + let before = h.read_config().unwrap(); + success(h.run_submod(&["sync"]).unwrap()); + assert!(!h.work_dir.join("disabled").exists()); + assert!(!h.work_dir.join("none").exists()); + assert_eq!(h.index_gitlink_mode("disabled"), None); + assert_eq!(h.index_gitlink_mode("none"), None); + assert_eq!(h.read_config().unwrap(), before); +} + +#[test] +fn r13_phase4_update_none_reconciles_safe_metadata_without_materialization() { + let h = fixture(); + let remote = h.create_test_remote("none").unwrap(); + let pin = register(&h, remote.as_ref(), "library"); + h.git_stdout(&["add", ".gitmodules", "library"]); + h.git_stdout(&["commit", "-m", "Record library"]); + h.git_stdout(&["submodule", "deinit", "--", "library"]); + let unavailable = h.temp_dir.path().join("unavailable.git"); + h.create_config(&declaration( + "library", + &unavailable, + "update = \"none\"\nignore = \"all\"", + )) + .unwrap(); + success(h.run_submod(&["sync"]).unwrap()); + assert!(!h.work_dir.join("library/.git").exists()); + assert_eq!( + h.git_stdout(&["ls-files", "--stage", "library"]), + format!("160000 {pin} 0\tlibrary") + ); + assert_eq!( + h.git_stdout(&["config", "-f", ".gitmodules", "submodule.library.update"]), + "none" + ); + assert_eq!( + h.git_stdout(&["config", "-f", ".gitmodules", "submodule.library.ignore"]), + "all" + ); +} + +#[test] +fn r19_phase4_no_init_is_transient_and_later_init_materializes() { + let h = fixture(); + let remote = h.create_test_remote("deferred").unwrap(); + success( + h.run_submod(&[ + "add", + remote.to_str().unwrap(), + "--name", + "library", + "--no-init", + ]) + .unwrap(), + ); + assert!(!h.work_dir.join("library/.git").exists()); + let raw: toml::Value = toml::from_str(&h.read_config().unwrap()).unwrap(); + assert_ne!( + raw["library"].get("active").and_then(toml::Value::as_bool), + Some(false) + ); + success(h.run_submod(&["init"]).unwrap()); + assert_eq!(h.index_gitlink_mode("library").as_deref(), Some("160000")); + assert!(h.work_dir.join("library/src/main.c").is_file()); +} + +#[test] +fn r19_phase4_unmanaged_module_is_preserved_and_reported() { + let h = fixture(); + let remote = h.create_test_remote("unmanaged").unwrap(); + register(&h, remote.as_ref(), "outsider"); + h.create_config("").unwrap(); + let before = fingerprint(&h, &h.work_dir, "outsider"); + let text = success(h.run_submod(&["sync"]).unwrap()).to_lowercase(); + assert!( + text.contains("unmanaged") && text.contains("outsider"), + "{text}" + ); + unchanged(before, fingerprint(&h, &h.work_dir, "outsider")); +} + +#[test] +fn r13_phase4_explicit_add_none_keeps_initial_checkout_on_sync() { + let h = fixture(); + let remote = h.create_test_remote("explicit-none").unwrap(); + success( + h.run_submod(&[ + "add", + remote.to_str().unwrap(), + "--name", + "library", + "--update", + "none", + ]) + .unwrap(), + ); + assert_eq!(h.index_gitlink_mode("library").as_deref(), Some("160000")); + assert!(h.work_dir.join("library/src/main.c").is_file()); + let pin = h.git_at(&h.work_dir.join("library"), &["rev-parse", "HEAD"]); + success(h.run_submod(&["sync"]).unwrap()); + assert_eq!( + h.git_at(&h.work_dir.join("library"), &["rev-parse", "HEAD"]), + pin + ); + assert!(h.work_dir.join("library/src/main.c").is_file()); + let before = fingerprint(&h, &h.work_dir, "library"); + sync_without_fetch(&h, &h.work_dir); + unchanged(before, fingerprint(&h, &h.work_dir, "library")); +} + +#[test] +fn r24_phase4_batch_preflight_conflict_prevents_earlier_mutation() { + let h = fixture(); + let remote = h.create_test_remote("batch").unwrap(); + h.create_config( + &(declaration("a_good", remote.as_ref(), "") + + &declaration("b_conflict", remote.as_ref(), "") + + &declaration("c_pending", remote.as_ref(), "")), + ) + .unwrap(); + fs::create_dir(h.work_dir.join("b_conflict")).unwrap(); + fs::write(h.work_dir.join("b_conflict/precious"), b"keep me").unwrap(); + let before = h.preservation_snapshot(); + let output = h.run_submod(&["sync"]).unwrap(); + assert!(!output.status.success()); + assert_eq!(before, h.preservation_snapshot()); + assert!(!h.work_dir.join("a_good").exists()); + assert!(!h.work_dir.join("c_pending").exists()); + assert_eq!( + fs::read(h.work_dir.join("b_conflict/precious")).unwrap(), + b"keep me" + ); +} + +#[test] +fn r24_phase4_runtime_failure_reports_pending_and_retry_preserves_completed() { + let h = fixture(); + let remote = h.create_test_remote("batch-retry").unwrap(); + let unavailable = h.temp_dir.path().join("later.git"); + h.create_config( + &(declaration("a_good", remote.as_ref(), "") + + &declaration("b_failed", &unavailable, "") + + &declaration("c_pending", remote.as_ref(), "")), + ) + .unwrap(); + let declaration_before = h.read_config().unwrap(); + let output = h.run_submod(&["sync"]).unwrap(); + assert_eq!(output.status.code(), Some(1)); + let text = String::from_utf8_lossy(&output.stderr); + for row in [ + "a_good at a_good: changed:", + "b_failed at b_failed: failed:", + "c_pending at c_pending: pending:", + ] { + assert!(text.contains(row), "{text}"); + } + assert!( + text.contains( + "Sync incomplete summary: 1 changed, 0 unchanged, 0 skipped, 1 failed, 1 pending." + ), + "{text}" + ); + assert!(!String::from_utf8_lossy(&output.stdout).contains("summary:")); + for stream in [&output.stdout, &output.stderr] { + let normalized = String::from_utf8_lossy(stream).to_lowercase(); + assert!(!normalized.contains("sync complete"), "{normalized}"); + assert!(!normalized.contains("sync summary:"), "{normalized}"); + } + assert_eq!(h.index_gitlink_mode("a_good").as_deref(), Some("160000")); + assert!(!h.work_dir.join("c_pending").exists()); + assert_eq!(h.read_config().unwrap(), declaration_before); + let pin = h.git_at(&h.work_dir.join("a_good"), &["rev-parse", "HEAD"]); + assert!(text.contains(&format!("(target {pin})")), "{text}"); + h.git_at( + &h.work_dir.join("a_good"), + &["branch", "preserved-after-failure"], + ); + success( + h.git_cmd() + .args(["clone", "--bare"]) + .arg(remote.as_ref()) + .arg(&unavailable) + .output() + .unwrap(), + ); + success(h.run_submod(&["sync"]).unwrap()); + for name in ["a_good", "b_failed", "c_pending"] { + assert_eq!(h.index_gitlink_mode(name).as_deref(), Some("160000")); + assert!(h.work_dir.join(name).join("src/main.c").is_file()); + } + assert_eq!( + h.git_at( + &h.work_dir.join("a_good"), + &["rev-parse", "refs/heads/preserved-after-failure"] + ), + pin + ); +} + +fn divergent_strategy(strategy: &str) { + let h = fixture(); + let remote = h.create_test_remote(strategy).unwrap(); + let old = register(&h, remote.as_ref(), "library"); + let pin = h.advance_test_remote(strategy).unwrap(); + let child = h.work_dir.join("library"); + h.git_at(&child, &["fetch", "origin"]); + h.git_at(&child, &["checkout", "--detach", &pin]); + h.git_stdout(&["add", "library"]); + h.git_at(&child, &["checkout", "-b", "local-work", &old]); + fs::write(child.join("LOCAL.txt"), "local history\n").unwrap(); + h.git_at(&child, &["add", "LOCAL.txt"]); + h.git_at(&child, &["commit", "-m", "Local divergent work"]); + let local = h.git_at(&child, &["rev-parse", "HEAD"]); + h.git_at(&child, &["branch", "saved-local", &local]); + h.create_config(&declaration( + "library", + remote.as_ref(), + &format!("update = {strategy:?}"), + )) + .unwrap(); + success(h.run_submod(&["sync"]).unwrap()); + let result = h.git_at(&child, &["rev-parse", "HEAD"]); + assert_eq!( + h.git_stdout(&["ls-files", "--stage", "library"]), + format!("160000 {pin} 0\tlibrary") + ); + assert!(child.join("ADVANCE.txt").is_file()); + assert_eq!(h.git_at(&child, &["rev-parse", "saved-local"]), local); + if strategy == "checkout" { + assert_eq!(result, pin); + assert!(!child.join("LOCAL.txt").exists()); + } else { + assert_ne!(result, pin); + assert_eq!(h.git_at(&child, &["merge-base", &pin, "HEAD"]), pin); + assert_eq!( + fs::read_to_string(child.join("LOCAL.txt")).unwrap(), + "local history\n" + ); + if strategy == "merge" { + assert_eq!(h.git_at(&child, &["merge-base", &local, "HEAD"]), local); + } + } + success(h.run_submod(&["check"]).unwrap()); + let before = fingerprint(&h, &h.work_dir, "library"); + sync_without_fetch(&h, &h.work_dir); + unchanged(before, fingerprint(&h, &h.work_dir, "library")); +} + +#[test] +fn r13_phase4_checkout_converges_divergent_head_to_parent_pin() { + divergent_strategy("checkout"); +} + +#[test] +fn r13_phase4_merge_preserves_descendant_and_second_sync_is_noop() { + divergent_strategy("merge"); +} + +#[test] +fn r13_phase4_rebase_preserves_descendant_and_second_sync_is_noop() { + divergent_strategy("rebase"); +} + +#[test] +fn r13_phase4_metadata_change_never_moves_divergent_head() { + let h = fixture(); + let remote = h.create_test_remote("metadata-divergence").unwrap(); + let pin = register(&h, remote.as_ref(), "library"); + let child = h.work_dir.join("library"); + h.git_at(&child, &["checkout", "-b", "local-work", "HEAD~1"]); + fs::write(child.join("LOCAL.txt"), "retain local commit\n").unwrap(); + h.git_at(&child, &["add", "LOCAL.txt"]); + h.git_at(&child, &["commit", "-m", "Local work"]); + let local = h.git_at(&child, &["rev-parse", "HEAD"]); + assert_ne!(local, pin); + h.create_config(&declaration("library", remote.as_ref(), "")) + .unwrap(); + success( + h.run_submod(&[ + "change", "library", "--branch", "feature", "--ignore", "all", + ]) + .unwrap(), + ); + assert_eq!(h.git_at(&child, &["rev-parse", "HEAD"]), local); + assert_eq!( + h.git_at(&child, &["symbolic-ref", "HEAD"]), + "refs/heads/local-work" + ); + assert_eq!( + h.git_stdout(&["ls-files", "--stage", "library"]), + format!("160000 {pin} 0\tlibrary") + ); + assert_eq!( + fs::read_to_string(child.join("LOCAL.txt")).unwrap(), + "retain local commit\n" + ); + assert_eq!( + h.git_stdout(&["config", "-f", ".gitmodules", "submodule.library.branch"]), + "feature" + ); +} + +#[test] +fn r13_phase4_dot_branch_materializes_parent_symbolic_branch() { + let h = fixture(); + let remote = h.create_test_remote("dot-branch").unwrap(); + h.git_stdout(&["checkout", "-b", "feature"]); + h.create_config(&declaration("library", remote.as_ref(), "branch = \".\"")) + .unwrap(); + let expected = h.git_at(remote.as_ref(), &["rev-parse", "refs/heads/feature"]); + success(h.run_submod(&["sync"]).unwrap()); + assert_eq!( + h.git_at(&h.work_dir.join("library"), &["rev-parse", "HEAD"]), + expected + ); + assert_eq!( + h.git_stdout(&["ls-files", "--stage", "library"]), + format!("160000 {expected} 0\tlibrary") + ); + assert_eq!( + h.git_stdout(&["config", "-f", ".gitmodules", "submodule.library.branch"]), + "." + ); +} + +#[test] +fn r13_phase4_dot_branch_detached_parent_refuses_before_batch_mutation() { + let h = fixture(); + let remote = h.create_test_remote("detached-dot").unwrap(); + h.git_stdout(&["checkout", "--detach"]); + h.create_config( + &(declaration("a_good", remote.as_ref(), "") + + &declaration("b_dot", remote.as_ref(), "branch = \".\"")), + ) + .unwrap(); + let before = h.preservation_snapshot(); + let output = h.run_submod(&["sync"]).unwrap(); + assert!(!output.status.success()); + let text = format!( + "{}{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ) + .to_lowercase(); + assert!( + text.contains("detach") && (text.contains("branch") || text.contains("head")), + "{text}" + ); + assert_eq!(before, h.preservation_snapshot()); + assert!(!h.work_dir.join("a_good").exists()); + assert!(!h.work_dir.join("b_dot").exists()); +} + +#[test] +fn r21_phase4_missing_gitmodules_reconstructs_exact_managed_pin() { + let h = fixture(); + let remote = h.create_test_remote("missing-registration").unwrap(); + let pin = register(&h, remote.as_ref(), "library"); + h.create_config(&declaration("library", remote.as_ref(), "")) + .unwrap(); + h.git_stdout(&["add", ".gitmodules", "library", "submod.toml"]); + h.git_stdout(&["commit", "-m", "Record pin"]); + h.git_stdout(&["submodule", "deinit", "--", "library"]); + h.git_stdout(&["rm", ".gitmodules"]); + assert_ne!(h.advance_test_remote("missing-registration").unwrap(), pin); + success(h.run_submod(&["sync"]).unwrap()); + assert_eq!( + h.git_stdout(&[ + "config", + "-f", + ".gitmodules", + "--get-regexp", + "^submodule\\..*\\.path$" + ]), + "submodule.library.path library" + ); + assert_eq!( + h.git_stdout(&["ls-files", "--stage", "library"]), + format!("160000 {pin} 0\tlibrary") + ); + assert_eq!( + h.git_at(&h.work_dir.join("library"), &["rev-parse", "HEAD"]), + pin + ); + assert!(h.work_dir.join("library/src/main.c").is_file()); + assert!(!h.work_dir.join("library/ADVANCE.txt").exists()); +} + +fn declaration_without_gitlink(h: &TestHarness, remote: &Path) { + h.create_config(&declaration("library", remote, "")) + .unwrap(); + h.git_stdout(&[ + "config", + "-f", + ".gitmodules", + "submodule.logical.path", + "library", + ]); + h.git_stdout(&[ + "config", + "-f", + ".gitmodules", + "submodule.logical.url", + remote.to_str().unwrap(), + ]); + h.git_stdout(&["add", ".gitmodules"]); + h.git_stdout(&["commit", "-m", "Incomplete registration"]); + assert_eq!(h.index_gitlink_mode("library"), None); +} + +#[test] +fn r21_phase4_registration_without_gitlink_completes_empty_destination() { + let h = fixture(); + let remote = h.create_test_remote("missing-gitlink").unwrap(); + declaration_without_gitlink(&h, remote.as_ref()); + h.create_config(&declaration("library", remote.as_ref(), "shallow = false")) + .unwrap(); + fs::create_dir(h.work_dir.join("library")).unwrap(); + success(h.run_submod(&["sync"]).unwrap()); + let pin = h.git_at(remote.as_ref(), &["rev-parse", "HEAD"]); + assert_eq!( + h.git_stdout(&["ls-files", "--stage", "library"]), + format!("160000 {pin} 0\tlibrary") + ); + assert_eq!( + h.git_at(&h.work_dir.join("library"), &["rev-parse", "HEAD"]), + pin + ); + assert_eq!( + h.git_stdout(&[ + "config", + "-f", + ".gitmodules", + "--get-regexp", + "^submodule\\..*\\.path$" + ]), + "submodule.logical.path library" + ); + assert!(h.work_dir.join("library/src/main.c").is_file()); + assert_eq!( + h.git_stdout(&[ + "config", + "--blob", + ":.gitmodules", + "--get", + "submodule.logical.shallow", + ]), + "false" + ); + assert_eq!( + h.git_stdout(&[ + "config", + "-f", + ".gitmodules", + "--get", + "submodule.logical.shallow", + ]), + "false" + ); + assert!( + h.git_at(&h.work_dir, &["diff", "--quiet", "--", ".gitmodules"]) + .is_empty() + ); +} + +#[test] +fn r21_phase4_registration_without_gitlink_refuses_ambiguous_content_unchanged() { + let h = fixture(); + let remote = h.create_test_remote("ambiguous-gitlink").unwrap(); + declaration_without_gitlink(&h, remote.as_ref()); + fs::create_dir(h.work_dir.join("library")).unwrap(); + fs::write( + h.work_dir.join("library/precious.txt"), + "independent content\n", + ) + .unwrap(); + let before = h.preservation_snapshot(); + let output = h.run_submod(&["sync"]).unwrap(); + assert!(!output.status.success()); + assert_eq!(before, h.preservation_snapshot()); + assert_eq!( + fs::read_to_string(h.work_dir.join("library/precious.txt")).unwrap(), + "independent content\n" + ); + assert!(!h.work_dir.join("library/.git").exists()); +} + +#[test] +fn r21_phase4_manual_toml_path_edit_refuses_init_and_sync_without_mutation() { + for command in ["init", "sync"] { + let h = fixture(); + let remote = h.create_test_remote("manual-path-edit").unwrap(); + let pin = register(&h, remote.as_ref(), "library"); + h.create_config(&declaration( + "library", + remote.as_ref(), + "path = \"library\"", + )) + .unwrap(); + h.git_stdout(&["add", ".gitmodules", "library", "submod.toml"]); + h.git_stdout(&["commit", "-m", "Record original module path"]); + h.create_config(&declaration( + "library", + remote.as_ref(), + "path = \"moved-library\"", + )) + .unwrap(); + + let child = h.work_dir.join("library"); + let gitdir = h.git_at(&child, &["rev-parse", "--absolute-git-dir"]); + let core_worktree = h.git_at(&child, &["config", "--local", "core.worktree"]); + let before = fingerprint(&h, &h.work_dir, "library"); + let output = h.run_submod(&[command]).unwrap(); + let text = format!( + "{}{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + assert!( + !output.status.success(), + "{command} accepted an unrequested move: {text}" + ); + assert!( + text.contains("library") && text.contains("moved-library"), + "{command} must identify both conflicting paths: {text}" + ); + unchanged(before, fingerprint(&h, &h.work_dir, "library")); + assert!( + !h.work_dir.join("moved-library").exists(), + "{command} created the new path" + ); + assert_eq!(h.git_at(&child, &["rev-parse", "HEAD"]), pin); + assert_eq!( + h.git_at(&child, &["rev-parse", "--absolute-git-dir"]), + gitdir + ); + assert_eq!( + h.git_at(&child, &["config", "--local", "core.worktree"]), + core_worktree + ); + assert_eq!( + h.git_stdout(&["ls-files", "--stage", "library"]), + format!("160000 {pin} 0\tlibrary") + ); + assert_eq!(h.index_gitlink_mode("moved-library"), None); + } +} + +fn remote_tracking_update(explicit_branch: bool) { + { + let h = fixture(); + let remote = h.create_test_remote("remote-tracking").unwrap(); + if !explicit_branch { + h.git_at( + remote.as_ref(), + &["symbolic-ref", "HEAD", "refs/heads/feature"], + ); + } + let pin = register(&h, remote.as_ref(), "library"); + let settings = if explicit_branch { + "branch = \"feature\"" + } else { + "" + }; + h.create_config(&declaration("library", remote.as_ref(), settings)) + .unwrap(); + if explicit_branch { + h.git_stdout(&[ + "config", + "-f", + ".gitmodules", + "submodule.library.branch", + "feature", + ]); + } + h.git_stdout(&["add", ".gitmodules", "library", "submod.toml"]); + h.git_stdout(&["commit", "-m", "Record initial pin"]); + + let remote_work = h.temp_dir.path().join("remote-tracking_work"); + h.git_at(&remote_work, &["checkout", "feature"]); + fs::write( + remote_work.join("TRACKING.txt"), + "non-main tracking update\n", + ) + .unwrap(); + h.git_at(&remote_work, &["add", "TRACKING.txt"]); + h.git_at(&remote_work, &["commit", "-m", "Advance tracking branch"]); + h.git_at(&remote_work, &["push", "origin", "feature"]); + let target = h.git_at(&remote_work, &["rev-parse", "HEAD"]); + assert_ne!(pin, target); + + success(h.run_submod(&["update"]).unwrap()); + let child = h.work_dir.join("library"); + assert_eq!(h.git_at(&child, &["rev-parse", "HEAD"]), pin); + assert!(!child.join("TRACKING.txt").exists()); + let index_before = h.git_stdout(&["ls-files", "--stage"]); + success(h.run_submod(&["update", "--remote"]).unwrap()); + assert_eq!(h.git_at(&child, &["rev-parse", "HEAD"]), target); + assert_eq!( + fs::read_to_string(child.join("TRACKING.txt")).unwrap(), + "non-main tracking update\n" + ); + assert_eq!(h.git_stdout(&["ls-files", "--stage"]), index_before); + assert_eq!( + h.git_stdout(&["ls-files", "--stage", "library"]), + format!("160000 {pin} 0\tlibrary") + ); + assert_eq!( + h.git_stdout(&["diff", "--name-only", "--", "library"]), + "library" + ); + assert!( + h.git_stdout(&["diff", "--cached", "--name-only", "--", "library"]) + .is_empty() + ); + } +} + +fn recursive_selection(command: &str) { + { + let h = fixture(); + let leaf_remote = h.create_test_remote("nested-leaf").unwrap(); + let outer_remote = h.create_test_remote("nested-outer").unwrap(); + let outer_work = h.temp_dir.path().join("nested-outer_work"); + h.git_at( + &outer_work, + &[ + "submodule", + "add", + "--name", + "nested-logical", + leaf_remote.to_str().unwrap(), + "deps/leaf", + ], + ); + let leaf_pin = h.git_at(&outer_work.join("deps/leaf"), &["rev-parse", "HEAD"]); + h.git_at(&outer_work, &["add", ".gitmodules", "deps/leaf"]); + h.git_at(&outer_work, &["commit", "-m", "Pin nested module"]); + h.git_at(&outer_work, &["push", "origin", "main"]); + let outer_pin = h.git_at(&outer_work, &["rev-parse", "HEAD"]); + assert_ne!(h.advance_test_remote("nested-leaf").unwrap(), leaf_pin); + h.create_config(&declaration( + "library", + outer_remote.as_ref(), + "fetch = \"always\"", + )) + .unwrap(); + + // Fetch recursion policy alone must not opt into recursive materialization. + if command == "update" { + success(h.run_submod(&["init"]).unwrap()); + success(h.run_submod(&["update"]).unwrap()); + } else { + success(h.run_submod(&[command]).unwrap()); + } + let outer = h.work_dir.join("library"); + let leaf = outer.join("deps/leaf"); + assert_eq!(h.git_at(&outer, &["rev-parse", "HEAD"]), outer_pin); + assert_eq!( + h.git_at(&outer, &["ls-files", "--stage", "deps/leaf"]), + format!("160000 {leaf_pin} 0\tdeps/leaf") + ); + assert!(!leaf.join(".git").exists()); + assert!(!leaf.join("src/main.c").exists()); + let parent_index = h.git_stdout(&["ls-files", "--stage"]); + let outer_index = h.git_at(&outer, &["ls-files", "--stage"]); + + success(h.run_submod(&[command, "--recursive"]).unwrap()); + assert!(leaf.join(".git").is_file()); + assert_eq!(h.git_at(&leaf, &["rev-parse", "HEAD"]), leaf_pin); + assert!(leaf.join("src/main.c").is_file()); + assert!(!leaf.join("ADVANCE.txt").exists()); + assert_eq!(h.git_stdout(&["ls-files", "--stage"]), parent_index); + assert_eq!(h.git_at(&outer, &["ls-files", "--stage"]), outer_index); + assert_eq!(h.git_at(&outer, &["rev-parse", "HEAD"]), outer_pin); + } +} + +#[test] +fn r13_phase4_remote_update_uses_non_main_default_without_staging_parent_pin() { + remote_tracking_update(false); +} + +#[test] +fn r13_phase4_remote_update_uses_configured_branch_without_staging_parent_pin() { + remote_tracking_update(true); +} + +#[test] +fn r19_r20_phase4_recursive_selection_init_materializes_real_nested_pins() { + recursive_selection("init"); +} + +#[test] +fn r19_r20_phase4_recursive_selection_sync_materializes_real_nested_pins() { + recursive_selection("sync"); +} + +#[test] +fn r24_phase4_dirty_later_transition_preflights_before_earlier_add() { + let h = fixture(); + let remote = h.create_test_remote("dirty-transition").unwrap(); + let old = register(&h, remote.as_ref(), "b_dirty"); + let remote_work = h.temp_dir.path().join("dirty-transition_work"); + fs::write(remote_work.join("LICENSE"), "upstream license\n").unwrap(); + h.git_at(&remote_work, &["add", "LICENSE"]); + h.git_at(&remote_work, &["commit", "-m", "Change tracked content"]); + h.git_at(&remote_work, &["push", "origin", "main"]); + let target = h.git_at(&remote_work, &["rev-parse", "HEAD"]); + let child = h.work_dir.join("b_dirty"); + h.git_at(&child, &["fetch", "origin"]); + h.git_at(&child, &["checkout", "--detach", &target]); + h.git_stdout(&["add", "b_dirty", ".gitmodules"]); + h.git_stdout(&["commit", "-m", "Record required transition"]); + h.git_at(&child, &["checkout", "--detach", &old]); + fs::write(child.join("LICENSE"), "precious dirty content\n").unwrap(); + h.create_config( + &(declaration("a_good", remote.as_ref(), "") + + &declaration("b_dirty", remote.as_ref(), "")), + ) + .unwrap(); + let before = fingerprint(&h, &h.work_dir, "b_dirty"); + let output = h.run_submod(&["sync"]).unwrap(); + assert!( + !output.status.success(), + "dirty transition unexpectedly succeeded" + ); + unchanged(before, fingerprint(&h, &h.work_dir, "b_dirty")); + assert!(!h.work_dir.join("a_good").exists()); + assert_eq!(h.index_gitlink_mode("a_good"), None); + assert_eq!(h.git_at(&child, &["rev-parse", "HEAD"]), old); + assert_eq!( + h.git_stdout(&["ls-files", "--stage", "b_dirty"]), + format!("160000 {target} 0\tb_dirty") + ); + assert_eq!( + fs::read_to_string(child.join("LICENSE")).unwrap(), + "precious dirty content\n" + ); +} + +fn init_wrong_pin(strategy: &str) { + let h = fixture(); + let remote = h.create_test_remote("init-wrong-pin").unwrap(); + let old = register(&h, remote.as_ref(), "library"); + let target = h.advance_test_remote("init-wrong-pin").unwrap(); + let child = h.work_dir.join("library"); + h.git_at(&child, &["fetch", "origin"]); + h.git_at(&child, &["checkout", "--detach", &target]); + h.git_stdout(&["add", "library", ".gitmodules"]); + h.git_stdout(&["commit", "-m", "Record new pin"]); + h.git_at(&child, &["checkout", "--detach", &old]); + assert!(!child.join("ADVANCE.txt").exists()); + assert!(h.git_at(&child, &["status", "--porcelain"]).is_empty()); + h.create_config(&declaration( + "library", + remote.as_ref(), + &format!("update = {strategy:?}"), + )) + .unwrap(); + let parent_index = h.git_stdout(&["ls-files", "--stage", "library"]); + success(h.run_submod(&["init"]).unwrap()); + assert_eq!(h.git_at(&child, &["rev-parse", "HEAD"]), target); + assert_eq!( + fs::read_to_string(child.join("ADVANCE.txt")).unwrap(), + "advanced init-wrong-pin\n" + ); + assert_eq!( + h.git_stdout(&["ls-files", "--stage", "library"]), + parent_index + ); +} + +#[test] +fn r12_phase4_init_initialized_wrong_pin_checkout_converges() { + init_wrong_pin("checkout"); +} + +#[test] +fn r13_phase4_init_initialized_wrong_pin_merge_converges() { + init_wrong_pin("merge"); +} + +#[test] +fn r13_phase4_init_initialized_wrong_pin_rebase_converges() { + init_wrong_pin("rebase"); +} + +#[test] +fn r21_phase4_unmerged_gitlink_stages_refuse_all_lifecycle_commands_unchanged() { + use std::io::Write; + use std::process::Stdio; + + for command in ["init", "update", "sync"] { + let h = fixture(); + let remote = h.create_test_remote("unmerged-gitlink").unwrap(); + let ours = register(&h, remote.as_ref(), "library"); + let child = h.work_dir.join("library"); + let base = h.git_at(&child, &["rev-parse", "HEAD~1"]); + let theirs = h.git_at(&child, &["rev-parse", "refs/remotes/origin/feature"]); + assert_ne!(base, ours); + assert_ne!(ours, theirs); + h.create_config(&declaration("library", remote.as_ref(), "")) + .unwrap(); + h.git_stdout(&["add", ".gitmodules", "library", "submod.toml"]); + h.git_stdout(&["commit", "-m", "Record clean initial state"]); + let mut index = h + .git_cmd() + .args(["update-index", "--index-info"]) + .current_dir(&h.work_dir) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap(); + let input = format!( + "0 {}\tlibrary\n160000 {base} 1\tlibrary\n160000 {ours} 2\tlibrary\n160000 {theirs} 3\tlibrary\n", + "0".repeat(40) + ); + index + .stdin + .take() + .unwrap() + .write_all(input.as_bytes()) + .unwrap(); + success(index.wait_with_output().unwrap()); + let stages = format!( + "160000 {base} 1\tlibrary\n160000 {ours} 2\tlibrary\n160000 {theirs} 3\tlibrary" + ); + assert_eq!(h.git_stdout(&["ls-files", "--unmerged", "library"]), stages); + let before = fingerprint(&h, &h.work_dir, "library"); + let output = h.run_submod(&[command]).unwrap(); + let text = format!( + "{}{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ) + .to_lowercase(); + assert!( + !output.status.success(), + "{command} accepted an unmerged gitlink: {text}" + ); + assert!( + text.contains("unmerged") || text.contains("conflict"), + "{command} lacks conflict diagnosis: {text}" + ); + assert!( + !text.contains("updated") && !text.contains("sync complete"), + "{command} printed misleading success: {text}" + ); + unchanged(before, fingerprint(&h, &h.work_dir, "library")); + assert_eq!(h.git_stdout(&["ls-files", "--unmerged", "library"]), stages); + assert_eq!(h.git_at(&child, &["rev-parse", "HEAD"]), ours); + assert!(child.join("src/main.c").is_file()); + } +} + +#[test] +fn r31_phase4_mixed_metadata_and_add_preserves_gitmodules_layers_and_retries() { + let h = fixture(); + let remote = h.create_test_remote("mixed-metadata-add").unwrap(); + let existing_pin = register(&h, remote.as_ref(), "a_existing"); + h.git_stdout(&["add", ".gitmodules", "a_existing"]); + h.git_stdout(&["commit", "-m", "Record existing module"]); + h.create_config( + &(declaration("a_existing", remote.as_ref(), "ignore = \"all\"") + + &declaration("b_new", remote.as_ref(), "")), + ) + .unwrap(); + let declaration_before = h.read_config().unwrap(); + let readme_index = h.git_stdout(&["ls-files", "--stage", "README.md"]); + let expected_new_pin = h.git_at(remote.as_ref(), &["rev-parse", "HEAD"]); + + success(h.run_submod(&["sync"]).unwrap()); + for (name, pin) in [("a_existing", &existing_pin), ("b_new", &expected_new_pin)] { + assert_eq!( + h.git_stdout(&["ls-files", "--stage", name]), + format!("160000 {pin} 0\t{name}") + ); + assert_eq!( + h.git_at(&h.work_dir.join(name), &["rev-parse", "HEAD"]), + *pin + ); + assert!(h.work_dir.join(name).join("src/main.c").is_file()); + let key = format!("submodule.{name}.path"); + assert_eq!(h.git_stdout(&["config", "-f", ".gitmodules", &key]), name); + assert_eq!( + h.git_stdout(&["config", "--blob", ":.gitmodules", "--get", &key]), + name + ); + let key = format!("submodule.{name}.url"); + assert_eq!( + h.git_stdout(&["config", "--blob", ":.gitmodules", "--get", &key]), + remote.to_str().unwrap() + ); + } + // Adding b_new stages its registration, while a_existing's policy edit + // remains an independent worktree change for review. + assert_eq!( + h.git_stdout(&["config", "-f", ".gitmodules", "submodule.a_existing.ignore"]), + "all" + ); + let staged_ignore = h + .git_cmd() + .args([ + "config", + "--blob", + ":.gitmodules", + "--get", + "submodule.a_existing.ignore", + ]) + .current_dir(&h.work_dir) + .output() + .unwrap(); + assert_eq!(staged_ignore.status.code(), Some(1)); + assert!(staged_ignore.stdout.is_empty()); + assert!(staged_ignore.stderr.is_empty()); + assert_eq!( + h.git_stdout(&["config", "--local", "submodule.a_existing.ignore"]), + "all" + ); + assert_eq!( + h.git_stdout(&["ls-files", "--stage", "README.md"]), + readme_index + ); + assert_eq!(h.read_config().unwrap(), declaration_before); + let existing_before = fingerprint(&h, &h.work_dir, "a_existing"); + let new_before = fingerprint(&h, &h.work_dir, "b_new"); + sync_without_fetch(&h, &h.work_dir); + unchanged(existing_before, fingerprint(&h, &h.work_dir, "a_existing")); + unchanged(new_before, fingerprint(&h, &h.work_dir, "b_new")); +} + +#[test] +fn r31_phase4_incomplete_registration_unstaged_comment_refuses_without_removing_empty_dir() { + let h = fixture(); + let remote = h.create_test_remote("completion-layer-conflict").unwrap(); + declaration_without_gitlink(&h, remote.as_ref()); + let gitmodules = h.work_dir.join(".gitmodules"); + let staged_gitmodules = h.git_stdout(&["show", ":.gitmodules"]); + let mut edited = fs::read_to_string(&gitmodules).unwrap(); + edited.push_str("\n# unrelated user edit\n"); + fs::write(&gitmodules, &edited).unwrap(); + let empty = h.work_dir.join("library"); + fs::create_dir(&empty).unwrap(); + #[cfg(unix)] + let inode_before = { + use std::os::unix::fs::MetadataExt; + fs::metadata(&empty).unwrap().ino() + }; + let before = h.preservation_snapshot(); + let index_before = fs::read(h.work_dir.join(".git/index")).unwrap(); + let output = h.run_submod(&["sync"]).unwrap(); + assert!(!output.status.success()); + assert_eq!(before, h.preservation_snapshot()); + assert_eq!( + fs::read(h.work_dir.join(".git/index")).unwrap(), + index_before + ); + assert_eq!(fs::read_to_string(&gitmodules).unwrap(), edited); + assert_eq!(h.git_stdout(&["show", ":.gitmodules"]), staged_gitmodules); + assert!(empty.is_dir()); + assert!(fs::read_dir(&empty).unwrap().next().is_none()); + #[cfg(unix)] + { + use std::os::unix::fs::MetadataExt; + assert_eq!(fs::metadata(&empty).unwrap().ino(), inode_before); + } + assert_eq!(h.index_gitlink_mode("library"), None); + assert!(!h.work_dir.join(".git/modules/logical").exists()); +} + +#[test] +fn r19_r20_phase4_recursive_selection_update_materializes_real_nested_pins() { + recursive_selection("update"); +} diff --git a/tests/reconciliation_metadata_tests.rs b/tests/reconciliation_metadata_tests.rs new file mode 100644 index 00000000..5942f52a --- /dev/null +++ b/tests/reconciliation_metadata_tests.rs @@ -0,0 +1,1034 @@ +//! Phase 4 CLI acceptance: managed metadata, exact identity, and index ownership. +use std::{fs, path::Path, process::Output}; +mod common; +use common::TestHarness; + +const NAME: &str = "logical.name"; +const CHILD: &str = "vendor/checkout"; + +fn ok(output: Output) { + assert!(output.status.success(), "CLI failed: {output:?}"); +} + +fn fixture() -> TestHarness { + let h = TestHarness::new().unwrap(); + h.init_git_repo().unwrap(); + let remote = h.create_test_remote("source").unwrap(); + h.git_stdout(&[ + "submodule", + "add", + "--name", + NAME, + remote.to_str().unwrap(), + CHILD, + ]); + h.git_stdout(&[ + "config", + "-f", + ".gitmodules", + "submodule.logical.name.custom", + "portable-keep", + ]); + h.git_stdout(&[ + "config", + "--local", + "submodule.logical.name.custom", + "local-keep", + ]); + h.git_stdout(&[ + "config", + "-f", + ".gitmodules", + "submodule.unmanaged.path", + "vendor/checkout-extra", + ]); + h.git_stdout(&[ + "config", + "-f", + ".gitmodules", + "submodule.unmanaged.url", + remote.to_str().unwrap(), + ]); + h.git_stdout(&[ + "config", + "--local", + "submodule.unmanaged.custom", + "other-keep", + ]); + h.create_config(&format!( + "[alias]\nurl = {:?}\npath = {CHILD:?}\n", + remote.to_str().unwrap() + )) + .unwrap(); + h.git_stdout(&["add", ".gitmodules", "submod.toml"]); + h.git_stdout(&[ + "commit", + "-m", + "native registration with distinct identities", + ]); + h +} + +fn value(h: &TestHarness, cwd: &Path, scope: &[&str], key: &str) -> Option { + let out = h + .git_cmd() + .current_dir(cwd) + .arg("config") + .args(scope) + .args(["--get", key]) + .output() + .unwrap(); + assert!( + out.status.success() || out.status.code() == Some(1), + "{out:?}" + ); + out.status + .success() + .then(|| String::from_utf8(out.stdout).unwrap().trim().to_owned()) +} + +fn assert_managed(h: &TestHarness, cwd: &Path, expected: &[(&str, Option<&str>)]) { + for scope in [&["-f", ".gitmodules"][..], &["--local"][..], &[][..]] { + for (field, expected) in expected { + let key = format!("submodule.{NAME}.{field}"); + assert_eq!( + value(h, cwd, scope, &key).as_deref(), + *expected, + "scope={scope:?} key={key}" + ); + } + } + assert_eq!( + value( + h, + cwd, + &["-f", ".gitmodules"], + "submodule.logical.name.custom" + ) + .as_deref(), + Some("portable-keep") + ); + assert_eq!( + value(h, cwd, &["--local"], "submodule.logical.name.custom").as_deref(), + Some("local-keep") + ); + assert_eq!( + value(h, cwd, &["--local"], "submodule.unmanaged.custom").as_deref(), + Some("other-keep") + ); + assert_eq!( + value(h, cwd, &["-f", ".gitmodules"], "submodule.unmanaged.path").as_deref(), + Some("vendor/checkout-extra") + ); + assert!(value(h, cwd, &["-f", ".gitmodules"], "submodule.alias.path").is_none()); + assert!( + value( + h, + cwd, + &["-f", ".gitmodules"], + "submodule.vendor/checkout.path" + ) + .is_none() + ); +} + +fn child_state(h: &TestHarness, cwd: &Path) -> (String, String, Option>) { + let child = cwd.join(CHILD); + let gitdir = h.git_at(&child, &["rev-parse", "--absolute-git-dir"]); + ( + h.git_at(&child, &["rev-parse", "HEAD"]), + h.git_at(&child, &["show-ref"]), + fs::read(Path::new(&gitdir).join("FETCH_HEAD")).ok(), + ) +} + +#[test] +fn r14_r16_change_sets_exact_managed_identity_without_checkout_or_staging() { + let h = fixture(); + let state = child_state(&h, &h.work_dir); + let index = h.git_stdout(&["ls-files", "--stage"]); + let new_url = h.temp_dir.path().join("unreachable-source"); + ok(h.run_submod(&[ + "change", + "alias", + "--url", + new_url.to_str().unwrap(), + "--branch", + "topic", + "--ignore", + "dirty", + "--update", + "merge", + "--fetch", + "on-demand", + "--shallow", + "true", + ]) + .unwrap()); + assert_eq!( + child_state(&h, &h.work_dir), + state, + "metadata-only change fetched or moved child" + ); + assert_eq!( + h.git_stdout(&["ls-files", "--stage"]), + index, + "policy edit staged files" + ); + assert_managed( + &h, + &h.work_dir, + &[ + ("url", Some(new_url.to_str().unwrap())), + ("branch", Some("topic")), + ("ignore", Some("dirty")), + ("update", Some("merge")), + ("fetchRecurseSubmodules", Some("on-demand")), + ("shallow", Some("true")), + ], + ); + assert_eq!( + value(&h, &h.work_dir.join(CHILD), &[], "remote.origin.url").as_deref(), + Some(new_url.to_str().unwrap()) + ); + let before = h.preservation_snapshot(); + ok(h.run_submod(&[ + "change", + "alias", + "--url", + new_url.to_str().unwrap(), + "--branch", + "topic", + "--ignore", + "dirty", + "--update", + "merge", + "--fetch", + "on-demand", + "--shallow", + "true", + ]) + .unwrap()); + assert_eq!( + h.preservation_snapshot(), + before, + "identical change rewrote metadata" + ); + assert_eq!(child_state(&h, &h.work_dir), state); +} + +#[test] +fn r14_unset_removes_stale_portable_and_local_overrides() { + let h = fixture(); + h.git_stdout(&["config", "extensions.worktreeConfig", "true"]); + h.git_stdout(&[ + "config", + "--worktree", + "submodule.logical.name.custom", + "worktree-keep", + ]); + let mut toml = h.read_config().unwrap(); + toml.push_str( + "branch = 'topic'\nignore = 'dirty'\nupdate = 'merge'\nfetch = 'always'\nshallow = true\n", + ); + h.create_config(&toml).unwrap(); + for scope in [ + &["-f", ".gitmodules"][..], + &["--local"][..], + &["--worktree"][..], + ] { + for (field, val) in [ + ("branch", "topic"), + ("ignore", "dirty"), + ("update", "merge"), + ("fetchRecurseSubmodules", "true"), + ("shallow", "true"), + ] { + h.git_stdout( + &[ + &["config"].as_slice(), + scope, + &[&format!("submodule.{NAME}.{field}"), val], + ] + .concat(), + ); + } + } + h.git_stdout(&["add", ".gitmodules", "submod.toml"]); + h.git_stdout(&["commit", "-m", "seed stale managed overrides"]); + let index = h.git_stdout(&["ls-files", "--stage"]); + let state = child_state(&h, &h.work_dir); + ok(h.run_submod(&[ + "change", + "alias", + "--unset", + "branch,ignore,update,fetch,shallow", + ]) + .unwrap()); + assert_managed(&h, &h.work_dir, &[("branch", None)]); + // Native defaults may be represented by absence or their explicit value. + for scope in [&["-f", ".gitmodules"][..], &["--local"][..], &[][..]] { + for (field, default) in [ + ("ignore", "none"), + ("update", "checkout"), + ("fetchRecurseSubmodules", "on-demand"), + ("shallow", "false"), + ] { + let actual = value(&h, &h.work_dir, scope, &format!("submodule.{NAME}.{field}")); + assert!( + actual.is_none() || actual.as_deref() == Some(default), + "stale {field}: {actual:?}" + ); + } + } + for field in [ + "branch", + "ignore", + "update", + "fetchRecurseSubmodules", + "shallow", + ] { + assert_eq!( + value( + &h, + &h.work_dir, + &["--worktree"], + &format!("submodule.{NAME}.{field}") + ), + None, + "unset left a worktree override" + ); + } + assert_eq!(h.git_stdout(&["ls-files", "--stage"]), index); + assert_eq!( + value( + &h, + &h.work_dir, + &["--worktree"], + "submodule.logical.name.custom" + ) + .as_deref(), + Some("worktree-keep") + ); + assert_eq!(child_state(&h, &h.work_dir), state); +} + +#[test] +fn r14_defaults_reconcile_metadata_without_materializing_missing_checkout() { + let h = fixture(); + h.git_stdout(&["submodule", "deinit", "-f", "--", CHILD]); + h.git_stdout(&[ + "config", + "--local", + "submodule.logical.name.custom", + "local-keep", + ]); + let index = h.git_stdout(&["ls-files", "--stage"]); + ok(h.run_submod(&[ + "change-global", + "--ignore", + "all", + "--fetch", + "never", + "--update", + "none", + ]) + .unwrap()); + assert!(!h.work_dir.join(CHILD).join(".git").exists()); + assert_eq!(h.git_stdout(&["ls-files", "--stage"]), index); + assert_managed( + &h, + &h.work_dir, + &[ + ("ignore", Some("all")), + ("fetchRecurseSubmodules", Some("false")), + ("update", Some("none")), + ], + ); +} + +#[test] +fn r14_worktree_overrides_cannot_mask_managed_changes() { + let h = fixture(); + h.git_stdout(&["config", "extensions.worktreeConfig", "true"]); + for (field, val) in [ + ("url", "/missing/old"), + ("branch", "stale"), + ("ignore", "all"), + ("update", "rebase"), + ("fetchRecurseSubmodules", "true"), + ("shallow", "true"), + ("custom", "worktree-keep"), + ] { + h.git_stdout(&[ + "config", + "--worktree", + &format!("submodule.{NAME}.{field}"), + val, + ]); + } + let state = child_state(&h, &h.work_dir); + let remote = value( + &h, + &h.work_dir, + &["-f", ".gitmodules"], + "submodule.logical.name.url", + ) + .unwrap(); + ok(h.run_submod(&[ + "change", + "alias", + "--url", + &remote, + "--branch", + "main", + "--ignore", + "dirty", + "--update", + "none", + "--fetch", + "never", + "--shallow", + "false", + ]) + .unwrap()); + assert_managed( + &h, + &h.work_dir, + &[ + ("url", Some(&remote)), + ("branch", Some("main")), + ("ignore", Some("dirty")), + ("update", Some("none")), + ("fetchRecurseSubmodules", Some("false")), + ("shallow", Some("false")), + ], + ); + assert_eq!( + value( + &h, + &h.work_dir, + &["--worktree"], + "submodule.logical.name.custom" + ) + .as_deref(), + Some("worktree-keep") + ); + assert_eq!(child_state(&h, &h.work_dir), state); +} + +fn custom_config_invocation(linked: bool, nested: bool) { + let h = fixture(); + fs::create_dir_all(h.work_dir.join("settings")).unwrap(); + fs::rename(h.config_path(), h.work_dir.join("settings/modules.toml")).unwrap(); + h.git_stdout(&["add", "-A"]); + h.git_stdout(&["commit", "-m", "custom config"]); + let root = if linked { + let path = h.temp_dir.path().join("linked"); + h.git_stdout(&["worktree", "add", "-b", "linked", path.to_str().unwrap()]); + path + } else { + h.work_dir.clone() + }; + let cwd = if nested { + root.join("nested/deep") + } else { + root.clone() + }; + fs::create_dir_all(&cwd).unwrap(); + let config = if nested { + "../../settings/modules.toml" + } else { + "settings/modules.toml" + }; + ok(h.run_submod_at( + &cwd, + &["--config", config, "change", "alias", "--ignore", "dirty"], + ) + .unwrap()); + assert_eq!( + value( + &h, + &root, + &["-f", ".gitmodules"], + "submodule.logical.name.ignore" + ) + .as_deref(), + Some("dirty") + ); + assert!(!root.join("submod.toml").exists()); + if nested { + assert!(!cwd.join(CHILD).exists()); + } + if linked { + assert!( + !root.join(CHILD).join(".git").exists(), + "metadata edit materialized linked checkout" + ); + } +} + +#[test] +fn r17_custom_config_root() { + custom_config_invocation(false, false); +} +#[test] +fn r17_custom_config_nested() { + custom_config_invocation(false, true); +} +#[test] +fn r17_custom_config_linked_nested() { + custom_config_invocation(true, true); +} + +#[test] +fn r31_metadata_change_preserves_or_refuses_both_gitmodules_layers() { + let h = fixture(); + h.git_stdout(&[ + "config", + "-f", + ".gitmodules", + "submodule.unmanaged.staged", + "index-only", + ]); + h.git_stdout(&["add", ".gitmodules"]); + h.git_stdout(&[ + "config", + "-f", + ".gitmodules", + "submodule.unmanaged.unstaged", + "worktree-only", + ]); + let index = h + .git_cmd() + .current_dir(&h.work_dir) + .args(["show", ":.gitmodules"]) + .output() + .unwrap() + .stdout; + let bytes = fs::read(h.work_dir.join(".gitmodules")).unwrap(); + let snapshot = h.preservation_snapshot(); + let state = child_state(&h, &h.work_dir); + let result = h + .run_submod(&["change", "alias", "--ignore", "dirty"]) + .unwrap(); + assert_eq!( + h.git_cmd() + .current_dir(&h.work_dir) + .args(["show", ":.gitmodules"]) + .output() + .unwrap() + .stdout, + index, + "changed staged blob" + ); + if result.status.success() { + assert_eq!( + value( + &h, + &h.work_dir, + &["-f", ".gitmodules"], + "submodule.unmanaged.staged" + ) + .as_deref(), + Some("index-only") + ); + assert_eq!( + value( + &h, + &h.work_dir, + &["-f", ".gitmodules"], + "submodule.unmanaged.unstaged" + ) + .as_deref(), + Some("worktree-only") + ); + assert_managed(&h, &h.work_dir, &[("ignore", Some("dirty"))]); + } else { + assert_eq!(fs::read(h.work_dir.join(".gitmodules")).unwrap(), bytes); + assert_eq!( + h.preservation_snapshot(), + snapshot, + "refusal mutated metadata" + ); + } + assert_eq!(child_state(&h, &h.work_dir), state); +} + +#[test] +fn r14_second_update_none_sync_is_exact_noop() { + let h = fixture(); + let mut config = h.read_config().unwrap(); + config.push_str("update = 'none'\n"); + h.create_config(&config).unwrap(); + ok(h.run_submod(&["sync"]).unwrap()); + let before = h.preservation_snapshot(); + let child = child_state(&h, &h.work_dir); + let child_common = h.git_at( + &h.work_dir.join(CHILD), + &["rev-parse", "--path-format=absolute", "--git-common-dir"], + ); + let config_paths = [ + h.config_path(), + h.work_dir.join(".gitmodules"), + h.work_dir.join(".git/config"), + Path::new(&child_common).join("config"), + ]; + let metadata: Vec<_> = config_paths + .iter() + .map(|p| fs::metadata(p).unwrap()) + .collect(); + ok(h.run_submod(&["sync"]).unwrap()); + for (path, before) in config_paths.iter().zip(metadata) { + let after = fs::metadata(path).unwrap(); + assert_eq!( + after.modified().unwrap(), + before.modified().unwrap(), + "no-op changed mtime: {}", + path.display() + ); + #[cfg(unix)] + { + use std::os::unix::fs::MetadataExt; + assert_eq!( + after.ino(), + before.ino(), + "no-op replaced inode: {}", + path.display() + ); + } + } + assert_eq!(h.preservation_snapshot(), before); + assert_eq!(child_state(&h, &h.work_dir), child); +} + +#[test] +fn r14_linked_url_change_updates_selected_child_only() { + let h = fixture(); + h.git_stdout(&["config", "extensions.worktreeConfig", "true"]); + let linked = h.temp_dir.path().join("linked"); + h.git_stdout(&["worktree", "add", "-b", "linked", linked.to_str().unwrap()]); + h.git_at(&linked, &["submodule", "update", "--init", "--", CHILD]); + h.git_at( + &linked, + &[ + "config", + "--worktree", + "submodule.logical.name.url", + "/stale/worktree-url", + ], + ); + h.git_at( + &linked, + &[ + "config", + "--worktree", + "submodule.logical.name.active", + "false", + ], + ); + h.git_at( + &linked, + &[ + "config", + "--worktree", + "submodule.logical.name.custom", + "linked-keep", + ], + ); + let main_url = value(&h, &h.work_dir.join(CHILD), &[], "remote.origin.url"); + let main_state = child_state(&h, &h.work_dir); + let linked_state = child_state(&h, &linked); + let index = h.git_at(&linked, &["ls-files", "--stage"]); + let new_url = h.temp_dir.path().join("unreachable-replacement"); + ok(h.run_submod_at( + &linked, + &["change", "alias", "--url", new_url.to_str().unwrap()], + ) + .unwrap()); + for cwd in [&linked, &linked.join(CHILD)] { + let key = if cwd == &linked { + "submodule.logical.name.url" + } else { + "remote.origin.url" + }; + assert_eq!( + value(&h, cwd, &[], key).as_deref(), + Some(new_url.to_str().unwrap()) + ); + } + assert_eq!( + value(&h, &h.work_dir.join(CHILD), &[], "remote.origin.url"), + main_url + ); + assert_eq!( + value( + &h, + &linked, + &["--worktree"], + "submodule.logical.name.custom" + ) + .as_deref(), + Some("linked-keep") + ); + assert_eq!(child_state(&h, &h.work_dir), main_state); + assert_eq!(child_state(&h, &linked), linked_state); + assert_eq!(h.git_at(&linked, &["ls-files", "--stage"]), index); +} + +fn refuse_structural_edit_with_two_layers(operation: &str) { + let h = fixture(); + h.git_stdout(&[ + "config", + "-f", + ".gitmodules", + "submodule.unmanaged.staged", + "index-only", + ]); + h.git_stdout(&["add", ".gitmodules"]); + h.git_stdout(&[ + "config", + "-f", + ".gitmodules", + "submodule.unmanaged.unstaged", + "worktree-only", + ]); + let index_blob = h + .git_cmd() + .current_dir(&h.work_dir) + .args(["show", ":.gitmodules"]) + .output() + .unwrap() + .stdout; + let snapshot = h.preservation_snapshot(); + let state = child_state(&h, &h.work_dir); + let remote = value( + &h, + &h.work_dir, + &["-f", ".gitmodules"], + "submodule.logical.name.url", + ) + .unwrap(); + let args = match operation { + "add" => vec![ + "add", + &remote, + "--name", + "another", + "--path", + "vendor/another", + ], + "move" => vec!["change", "alias", "--path", "vendor/moved"], + "delete" => vec!["delete", "alias"], + _ => unreachable!(), + }; + let output = h.run_submod(&args).unwrap(); + assert!( + !output.status.success(), + "structural edit must refuse unresolved .gitmodules layers: {output:?}" + ); + let diagnostic = format!( + "{}{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + assert!( + diagnostic.contains(".gitmodules"), + "refusal must identify the conflicting metadata layers: {diagnostic}" + ); + assert_eq!( + h.preservation_snapshot(), + snapshot, + "refusal changed TOML, working .gitmodules, local config, refs or index entries" + ); + assert_eq!( + h.git_cmd() + .current_dir(&h.work_dir) + .args(["show", ":.gitmodules"]) + .output() + .unwrap() + .stdout, + index_blob + ); + assert_eq!(child_state(&h, &h.work_dir), state); + assert!(!h.work_dir.join("vendor/another").exists()); + assert!(!h.work_dir.join("vendor/moved").exists()); +} + +#[test] +fn r31_add_refuses_two_gitmodules_layers() { + refuse_structural_edit_with_two_layers("add"); +} +#[test] +fn r31_move_refuses_two_gitmodules_layers() { + refuse_structural_edit_with_two_layers("move"); +} +#[test] +fn r31_delete_refuses_two_gitmodules_layers() { + refuse_structural_edit_with_two_layers("delete"); +} + +fn linked_metadata_fixture(h: &TestHarness) -> std::path::PathBuf { + h.git_stdout(&["config", "extensions.worktreeConfig", "true"]); + let linked = h.temp_dir.path().join("review-linked"); + h.git_stdout(&[ + "worktree", + "add", + "-b", + "review-linked", + linked.to_str().unwrap(), + ]); + h.git_at(&linked, &["submodule", "update", "--init", "--", CHILD]); + h.git_at( + &linked, + &[ + "config", + "--worktree", + "submodule.logical.name.custom", + "worktree-keep", + ], + ); + linked +} + +#[test] +fn r14_relative_url_remains_portable_and_resolves_selected_child_without_fetch() { + let h = fixture(); + let parent_remote = h.temp_dir.path().join("remotes/parent.git"); + h.git_stdout(&["remote", "add", "origin", parent_remote.to_str().unwrap()]); + let linked = linked_metadata_fixture(&h); + let expected = h.temp_dir.path().join("remotes/replacement.git"); + let state = child_state(&h, &linked); + let main_state = child_state(&h, &h.work_dir); + let main_url = value(&h, &h.work_dir.join(CHILD), &[], "remote.origin.url"); + let index = h.git_at(&linked, &["ls-files", "--stage"]); + let parent_head = h.git_at(&linked, &["rev-parse", "HEAD"]); + let parent_refs = h.git_at(&linked, &["show-ref"]); + ok( + h.run_submod_at(&linked, &["change", "alias", "--url", "../replacement.git"]) + .unwrap(), + ); + assert_eq!( + value( + &h, + &linked, + &["-f", ".gitmodules"], + "submodule.logical.name.url" + ) + .as_deref(), + Some("../replacement.git") + ); + let config: toml::Value = + toml::from_str(&fs::read_to_string(linked.join("submod.toml")).unwrap()).unwrap(); + assert_eq!(config["alias"]["url"].as_str(), Some("../replacement.git")); + for scope in [&["--local"][..], &[][..]] { + assert_eq!( + value(&h, &linked, scope, "submodule.logical.name.url").as_deref(), + Some(expected.to_str().unwrap()) + ); + } + assert_eq!( + value(&h, &linked.join(CHILD), &[], "remote.origin.url").as_deref(), + Some(expected.to_str().unwrap()) + ); + assert_eq!( + value(&h, &h.work_dir.join(CHILD), &[], "remote.origin.url"), + main_url + ); + assert_eq!(child_state(&h, &linked), state); + assert_eq!(child_state(&h, &h.work_dir), main_state); + assert_eq!(h.git_at(&linked, &["ls-files", "--stage"]), index); + assert_eq!(h.git_at(&linked, &["rev-parse", "HEAD"]), parent_head); + assert_eq!(h.git_at(&linked, &["show-ref"]), parent_refs); +} + +fn held_metadata_lock_refuses_before_mutation(parent_worktree_lock: bool) { + let h = fixture(); + let linked = linked_metadata_fixture(&h); + let parent_gitdir = + std::path::PathBuf::from(h.git_at(&linked, &["rev-parse", "--absolute-git-dir"])); + let parent_common = std::path::PathBuf::from(h.git_at( + &linked, + &["rev-parse", "--path-format=absolute", "--git-common-dir"], + )); + let child_common = std::path::PathBuf::from(h.git_at( + &linked.join(CHILD), + &["rev-parse", "--path-format=absolute", "--git-common-dir"], + )); + let lock = if parent_worktree_lock { + parent_gitdir.join("config.worktree.lock") + } else { + child_common.join("config.lock") + }; + fs::write(&lock, b"held by another Git writer\n").unwrap(); + let files = [ + linked.join("submod.toml"), + linked.join(".gitmodules"), + parent_common.join("config"), + parent_gitdir.join("config.worktree"), + child_common.join("config"), + parent_gitdir.join("index"), + ]; + let before: Vec<_> = files.iter().map(|p| fs::read(p).unwrap()).collect(); + let child_before = child_state(&h, &linked); + let main_before = child_state(&h, &h.work_dir); + let parent_refs = h.git_at(&linked, &["show-ref"]); + let output = h + .run_submod_at( + &linked, + &[ + "change", + "alias", + "--url", + "/unreachable/replacement", + "--ignore", + "dirty", + ], + ) + .unwrap(); + assert!( + !output.status.success(), + "held Git lock must refuse before mutation: {output:?}" + ); + let diagnostic = format!( + "{}{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + assert!( + diagnostic.contains("lock"), + "missing lock diagnostic: {diagnostic}" + ); + for (path, bytes) in files.iter().zip(before) { + assert_eq!( + fs::read(path).unwrap(), + bytes, + "modified {} despite held {}", + path.display(), + lock.display() + ); + } + assert_eq!(fs::read(&lock).unwrap(), b"held by another Git writer\n"); + assert_eq!(child_state(&h, &linked), child_before); + assert_eq!(child_state(&h, &h.work_dir), main_before); + assert_eq!(h.git_at(&linked, &["show-ref"]), parent_refs); +} + +#[test] +fn r14_parent_worktree_config_lock_refuses_before_mutation() { + held_metadata_lock_refuses_before_mutation(true); +} + +#[test] +fn r14_selected_child_common_config_lock_refuses_before_mutation() { + held_metadata_lock_refuses_before_mutation(false); +} + +#[test] +fn r14_child_branch_upstream_remote_selected_and_origin_preserved() { + let h = fixture(); + let child = h.work_dir.join(CHILD); + h.git_at(&child, &["checkout", "-b", "selected"]); + let old_url = value(&h, &child, &[], "remote.origin.url").unwrap(); + h.git_at(&child, &["remote", "add", "upstream", &old_url]); + h.git_at(&child, &["config", "branch.selected.remote", "upstream"]); + h.git_at( + &child, + &[ + "remote", + "set-url", + "origin", + "/deliberately/different-origin", + ], + ); + let expected = h.temp_dir.path().join("unreachable-upstream"); + let state = child_state(&h, &h.work_dir); + let index = h.git_stdout(&["ls-files", "--stage"]); + ok( + h.run_submod(&["change", "alias", "--url", expected.to_str().unwrap()]) + .unwrap(), + ); + assert_eq!( + value(&h, &child, &[], "remote.upstream.url").as_deref(), + Some(expected.to_str().unwrap()) + ); + assert_eq!( + value(&h, &child, &[], "remote.origin.url").as_deref(), + Some("/deliberately/different-origin") + ); + assert_eq!( + value(&h, &child, &[], "branch.selected.remote").as_deref(), + Some("upstream") + ); + assert_eq!( + value(&h, &h.work_dir, &["--local"], "submodule.logical.name.url").as_deref(), + Some(expected.to_str().unwrap()) + ); + assert_eq!(child_state(&h, &h.work_dir), state); + assert_eq!(h.git_stdout(&["ls-files", "--stage"]), index); +} + +#[test] +fn r14_relative_parent_remote_resolves_distinct_nested_child_url() { + let h = fixture(); + let child = h.work_dir.join(CHILD); + h.git_stdout(&["remote", "add", "origin", "../remotes/parent.git"]); + // Ask native Git for the platform's path-relative URLs, then restore all + // oracle writes before exercising Submod from the original state. + let child_common = h.git_at( + &child, + &["rev-parse", "--path-format=absolute", "--git-common-dir"], + ); + let files = [ + h.work_dir.join(".gitmodules"), + h.work_dir.join(".git/config"), + Path::new(&child_common).join("config"), + ]; + let original: Vec<_> = files.iter().map(|p| fs::read(p).unwrap()).collect(); + h.git_stdout(&[ + "config", + "-f", + ".gitmodules", + "submodule.logical.name.url", + "../replacement.git", + ]); + h.git_stdout(&["submodule", "sync", "--", CHILD]); + let expected_parent = + value(&h, &h.work_dir, &["--local"], "submodule.logical.name.url").unwrap(); + let expected_child = value(&h, &child, &[], "remote.origin.url").unwrap(); + assert_ne!( + expected_parent, expected_child, + "fixture must exercise distinct relative URL bases" + ); + for (path, bytes) in files.iter().zip(original) { + fs::write(path, bytes).unwrap(); + } + let state = child_state(&h, &h.work_dir); + let index = h.git_stdout(&["ls-files", "--stage"]); + ok( + h.run_submod(&["change", "alias", "--url", "../replacement.git"]) + .unwrap(), + ); + assert_eq!( + value( + &h, + &h.work_dir, + &["-f", ".gitmodules"], + "submodule.logical.name.url" + ) + .as_deref(), + Some("../replacement.git") + ); + for scope in [&["--local"][..], &[][..]] { + assert_eq!( + value(&h, &h.work_dir, scope, "submodule.logical.name.url").as_deref(), + Some(expected_parent.as_str()) + ); + } + assert_eq!( + value(&h, &child, &[], "remote.origin.url").as_deref(), + Some(expected_child.as_str()) + ); + assert_eq!(child_state(&h, &h.work_dir), state); + assert_eq!(h.git_stdout(&["ls-files", "--stage"]), index); +} diff --git a/tests/reconciliation_sparse_tests.rs b/tests/reconciliation_sparse_tests.rs new file mode 100644 index 00000000..b3472fd1 --- /dev/null +++ b/tests/reconciliation_sparse_tests.rs @@ -0,0 +1,287 @@ +// SPDX-License-Identifier: LicenseRef-PlainMIT OR MIT +//! R15: real Git sparse policy reconciliation and preservation. +mod common; +use common::TestHarness; +use std::{fs, path::PathBuf, process::Output}; + +fn git(h: &TestHarness, args: &[&str]) -> String { + let out = h + .git_cmd() + .current_dir(h.work_dir.join("m")) + .args(args) + .output() + .unwrap(); + assert!( + out.status.success(), + "git {args:?}: {}", + String::from_utf8_lossy(&out.stderr) + ); + String::from_utf8(out.stdout).unwrap().trim().to_owned() +} +fn success(out: Output) { + assert!( + out.status.success(), + "stdout={} stderr={}", + String::from_utf8_lossy(&out.stdout), + String::from_utf8_lossy(&out.stderr) + ); +} +fn policy(h: &TestHarness, patterns: Option<&[&str]>, native: bool) { + let url = git(h, &["remote", "get-url", "origin"]); + let mut doc = + format!("[m]\nurl = {url:?}\npath = \"m\"\nuse_git_default_sparse_checkout = {native}\n"); + if let Some(patterns) = patterns { + doc.push_str(&format!("sparse_paths = {patterns:?}\n")); + } + fs::write(h.config_path(), doc).unwrap(); +} +fn fixture() -> TestHarness { + let h = TestHarness::new().unwrap(); + h.init_git_repo().unwrap(); + let remote = h.create_complex_remote("r15").unwrap(); + h.run_submod_success(&[ + "add", + &format!("file://{}", remote.display()), + "--name", + "m", + "--path", + "m", + ]) + .unwrap(); + let out = h + .git_cmd() + .current_dir(&h.work_dir) + .args(["commit", "-am", "record module"]) + .output() + .unwrap(); + success(out); + policy(&h, Some(&["/src/", "/docs/"]), false); + git( + &h, + &[ + "sparse-checkout", + "set", + "--no-cone", + "!/*", + "/src/", + "/docs/", + ], + ); + h +} +fn exact(h: &TestHarness, patterns: &str) { + assert_eq!(git(h, &["config", "--bool", "core.sparseCheckout"]), "true"); + assert_eq!( + git(h, &["config", "--bool", "core.sparseCheckoutCone"]), + "false" + ); + assert_eq!( + fs::read_to_string(h.get_sparse_checkout_file_path("m")).unwrap(), + patterns + ); +} +fn snapshot(h: &TestHarness) -> Vec<(PathBuf, Option>)> { + let dir = PathBuf::from(git(h, &["rev-parse", "--absolute-git-dir"])); + let paths = [ + dir.join("config"), + dir.join("config.worktree"), + dir.join("index"), + dir.join("info/sparse-checkout"), + h.config_path(), + h.work_dir.join(".git/index"), + h.work_dir.join("m/src/lib.rs"), + h.work_dir.join("m/docs/API.md"), + ]; + paths + .into_iter() + .map(|p| { + let bytes = fs::read(&p).ok(); + (p, bytes) + }) + .collect() +} + +#[test] +fn r15_ordered_globs_and_negation_materialize_exact_files() { + let h = fixture(); + policy( + &h, + Some(&[ + "/*", + "!/docs/*", + "!/tests/*", + "!/examples/*", + "!/src/*.rs", + "/src/lib.rs", + ]), + false, + ); + success(h.run_submod(&["sync"]).unwrap()); + exact( + &h, + "!/*\n/*\n!/docs/*\n!/tests/*\n!/examples/*\n!/src/*.rs\n/src/lib.rs\n", + ); + assert!(h.work_dir.join("m/src/lib.rs").is_file()); + assert!(h.work_dir.join("m/README.md").is_file()); + for path in ["docs/API.md", "tests/test.rs", "examples/basic.rs"] { + assert!(!h.work_dir.join("m").join(path).exists(), "{path}"); + } +} +fn drift(kind: &str) { + let h = fixture(); + match kind { + "extra" => { + fs::write( + h.get_sparse_checkout_file_path("m"), + "!/*\n/src/\n/docs/\n/tests/\n", + ) + .unwrap(); + } + "order" => { + fs::write(h.get_sparse_checkout_file_path("m"), "!/*\n/docs/\n/src/\n").unwrap(); + } + "enabled" => { + git(&h, &["sparse-checkout", "disable"]); + } + "cone" => { + git(&h, &["sparse-checkout", "set", "--cone", "src", "docs"]); + } + _ => unreachable!(), + } + let before = snapshot(&h); + assert_eq!( + h.run_submod(&["check"]).unwrap().status.code(), + Some(1), + "{kind} drift must fail check" + ); + assert_eq!(snapshot(&h), before, "check mutated {kind} drift"); + success(h.run_submod(&["sync"]).unwrap()); + exact(&h, "!/*\n/src/\n/docs/\n"); + assert!(h.work_dir.join("m/src/lib.rs").is_file()); + assert!(!h.work_dir.join("m/tests/test.rs").exists()); +} +#[test] +fn r15_extra_patterns_drift() { + drift("extra"); +} +#[test] +fn r15_reordered_patterns_drift() { + drift("order"); +} +#[test] +fn r15_disabled_sparse_drift() { + drift("enabled"); +} +#[test] +fn r15_cone_mode_drift() { + drift("cone"); +} +#[test] +fn r15_app_default_mode_change_removes_automatic_prefix() { + let h = fixture(); + policy(&h, Some(&["/src/", "/docs/"]), true); + success(h.run_submod(&["sync"]).unwrap()); + exact(&h, "/src/\n/docs/\n"); +} +fn disable(empty: bool) { + let h = fixture(); + policy(&h, if empty { Some(&[]) } else { None }, false); + success(h.run_submod(&["sync"]).unwrap()); + assert_eq!( + git(&h, &["config", "--bool", "core.sparseCheckout"]), + "false" + ); + for path in [ + "src/lib.rs", + "docs/API.md", + "tests/test.rs", + "examples/basic.rs", + "README.md", + "Cargo.toml", + ] { + let expected = git(&h, &["show", &format!("HEAD:{path}")]); + assert_eq!( + fs::read_to_string(h.work_dir.join("m").join(path)) + .unwrap() + .trim(), + expected + ); + } +} +#[test] +fn r15_removed_patterns_restore_full_checkout() { + disable(false); +} +#[test] +fn r15_empty_patterns_restore_full_checkout() { + disable(true); +} +#[test] +fn r15_unchanged_sync_preserves_bytes_and_skips_sparse_commands() { + let h = fixture(); + // Settle unrelated metadata once; the second sync is the no-op under test. + success(h.run_submod(&["sync"]).unwrap()); + let before = snapshot(&h); + let trace = h.temp_dir.path().join("trace.json"); + let out = std::process::Command::new(&h.submod_bin) + .arg("sync") + .current_dir(&h.work_dir) + .env("GIT_CONFIG_GLOBAL", h.temp_dir.path().join("gitconfig")) + .env("GIT_CONFIG_NOSYSTEM", "1") + .env("GIT_TRACE", &trace) + .output() + .unwrap(); + success(out); + assert_eq!(snapshot(&h), before); + let trace = fs::read_to_string(trace).unwrap(); + for line in trace.lines() { + assert!(!line.contains(" read-tree "), "checkout reapplied: {line}"); + for operation in ["set", "reapply", "init", "disable"] { + assert!( + !line.contains(&format!(" sparse-checkout {operation}")), + "sparse reapplied: {line}" + ); + } + } +} +fn dirty(excluded: bool) { + let h = fixture(); + let path = if excluded { + "tests/test.rs" + } else { + "src/lib.rs" + }; + let file = h.work_dir.join("m").join(path); + fs::create_dir_all(file.parent().unwrap()).unwrap(); + fs::write(&file, b"local edits must survive\n").unwrap(); + policy(&h, if excluded { None } else { Some(&["/docs/"]) }, false); + let before = snapshot(&h); + let out = h.run_submod(&["sync"]).unwrap(); + assert_eq!(out.status.code(), Some(1)); + let diagnostic = format!( + "{}{}", + String::from_utf8_lossy(&out.stdout), + String::from_utf8_lossy(&out.stderr) + ) + .to_lowercase(); + assert!( + diagnostic.contains("dirty") + || diagnostic.contains("local changes") + || diagnostic.contains("overwrite"), + "wrong refusal: {diagnostic}" + ); + assert_eq!(fs::read(file).unwrap(), b"local edits must survive\n"); + assert_eq!( + snapshot(&h), + before, + "refused sparse edit changed repository state" + ); +} +#[test] +fn r15_dirty_excluded_path_refuses_disable_without_overwrite() { + dirty(true); +} +#[test] +fn r15_dirty_included_path_refuses_exclusion_without_overwrite() { + dirty(false); +} diff --git a/tests/security_tests.rs b/tests/security_tests.rs index ad375b8b..038cc45c 100644 --- a/tests/security_tests.rs +++ b/tests/security_tests.rs @@ -273,3 +273,479 @@ mod tests { ); } } + +#[test] +fn regression_r01_config_root_target_rejected_before_writes() { + for target in ["", ".", ".git", ".."] { + let h = common::TestHarness::new().unwrap(); + h.init_git_repo().unwrap(); + let remote = h.create_test_remote("unsafe").unwrap(); + h.create_config(&format!( + "[unsafe]\npath = {target:?}\nurl = {:?}\n", + remote.to_str().unwrap() + )) + .unwrap(); + let before = h.preservation_snapshot(); + let sentinel = std::fs::read(h.work_dir.join("README.md")).unwrap(); + let output = h.run_submod(&["init"]).unwrap(); + assert_eq!( + std::fs::read(h.work_dir.join("README.md")).ok(), + Some(sentinel), + "target {target:?}: {output:?}" + ); + assert_eq!( + h.preservation_snapshot(), + before, + "target {target:?}: {output:?}" + ); + assert!( + !output.status.success(), + "unsafe target {target:?} accepted: {output:?}" + ); + } +} + +fn phase2_unsafe_target_case(target: &str, command: &[&str]) { + let h = common::TestHarness::new().unwrap(); + h.init_git_repo().unwrap(); + let remote = h.create_test_remote("boundary").unwrap(); + let outside = h.temp_dir.path().join("outside"); + std::fs::create_dir(&outside).unwrap(); + std::fs::write(outside.join("sentinel"), b"outside\0\xff").unwrap(); + let target = if target == "ABSOLUTE" { + outside.to_str().unwrap() + } else { + target + }; + h.create_config(&format!( + "[unsafe]\npath = {target:?}\nurl = {:?}\n", + remote.to_str().unwrap() + )) + .unwrap(); + let before = h.preservation_snapshot(); + let root = std::fs::read(h.work_dir.join("README.md")).unwrap(); + let output = h.run_submod(command).unwrap(); + assert_eq!( + std::fs::read(outside.join("sentinel")).unwrap(), + b"outside\0\xff", + "{output:?}" + ); + assert_eq!( + std::fs::read(h.work_dir.join("README.md")).ok(), + Some(root), + "{output:?}" + ); + assert_eq!(h.preservation_snapshot(), before, "{output:?}"); + assert!( + !output.status.success(), + "unsafe {target:?} accepted: {output:?}" + ); +} + +macro_rules! phase2_unsafe_target { + ($name:ident, $target:expr, $args:expr) => { + #[test] + fn $name() { + phase2_unsafe_target_case($target, $args); + } + }; +} +phase2_unsafe_target!(phase2_r01_dot_init, ".", &["init"]); +phase2_unsafe_target!(phase2_r01_git_init, ".git", &["init"]); +phase2_unsafe_target!(phase2_r01_parent_init, "../outside", &["init"]); +phase2_unsafe_target!(phase2_r01_absolute_init, "ABSOLUTE", &["init"]); +phase2_unsafe_target!(phase2_r01_dot_delete, ".", &["delete", "unsafe"]); +phase2_unsafe_target!(phase2_r01_git_delete, ".git", &["delete", "unsafe"]); +phase2_unsafe_target!(phase2_r01_absolute_reset, "ABSOLUTE", &["reset", "unsafe"]); + +#[test] +fn phase2_r01_cli_administrative_name_preserves_metadata() { + let h = common::TestHarness::new().unwrap(); + h.init_git_repo().unwrap(); + h.create_config("[defaults]\n").unwrap(); + let remote = h.create_test_remote("admin-name").unwrap(); + let before = h.preservation_snapshot(); + let output = h + .run_submod(&[ + "add", + remote.to_str().unwrap(), + "--name", + "../../config", + "--path", + "safe", + ]) + .unwrap(); + assert_eq!(h.preservation_snapshot(), before, "{output:?}"); + assert!(!h.work_dir.join("safe").exists()); + assert!(!output.status.success(), "{output:?}"); +} + +#[cfg(unix)] +#[test] +fn phase2_r01_config_symlink_ancestor_preserves_outside() { + let h = common::TestHarness::new().unwrap(); + h.init_git_repo().unwrap(); + let remote = h.create_test_remote("symlink-config").unwrap(); + let outside = h.temp_dir.path().join("outside"); + std::fs::create_dir(&outside).unwrap(); + std::fs::write(outside.join("sentinel"), b"outside\0\xff").unwrap(); + std::os::unix::fs::symlink(&outside, h.work_dir.join("escape")).unwrap(); + h.create_config(&format!( + "[unsafe]\npath = \"escape/child\"\nurl = {:?}\n", + remote.to_str().unwrap() + )) + .unwrap(); + let before = h.preservation_snapshot(); + let output = h.run_submod(&["init"]).unwrap(); + assert_eq!( + std::fs::read(outside.join("sentinel")).unwrap(), + b"outside\0\xff" + ); + assert!(!outside.join("child").exists(), "{output:?}"); + assert_eq!(h.preservation_snapshot(), before, "{output:?}"); + assert!(!output.status.success(), "{output:?}"); +} + +#[test] +fn phase2_r01_redirected_child_gitfile_preserves_unrelated_repository() { + let h = common::TestHarness::new().unwrap(); + h.init_git_repo().unwrap(); + let other = common::TestHarness::new().unwrap(); + other.init_git_repo().unwrap(); + let remote = h.create_test_remote("redirect").unwrap(); + h.git_stdout(&[ + "submodule", + "add", + "--name", + "logical", + remote.to_str().unwrap(), + "child", + ]); + h.create_config(&format!( + "[alias]\npath = \"child\"\nurl = {:?}\n", + remote.to_str().unwrap() + )) + .unwrap(); + other.git_stdout(&["config", "core.worktree", other.work_dir.to_str().unwrap()]); + let gitdir = other.git_stdout(&["rev-parse", "--absolute-git-dir"]); + let pointer = format!("gitdir: {gitdir}\n"); + std::fs::write(h.work_dir.join("child/.git"), &pointer).unwrap(); + std::fs::write(h.work_dir.join("child/LICENSE"), b"intended child\0\xff").unwrap(); + std::fs::write(other.work_dir.join("README.md"), b"unrelated dirty\0\xff").unwrap(); + let before = h.preservation_snapshot(); + let other_before = other.preservation_snapshot(); + let output = h.run_submod(&["reset", "alias"]).unwrap(); + assert_eq!( + std::fs::read(other.work_dir.join("README.md")).unwrap(), + b"unrelated dirty\0\xff", + "{output:?}" + ); + assert_eq!( + std::fs::read(h.work_dir.join("child/LICENSE")).unwrap(), + b"intended child\0\xff" + ); + assert_eq!( + std::fs::read_to_string(h.work_dir.join("child/.git")).unwrap(), + pointer + ); + assert_eq!(other.preservation_snapshot(), other_before, "{output:?}"); + assert_eq!(h.preservation_snapshot(), before, "{output:?}"); + assert!( + !output.status.success(), + "redirected child accepted: {output:?}" + ); +} + +#[test] +fn phase2_r01_change_path_rejects_parent_escape_before_writes() { + let h = common::TestHarness::new().unwrap(); + h.init_git_repo().unwrap(); + let remote = h.create_test_remote("move-boundary").unwrap(); + h.git_stdout(&[ + "submodule", + "add", + "--name", + "logical", + remote.to_str().unwrap(), + "child", + ]); + h.create_config(&format!( + "[alias]\npath = \"child\"\nurl = {:?}\n", + remote.to_str().unwrap() + )) + .unwrap(); + let child = h.work_dir.join("child"); + let refs = h.git_at(&child, &["show-ref"]); + let gitdir = h.git_at(&child, &["rev-parse", "--absolute-git-dir"]); + let before = h.preservation_snapshot(); + let outside = h.temp_dir.path().join("outside"); + std::fs::create_dir(&outside).unwrap(); + std::fs::write(outside.join("sentinel"), b"outside\0\xff").unwrap(); + let output = h + .run_submod(&["change", "alias", "--path", "../outside/moved"]) + .unwrap(); + assert_eq!( + std::fs::read(outside.join("sentinel")).unwrap(), + b"outside\0\xff" + ); + assert!(!outside.join("moved").exists(), "{output:?}"); + assert_eq!(h.preservation_snapshot(), before, "{output:?}"); + assert_eq!(h.git_at(&child, &["show-ref"]), refs); + assert_eq!( + h.git_at(&child, &["rev-parse", "--absolute-git-dir"]), + gitdir + ); + assert!(!output.status.success(), "{output:?}"); +} + +#[test] +fn phase2_r01_cli_root_and_admin_paths_rejected() { + for target in ["", ".", "./", ".git", ".git/objects", "../outside"] { + let h = common::TestHarness::new().unwrap(); + h.init_git_repo().unwrap(); + h.create_config("[defaults]\n").unwrap(); + let remote = h.create_test_remote("cli-boundary").unwrap(); + let before = h.preservation_snapshot(); + let root = std::fs::read(h.work_dir.join("README.md")).unwrap(); + let output = h + .run_submod(&[ + "add", + remote.to_str().unwrap(), + "--name", + "alias", + "--path", + target, + ]) + .unwrap(); + assert_eq!( + std::fs::read(h.work_dir.join("README.md")).ok(), + Some(root), + "target {target:?}: {output:?}" + ); + assert_eq!( + h.preservation_snapshot(), + before, + "target {target:?}: {output:?}" + ); + assert!(!h.temp_dir.path().join("outside").exists(), "{output:?}"); + assert!(!output.status.success(), "target {target:?}: {output:?}"); + } +} + +#[cfg(unix)] +#[test] +fn phase2_r01_add_refuses_symlinked_storage_ancestor() { + let h = common::TestHarness::new().unwrap(); + h.init_git_repo().unwrap(); + h.create_config("[defaults]\n").unwrap(); + let remote = h.create_test_remote("storage-ancestor").unwrap(); + let outside = h.temp_dir.path().join("outside-storage"); + std::fs::create_dir(&outside).unwrap(); + let gitdir = outside.join("retained"); + let worktree = outside.join("separate"); + h.git_stdout(&[ + "clone", + "--separate-git-dir", + gitdir.to_str().unwrap(), + remote.to_str().unwrap(), + worktree.to_str().unwrap(), + ]); + h.git_at( + &worktree, + &["config", "core.worktree", worktree.to_str().unwrap()], + ); + h.git_at(&worktree, &["branch", "outside-history"]); + std::fs::write(worktree.join("sentinel"), b"outside sentinel\0\xff").unwrap(); + assert_eq!( + h.git_at(&worktree, &["remote", "get-url", "origin"]), + remote.to_str().unwrap() + ); + std::fs::create_dir_all(h.work_dir.join(".git/modules")).unwrap(); + std::os::unix::fs::symlink(&outside, h.work_dir.join(".git/modules/nested")).unwrap(); + let before = h.preservation_snapshot(); + let parent_index = std::fs::read(h.work_dir.join(".git/index")).unwrap(); + let head = h.git_at(&worktree, &["rev-parse", "HEAD"]); + let refs = h.git_at(&worktree, &["show-ref"]); + let index = h.git_at(&worktree, &["ls-files", "--stage"]); + let admin_bytes: Vec<_> = ["HEAD", "config", "index", "refs/heads/outside-history"] + .into_iter() + .map(|path| (path, std::fs::read(gitdir.join(path)).unwrap())) + .collect(); + let paths = h.git_at(&worktree, &["ls-files", "-z"]); + let files: Vec<_> = paths + .split('\0') + .filter(|path| !path.is_empty()) + .chain([".git", "sentinel"]) + .map(|path| (path.to_owned(), std::fs::read(worktree.join(path)).unwrap())) + .collect(); + + let output = h + .run_submod(&[ + "add", + remote.to_str().unwrap(), + "--name", + "nested/retained", + "--path", + "child", + ]) + .unwrap(); + + assert!( + !output.status.success(), + "storage escape accepted: {output:?}" + ); + assert_eq!(h.preservation_snapshot(), before, "{output:?}"); + assert_eq!( + std::fs::read(h.work_dir.join(".git/index")).unwrap(), + parent_index + ); + assert!(!h.work_dir.join("child").exists()); + assert_eq!(h.git_at(&worktree, &["rev-parse", "HEAD"]), head); + assert_eq!(h.git_at(&worktree, &["show-ref"]), refs); + assert_eq!(h.git_at(&worktree, &["ls-files", "--stage"]), index); + for (path, bytes) in admin_bytes { + assert_eq!( + std::fs::read(gitdir.join(path)).unwrap(), + bytes, + "outside gitdir changed: {path}" + ); + } + for (path, bytes) in files { + assert_eq!( + std::fs::read(worktree.join(&path)).unwrap(), + bytes, + "outside checkout changed: {path}" + ); + } +} + +#[test] +fn phase2_r01_init_accepts_valid_external_child_gitdir() { + let h = common::TestHarness::new().unwrap(); + h.init_git_repo().unwrap(); + let remote = h.create_test_remote("valid-external-gitdir").unwrap(); + let gitdir = h.temp_dir.path().join("external-child.git"); + let child = h.work_dir.join("child"); + h.git_stdout(&[ + "init", + "--separate-git-dir", + gitdir.to_str().unwrap(), + child.to_str().unwrap(), + ]); + h.git_at( + &child, + &["config", "core.worktree", child.to_str().unwrap()], + ); + h.git_at( + &child, + &["remote", "add", "origin", remote.to_str().unwrap()], + ); + h.git_at(&child, &["fetch", "origin"]); + h.git_at(&child, &["checkout", "--detach", "origin/main"]); + h.git_at(&child, &["branch", "retained-history"]); + h.git_stdout(&[ + "config", + "-f", + ".gitmodules", + "submodule.logical.path", + "child", + ]); + h.git_stdout(&[ + "config", + "-f", + ".gitmodules", + "submodule.logical.url", + remote.to_str().unwrap(), + ]); + h.git_stdout(&["config", "submodule.logical.url", remote.to_str().unwrap()]); + h.git_stdout(&["config", "submodule.logical.active", "true"]); + h.create_config(&format!( + "[logical]\npath = \"child\"\nurl = {:?}\n", + remote.to_str().unwrap() + )) + .unwrap(); + h.git_stdout(&["add", ".gitmodules", "submod.toml", "child"]); + h.git_stdout(&["commit", "-m", "Record valid external child gitdir"]); + let before = h.preservation_snapshot(); + let parent_index = std::fs::read(h.work_dir.join(".git/index")).unwrap(); + let head = h.git_at(&child, &["rev-parse", "HEAD"]); + let refs = h.git_at(&child, &["show-ref"]); + let actual_gitdir = h.git_at(&child, &["rev-parse", "--absolute-git-dir"]); + assert_eq!( + std::fs::canonicalize(&actual_gitdir).unwrap(), + std::fs::canonicalize(&gitdir).unwrap() + ); + let config = std::fs::read(gitdir.join("config")).unwrap(); + let paths = h.git_at(&child, &["ls-files", "-z"]); + let files: Vec<_> = paths + .split('\0') + .filter(|path| !path.is_empty()) + .chain(std::iter::once(".git")) + .map(|path| (path.to_owned(), std::fs::read(child.join(path)).unwrap())) + .collect(); + + h.run_submod_success(&["init"]).unwrap(); + + assert_eq!(h.preservation_snapshot(), before); + assert_eq!( + std::fs::read(h.work_dir.join(".git/index")).unwrap(), + parent_index + ); + assert_eq!( + h.git_at(&child, &["rev-parse", "--absolute-git-dir"]), + actual_gitdir + ); + assert_eq!(h.git_at(&child, &["rev-parse", "HEAD"]), head); + assert_eq!(h.git_at(&child, &["show-ref"]), refs); + assert_eq!(std::fs::read(gitdir.join("config")).unwrap(), config); + for (path, bytes) in files { + assert_eq!( + std::fs::read(child.join(&path)).unwrap(), + bytes, + "intended checkout changed: {path}" + ); + } +} + +#[cfg(unix)] +#[test] +fn phase2_r01_init_accepts_gitfile_target_symlink_to_valid_gitdir() { + use std::os::unix::fs::symlink; + + let h = common::TestHarness::new().unwrap(); + h.init_git_repo().unwrap(); + let remote = h.create_test_remote("gitfile-target-link").unwrap(); + h.git_stdout(&[ + "submodule", + "add", + "--name", + "logical", + remote.to_str().unwrap(), + "child", + ]); + h.create_config(&format!( + "[logical]\npath = \"child\"\nurl = {:?}\n", + remote.to_str().unwrap() + )) + .unwrap(); + let child = h.work_dir.join("child"); + let actual_gitdir = h.git_at(&child, &["rev-parse", "--absolute-git-dir"]); + let linked_gitdir = h.temp_dir.path().join("linked-child-gitdir"); + symlink(&actual_gitdir, &linked_gitdir).unwrap(); + let pointer = format!("gitdir: {}\n", linked_gitdir.display()); + fs::write(child.join(".git"), pointer.as_bytes()).unwrap(); + assert_eq!( + fs::canonicalize(h.git_at(&child, &["rev-parse", "--absolute-git-dir"])).unwrap(), + fs::canonicalize(&actual_gitdir).unwrap() + ); + let before = h.preservation_snapshot(); + let child_head = h.git_at(&child, &["rev-parse", "HEAD"]); + let child_refs = h.git_at(&child, &["show-ref"]); + + h.run_submod_success(&["init"]).unwrap(); + + assert_eq!(h.preservation_snapshot(), before); + assert_eq!(fs::read_to_string(child.join(".git")).unwrap(), pointer); + assert_eq!(h.git_at(&child, &["rev-parse", "HEAD"]), child_head); + assert_eq!(h.git_at(&child, &["show-ref"]), child_refs); +} diff --git a/tests/sparse_checkout_tests.rs b/tests/sparse_checkout_tests.rs index 06a62988..cf08302c 100644 --- a/tests/sparse_checkout_tests.rs +++ b/tests/sparse_checkout_tests.rs @@ -141,13 +141,43 @@ mod tests { fs::write(&sparse_file, "tests\nexamples\n").expect("Failed to modify sparse file"); // Run check command to detect mismatch - let stdout = harness - .run_submod_success(&["check"]) - .expect("Failed to run check"); + let before = harness.preservation_snapshot(); + let child_before = + harness.git_stdout(&["-C", "lib/sparse-mismatch", "status", "--porcelain=v1"]); + let child_index_before = + harness.git_stdout(&["-C", "lib/sparse-mismatch", "ls-files", "--stage"]); + let child_head_before = + harness.git_stdout(&["-C", "lib/sparse-mismatch", "rev-parse", "HEAD"]); + let child_config_before = + harness.git_stdout(&["-C", "lib/sparse-mismatch", "config", "--local", "--list"]); + let output = harness.run_submod(&["check"]).expect("Failed to run check"); + assert_eq!(output.status.code(), Some(1), "{output:?}"); + assert_eq!(harness.preservation_snapshot(), before); + assert_eq!( + harness.git_stdout(&["-C", "lib/sparse-mismatch", "status", "--porcelain=v1"]), + child_before + ); + assert_eq!( + harness.git_stdout(&["-C", "lib/sparse-mismatch", "ls-files", "--stage"]), + child_index_before + ); + assert_eq!( + harness.git_stdout(&["-C", "lib/sparse-mismatch", "rev-parse", "HEAD"]), + child_head_before + ); + assert_eq!( + harness.git_stdout(&["-C", "lib/sparse-mismatch", "config", "--local", "--list"]), + child_config_before + ); + let stdout = String::from_utf8_lossy(&output.stdout); - assert!(stdout.contains("Sparse checkout mismatch")); - assert!(stdout.contains("Expected:")); - assert!(stdout.contains("Current:")); + assert!(stdout.contains("sparse-mismatch: drift: sparse patterns differ")); + assert_eq!( + fs::read_to_string(&sparse_file).unwrap(), + "tests\nexamples\n" + ); + assert!(stdout.contains("expected [\"!/*\", \"src\", \"docs\"]")); + assert!(stdout.contains(r#"current ["tests", "examples"]"#)); } #[test] @@ -191,11 +221,65 @@ sparse_paths = ["src", "docs"] fs::remove_file(&sparse_file).expect("Failed to remove sparse file"); } + harness.git_stdout(&[ + "-C", + "lib/sparse-disabled", + "config", + "core.sparseCheckout", + "false", + ]); + // Run check to detect missing sparse configuration - let stdout = harness - .run_submod_success(&["check"]) - .expect("Failed to run check"); - assert!(stdout.contains("Sparse checkout not configured")); + let before = harness.preservation_snapshot(); + let child_before = + harness.git_stdout(&["-C", "lib/sparse-disabled", "status", "--porcelain=v1"]); + let child_index_before = + harness.git_stdout(&["-C", "lib/sparse-disabled", "ls-files", "--stage"]); + let child_head_before = + harness.git_stdout(&["-C", "lib/sparse-disabled", "rev-parse", "HEAD"]); + let child_config_before = + harness.git_stdout(&["-C", "lib/sparse-disabled", "config", "--local", "--list"]); + let output = harness.run_submod(&["check"]).expect("Failed to run check"); + assert_eq!(output.status.code(), Some(1), "{output:?}"); + assert_eq!(harness.preservation_snapshot(), before); + assert_eq!( + harness.git_stdout(&["-C", "lib/sparse-disabled", "status", "--porcelain=v1"]), + child_before + ); + assert_eq!( + harness.git_stdout(&["-C", "lib/sparse-disabled", "ls-files", "--stage"]), + child_index_before + ); + assert_eq!( + harness.git_stdout(&["-C", "lib/sparse-disabled", "rev-parse", "HEAD"]), + child_head_before + ); + assert_eq!( + harness.git_stdout(&["-C", "lib/sparse-disabled", "config", "--local", "--list"]), + child_config_before + ); + let stdout = String::from_utf8_lossy(&output.stdout); + let outcome = stdout + .lines() + .find(|line| line.starts_with("sparse-disabled: drift:")) + .unwrap_or_else(|| panic!("missing named drift outcome: {stdout}")); + assert!( + outcome.contains("sparse checkout is not configured"), + "{stdout}" + ); + assert!(!sparse_file.exists()); + assert_eq!( + harness + .git_stdout(&[ + "-C", + "lib/sparse-disabled", + "config", + "--get", + "core.sparseCheckout" + ]) + .trim(), + "false" + ); } #[test] @@ -327,10 +411,47 @@ sparse_paths = ["src", "docs", "*.md"] .run_submod_success(&["check", "--verbose"]) .expect("Failed to run check"); - // Should show different status for each submodule - assert!(stdout.contains("no-sparse")); - assert!(stdout.contains("with-sparse")); - assert!(stdout.contains("Sparse checkout configured correctly")); + for name in ["no-sparse", "with-sparse"] { + assert!(stdout.contains(&format!("{name}: unchanged (matches configured state)"))); + } + assert_eq!( + harness.git_stdout(&[ + "-C", + "lib/with-sparse", + "config", + "--get", + "core.sparseCheckout" + ]), + "true" + ); + assert_eq!( + fs::read_to_string(harness.get_sparse_checkout_file_path("lib/with-sparse")).unwrap(), + "!/*\nsrc\ndocs\n" + ); + let full_sparse = harness + .git_cmd() + .current_dir(&harness.work_dir) + .args([ + "-C", + "lib/no-sparse", + "config", + "--get", + "core.sparseCheckout", + ]) + .output() + .unwrap(); + assert!( + full_sparse.status.code() == Some(1) + || (full_sparse.status.success() + && String::from_utf8_lossy(&full_sparse.stdout).trim() == "false") + ); + assert!( + !harness + .get_sparse_checkout_file_path("lib/no-sparse") + .exists() + ); + assert!(harness.file_exists("lib/no-sparse/tests/test.rs")); + assert!(!harness.file_exists("lib/with-sparse/tests/test.rs")); } #[test] @@ -567,4 +688,68 @@ use_git_default_sparse_checkout = true "docs pattern must be present" ); } + + /// R15: removing the declaration disables sparse checkout and restores the + /// complete clean tree rather than leaving stale exclusions active. + #[test] + fn r15_removing_sparse_paths_disables_sparse_and_restores_files() { + let harness = TestHarness::new().expect("harness"); + harness.init_git_repo().expect("parent repo"); + let remote = harness.create_complex_remote("r15-remote").expect("remote"); + let url = format!("file://{}", remote.display()); + harness + .run_submod_success(&[ + "add", + &url, + "--name", + "library", + "--path", + "deps/library", + "--sparse-paths", + "src,docs", + ]) + .expect("sparse add"); + assert!( + !harness + .work_dir + .join("deps/library/examples/basic.rs") + .exists() + ); + + let config = harness.read_config().expect("read config"); + let without_sparse = config + .lines() + .filter(|line| !line.trim_start().starts_with("sparse_paths")) + .collect::>() + .join("\n"); + harness + .create_config(&(without_sparse + "\n")) + .expect("remove sparse declaration"); + + let output = harness.run_submod(&["sync"]).expect("sync"); + assert!( + output.status.success(), + "sync: {}", + String::from_utf8_lossy(&output.stderr) + ); + assert_eq!( + harness.git_at( + &harness.work_dir.join("deps/library"), + &["config", "--bool", "core.sparseCheckout"] + ), + "false" + ); + assert!( + harness + .work_dir + .join("deps/library/examples/basic.rs") + .is_file() + ); + assert!( + harness + .work_dir + .join("deps/library/tests/test.rs") + .is_file() + ); + } } From b98a13b8ae069e217ae075960433d2f4841a0542 Mon Sep 17 00:00:00 2001 From: Adam Poulemanos Date: Fri, 11 Sep 2026 10:43:55 -0400 Subject: [PATCH 02/27] chore: deps updates, clippy, formatting --- .serena/.gitignore | 2 + .serena/project.yml | 169 +++++++++++++++ Cargo.lock | 490 +++++++++++++++++++++++++------------------- Cargo.toml | 35 ++-- src/config.rs | 8 +- src/git_manager.rs | 7 +- src/git_ops/mod.rs | 20 +- src/long_abouts.rs | 4 +- src/options.rs | 1 + src/utilities.rs | 2 +- 10 files changed, 486 insertions(+), 252 deletions(-) create mode 100644 .serena/.gitignore create mode 100644 .serena/project.yml diff --git a/.serena/.gitignore b/.serena/.gitignore new file mode 100644 index 00000000..2e510aff --- /dev/null +++ b/.serena/.gitignore @@ -0,0 +1,2 @@ +/cache +/project.local.yml diff --git a/.serena/project.yml b/.serena/project.yml new file mode 100644 index 00000000..729aa4bc --- /dev/null +++ b/.serena/project.yml @@ -0,0 +1,169 @@ +# the name by which the project can be referenced within Serena/when chatting with the LLM. +project_name: "submod" + +# list of language servers to start when using the LSP backend; choose from: +# ada al angular ansible bash +# bsl clojure cpp cpp_ccls crystal +# csharp csharp_omnisharp cue dart deno +# elixir elm erlang fortran fsharp +# gdscript gleam go groovy haskell +# haxe hlsl html java json +# julia kotlin latex lean4 lua +# luau markdown matlab msl nextflow +# nix ocaml pascal perl php +# php_phpactor php_phpantom powershell python python_basedpyright +# python_jedi python_pyrefly python_ty qml r +# rego ruby ruby_solargraph rust scala +# scss solidity svelte swift systemverilog +# terraform toml typescript typescript_vts vue +# wolfram yaml zig +# (This list may be outdated; generated with scripts/print_language_list.py; +# For the current list, see values of the LanguageServerId enum here: +# https://github.com/oraios/serena/blob/main/src/solidlsp/ls_config.py) +# For some languages, there are several alternative language servers, e.g. csharp_omnisharp, ruby_solargraph.) +# Note: +# - For C, use cpp +# - For JavaScript, use typescript +# - For Angular projects, use angular (subsumes typescript+html; requires `npm install` in the project root) +# - For Svelte projects, use svelte (subsumes typescript/javascript for .svelte projects; requires npm) +# - For Deno projects, use deno (serves the same .ts/.js files as typescript; requires the deno CLI on PATH) +# - For SCSS / Sass / plain CSS, use scss (some-sass-language-server handles all three) +# - For Free Pascal/Lazarus, use pascal +# Special requirements: +# Some language servers require additional setup/installations. +# See here for details: https://oraios.github.io/serena/01-about/020_programming-languages.html#language-servers +# When using multiple language servers, the first language server that supports a given file will be used for that file. +# The first language server is the default language and the respective language server will be used as a fallback. +# Note that when using the JetBrains backend, language servers are not used and this list is correspondingly ignored. +language_servers: +- rust + +# the encoding used by text files in the project +# For a list of possible encodings, see https://docs.python.org/3.11/library/codecs.html#standard-encodings +encoding: "utf-8" + +# optional shell command to run before the language backend (LSP or JetBrains) is initialised. +# the command runs in the project root directory and is only executed if the project is trusted +# (see trusted_project_path_patterns in the global configuration). +# serena waits for the command to exit: a non-zero exit code is logged as an error but does not +# abort activation. a per-project timeout (activation_command_timeout, default 180s) is the safety +# backstop for non-terminating commands; on expiry the process is killed and activation continues. +# example: activation_command: "npx nx run-many -t build" +activation_command: + +# maximum time in seconds to wait for activation_command to complete before killing it (default 180s). +# must be a positive number. +activation_command_timeout: 180.0 + +# line ending convention to use when writing source files. +# Possible values: unset (use global setting), "lf", "crlf", or "native" (platform default) +# This does not affect Serena's own files (e.g. memories and configuration files), which always use native line endings. +line_ending: + +# The language backend to use for this project. +# If not set, the global setting from serena_config.yml is used. +# Valid values: LSP, JetBrains +# Note: the backend is fixed at startup. If a project with a different backend +# is activated post-init, an error will be returned. +language_backend: + +# whether to use project's .gitignore files to ignore files +ignore_all_files_in_gitignore: true + +# advanced configuration option allowing to configure language server-specific options. +# Maps the language key to the options. +# The settings are considered only if the project is trusted (see global configuration to define trusted projects). +# See https://oraios.github.io/serena/02-usage/050_configuration.html#language-server-specific-settings +ls_specific_settings: {} + +# list of workspace folder paths (LSP backend only). +# These folders will be used to build up Serena's symbol index. +# Paths must be within the project root and should thus be relative to the project root. +# Furthermore, the paths should not be filtered by ignore settings. +# Default setting: The entire project root folder (".") is considered. +# In (large) monorepos, this can be used to index only subfolders of the project root, e.g. +# ls_workspace_folders: +# - "./subproject1" +# - "./subproject2" +ls_workspace_folders: +- "." + +# list of additional workspace folder paths for cross-package reference support. +# Paths can be absolute or relative to the project root. +# Each folder is registered as an LSP workspace folder, enabling language servers to discover +# symbols and references across package boundaries, but these folders are not indexed by Serena, +# i.e. the respective symbols will not be found using Serena's symbol search tools. +# Example: +# additional_workspace_folders: +# - ../sibling-package +# - ../shared-lib +ls_additional_workspace_folders: [] + +# list of additional paths to ignore in this project. +# Same syntax as gitignore, so you can use * and **. +# Important: quote patterns that start with `*`, otherwise YAML treats them as aliases. +# Example: +# ignored_paths: +# - "examples/**" +# - ".worktrees/**" +# - "**/bin/**" +# - "**/obj/**" +# Note: global ignored_paths from serena_config.yml are also applied additively. +ignored_paths: [] + +# whether the project is in read-only mode +# If set to true, all editing tools will be disabled and attempts to use them will result in an error +# Added on 2025-04-18 +read_only: false + +# list of tool names to exclude. +# This extends the existing exclusions (e.g. from the global configuration) +# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html +excluded_tools: [] + +# list of tools to include that would otherwise be disabled (particularly optional tools that are disabled by default). +# This extends the existing inclusions (e.g. from the global configuration). +# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html +included_optional_tools: [] + +# fixed set of tools to use as the base tool set (if non-empty), replacing Serena's default set of tools. +# This cannot be combined with non-empty excluded_tools or included_optional_tools. +# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html +fixed_tools: [] + +# list of mode names that are to be activated by default, overriding the setting in the global configuration. +# The full set of modes to be activated is base_modes (from global config) + default_modes + added_modes. +# If the setting is undefined/empty, the default_modes from the global configuration (serena_config.yml) apply. +# Otherwise, this overrides the setting from the global configuration (serena_config.yml). +# Therefore, you can set this to [] if you do not want the default modes defined in the global config to apply +# for this project. +# This setting can, in turn, be overridden by CLI parameters (--mode). +# See https://oraios.github.io/serena/02-usage/050_configuration.html#modes +default_modes: + +# list of mode names to be activated additionally for this project, e.g. ["query-projects"] +# The full set of modes to be activated is base_modes (from global config) + default_modes + added_modes. +# See https://oraios.github.io/serena/02-usage/050_configuration.html#modes +added_modes: + +# initial prompt for the project. It will always be given to the LLM upon activating the project +# (contrary to the memories, which are loaded on demand). +initial_prompt: "" + +# time budget (seconds) per tool call for the retrieval of additional symbol information +# such as docstrings or parameter information. +# This overrides the corresponding setting in the global configuration; see the documentation there. +# If null or missing, use the setting from the global configuration. +symbol_info_budget: + +# list of regex patterns which, when matched, mark a memory entry as read‑only. +# Extends the list from the global configuration, merging the two lists. +read_only_memory_patterns: [] + +# list of regex patterns for memories to completely ignore. +# Matching memories will not appear in list_memories or activate_project output +# and cannot be accessed via read_memory or write_memory. +# To access ignored memory files, use the read_file tool on the raw file path. +# Extends the list from the global configuration, merging the two lists. +# Example: ["_archive/.*", "_episodes/.*"] +ignored_memory_patterns: [] diff --git a/Cargo.lock b/Cargo.lock index 13907074..ad0e109e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4,9 +4,9 @@ version = 4 [[package]] name = "aho-corasick" -version = "1.1.4" +version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" dependencies = [ "memchr", ] @@ -118,21 +118,6 @@ version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" -[[package]] -name = "bisync" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5020822f6d6f23196ccaf55e228db36f9de1cf788052b37992e17cbc96ec41a7" -dependencies = [ - "bisync_macros", -] - -[[package]] -name = "bisync_macros" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d21f40d350a700f6aa107e45fb26448cf489d34794b2ba4522181dc9f1173af6" - [[package]] name = "bitflags" version = "1.3.2" @@ -141,9 +126,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.13.1" +version = "2.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" [[package]] name = "block-buffer" @@ -156,9 +141,9 @@ dependencies = [ [[package]] name = "bstr" -version = "1.13.0" +version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f7dc094d718f2e1c1559ad110e27eeaae14a5465d3d56dd6dbd793079fbd530" +checksum = "6bb31b46c14244e20ee9984b11bf5c992b91fb6939fea616e3512c8baecdbe5f" dependencies = [ "memchr", "regex-automata", @@ -197,9 +182,9 @@ checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" [[package]] name = "cc" -version = "1.4.0" +version = "1.4.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5add81bb678e6cb321aff7fa0dc7689ad82b112dbc032cea19f91d6b8e3582b9" +checksum = "005ec2760ca554fae18df7a11195552ec576cd665632a881bc011d5bb2fd4d80" dependencies = [ "find-msvc-tools", "jobserver", @@ -242,9 +227,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.6.4" +version = "4.6.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d91e0c145792ef73a6ad36d27c75ac09f1832222a3c209689d90f534685ee5b7" +checksum = "473c7e07f409a8d772161724aa8db6a765a2532a70f9667eeb7b49d3d02fbdca" dependencies = [ "clap_builder", "clap_derive", @@ -252,9 +237,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.6.2" +version = "4.6.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f09628afdcc538b57f3c6341e9c8e9970f18e4a481690a64974d7023bd33548b" +checksum = "7b48fea5a88e9ae728a2dcbedbfc0e730f7d60da42e1cb049a83c9fb8b789889" dependencies = [ "anstream", "anstyle", @@ -267,18 +252,18 @@ dependencies = [ [[package]] name = "clap_complete" -version = "4.6.7" +version = "4.6.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db8b397918185f0161ff3d6fcaa9e4bfc09b8367caf6e1d4a2848e5477ed027b" +checksum = "3be2ad0423bdbbb0e25bc89add796f3559706d4a95e1bc98e4d9662a957b6a19" dependencies = [ "clap", ] [[package]] name = "clap_complete_nushell" -version = "4.6.1" +version = "4.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "933b05d5d83ff65fd7eaf5d106c792f2264908790a2642aca57429767b762ce2" +checksum = "ffb66bc82eb9c92b1727310ae2c5868df22ae7cf46185bc5c544a4fa71955e49" dependencies = [ "clap", "clap_complete", @@ -293,7 +278,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn 3.0.3", + "syn 3.0.5", ] [[package]] @@ -317,6 +302,12 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" +[[package]] +name = "core_detect" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48" + [[package]] name = "cpufeatures" version = "0.2.17" @@ -328,9 +319,9 @@ dependencies = [ [[package]] name = "crc32fast" -version = "1.5.0" +version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +checksum = "8498c871161e1742aaa9d52551b2d6ebdd4c3d45a3be423e3728f33b955be550" dependencies = [ "cfg-if", ] @@ -372,18 +363,18 @@ dependencies = [ [[package]] name = "crossbeam-channel" -version = "0.5.16" +version = "0.5.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d85363c37faeca707aef026efa9f3b34d077bce547e48f770770625c6013679e" +checksum = "98b0cc327b5bc766e7fda9c9260cc0fa81b43a8e240440422dff70788e3f9ef1" dependencies = [ "crossbeam-utils", ] [[package]] name = "crossbeam-deque" -version = "0.8.7" +version = "0.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5181e0de7b61eb03a81e347d6dd8797bae9da5146707b51077e2d71a54ec0ceb" +checksum = "622f3fc73690be383c7214310406f28a90e6edeadc3cea882f9d71e495b9711a" dependencies = [ "crossbeam-epoch", "crossbeam-utils", @@ -391,18 +382,18 @@ dependencies = [ [[package]] name = "crossbeam-epoch" -version = "0.9.20" +version = "0.9.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f" +checksum = "dc74980687109a3b14c72fd458107bf0baa1da1a1a805e178d15501ba9b86d9d" dependencies = [ "crossbeam-utils", ] [[package]] name = "crossbeam-utils" -version = "0.8.22" +version = "0.8.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" +checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" [[package]] name = "crunchy" @@ -483,17 +474,23 @@ checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" [[package]] name = "either" -version = "1.17.0" +version = "1.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e5e8f6c15a24b9a3ee5efec809ccd006d3b30e8b3bb63c39af737c7f87daa1d" +checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" [[package]] name = "encoding_rs" -version = "0.8.35" +version = "0.8.41" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +checksum = "7b5ef0006ac9ab233c38522f5ae99cae3625151de8f706cacee1cba4b8e2832a" dependencies = [ "cfg-if", + "core_detect", + "multiversion", + "multiversion_no_op", + "rustversion", + "scopeguard", + "simdutf8", ] [[package]] @@ -555,9 +552,9 @@ dependencies = [ [[package]] name = "find-msvc-tools" -version = "0.1.9" +version = "0.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +checksum = "3e0f1c7c3a72c66fd80abe965175f7523475c0489a87d3ff9d6e8c87d87a9d2d" [[package]] name = "fnv" @@ -573,21 +570,21 @@ checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" [[package]] name = "futures-core" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" [[package]] name = "futures-task" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" [[package]] name = "futures-util" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" dependencies = [ "futures-core", "futures-task", @@ -622,7 +619,7 @@ version = "0.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ddddbf932745a6be37109b6112d3ee09696106f848449069d3a57bba937ab82e" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "libc", "libgit2-sys", "log", @@ -630,9 +627,9 @@ dependencies = [ [[package]] name = "gix" -version = "0.86.0" +version = "0.87.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb3790fd8981cba7949f1ba924ef865d902df731627bc5998d14164063892fce" +checksum = "fdefc1465d8631807deaf504dacdeff628b978de515653b47976ca7c28ab2a7a" dependencies = [ "gix-actor", "gix-attributes", @@ -653,12 +650,14 @@ dependencies = [ "gix-ignore", "gix-index", "gix-lock", + "gix-note", "gix-object", "gix-odb", "gix-pack", "gix-path", "gix-pathspec", "gix-protocol", + "gix-quote", "gix-ref", "gix-refspec", "gix-revision", @@ -683,9 +682,9 @@ dependencies = [ [[package]] name = "gix-actor" -version = "0.41.2" +version = "0.42.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33f9308ad6fd35b2a865cbe4117ac61b2be59e4a9ef1621c7a9794f7c8e52c5b" +checksum = "e37efa99929ac62f980fb1a7dcd09dea85b3aa6ead6ba0498362add3f4e698de" dependencies = [ "bstr", "gix-date", @@ -694,9 +693,9 @@ dependencies = [ [[package]] name = "gix-attributes" -version = "0.34.0" +version = "0.35.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c31c593692ebdc1e38858d9a2b56f6a594c501e24a38971fe6685571f5a07be0" +checksum = "76b83782ac69ae28921a6e8fbcf2e24069613f1518030b97ae622b079abffa54" dependencies = [ "bstr", "gix-features", @@ -711,40 +710,39 @@ dependencies = [ [[package]] name = "gix-bitmap" -version = "0.3.3" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7cd1d118d0f5d88b96e6f6e13b566475fef4797ead4a02c26fed36c1375066f7" +checksum = "f17013c7ef5cb95ebb4bf4978201e6a8a42ffea9d666fd5388f1b7e742307d80" dependencies = [ "gix-error", ] [[package]] name = "gix-chunk" -version = "0.7.3" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2a871e5cab12ba568845714473505deefffb3c04eb47f4708ce344cd459c1cc" +checksum = "00e32f938b3745ac93d73bc7490b6a15a661b2fc81973bfe90e275cc53c908e1" dependencies = [ "gix-error", ] [[package]] name = "gix-command" -version = "0.9.1" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00706d4fef135ef4b01680d5218c6ee40cda8baf697b864296cbc887d19118f6" +checksum = "aa00adbd2c77f584cfe41f348772d2f131af1fa519a7f6b98638f1144ead96a4" dependencies = [ "bstr", "gix-path", "gix-quote", "gix-trace", - "shell-words", ] [[package]] name = "gix-commitgraph" -version = "0.38.0" +version = "0.39.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2cd7f054ae2727223fe46dd39c012f066b12f532962d336d29ee193261787da" +checksum = "6b93c9fb1f5be01bba54a7a3b7455d359efc68555539c75ef74b8021bb6db497" dependencies = [ "bstr", "gix-chunk", @@ -756,9 +754,9 @@ dependencies = [ [[package]] name = "gix-config" -version = "0.59.0" +version = "0.60.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "103d11bef95c467577ecfa8b7b86a22e65af3507b2c9bfa3809a4afbae7df301" +checksum = "f973c28c0a4871a7926fc90c8377d4458fd6cbb19692f56582b4d2022313ae90" dependencies = [ "bstr", "gix-config-value", @@ -775,11 +773,11 @@ dependencies = [ [[package]] name = "gix-config-value" -version = "0.19.0" +version = "0.19.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f813e312a3f7f327187823cd4c754a3e4d948c98907d11e8f37554a2b6b8059" +checksum = "6f6af5321bfd3711a279d6b244d58532ba1cfabf9eb6374791f19929d8970082" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "bstr", "gix-path", "libc", @@ -788,9 +786,9 @@ dependencies = [ [[package]] name = "gix-date" -version = "0.15.6" +version = "0.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e47b9e8cdc688296609b706428de570f88b1e0eed7156dde7b4a89d26fa4567" +checksum = "e63d9aa18f29facd571c8953e66224ee0075b9e16622024794555ed4acceea85" dependencies = [ "bstr", "gix-error", @@ -800,9 +798,9 @@ dependencies = [ [[package]] name = "gix-diff" -version = "0.66.0" +version = "0.67.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fee7d89a3c507491cdfc57a1d1e0e300214720b4f7709ebc253e422f99822bfc" +checksum = "1b1689ff5ddeee4acfb2a43e875a1072a76d208624b15a9065c938b39cb0da2a" dependencies = [ "bstr", "gix-attributes", @@ -824,9 +822,9 @@ dependencies = [ [[package]] name = "gix-dir" -version = "0.28.0" +version = "0.29.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f24bc78f283946ac64757c8a61ffa71f0230aa1e8d98cbb0771db479871dd5b6" +checksum = "8342d5eb0ea054ed6ef807e628e38f06dd51eceeec9529767d8b23e33eff9c09" dependencies = [ "bstr", "gix-discover", @@ -844,9 +842,9 @@ dependencies = [ [[package]] name = "gix-discover" -version = "0.54.0" +version = "0.55.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9f517766fa1101dfe2606c1a19a8ffa699099030995a9194445446dfe261bdf" +checksum = "ec32a30ec2934735599c2545f30067e80bd255fd3454b52a5d59bc02b52874a3" dependencies = [ "bstr", "dunce", @@ -859,18 +857,18 @@ dependencies = [ [[package]] name = "gix-error" -version = "0.2.5" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a9292309fd944e71b2a3c96d3c03a6feb8852db646febdde7cbb9f79cb5f329" +checksum = "4a1ba536602db507119eb5f3cdf80bef3ca5a5ec0dfc6e9c7f46dc70ade7c112" dependencies = [ "bstr", ] [[package]] name = "gix-features" -version = "0.49.0" +version = "0.49.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20aa09e83a48dc02c5f5f08578aa79d3ab1bab4618b8c362f88684645a02bdcc" +checksum = "39c0e59d9d253dcccc38c3a46b91bfb9b46bd63eed54fe1a719e12194884d52a" dependencies = [ "bytes", "crc32fast", @@ -887,9 +885,9 @@ dependencies = [ [[package]] name = "gix-filter" -version = "0.33.0" +version = "0.34.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e7b5dbf524d97e839f642930c76d7f011c0791e7d11d8148989ac5af7c76aa8" +checksum = "9c30b28d957e2e6f1e1bbfbfd26b8e0bb0aab68d8a5e730fb4fd3049943575e3" dependencies = [ "bstr", "encoding_rs", @@ -908,9 +906,9 @@ dependencies = [ [[package]] name = "gix-fs" -version = "0.22.0" +version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "865cf13fcaf5455220546cb9607c416bd1be9a6caafd143655a362fdeab64e80" +checksum = "ebcfa9fd253f25350a3b21b3dd74034a446098e373c6123d4cee3519894f12ef" dependencies = [ "bstr", "gix-features", @@ -921,11 +919,11 @@ dependencies = [ [[package]] name = "gix-glob" -version = "0.27.0" +version = "0.27.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "421e92a711554fa5827d1b0599d3389acdd0f6729e97a8c5a57d79af1e50bf36" +checksum = "b417cf515fd8c91468b578071f76d6cba716f8a1eccd853906bff4908b2c1413" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "bstr", "gix-features", "gix-path", @@ -933,9 +931,9 @@ dependencies = [ [[package]] name = "gix-hash" -version = "0.26.0" +version = "0.26.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13adaa73415fd6c902310923f68d0b98e8cecf14b33ea58c02cc387cee56f54e" +checksum = "380b9c423a54f0821064b954b5a5ab25c660812f6a91da03c3f1cb4b10762aa5" dependencies = [ "faster-hex", "gix-features", @@ -956,9 +954,9 @@ dependencies = [ [[package]] name = "gix-ignore" -version = "0.22.0" +version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12cff8e8aa125e39377456073e63df3334d9e5741372ddcc226198015076dda2" +checksum = "65859a2f7de5e159d4344a5486ebf53b6bac22d5ec6afa836e47c10ae88a7f0f" dependencies = [ "bstr", "gix-glob", @@ -969,9 +967,9 @@ dependencies = [ [[package]] name = "gix-imara-diff" -version = "0.2.4" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a791e6620676a875f362f3156ed213e73ca099a09bf992c18812abe65cc37b1" +checksum = "1c91d8cffac8849493a82233811bd02b2b183b8cf39bf704de0fa0841b737595" dependencies = [ "bstr", "hashbrown 0.17.1", @@ -979,11 +977,11 @@ dependencies = [ [[package]] name = "gix-index" -version = "0.54.0" +version = "0.55.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5009c4e7e9f9b4cfaaab1153e49133eb04d79c015b5702d6c3d2ab94271a89c6" +checksum = "632e16cb48b0e88a747e106924cb2765194105d8070a7a961b0d080ed806c632" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "bstr", "filetime", "fnv", @@ -1016,18 +1014,43 @@ dependencies = [ "thiserror", ] +[[package]] +name = "gix-macros" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3836b4b051393464a753c5a08fe19c7ce0d8b77574a92bd558972941d4553cb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "gix-note" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9af6bbdb3f3c62b4920407c479af1cc0beb27fd75a762782d48baed53d330958" +dependencies = [ + "gix-error", + "gix-hash", + "gix-hashtable", + "gix-object", +] + [[package]] name = "gix-object" -version = "0.63.0" +version = "0.64.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e48c235e7f886eb819fc878af75be889333dd3c38bee02ed7af48ae2cf596c4" +checksum = "56fef799ca40cdeab4de5a3e74a696d8fa9b9c6264592646bf022e026f13ce2c" dependencies = [ "bstr", "gix-actor", + "gix-command", "gix-date", "gix-features", "gix-hash", "gix-hashtable", + "gix-tempfile", "gix-utils", "gix-validate", "itoa", @@ -1037,9 +1060,9 @@ dependencies = [ [[package]] name = "gix-odb" -version = "0.83.0" +version = "0.84.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8dd494ffb5037e62b8220109e894d2861ff2150a2cacbfccdba57ae1ebab2b96" +checksum = "cf45d195b71cb6363886e7b466821bf4dffd7aba1b6b814ff75488e0061d72a7" dependencies = [ "arc-swap", "gix-features", @@ -1059,11 +1082,12 @@ dependencies = [ [[package]] name = "gix-pack" -version = "0.73.0" +version = "0.74.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d5446127b269706e85998065267ddd2ccc3550179da6780b22fe496175ccb20" +checksum = "3d199d515cbdcf05f531f3859be9771fc12acac7b7fe0f5b581b2c1ab1c3a61e" dependencies = [ "clru", + "crossbeam-deque", "gix-chunk", "gix-error", "gix-features", @@ -1080,9 +1104,9 @@ dependencies = [ [[package]] name = "gix-packetline" -version = "0.22.0" +version = "0.22.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3766025c72319c4accdd854a18e6f0dd176c8eb0f3bc8a60a7765be2b50cabf2" +checksum = "792bd92bea390087e6223b18d4d023decc36467de31e6b066b69b94beea1b9e2" dependencies = [ "bstr", "faster-hex", @@ -1092,9 +1116,9 @@ dependencies = [ [[package]] name = "gix-path" -version = "0.12.3" +version = "0.12.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ed3e8d7a82e886e17a72e03d4ba0c13db6f2219b6cd4e2900b4cae426ec20c9" +checksum = "38fc6f029ea67de83cbcbd33fd98c05a48360d2932b39d9fdacbc6eae802d475" dependencies = [ "bstr", "gix-trace", @@ -1104,11 +1128,11 @@ dependencies = [ [[package]] name = "gix-pathspec" -version = "0.19.0" +version = "0.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49f6fa5f8007f008187c3f60b4373209ca83d1cc947f35ede03e16cd15a4d137" +checksum = "c92e44c63ba53bb55aa88ee48847664beae9446417621582fb14dfc69b2f8c72" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "bstr", "gix-attributes", "gix-config-value", @@ -1119,15 +1143,15 @@ dependencies = [ [[package]] name = "gix-protocol" -version = "0.64.0" +version = "0.65.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dede40e89c1e90f548415f50636bb051f6d9c60f68b8b710bc07825722d19588" +checksum = "68878c37ae168b474c762d70adc8a8e0f9323ed0d80444a672e2eb561fa16691" dependencies = [ - "bisync", "bstr", "gix-date", "gix-features", "gix-hash", + "gix-macros", "gix-ref", "gix-shallow", "gix-transport", @@ -1138,9 +1162,9 @@ dependencies = [ [[package]] name = "gix-quote" -version = "0.7.2" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6e541fc33cc2b783b7979040d445a0c86a2eca747c8faea4ca84230d06ae6ef" +checksum = "f74795eb76eab8313063849f9cbd2bbcdc6e4692f71c1d7d0244ab11cd777329" dependencies = [ "bstr", "gix-error", @@ -1149,9 +1173,9 @@ dependencies = [ [[package]] name = "gix-ref" -version = "0.66.0" +version = "0.67.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eeb0c90a8f6202ceaaa22996cbf837c943ccb2d8af9ff3490f0758305e6b7883" +checksum = "8328387e7ab354e1dc3afb63e6b4d1866f82d7ce035222bc9d5baaf36bc88020" dependencies = [ "gix-actor", "gix-features", @@ -1169,15 +1193,12 @@ dependencies = [ [[package]] name = "gix-refspec" -version = "0.44.0" +version = "0.45.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7406282cc0259b51f6aee299ca3d31279a020530363152a2e6c96e8a7f7bbc83" +checksum = "7de280d46e8fd9e4d3e7e2ab4276b965e377ba68b2b9a8fce56371015e8bb059" dependencies = [ "bstr", - "gix-error", - "gix-glob", "gix-hash", - "gix-revision", "gix-validate", "smallvec", "thiserror", @@ -1185,9 +1206,9 @@ dependencies = [ [[package]] name = "gix-revision" -version = "0.48.0" +version = "0.49.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e55e09d4a1ecf2beecc8c09cafcad37979e805b31f588b0e957e191df5783681" +checksum = "4577b864c3e134697e91564553e43230c2e401bb1bcc51cfa998edca21c95078" dependencies = [ "bstr", "gix-commitgraph", @@ -1201,9 +1222,9 @@ dependencies = [ [[package]] name = "gix-revwalk" -version = "0.34.0" +version = "0.35.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "36c113c0a53294dc6280ffc06cbcc4f50f820397e97d6a00b429a44b8db26e29" +checksum = "248823eefe405e2c0754b74f6f43ab6faab68670cdbf2d6e114cb0471f766450" dependencies = [ "gix-commitgraph", "gix-date", @@ -1221,7 +1242,7 @@ version = "0.14.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "af4fe6c152c1d50aea36f299825702cd37e303307832fec1d0fdd5844e47ce2f" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "gix-path", "libc", "windows-sys", @@ -1242,9 +1263,9 @@ dependencies = [ [[package]] name = "gix-status" -version = "0.33.0" +version = "0.34.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f83b1c74e69b90411fbfe89ccebc47aa49457ce4eb9b942b1311258fc863d22" +checksum = "1401d871c01d82add5a9f654439b00722d0c632b332cd2cb63193f94327c8458" dependencies = [ "bstr", "filetime", @@ -1267,9 +1288,9 @@ dependencies = [ [[package]] name = "gix-submodule" -version = "0.33.0" +version = "0.34.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5fd98077a56d08886112e6b08dc94076d03539f4bc0b9d7880e4be2b8a640d8c" +checksum = "da85564d6725c483b8d95d7b31d1db0b78c29e462a2b481949e2f18e1ed55a6a" dependencies = [ "bstr", "gix-config", @@ -1301,9 +1322,9 @@ checksum = "be3eb81d9dc914335923e50d52829c551feefd6a72d176c4130c546b67a60814" [[package]] name = "gix-transport" -version = "0.58.0" +version = "0.59.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7c1bcf30081eb8ab04540a5795c67a2fcb22cb2e976e8b1a4ea657b1ed61469" +checksum = "b3f41a64939953ff49117df4eb91334d299abc59ff57a90fb2193d8ffa5886f1" dependencies = [ "bstr", "gix-command", @@ -1318,11 +1339,11 @@ dependencies = [ [[package]] name = "gix-traverse" -version = "0.60.0" +version = "0.61.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "008c5cd879e46e86b5c2469e633611978b18775d53d05668d691bc13088bd409" +checksum = "9af3503668739f4de5dba57fe15cfd9adc443b08bcbbf195e5de16b648872245" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "gix-commitgraph", "gix-date", "gix-hash", @@ -1335,9 +1356,9 @@ dependencies = [ [[package]] name = "gix-url" -version = "0.37.0" +version = "0.38.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42d10e53b8eae21ee601687f47bbbd6cb2ed7162cb4c1cafdd422fb7ec64cbee" +checksum = "5bda80ccb4bc81fb9fb07a975916eb0c7a889bcbcb0c8a239c3f82a9cc2abdda" dependencies = [ "bstr", "gix-path", @@ -1348,9 +1369,9 @@ dependencies = [ [[package]] name = "gix-utils" -version = "0.3.5" +version = "0.3.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1795bd2a970ca8b2185318c2abb97d955c71992f1cf28de73ad3b593a9f3ce8" +checksum = "0da1c46491b49458a446cc76f0085860f8164c2290742e0aa8c653ce67240a97" dependencies = [ "bstr", "fastrand", @@ -1360,18 +1381,18 @@ dependencies = [ [[package]] name = "gix-validate" -version = "0.11.3" +version = "0.11.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a034e84d1e04e1b1f20f51f12491da230b6ac8b925d0c8e1b89bcd87a7c5ccc" +checksum = "4dae8780f63ed8a803b8bdabbd7aa5f5c5d74592c8b50eed875c1bb4f6545a6a" dependencies = [ "bstr", ] [[package]] name = "gix-worktree" -version = "0.55.0" +version = "0.56.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "31eb8e675122e83585e461fe28f68ff8c5ed55b49017b697e7e76423ff973424" +checksum = "3ed36b627476e2072c129900a17c977a38052b5497e27308159bc881fbf4eecf" dependencies = [ "bstr", "gix-attributes", @@ -1388,9 +1409,9 @@ dependencies = [ [[package]] name = "gix-worktree-stream" -version = "0.35.0" +version = "0.36.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b088c8724e7be120c4798dd86925cf05332c9d356a463542578600c50c7a549" +checksum = "de5ec0ccbab39c9994656ea031ded8ffd90b370a3cc8e360fbe3c4ebe7c64964" dependencies = [ "gix-attributes", "gix-error", @@ -1480,9 +1501,9 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" [[package]] name = "indexmap" -version = "2.14.0" +version = "2.14.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" dependencies = [ "equivalent", "hashbrown 0.17.1", @@ -1574,9 +1595,9 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.103" +version = "0.3.105" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" +checksum = "ce57d20d1ea864ce2ac172ab472d409214f4fd359f0b2a2775abdf522e2af99e" dependencies = [ "cfg-if", "futures-util", @@ -1591,9 +1612,9 @@ checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" [[package]] name = "libgit2-sys" -version = "0.18.7+1.9.6" +version = "0.18.8+1.9.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23c7391e4b9f4ffab1a624223cc1d7385ff9a678f490768add717de7ea2f4d89" +checksum = "7f7c568b25d7489bc3fb2988ed69ab111d2944d2f5fec3d5c987fe545ea97b50" dependencies = [ "cc", "libc", @@ -1630,9 +1651,9 @@ dependencies = [ [[package]] name = "log" -version = "0.4.33" +version = "0.4.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" [[package]] name = "memchr" @@ -1649,6 +1670,33 @@ dependencies = [ "libc", ] +[[package]] +name = "multiversion" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ca4bea16ffc3f443cf7d866912118196bfef4c6a1556ca00f9f9b00bb43f7c" +dependencies = [ + "multiversion-macros", +] + +[[package]] +name = "multiversion-macros" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d416831a7317ef4b08bee00b69cbbb9c8763da7959a7026244d6266869f9c83" +dependencies = [ + "proc-macro2", + "quote", + "rustversion", + "syn 3.0.5", +] + +[[package]] +name = "multiversion_no_op" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743fb55ba31b18fb1ecef6bdc9aa2743314978ac084044301a7eee33fb99a20d" + [[package]] name = "nonempty" version = "0.12.0" @@ -1729,9 +1777,9 @@ checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" [[package]] name = "pkg-config" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" [[package]] name = "plotters" @@ -1763,15 +1811,15 @@ dependencies = [ [[package]] name = "portable-atomic" -version = "1.14.0" +version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d20d5497ef88037a52ff98267d066e7f11fcc5e99bbfbd58a42336193aacec3" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" [[package]] name = "portable-atomic-util" -version = "0.2.7" +version = "0.2.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618" +checksum = "10ab3eb7f3becc3a1cbc4f2c6f20267996cfc1a6467a873763411b136a122715" dependencies = [ "portable-atomic", ] @@ -1835,7 +1883,7 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", ] [[package]] @@ -1852,9 +1900,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.16" +version = "0.4.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fcfdb36bda0c880c5931cdc7a2bcdc8ba4556847b9d912bca70bc94708711ad" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" dependencies = [ "aho-corasick", "memchr", @@ -1873,7 +1921,7 @@ version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "errno", "libc", "linux-raw-sys", @@ -1928,7 +1976,7 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn 3.0.3", + "syn 3.0.5", ] [[package]] @@ -1983,18 +2031,18 @@ dependencies = [ "sha1", ] -[[package]] -name = "shell-words" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77" - [[package]] name = "shlex" version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + [[package]] name = "slab" version = "0.4.12" @@ -2003,9 +2051,9 @@ checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" [[package]] name = "smallvec" -version = "1.15.2" +version = "1.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" +checksum = "ba467056f1b547ed52077911161fc86985becbc60e8e1857c8a144dab0def891" [[package]] name = "stable_deref_trait" @@ -2024,7 +2072,7 @@ name = "submod" version = "0.4.0" dependencies = [ "anyhow", - "bitflags 2.13.1", + "bitflags 2.13.2", "clap", "clap_complete", "clap_complete_nushell", @@ -2036,8 +2084,8 @@ dependencies = [ "serde", "tempfile", "thiserror", - "toml 1.1.3+spec-1.1.0", - "toml_edit", + "toml 1.1.6+spec-1.1.0", + "toml_edit 0.25.15+spec-1.1.0", ] [[package]] @@ -2053,9 +2101,9 @@ dependencies = [ [[package]] name = "syn" -version = "3.0.3" +version = "3.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +checksum = "12df2e0110f65b775f769bb17ef989067a1d931b2eb822bd4346631eeada89f9" dependencies = [ "proc-macro2", "quote", @@ -2087,22 +2135,22 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.19" +version = "2.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" dependencies = [ "thiserror-impl", ] [[package]] name = "thiserror-impl" -version = "2.0.19" +version = "2.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" dependencies = [ "proc-macro2", "quote", - "syn 3.0.3", + "syn 3.0.5", ] [[package]] @@ -2117,9 +2165,9 @@ dependencies = [ [[package]] name = "tinyvec" -version = "1.12.0" +version = "1.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" +checksum = "4cf0ded5c4e56918d8f8a339e1bb67d038d3bc6d144ac407904015ba2e4cde9b" dependencies = [ "tinyvec_macros", ] @@ -2139,14 +2187,14 @@ dependencies = [ "serde", "serde_spanned 0.6.9", "toml_datetime 0.6.11", - "toml_edit", + "toml_edit 0.22.27", ] [[package]] name = "toml" -version = "1.1.3+spec-1.1.0" +version = "1.1.6+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53c96ecdfa941c8fc4fcaed14f99ada8ebed502eef533015095a07e3301d4c3c" +checksum = "920602543f0911ab71da12c50d59701da54c196d1a2bf5cb4b75667f137a406a" dependencies = [ "indexmap", "serde_core", @@ -2189,11 +2237,24 @@ dependencies = [ "winnow 0.7.15", ] +[[package]] +name = "toml_edit" +version = "0.25.15+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1340ea94a5856333492c9064b02c778b191dd2c853778d9609debdcdfea3a614" +dependencies = [ + "indexmap", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 1.0.4", +] + [[package]] name = "toml_parser" -version = "1.1.2+spec-1.1.0" +version = "1.1.3+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" dependencies = [ "winnow 1.0.4", ] @@ -2297,9 +2358,9 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.126" +version = "0.2.128" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" +checksum = "aecb87a33d3b0c5e3b7aa46336eaf486cffafbd281b195e4c8b80d50df2351bf" dependencies = [ "cfg-if", "once_cell", @@ -2310,9 +2371,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.126" +version = "0.2.128" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" +checksum = "a690d511e3c1a8b3a55e33511e3c2c00c78415cd23650f32b808627f5696b9ed" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -2320,31 +2381,31 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.126" +version = "0.2.128" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" +checksum = "411e4887f0071ef2d2164a9d5fdf2d20efbef78fccd3a78b0c10a1dc5295e48a" dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.5", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.126" +version = "0.2.128" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" +checksum = "81941cd78d0c92026c33e5e01312845a4cb1e9af3407f9134b100dd03144103e" dependencies = [ "unicode-ident", ] [[package]] name = "web-sys" -version = "0.3.103" +version = "0.3.105" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141" +checksum = "9fbddc4a036f00ec4f18c83445bd3115cb306a91da554919a099d9222fe4a7f8" dependencies = [ "js-sys", "wasm-bindgen", @@ -2410,21 +2471,24 @@ name = "winnow" version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +dependencies = [ + "memchr", +] [[package]] name = "zerocopy" -version = "0.8.55" +version = "0.8.57" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5a105cd7b140f6eeec8acff2ea38135d3cab283ada58540f629fe51e46696eb" +checksum = "d35102a9f36d089ccae9e4c6802bc118be4487b80aaffc0ab4e0cf5ce92d2873" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.55" +version = "0.8.57" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fe976fb70c78cd64cccfe3a6fc142244e8a77b70959b30faf9d0ac37ee228eb" +checksum = "146c01f5ab44258da43cf276c74a2763db2ff3969c9c652c3f2de07041d0b2bc" dependencies = [ "proc-macro2", "quote", @@ -2433,9 +2497,9 @@ dependencies = [ [[package]] name = "zlib-rs" -version = "0.6.6" +version = "0.6.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b142a20ec14a91d5bc708c1dc21b080c550113d8aa77afa29635673a65dd02c5" +checksum = "34b31d188d9d685a4f9c7b46d6e36631b07058d2cfe190267adce54dc230bf12" [[package]] name = "zmij" diff --git a/Cargo.toml b/Cargo.toml index 4ef4c313..f8321a41 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,13 +14,7 @@ repository = "https://github.com/bashandbone/submod" homepage = "https://github.com/bashandbone/submod" documentation = "https://docs.rs/submod" readme = "README.md" -keywords = [ - "git", - "submodule", - "gitoxide", - "cli", - "sparse-checkout", -] +keywords = ["git", "submodule", "gitoxide", "cli", "sparse-checkout"] categories = ["command-line-utilities", "development-tools"] resolver = "3" @@ -41,22 +35,22 @@ rustdoc-args = ["--cfg", "docsrs"] [dependencies] # Gitoxide ops -gix = { version = "^0.86.0", default-features = false, features = [ +gix = { version = "^0.87.1", default-features = false, features = [ "max-performance-safe", "status", - "sha1" + "sha1", ] } -gix-submodule = { version = "^0.33.0", features = ["sha1"]} +gix-submodule = { version = "^0.34.0", features = ["sha1"] } # CLI -clap = { version = "4.6.4", features = [ +clap = { version = "4.6.6", features = [ "derive", "cargo", "unicode", "wrap_help", ] } -clap_complete = "4.6.7" -clap_complete_nushell = "4.6.1" +clap_complete = "4.6.9" +clap_complete_nushell = "4.6.2" serde = { version = "1.0.229", features = ["derive"] } # TOML config @@ -64,16 +58,16 @@ figment = { version = "0.10.19", default-features = false, features = ["toml"] } # errors anyhow = "1.0.104" -thiserror = "2.0.19" +thiserror = "2.0.20" # bitflags for status flags -bitflags = "2.13.1" +bitflags = "2.13.2" # As of submod v0.2.0, git2 no longer optional # gix_submodule just isn't mature enough to realistically provide our functionality without falling back to git2 git2 = { version = "0.21.0" } -toml = "1.1.3" -toml_edit = "0.22.27" +toml = "1.1.6" +toml_edit = "0.25.15" tempfile = "3.27.0" [lib] @@ -90,7 +84,6 @@ figment = { version = "0.10.19", default-features = false, features = [ "toml", ] } criterion = "0.8.2" -toml = "1.1.3" [lints.rust] # Deny unsafe code unless explicitly allowed @@ -102,7 +95,11 @@ missing_docs = "warn" # Warn about unreachable code unreachable_code = "warn" # Allow cfg(coverage) and cfg(coverage_nightly) set by cargo-llvm-cov -unexpected_cfgs = { level = "warn", check-cfg = ['cfg(coverage)', 'cfg(coverage_nightly)', 'cfg(coverage_attribute)'] } +unexpected_cfgs = { level = "warn", check-cfg = [ + 'cfg(coverage)', + 'cfg(coverage_nightly)', + 'cfg(coverage_attribute)', +] } [lints.clippy] # Cargo-specific lints diff --git a/src/config.rs b/src/config.rs index 1eacef28..7e540ccb 100644 --- a/src/config.rs +++ b/src/config.rs @@ -819,10 +819,10 @@ impl SubmoduleEntries { /// Remove a sparse pattern. pub fn remove_sparse_path(&mut self, name: &str, path: &str) { - if let Some(entry) = self.submodules.as_mut().and_then(|m| m.get_mut(name)) { - if let Some(paths) = &mut entry.sparse_paths { - paths.retain(|p| p != path); - } + if let Some(entry) = self.submodules.as_mut().and_then(|m| m.get_mut(name)) + && let Some(paths) = &mut entry.sparse_paths + { + paths.retain(|p| p != path); } } diff --git a/src/git_manager.rs b/src/git_manager.rs index 76983885..b6ae1ed0 100644 --- a/src/git_manager.rs +++ b/src/git_manager.rs @@ -128,6 +128,7 @@ pub enum SubmoduleError { impl SubmoduleError { /// Return the documented process status for this error category. + #[must_use] pub const fn exit_code(&self) -> u8 { match self { Self::ConfigError(_) | Self::InvalidPath(_) | Self::SubmoduleNotFound { .. } => 2, @@ -567,7 +568,7 @@ impl GitManager { let mut targets = Vec::new(); for name in names { let settings = Self::managed_settings(&self.config, name)?; - let path = settings.path.clone().unwrap_or_else(|| name.to_string()); + let path = settings.path.clone().unwrap_or_else(|| name.clone()); if self.registration_for_path(&path)?.is_some() { targets.push((name.clone(), path, settings)); } @@ -1889,7 +1890,7 @@ impl GitManager { effective .branch .as_ref() - .map_or_else(|| "".to_string(), |branch| branch.as_config_value()), + .map_or_else(|| "".to_string(), super::options::SerializableBranch::as_config_value), effective.update, effective.ignore, effective.active.unwrap_or(true), @@ -2080,7 +2081,7 @@ impl GitManager { } let expected_sparse = if effective.use_git_default_sparse_checkout.unwrap_or(false) { - effective.sparse_paths.clone().unwrap_or_default() + effective.sparse_paths.unwrap_or_default() } else { Self::build_deny_all_sparse_patterns(effective.sparse_paths.as_deref().unwrap_or(&[])) }; diff --git a/src/git_ops/mod.rs b/src/git_ops/mod.rs index dfbc899f..15ba901b 100644 --- a/src/git_ops/mod.rs +++ b/src/git_ops/mod.rs @@ -2682,7 +2682,7 @@ impl GitOperations for GitOpsManager { .collect(); match opts.strategy { SerializableUpdate::Checkout | SerializableUpdate::Unspecified => { - args.push("--checkout".into()) + args.push("--checkout".into()); } SerializableUpdate::Merge => args.push("--merge".into()), SerializableUpdate::Rebase => args.push("--rebase".into()), @@ -2728,15 +2728,7 @@ impl GitOperations for GitOpsManager { String::from_utf8_lossy(&head_entry.stderr).trim() ); } - if !head_entry.stdout.is_empty() { - let mut args: Vec = vec!["rm".into()]; - if force { - args.push("--force".into()); - } - args.push("--".into()); - args.push(pathspec.clone()); - self.git(args)?; - } else { + if head_entry.stdout.is_empty() { // A newly added gitlink has no HEAD entry, so ordinary `git rm` refuses it as // staged. Deinitialize the already-verified clean checkout, remove only the exact // cached gitlink, then remove its exact portable section with Git's config parser. @@ -2761,6 +2753,14 @@ impl GitOperations for GitOpsManager { OsStr::new(&format!("submodule.{registered_name}")), ])?; self.git(["add", "--", ".gitmodules"])?; + } else { + let mut args: Vec = vec!["rm".into()]; + if force { + args.push("--force".into()); + } + args.push("--".into()); + args.push(pathspec.clone()); + self.git(args)?; } let local_pattern = format!( r"^submodule\.{}\.", diff --git a/src/long_abouts.rs b/src/long_abouts.rs index 47f7c9b0..5a379690 100644 --- a/src/long_abouts.rs +++ b/src/long_abouts.rs @@ -3,7 +3,7 @@ // SPDX-License-Identifier: LicenseRef-PlainMIT OR MIT //! A series of multiline strings for long-about text. We put them here to keep the command module somewhat readable. -pub const COMPLETE_ME: &str = r#" +pub const COMPLETE_ME: &str = r" Generates a shell completion script from the current command model on stdout. Save the output and load it using your shell's completion configuration. @@ -30,4 +30,4 @@ Usage: - Zsh: `submod completeme zsh > ~/.zsh/completions/_submod` or `submod completeme zsh > ~/.zfunc/_submod` - Nushell: `submod completeme nu` (save stdout as submod.nu and load it from your Nushell configuration) -"#; +"; diff --git a/src/options.rs b/src/options.rs index d48591b0..73726445 100644 --- a/src/options.rs +++ b/src/options.rs @@ -438,6 +438,7 @@ impl<'de> Deserialize<'de> for SerializableBranch { impl SerializableBranch { /// TOML spelling escapes literal branch names that coincide with user aliases. + #[must_use] pub fn as_config_value(&self) -> String { match self { Self::Name(name) diff --git a/src/utilities.rs b/src/utilities.rs index ce6743e3..fb824106 100644 --- a/src/utilities.rs +++ b/src/utilities.rs @@ -61,7 +61,7 @@ pub fn safe_human_text(input: &str) -> String { && (!looks_like_host_port(ordinary_authority) || malformed_authority_delimiter) { let extended_end = remaining - .find(|c: char| matches!(c, '/' | '?' | '#' | ',' | ')' | ']')) + .find(['/', '?', '#', ',', ')', ']']) .unwrap_or(remaining.len()); if remaining[..extended_end].contains('@') { authority_end = extended_end; From 9bbb0063b0cd6839bc49d291142f73b4c1b8e90d Mon Sep 17 00:00:00 2001 From: Adam Poulemanos Date: Fri, 11 Sep 2026 13:12:40 -0400 Subject: [PATCH 03/27] fix: cargo-deny warnings --- .serena/project.yml | 2 +- Cargo.toml | 16 ++++++++-------- _typos.toml | 2 +- deny.toml | 5 +++-- src/config.rs | 6 +++--- src/git_manager.rs | 18 +++--------------- tests/config_tests.rs | 2 ++ 7 files changed, 21 insertions(+), 30 deletions(-) diff --git a/.serena/project.yml b/.serena/project.yml index 729aa4bc..7c88fe71 100644 --- a/.serena/project.yml +++ b/.serena/project.yml @@ -42,7 +42,7 @@ language_servers: # For a list of possible encodings, see https://docs.python.org/3.11/library/codecs.html#standard-encodings encoding: "utf-8" -# optional shell command to run before the language backend (LSP or JetBrains) is initialised. +# optional shell command to run before the language backend (LSP or JetBrains) is initialized. # the command runs in the project root directory and is only executed if the project is trusted # (see trusted_project_path_patterns in the global configuration). # serena waits for the command to exit: a non-zero exit code is logged as an error but does not diff --git a/Cargo.toml b/Cargo.toml index f8321a41..7f24f31f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -43,18 +43,18 @@ gix = { version = "^0.87.1", default-features = false, features = [ gix-submodule = { version = "^0.34.0", features = ["sha1"] } # CLI -clap = { version = "4.6.6", features = [ +clap = { version = ">=4.0.0", features = [ "derive", "cargo", "unicode", "wrap_help", ] } -clap_complete = "4.6.9" -clap_complete_nushell = "4.6.2" +clap_complete = ">=4.0.0" +clap_complete_nushell = ">=4.0.0" -serde = { version = "1.0.229", features = ["derive"] } +serde = { version = "1.0.220", features = ["derive"] } # TOML config -figment = { version = "0.10.19", default-features = false, features = ["toml"] } +figment = { version = "^0.10.0", default-features = false, features = ["toml"] } # errors anyhow = "1.0.104" @@ -66,8 +66,8 @@ bitflags = "2.13.2" # As of submod v0.2.0, git2 no longer optional # gix_submodule just isn't mature enough to realistically provide our functionality without falling back to git2 git2 = { version = "0.21.0" } -toml = "1.1.6" -toml_edit = "0.25.15" +toml = ">=0.8.0" +toml_edit = ">=0.16.0" tempfile = "3.27.0" [lib] @@ -79,7 +79,7 @@ name = "submod" path = "src/main.rs" [dev-dependencies] -figment = { version = "0.10.19", default-features = false, features = [ +figment = { version = "0.10.0", default-features = false, features = [ "test", "toml", ] } diff --git a/_typos.toml b/_typos.toml index 50648687..d9cdc378 100755 --- a/_typos.toml +++ b/_typos.toml @@ -7,7 +7,7 @@ locale = "en-us" check-file = true check-filename = true extend-ignore-re = [ - "(?s)(#|//|