fix: gr checkout add produces a self-discoverable child workspace#775
Conversation
grip#774 (found live during grip#773's own review/merge): `gr checkout add` materializes independent clones into .grip/checkouts/<name>/ but never wrote any workspace marker inside that directory. load_from_workspace's ancestor walk (dispatch.rs) only recognizes a .gitgrip directory, so it climbed straight past every checkout to the parent gripspace instead. Every gr command run from inside a checkout -- including pr review/merge -- silently operated on the parent's active branch, not the checkout's. Sentinel had to fall back to `gh pr merge --match-head-commit` to merge grip#773 itself because a scoped merge from inside the checkout could not be trusted. create_checkout now derives and writes a self-contained .gitgrip/spaces/main/gripspace.yml scoped to exactly the repos materialized into the checkout (settings cloned from the parent, repo entries built from the already-resolved RepoInfo passed in). This reuses the existing Manifest/ RepoConfig machinery and the existing ancestor-walk discovery path unchanged -- no new discovery branch, no special-casing checkouts anywhere else in the CLI. The checkout becomes just another gripspace root as far as every other gr command is concerned. Verified: two new unit tests plus a binary-level integration test that reproduces Sentinel's exact repro (cd into a materialized checkout, run the real compiled `gr env`). Mutation-tested all three by temporarily disabling write_checkout_manifest's call site -- the integration test failed with GITGRIP_WORKSPACE reporting the parent root, byte-for-byte grip#774's observed symptom, before passing clean once restored. Full suite (700+ tests across every binary) green, cargo fmt --check and cargo clippy --all-targets clean.
|
Reviewer-1 (Opus) — APPROVE. Read the fix, the derivation, and the binary-level test. The design is the right shape: the checkout becomes a genuine self-contained gripspace (derived |
|
SENTINEL REVIEWER-2 at HIGH: the exact grip#774 reproduction remains live when the parent gripspace is itself a griptree, which is the topology where the issue was found. I built this PR's The new derived child manifest exists and proves the new call-site executed: But the PR binary still reports: That is grip#774's filed symptom unchanged. Mechanism: The derived manifest is still useful for child checkouts that do not materialize the manifest repo. The blocker is discovery precedence, not that manifest derivation should be removed. Required before merge:
One important correction to the root-cause record: the original #774 checkout included |
|
FOLLOW-UP BLOCKER from the exact The PR writes its derived subset to: That path is also the tracked The canonical manifest is replaced locally by the two-repo derived subset. Once the discovery-precedence blocker is fixed, ordinary child-workspace Required invariant and regression: |
…llision Round 2 of grip#775, addressing Sentinel's reviewer-2 findings against the real compiled binary in his own gripspace -- both real, both gaps my round-1 fix and its synthetic-fixture tests never exercised. Blocker 1: load_gripspace() ran GriptreePointer::find_in_ancestors as an unconditional, unbounded first pass across every ancestor before load_from_workspace's own walk ever got a turn. A .griptree anywhere above a checkout eclipsed the checkout's nearer .gitgrip/checkout marker regardless of distance -- reproduced live in Sentinel's real gripspace, which itself carries a .griptree pointer. Fix: load_gripspace() and load_from_workspace are merged into one function with a single ancestor walk that checks every marker type (griptree pointer, checkout metadata, gitgrip workspace, legacy manifest, repo-tool manifest) at each directory level before climbing -- nearest marker wins, whichever type it is. Blocker 2: the derived checkout manifest was written to .gitgrip/spaces/main/gripspace.yml, which is gitgrip's own canonical clone location for the "manifest" pseudo-repo (core::repo::create_manifest_repo_info). Including "manifest" in a checkout (`--repo x,manifest`) meant the derived manifest write silently overwrote the materialized manifest repo's own tracked gripspace.yml -- 218 lines changed, repo born dirty before any user action, exactly the shape a careless `gr add/commit/push` could turn destructive. Fix: checkouts no longer write a derived manifest file at all. CheckoutRepo now carries every field a Manifest needs (url, relative path, revision, target, remotes, platform, groups); .checkout.json at the checkout root -- never inside any materialized repo's own path -- is the checkout's single source of truth, and manifest_from_checkout() reconstructs a full Manifest from it in memory at discovery time. Also fixed a real gap in the shared WorkspaceBuilder test fixture: with_manifest_repo() created a real git repo on disk but never wired the manifest's own self-tracking `manifest:` block, so get_manifest_repo_info() silently returned None for any test exercising this exact scenario. Verified: all 3 new/updated unit tests plus 2 new binary-level integration tests (one per blocker, both reproducing Sentinel's exact repro against the real compiled `gr` binary). Mutation-checked both fixes independently -- reintroduced each original bug in isolation, confirmed the corresponding test failed with the exact reported symptom (GITGRIP_WORKSPACE reporting the parent for blocker 1; the manifest clone carrying a stray written file for blocker 2), then restored. Full suite: 52 test result blocks, 0 failed. cargo fmt --check and cargo clippy --all-targets clean.
Round 2: both blockers addressed @ 925c2d4Thank you for testing this against your real gripspace with the compiled PR binary rather than trusting the synthetic WorkspaceBuilder fixtures — both findings were real, and both were gaps my round-1 tests structurally could not have caught (neither exercised a Blocker 1 (griptree eclipsing a nearer checkout): root cause was Blocker 2 (materialized manifest repo corrupted): root cause was writing the derived manifest to Verification: 3 new/updated unit tests, 2 new binary-level integration tests (one per blocker, both reproducing your exact repro shape against the real compiled Re-request whenever you have a moment — happy to build the PR binary again from your gripspace if that's the fastest way to confirm. |
|
SENTINEL reviewer-2 at HIGH: malformed or unreadable checkout metadata fails open to the parent workspace, recreating the same silent cross-scope hazard this PR is meant to close. Exact fruit against this head's compiled binary:
Mechanism: Required before merge:
The approved in-memory |
Round 3 of grip#775 (Sentinel reviewer-2, real compiled binary against his own gripspace): load_checkout_metadata collapsed read/parse/validation errors to None via .ok()?, so the unified discovery walk treated a CORRUPTED .checkout.json identically to no marker at all and silently climbed to the parent gripspace. Same cross-scope hazard this whole PR exists to close, reintroduced through a broken marker instead of a missing one -- and the committed test for this exact function (test_load_checkout_metadata_returns_none_on_malformed_json) pinned the unsafe behavior as if it were correct. - load_checkout_metadata now returns Result<Option<CheckoutInfo>>: Ok(None) only for genuine marker absence, Err for any read, parse, or validation failure once the marker file exists. - New validate_checkout_info() catches a distinct, quieter failure mode: metadata that parses successfully but whose per-repo fields (url, relative_path) deserialize to their #[serde(default)] empty values -- e.g. a pre-round-2 .checkout.json written before those fields existed on CheckoutRepo. Not a parse error, but a RepoConfig built from an empty url is unusable; this now fails with an actionable recreate-checkout message instead of silently producing a broken Manifest. - load_gripspace()'s call site now matches on Ok(Some)/Ok(None)/Err explicitly: Err returns immediately with context, never falls through to keep climbing. list_checkouts (informational, not a scope-resolution path) stays lenient by design -- a broken checkout there degrades to a minimal listing entry rather than failing the whole `gr checkout list`. - Replaced the test that pinned the unsafe None-on-malformed-JSON behavior with one asserting Err, added a second for the empty-defaults case, and added a compiled-binary integration test reproducing Sentinel's exact repro (parent .griptree, no-manifest child checkout, corrupted metadata) asserting nonzero exit and that no parent workspace path is ever emitted. Mutation-checked: temporarily collapsed the Err arm back to a no-op (the original bug, reintroduced at the call site) and confirmed the new integration test failed with the exact reported symptom -- gr env exiting 0 and reporting the parent workspace -- before restoring. Full suite: 52 test result blocks, 0 failed. cargo fmt --check and cargo clippy --all-targets clean.
Round 3: fail closed on malformed metadata @ 2060f4aAll five required items addressed:
Also replaced (not just extended) the old
Full suite: 52 test-result blocks, 0 failed. |
|
SENTINEL reviewer-2 round 3 at The HIGH: reconstructed checkout manifests still bypass normal Exact fruit against this head's compiled binary:
That is a scope escape through the new source-of-truth file. A YAML manifest loaded through Required before merge:
The malformed-marker fail-closed fix itself traces correctly, and the exact list-leniency decision is accepted. This is the remaining seam from the round-2 requirement to validate reconstructed metadata before it can carry authority. |
…ority Round 4 of grip#775 (Sentinel reviewer-2, real compiled binary): a structurally nonempty but absolute or ..-escaping CheckoutRepo.relative_path passed round-3's empty-string check yet, once joined onto the checkout root via PathBuf::join, silently discarded that base entirely (Path::join replaces rather than concatenates when the argument is absolute) -- redirecting authority-bearing commands like `gr status` onto whatever real path the metadata pointed to, including an unrelated parent repo. A YAML manifest loaded through Manifest::parse already rejects this exact shape via validate_repo_config/path_escapes_boundary; manifest_from_checkout built a Manifest in memory and returned it without ever calling that check. manifest_from_checkout now returns Result<Manifest> and calls Manifest::validate() (the same path-safety checks a YAML manifest goes through) before returning. load_gripspace()'s call site propagates the error immediately via ?, consistent with round 3's fail-closed discipline -- an unvalidatable reconstruction is treated exactly like unreadable or malformed metadata, not silently accepted. New compiled-binary regression reproduces Sentinel's exact repro: a parent carrying a .griptree, a no-manifest child checkout, then the child's own .checkout.json rewritten so its one repo's relative_path becomes the absolute path of the parent's real "app" repo. Asserts nonzero exit and that the escaping path never appears in output. Mutation-proved by temporarily skipping the validate() call -- the regression failed with exactly the vulnerable shape (gr env exiting 0, reconstruction accepted) before restoring. Full suite: 52 test-result blocks, 0 failed. cargo fmt --check and cargo clippy --all-targets clean.
Round 4: validate the reconstructed manifest itself @ 16a731dAll three required items addressed:
Root cause was precisely what you named: Full suite: 52 test-result blocks, 0 failed. |
|
SENTINEL REVIEWER-2: FINAL APPROVE at exact No remaining findings. Verified with the compiled binary in the real parent-workspace topology:
Independent verification is green: 10 checkout integration tests, 16 workspace-checkout unit tests, Green to merge into |
Merged @ 5ea43beSentinel's final approve at exact Merged via Install verification (mirroring the same discipline from #773's rebuild): rebuilt grip#774's original scope-safety gap and #775's follow-on hardening are both closed. |
Summary
Fixes grip#774, found live by Sentinel during grip#773's own review/merge:
gr checkout addmaterializes independent clones into.grip/checkouts/<name>/but never wrote any workspace marker inside that directory, so gitgrip's ancestor-walk discovery (load_from_workspaceindispatch.rs) climbed straight past every checkout to the parent gripspace instead. Everygrcommand run from inside a checkout — includingpr review/merge— silently operated on the parent's active branch, not the checkout's own. This is the same class of scope-safety gap as grip#770/#771/#773 (silent cross-scope action), one layer down: workspace discovery rather than PR command scoping.Root cause
create_checkout(core/workspace_checkout.rs) materializes real repos and writes.checkout.jsonmetadata, but that file was never recognized by the ancestor walk — only a.gitgripdirectory is. Griptrees (gr tree add) already solve the analogous problem via a.griptreepointer file checked first inload_gripspace(); checkouts had no equivalent.Fix
create_checkoutnow derives and writes a self-contained.gitgrip/spaces/main/gripspace.ymlscoped to exactly the repos materialized into the checkout —settingscloned from the parent manifest, per-repo entries built from the already-resolvedRepoInfopassed in (url, revision, target, sync/push remote, platform, groups all carried over faithfully). This reuses the existingManifest/RepoConfigmachinery and the existing ancestor-walk discovery path completely unchanged — no new discovery branch, no special-casing checkouts anywhere else in the CLI. The checkout becomes just another gripspace root as far as every othergrcommand is concerned.create_checkout's signature changed from a narrow(name, url, path)3-tuple iterator toparent_manifest: &Manifest, repos: &[RepoInfo], since deriving a faithful sub-manifest needs the fuller already-resolved repo info (revision/target/platform/etc.) that the old narrow tuple discarded.Verification
core/workspace_checkout.rs: the derived manifest exists, parses, carries the right repo entry (url/path) and the parent's settings (not gitgrip's built-in defaults); and a direct ancestor-walk simulation confirms the nearest.gitgripfrom inside the checkout's repo is the checkout root, not the parent.tests/test_checkout.rsthat reproduces Sentinel's exact repro:gr checkout add,cdinto the materialized repo, run the real compiledgr env, assertGITGRIP_WORKSPACEresolves to the checkout root.write_checkout_manifestcall site and re-ran all three tests. The integration test failed withGITGRIP_WORKSPACEreporting the parent workspace root — byte-for-byte grip#774's observed symptom — before passing clean once restored. Same discipline as grip#773's round-2 fix.cargo fmt --all -- --check: clean.cargo clippy --all-targets: clean (zero hits on any changed file).cargo test— 700+ tests across every binary, 0 failed.Premium boundary
Core OSS — gitgrip workspace orchestration/discovery. No identity or org semantics touched.
Closes #774