Fix Push Error Handling and Add Cluster Trust Gate

The last commits try to fix this issue:
git push [entire] Pushing entire/checkpoints/v1 to origin.... [entire] Syncing entire/checkpoints/v1 with remote....[entire] Detected disconnected session metadata (local and remote share no common ancestor)
[entire] Warning: couldn't sync entire/checkpoints/v1: metadata reconciliation failed: failed to collect local commits: commit chain exceeded 1000 commits without reaching root; aborting reconciliation Everything up-to-date
The problem seems to be related to having a shallow clone of the checkpoint metadata branch, and for some reason the CLI is losing track of the local commits, which then causes it to think loads of commits are local, when in fact there is only a handful commits locally and the base commit is incorrect. Review the changes and ensure that first the tests confirm the problem, and then secondly that the fix fixes it.
I'll start by using the systematic-debugging skill since this is a bug investigation, and I need to understand the recent changes and verify both the test coverage and the fix.
Base directory for this skill: /home/coder/.claude/plugins/cache/claude-plugins-official/superpowers/5.1.0/skills/systematic-debugging
Systematic Debugging
Overview
Random fixes waste time and create new bugs. Quick patches mask underlying issues.
Core principle: ALWAYS find root cause before attempting fixes. Symptom fixes are failure.
Violating the letter of this process is violating the spirit of debugging.
The Iron Law
If you haven't completed Phase 1, you cannot propose fixes.
When to Use
Use for ANY technical issue:
- Test failures
- Bugs in production
- Unexpected behavior
- Performance problems
- Build failures
- Integration issues
Use this ESPECIALLY when:
- Under time pressure (emergencies make guessing tempting)
- "Just one quick fix" seems obvious
- You've already tried multiple fixes
- Previous fix didn't work
- You don't fully understand the issue
Don't skip when:
- Issue seems simple (simple bugs have root causes too)
- You're in a hurry (rushing guarantees rework)
- Manager wants it fixed NOW (systematic is faster than thrashing)
The Four Phases
You MUST complete each phase before proceeding to the next.
Phase 1: Root Cause Investigation
BEFORE attempting ANY fix:
-
Read Error Messages Carefully
- Don't skip past errors or warnings
- They often contain the exact solution
- Read stack traces completely
- Note line numbers, file paths, error codes
-
Reproduce Consistently
- Can you trigger it reliably?
- What are the exact steps?
- Does it happen every time?
- If not reproducible → gather more data, don't guess
-
Check Recent Changes
- What changed that could cause this?
- Git diff, recent commits
- New dependencies, config changes
- Environmental differences
-
Gather Evidence in Multi-Component Systems
WHEN system has multiple components (CI → build → signing, API → service → database):
BEFORE proposing fixes, add diagnostic instrumentation:
Example (multi-layer system):
This reveals: Which layer fails (secrets → workflow ✓, workflow → build ✗)
-
Trace Data Flow
WHEN error is deep in call stack:
See
root-cause-tracing.mdin this directory for the complete backward tracing technique.Quick version:
- Where does bad value originate?
- What called this with bad value?
- Keep tracing up until you find the source
- Fix at source, not at symptom
Phase 2: Pattern Analysis
Find the pattern before fixing:
-
Find Working Examples
- Locate similar working code in same codebase
- What works that's similar to what's broken?
-
Compare Against References
- If implementing pattern, read reference implementation COMPLETELY
- Don't skim - read every line
- Understand the pattern fully before applying
-
Identify Differences
- What's different between working and broken?
- List every difference, however small
- Don't assume "that can't matter"
-
Understand Dependencies
- What other components does this need?
- What settings, config, environment?
- What assumptions does it make?
Phase 3: Hypothesis and Testing
Scientific method:
-
Form Single Hypothesis
- State clearly: "I think X is the root cause because Y"
- Write it down
- Be specific, not vague
-
Test Minimally
- Make the SMALLEST possible change to test hypothesis
- One variable at a time
- Don't fix multiple things at once
-
Verify Before Continuing
- Did it work? Yes → Phase 4
- Didn't work? Form NEW hypothesis
- DON'T add more fixes on top
-
When You Don't Know
- Say "I don't understand X"
- Don't pretend to know
- Ask for help
- Research more
Phase 4: Implementation
Fix the root cause, not the symptom:
-
Create Failing Test Case
- Simplest possible reproduction
- Automated test if possible
- One-off test script if no framework
- MUST have before fixing
- Use the
superpowers:test-driven-developmentskill for writing proper failing tests
-
Implement Single Fix
- Address the root cause identified
- ONE change at a time
- No "while I'm here" improvements
- No bundled refactoring
-
Verify Fix
- Test passes now?
- No other tests broken?
- Issue actually resolved?
-
If Fix Doesn't Work
- STOP
- Count: How many fixes have you tried?
- If < 3: Return to Phase 1, re-analyze with new information
- If ≥ 3: STOP and question the architecture (step 5 below)
- DON'T attempt Fix #4 without architectural discussion
-
If 3+ Fixes Failed: Question Architecture
Pattern indicating architectural problem:
- Each fix reveals new shared state/coupling/problem in different place
- Fixes require "massive refactoring" to implement
- Each fix creates new symptoms elsewhere
STOP and question fundamentals:
- Is this pattern fundamentally sound?
- Are we "sticking with it through sheer inertia"?
- Should we refactor architecture vs. continue fixing symptoms?
Discuss with your human partner before attempting more fixes
This is NOT a failed hypothesis - this is a wrong architecture.
Red Flags - STOP and Follow Process
If you catch yourself thinking:
- "Quick fix for now, investigate later"
- "Just try changing X and see if it works"
- "Add multiple changes, run tests"
- "Skip the test, I'll manually verify"
- "It's probably X, let me fix that"
- "I don't fully understand but this might work"
- "Pattern says X but I'll adapt it differently"
- "Here are the main problems: [lists fixes without investigation]"
- Proposing solutions before tracing data flow
- "One more fix attempt" (when already tried 2+)
- Each fix reveals new problem in different place
ALL of these mean: STOP. Return to Phase 1.
If 3+ fixes failed: Question the architecture (see Phase 4.5)
your human partner's Signals You're Doing It Wrong
Watch for these redirections:
- "Is that not happening?" - You assumed without verifying
- "Will it show us...?" - You should have added evidence gathering
- "Stop guessing" - You're proposing fixes without understanding
- "Ultrathink this" - Question fundamentals, not just symptoms
- "We're stuck?" (frustrated) - Your approach isn't working
When you see these: STOP. Return to Phase 1.
Common Rationalizations
| Excuse | Reality |
|---|---|
| "Issue is simple, don't need process" | Simple issues have root causes too. Process is fast for simple bugs. |
| "Emergency, no time for process" | Systematic debugging is FASTER than guess-and-check thrashing. |
| "Just try this first, then investigate" | First fix sets the pattern. Do it right from the start. |
| "I'll write test after confirming fix works" | Untested fixes don't stick. Test first proves it. |
| "Multiple fixes at once saves time" | Can't isolate what worked. Causes new bugs. |
| "Reference too long, I'll adapt the pattern" | Partial understanding guarantees bugs. Read it completely. |
| "I see the problem, let me fix it" | Seeing symptoms ≠ understanding root cause. |
| "One more fix attempt" (after 2+ failures) | 3+ failures = architectural problem. Question pattern, don't fix again. |
Quick Reference
| Phase | Key Activities | Success Criteria |
|---|---|---|
| 1. Root Cause | Read errors, reproduce, check changes, gather evidence | Understand WHAT and WHY |
| 2. Pattern | Find working examples, compare | Identify differences |
| 3. Hypothesis | Form theory, test minimally | Confirmed or new hypothesis |
| 4. Implementation | Create test, fix, verify | Bug resolved, tests pass |
When Process Reveals "No Root Cause"
If systematic investigation reveals issue is truly environmental, timing-dependent, or external:
- You've completed the process
- Document what you investigated
- Implement appropriate handling (retry, timeout, error message)
- Add monitoring/logging for future investigation
But: 95% of "no root cause" cases are incomplete investigation.
Supporting Techniques
These techniques are part of systematic debugging and available in this directory:
root-cause-tracing.md- Trace bugs backward through call stack to find original triggerdefense-in-depth.md- Add validation at multiple layers after finding root causecondition-based-waiting.md- Replace arbitrary timeouts with condition polling
Related skills:
- superpowers:test-driven-development - For creating failing test case (Phase 4, Step 1)
- superpowers:verification-before-completion - Verify fix worked before claiming success
Real-World Impact
From debugging sessions:
- Systematic approach: 15-30 minutes to fix
- Random fixes approach: 2-3 hours of thrashing
- First-time fix rate: 95% vs 40%
- New bugs introduced: Near zero vs common
Only the test file changed; the source is back to its committed state. Here's my review.
Review summary
The fix is correct and the root-cause diagnosis matches your description. The chain was:
- Checkpoint metadata (
entire/checkpoints/v1) is routinely shallow —FetchMetadataTreeOnlyfetches the tip with--depth=1and nothing deepens it. - On a shallow clone,
git merge-basereports "no common ancestor" whenever the real ancestor lives below the shallow boundary (git has no objects there). isDisconnectedtrusted that exit code as proof of disconnection → a normal diverged-but-behind branch was misread as disconnected → the doomed full-history cherry-pick reconcile fired →collectCommitChainblew the 1000-commit cap. Exactly theEverything up-to-date+exceeded 1000 commitssymptom you hit.
The fix (metadataDisconnected) suppresses a merge-base miss when the metadata history reaches a shallow boundary, and doctor deepens first (gated, --deepen-scoped) so genuine disconnections are still caught.
Did the tests confirm the problem?
I verified by reverting the fix in-place:
TestMetadataDisconnected_ShallowSuppressesFalsePositivefails whenmetadataDisconnectedis reverted to the oldisDisconnected-only behavior. ✓ It genuinely reproduces the root cause.TestCollectCommitChain_StopsAtShallowBoundary/_DepthLimitcover the walk respecting.git/shallowand the cap.
Gap I found and closed
The existing detection test marks both the local and remote tips shallow. But the real incident is local complete/deep, only the freshly-fetched remote tip shallow-bounded — I confirmed this in a scratch repo: rev-list <localTip> never touches the boundary, so the suppression depends entirely on the remote-hash check. I proved the gap: a regression that checks only the local hash (hasReachableShallowBoundary(..., localHash)) still passed the existing test, yet would leave production broken.
I added two tests that close it (both fail under that regression, both pass with the fix):
TestMetadataDisconnected_ShallowRemoteTipDeepLocal— production-faithful detection case; asserts the local chain alone doesn't reach the boundary, isolating the remote-hash contribution.TestReconcileDisconnected_ShallowCloneNoOps— exercisesReconcileDisconnectedMetadataRefend-to-end with the real push-hook ref names (entire/checkpoints/v1local +originremote-tracking), asserting it no-ops instead of aborting — the directly reported symptom.
All of strategy and checkpoint/remote pass; mise run fmt + mise run lint are clean (0 issues).
One untested-but-adjacent item I deliberately left alone: MetadataHistoryShallowBounded (the doctor deepen gate from the P2 review fix) has no direct unit test — its underlying helper is now exercised by the new tests, but the "unrelated shallow boundary must not trigger a deepen" path is only validated at the helper level. That's best covered as a doctor integration test if you want it; say the word and I'll add it.
I moved the new tests to the main branch as I want to run them there. Fix the build errors
Build errors fixed. Here's what I did to port the strategy-level fix to main:
Production code (cmd/entire/cli/strategy/metadata_reconcile.go):
- Added
metadataDisconnected(ctx, repo, repoPath, localHash, remoteHash)— the shallow-aware wrapper that suppresses agit merge-basemiss when the metadata history reaches a shallow boundary. - Wired both callers to it instead of calling
isDisconnecteddirectly:IsMetadataDisconnected(warn/doctor hot path) andReconcileDisconnectedMetadataRef(push/doctor fix path). hasReachableShallowBoundarywas already present on main (common.go), so no port needed there.
Test code (metadata_reconcile_test.go):
- Added the
gitRunnerInDirhelper used by the four shallow-clone tests.
Verification:
go build ./cmd/entire/cli/...— clean (the earlier doctor.go diagnostic was stale; doctor.go on main doesn't reference the deepen helpers).- All 22 reconcile/disconnect tests pass, including the two I added (
TestMetadataDisconnected_ShallowRemoteTipDeepLocal,TestReconcileDisconnected_ShallowCloneNoOps). mise run fmt+mise run lint— 0 issues.
Two things to flag:
- I only ported the strategy-level fix — the part the tests and build needed. The original branch also had
doctorchanges (DeepenMetadataBranch,MetadataHistoryShallowBounded, ref-scoped--deepen, the post-deepen repo reopen). Those aren't required for the build or these tests and main'sdoctor.godoesn't reference them, so I left them out. Say the word if you want the doctor-side fix on main too. - The test file is still in an unmerged state (
git statusshowsboth modified: ...metadata_reconcile_test.go—UU). Content is resolved and builds/passes, but git still considers the merge unfinished. You'll want togit addit to mark it resolved. I didn't stage or commit anything.
They operate at different layers — one resolves refs, the other does the actual ancestry decision.
IsMetadataDisconnected (exported, metadata_reconcile.go:31) — the ref-resolving entry point:
- Takes a
repo+ aremoteRefName. - Resolves the local ref from
checkpoint.ResolveCommittedRefs(ctx).Primaryand looks up the remote ref by name. - Handles the "nothing to compare" cases early, returning
false(connected):- local ref missing →
false - remote ref missing →
false - both hashes equal →
false
- local ref missing →
- Only once it has two distinct, existing hashes does it delegate to
metadataDisconnected.
metadataDisconnected (unexported, metadata_reconcile.go:212) — the decision logic on two concrete hashes:
- Takes
repo,repoPath, and the two hash strings directly (no ref lookup, no equality/missing checks). - Runs
isDisconnected(git merge-base). - If merge-base finds an ancestor →
false. - If merge-base misses, it doesn't trust that blindly: it calls
hasReachableShallowBoundary. If either history reaches a.git/shallowboundary, the miss is treated as a false positive (shallow clone hiding the real ancestor) → returnsfalseand logs a debug line. Only a miss on a non-shallow history returnstrue.
In short: IsMetadataDisconnected = "figure out which two commits to compare, and short-circuit the trivial cases," then hands off to metadataDisconnected = "given these two commits, are they genuinely disconnected, accounting for shallow clones."
metadataDisconnected is also called directly by ReconcileDisconnectedMetadataRef, which does its own ref resolution (with different missing-ref semantics — it returns early with nil rather than a false verdict), so the shared decision logic was factored out into metadataDisconnected rather than living inside IsMetadataDisconnected.
What entire commands need the commits DAG for the metadata branch? Or in other words, what commands (or actions) can be affected by this?
I have the full call graph now. Here's the precise picture.
Key distinction: tip-tree reads vs. DAG walks
Almost all checkpoint reads only need the tip tree of entire/checkpoints/v1 — the sharded <id>/…/metadata.json files at the branch head. Those don't care about ancestry and aren't affected.
Only three kinds of operation walk the commit DAG (merge-base, rev-list, or first-parent chain), and those are what the shallow-clone bug hit:
- Disconnection detection —
metadataDisconnected/isDisconnected(merge-base) +hasReachableShallowBoundary(rev-list) - Reconciliation —
collectCommitChain(first-parent walk) + cherry-pick - Safe ref advancement / rebase —
getMergeBase+ replay (SafelyAdvanceLocalRef,collectCommitsSince)
Commands/actions that do real DAG work (can break)
| Command / action | Trigger | DAG work |
|---|---|---|
git push | pre-push hook → push_common.go | fetch → ReconcileDisconnectedMetadataRef (merge-base + collectCommitChain) → merge-base rebase of local-only commits. This is the originally reported failure. |
entire doctor | doctor.go | IsMetadataDisconnected (detect) + ReconcileDisconnectedMetadataRef (fix) |
entire session resume | resume.go | FetchMetadataTreeOnly (the --depth=1 that creates the shallow boundary) → FetchMetadataBranch → SafelyAdvanceLocalRef (merge-base + replay) |
| Metadata fetch/promote | FetchMetadataBranch / fetchMetadataFromOrigin → PromoteTmpRefSafely / SafelyAdvanceLocalRef | used by resume and dispatch-local; merge-base + replay |
Commands that only do the advisory merge-base check (WarnIfMetadataDisconnected)
These call the network-free, best-effort disconnection check (local ref vs. existing origin/… remote-tracking ref) purely to print the "branches disconnected, run entire doctor" warning — they don't reconcile. Before the fix, on a shallow clone these would emit a false warning; they wouldn't error:
entire checkpoint list/entire status→ListCheckpointsentire checkpoint explain/entire explain→explain.goentire checkpoint rewind→manual_commit_rewind.go- session-log / rewind-point reads →
getCheckpointLog,listCheckpoints(condensation read helpers)
Not DAG-dependent (unaffected)
git commit(prepare-commit-msg / post-commit condensation) — appends a commit to the branch tip; it builds the DAG but doesn't walk ancestry. (Its read helpers do call the advisory warn, so it's in the list above only for that.)entire cleanand actual checkpoint content reads — tip-tree reads viaListCommitted, no merge-base.
Bottom line
The blast radius is anything that syncs or repairs the metadata branch against origin on a shallow clone: git push (most affected — it both detected and rebased), entire doctor, and entire session resume / metadata fetch. Everything else either reads the tip tree (unaffected) or only emitted a spurious warning. Notably, resume's --depth=1 fetch is what introduces the shallow boundary that the push/doctor paths then trip over — so the bug typically manifests as: resume on a fresh machine → later git push blows up.
I undid the changes. Create a test that can reproduce the 1000 commit limit symptom based on a shallow clone of the metadata.
Done. The source file is unchanged (only the test file gained the new test), the test passes against the fix, and lint is clean.
What I added
TestReconcileDisconnected_ShallowClone1000CommitSymptom in metadata_reconcile_test.go (plus a small buildEmptyTreeCommit plumbing helper), reproducing the exact reported failure.
Scenario modelled (faithful to the incident):
- A shared ancestor
B, a remote tipCon top ofB, and a local metadata branch deeper thanMaxCommitTraversalDepth(1002 commits) on top ofB— a normal diverged-but-behind branch. - Only the remote tip is marked shallow (
.git/shallow = {C}), exactly what a--depth=1metadata fetch (resume/explain) produces — severingC→Bsogit merge-basereports "no common ancestor". - Refs are published as the push hook sees them: local
refs/heads/entire/checkpoints/v1+refs/remotes/origin/entire/checkpoints/v1.
It builds the deep chain via go-git plumbing (loose objects git CLI can read), so 1002 commits cost ~0.15s rather than 1002 git commit spawns.
Verification (both directions):
- Against the current fixed code → passes:
metadataDisconnectedrecognises the merge-base miss as a shallow artifact (the remote tip reaches the boundary) and no-ops; the local ref is left untouched. - With the suppression temporarily reverted → fails with the exact production error:
So it's a true regression guard: red on the bug, green on the fix. A precondition assertion (isDisconnected returns true on the shallow clone) documents that the merge-base miss — the trigger — is genuinely present.
One note: this drives ReconcileDisconnectedMetadataRef directly (the function that emitted the failed to collect local commits wrapper in the reported log). It doesn't spin up the full pre-push hook, so it doesn't assert the surrounding [entire] Warning: couldn't sync… text — that's produced one layer up in push_common.go. If you want the symptom reproduced at that outer layer too, I can add a push-path test, but this one pins the actual point of failure.
Here's the complete picture — there's exactly one code path that proactively creates a shallow boundary on the metadata branch.
The single proactive shallow fetch
FetchMetadataTreeOnly (git_operations.go:420) → fetchMetadataFromOrigin(…, fetchMetadataOpts{Shallow: true}) → remote.Fetch with Shallow: true → appends --depth=1 (checkpoint/remote/git.go:75).
Its own doc comment is explicit: "May leave .git/shallow set; FetchMetadataBranch will undo that when full ancestry is later needed." That .git/shallow entry is precisely what the merge-base false-positive (and the 1000-commit symptom) is built on.
Who triggers it
FetchMetadataTreeOnly has exactly one caller: getMetadataTree (resume.go:319), the "resolve the metadata tip cheaply" helper. It runs the tip-only fetch unconditionally ("always runs so the local lookup below doesn't return stale data"). getMetadataTree is reached from two commands:
entire session attach— viarefreshCheckpointRefs(attach.go:453)entire checkpoint explain(the export/prefix-match path) — viamatchCheckpointPrefixWithRemoteFallback(explain_export.go:195)
So a --depth=1 boundary gets planted whenever someone runs entire session attach or entire checkpoint explain and the metadata tree needs resolving from origin. A later git push (or entire doctor) then trips over that boundary — matching the "attach/explain on a fresh machine, then push blows up" sequence.
What is NOT shallow
FetchMetadataBranch(git_operations.go:412) — full fetch (NoFilter: true), and its comment notes it deliberately does not--unshallow(global property → would deepen unrelated branches). resume falls back to this when the tree-only probe is insufficient.DeepenMetadataBranch(git_operations.go) — the opposite operation: a ref-scoped--deepenthatentire doctorruns to undo the shallow boundary before checking for disconnection.- The push path (
push_common.go) fetches withoutShallowand explicitly refuses to--unshallow.
Bottom line: the shallow boundary originates solely from FetchMetadataTreeOnly's --depth=1, reached by entire session attach and entire checkpoint explain. Nothing else proactively shallow-fetches the metadata branch.
Is there any chance that different commands may be updating the shallow boundaries incorrectly? Otherwise, what could justify sometimes the merge base being completely lost and think that there are thousands of commits locally that are not in the remote?
I've now traced the full picture across the fetch orchestration, the ref-advance logic, and the orphan-creation path. Here's the analysis.
Part 1: Are commands updating .git/shallow incorrectly?
The CLI never hand-edits .git/shallow in production — only git does, via the fetch flags. So the boundaries themselves aren't corrupted. But the orchestration around them has two real defects:
1. The documented "undo shallow" never happens. FetchMetadataTreeOnly's doc says "FetchMetadataBranch will undo that [shallow] when full ancestry is later needed." But FetchMetadataBranch calls fetchMetadataFromOrigin(…, {NoFilter: true}) — Shallow:false, Unshallow:false, Deepen:0. A plain git fetch on a shallow repo does not remove .git/shallow and does not backfill below the boundary. So once any attach/explain plants a --depth=1 boundary, the resume "full-fetch fallback" silently leaves it in place. Shallowness is sticky — nothing in the normal flow ever clears it. Only entire doctor extends it.
2. The only deepen is bounded to 1000. DeepenMetadataBranch uses Deepen: strategy.MaxCommitTraversalDepth (= --deepen=1000), and keeps the repo shallow. If the true fork point sits more than 1000 commits below the boundary, even doctor's deepen won't reach it, so merge-base stays missing and doctor can't authoritatively resolve. The reconcile cap is the same 1000, so the two limits are at least consistent — but both cap out.
Repeated --depth=1 fetches (one per attach/explain) re-pin the boundary to the latest tip; git keeps boundaries still required by a reachable ref, so the local chain's boundary is normally preserved. It's fragile, not actively wrong.
Part 2: What makes merge-base completely lost + thousands of local commits?
That's not the shallow false-positive — that one has a hidden shared ancestor. A complete loss with a deep local line means the two histories genuinely share no ancestor. Two code-grounded mechanisms produce exactly that:
(A) Independent roots — the empty-orphan vector. EnsurePrimaryRef (common.go:456) creates a brand-new empty orphan root whenever the local ref is missing and origin/entire/checkpoints/v1 isn't present at that instant. The precondition is subtle: at line 462 it only reads the remote-tracking ref when PrimaryFetchableFromOrigin(), and if that ref hasn't been populated yet (no fetch, offline, or a skipped/failed bootstrap), remoteRef stays nil → line 510 mints a fresh orphan. The user then accumulates thousands of condensation commits (one per git commit) on that local-only root, while origin carries a different root. → merge-base genuinely empty, thousands of local commits absent from remote. This is the canonical disconnection the reconcile was written for.
(B) Remote branch rebuilt + stale objects. If entire/checkpoints/v1 on origin is ever rebuilt to a new disconnected history (another machine hitting (A) and pushing its orphan line, or a force-push/cleanup recreating the branch), and this clone still has the OLD history materialized in its pack from a prior full fetch as its local branch, then local (old line) vs remote (new line) share no ancestor, and the old line's thousands of objects are all present locally — so collectCommitChain happily walks them. This is precisely the case the collectCommitChain doc-comment calls out: "the walk would stroll past shallow boundaries into stale objects left in the pack… when the remote branch has been rebuilt since the last full fetch, producing a phantom chain."
How shallowness compounds (but isn't the root cause)
In both (A) and (B), shallowness makes detection worse, not the divergence itself:
- On a shallow clone, the warn/push paths can't distinguish a genuine disconnection from a shallow artifact, so
metadataDisconnectednow suppresses and defers to doctor — correct, but it means push silently stops repairing. - Doctor's
--deepen=1000can't help case (A) at all (no shared ancestor exists at any depth) and can't help (B) if the dead line is deeper than 1000. The only correct fix there is the cherry-pick reconcile — which the 1000-cap then refuses for very deep local lines. That's the dead-end loop the original report hit.
One mitigating nuance worth knowing: the fetch/resume path is already guarded — SafelyAdvanceLocalRef (common.go:152-159) refuses to walk when there's no merge-base on a shallow repo, erroring with "run entire doctor or git fetch --unshallow" instead of strolling. The unbounded walk only happens in ReconcileDisconnectedMetadataRef (push/doctor), which has no such pre-guard and relies entirely on the metadataDisconnected suppression.
Bottom line
- The CLI isn't corrupting
.git/shallow, but it creates shallow boundaries it can never fully clear (theFetchMetadataBranch"undo" is a no-op) and only ever deepens by a bounded 1000. - A completely lost merge-base with thousands of local commits is a genuine disconnection — overwhelmingly from
EnsurePrimaryRefminting a fresh orphan root when origin's tracking ref wasn't available (A), or a rebuilt remote branch leaving a stale deep local line (B) — not from the shallow false-positive.
If you want, I can: (1) fix FetchMetadataBranch to actually deepen/unshallow when full ancestry is requested (making its contract true), and/or (2) write a focused test reproducing mechanism (A) — orphan-root divergence with a deep local line — which is the more likely real-world cause than the pure shallow false-positive.
For shallow clones, what would be the impact to always only get the tip on the remote? The way checkpoints are structured it rarely has a conflict.
Verified the crux: all checkpoint data reads (ListCommitted → getSessionsBranchTree → WalkCheckpointShards) address the tip tree by sharded path. The commit DAG of entire/checkpoints/v1 is essentially transport, with one exception I found — getCheckpointAuthorFromRef (committed.go:2178) walks the commit log matching "Checkpoint: <id>" subjects to attribute authorship. That's the only thing that depends on commit-per-checkpoint granularity.
So your instinct is sound. Here's the impact assessment.
Why tip-only is viable
The metadata branch behaves like a content-addressed set keyed by shard path (<id[:2]>/<id[2:]>/…). Merging two states is a tree union, not a history replay. That means:
- You never need
merge-base→ the false-positive disconnection problem disappears entirely. - You never walk the local chain → the 1000-commit cap symptom is gone; reconcile becomes O(tree size), not O(history depth).
- It works uniformly on shallow and filtered (
blob:none) clones: a union tree is built from tree-entry hashes (mode+hash) only — remote blobs aren't needed to construct or push it, and the--depth=1fetch already brings the tip commit + full tree.
Mechanically it's a collapse of what the code already does: instead of cherryPickOnto applying per-commit deltas, you do one ApplyTreeChanges(remoteTipTree, localTipTree − remoteTipTree) → a single squash-merge commit parented on the remote tip. Push; on rejection, re-fetch tip and retry.
What you lose / must decide
-
Per-checkpoint author attribution.
getCheckpointAuthorFromRefwould return empty for squashed checkpoints (no"Checkpoint: <id>"subject survives). Fix: source author from the checkpoint's ownmetadata.json/session metadata in the tip tree rather than the commit. Worth confirming the field exists there before committing to this — it's the one real behavioral casualty. -
Deletions become ambiguous. With only two tips and no base, you can't distinguish "local added X" from "remote deleted X" → a naive union resurrects anything
entire clean/cleanup removed on the remote. Options: (a) accept it — a stale checkpoint reappears and gets re-cleaned next cycle (low harm for append-mostly data); or (b) keep tombstones. Given how rarely the branch deletes, (a) is probably fine but should be a conscious choice. -
Same-checkpoint concurrent edits. Two clones condensing the same checkpoint ID (multi-session) can produce different content at the same path. But within
<id>/sessions are themselves sharded (<id>/<n>/), so this is mostly additive too — the only true single-file conflict is the per-checkpoint summarymetadata.json(e.g.session_count/session list). That needs a deterministic merge rule (union the session list, recompute counts) rather than last-writer-wins. This is the residual of your "rarely conflicts" — accurate across checkpoints, not quite zero within one. -
Loss of commit-level provenance on v1 (when/by-whom each condensation landed). Mostly redundant with
metadata.json, modulo #1.
Net
For the failure you've been chasing, tip-only is a strict improvement: it removes the entire class of shallow-clone merge-base bugs and the deep-walk blowup, and it matches the data model (sharded, additive). The cost is concentrated in three rare edge cases — author attribution (needs re-sourcing), deletes (need a resurrection policy), and same-id concurrent summary merges (need a union rule). None of those are hot paths, and the first is the only hard dependency I can see in the current code.
If you want to pursue it, the cleanest framing is: make reconcile a mergeTipTrees(remoteTip, localTip) set-union producing one commit, and stop calling metadataDisconnected/collectCommitChain on the push path entirely. I can prototype that as a mergeTipTrees function plus a test (including the delete-resurrection and same-id-summary cases) so we can see the edge-case behavior concretely before deciding the policies — want me to?
Done. Prototype is in place, tests green, lint clean.
What I added
cmd/entire/cli/strategy/metadata_merge.go — mergeTipTrees(ctx, repo, remoteTreeHash, localTreeHash) (MergeTipResult, error):
- Pure tree-level set-union keyed by shard path. Reads only the two tip trees — never walks commit ancestry, so it's structurally immune to shallow boundaries, missing merge-bases, and deep histories.
- Built on the existing
FlattenTree+ApplyTreeChangesprimitives (I confirmedApplyTreeChangeskeys by path segment and ignoresEntry.Name, so feeding it flattened full-path entries is safe). - Returns observability:
TreeHash,AddedFromLocal,Conflicts. - Resolution: remote-only kept, local-only added, identical kept, differing → local wins + recorded as conflict. The doc spells out the deletion caveat explicitly.
metadata_merge_test.go — five tests, all passing:
| Test | Demonstrates |
|---|---|
AdditiveUnion | Common case — unique shards union cleanly, zero conflicts |
NoSharedAncestryStillUnions | The win — fully disconnected tips (the merge-base/1000-commit scenario) still merge, because no history is read |
DeleteResurrection | Downside, pinned — a remote-deleted shard is resurrected by a base-less union |
SameCheckpointConflict | Same checkpoint id on both sides: per-session files union additively; only the summary metadata.json truly conflicts (local-wins, reported) |
EmptyRemoteTip | Fresh/absent remote → everything local added |
What the edge cases tell us (the policy decisions)
- Deletes (
DeleteResurrection): a 2-way union can't tell "local added" from "remote deleted" → resurrection. Given how rarely the branch deletes (cleanup only), the cheapest policy is accept it (re-cleaned next cycle); the alternative is tombstones. This is now a conscious choice, not a surprise. - Same-id summary (
SameCheckpointConflict): the only genuine same-path conflict is the per-checkpoint summary file — sessions shard out additively. Blind local-wins loses the remote'ssession_count/session list, so the real implementation should do a semantic merge of that one file (union session list, recompute counts). TheConflictslist is what a caller would key off to do that.
Notes / next step
- I deliberately built the tree-level core only — no ref mutation, no commit creation, no push wiring — so the behavior is observable in isolation before committing to it.
mergeTipTreesis unexported and currently used only by tests (lint passes regardless). - The reverted fix is untouched; these are two new untracked files (
metadata_merge.go,metadata_merge_test.go). Nothing staged or committed.
If you want to take it further, the next increment is a MergeMetadataTips wrapper that creates the single squash commit on the remote tip and advances the local ref, then swapping the push path from ReconcileDisconnectedMetadataRef (merge-base + collectCommitChain) over to it — plus deciding the two policies above. Want me to do that wiring, or add the semantic summary-merge for the conflict file first?
Note that this branch uses linear history so delete resurrection should not really happen. Wire this up.
Now mergeTipTrees/MergeTipResult are superseded by MergeMetadataTip's internal diff. Let me check references and consolidate to avoid dead/duplicate code.