Skip to content

fix(explain): find checkpoints merged into non-default target branches#1727

Open
entire[bot] wants to merge 7 commits into
mainfrom
fix/931-ff-merge-session-refs
Open

fix(explain): find checkpoints merged into non-default target branches#1727
entire[bot] wants to merge 7 commits into
mainfrom
fix/931-ff-merge-session-refs

Conversation

@entire

@entire entire Bot commented Jul 12, 2026

Copy link
Copy Markdown

Trail: https://entire.io/gh/entireio/cli/trails/834

Fixes #931.

Summary

Merging a feature branch into a target branch dropped every session reference — checkpoint links and agent labels — from the target branch whenever the merge produced a merge commit and the target was not the repository's default branch. entire checkpoint list and entire checkpoint explain showed nothing for the merged work on that branch.

Root cause

getBranchCheckpoints and getAssociatedCommits scan history for Entire-Checkpoint trailers. On every branch that is not the repository default, they used a first-parent-only walk. Checkpoints brought in by a merge live on the merge commit's second parent, which a first-parent walk never visits — so the references vanished from the target branch.

A prior fix restored a full DAG walk, but only for the repository default branch (isOnDefault). Every other merge target stayed broken:

  • a non-default target such as a release branch, or
  • main itself, when git resolves the default branch elsewhere (e.g. origin/HEAD still points at master).

True fast-forward merges were already fine — the moved commits and their trailers become the target's linear (first-parent) history — which is why the symptom tracked "merge commit into a non-default target" rather than literally fast-forward.

Fix

Replace the first-parent walk on non-default branches with walkBranchOwnCommits: a DAG walk that follows every parent not reachable from the default branch, pruning (never descending into) commits shared with the default branch.

  • Discovers checkpoints merged in via second parents (the fast forward merge lost all session references in target branch #931 fix).
  • Still excludes the default branch's own history at the merge base — the exact property that motivated first-parent traversal — so "main merged into a feature branch" continues to exclude main's checkpoints, and the walk never explodes into main's full history.
  • Bounded by the existing commitScanLimit; zero new cost on write/commit/push paths.

One adjacent read-path gap closed alongside it

  • Deep-history leak in the reachable-from-default set. computeReachableFromMain marked only the most recent MaxCommitTraversalDepth (1000) commits of main's first-parent chain. A branch that merged main in long ago — with main since advanced past that bound — left the merged-in former-main-tip outside the set, so the walk descended into main's older history and surfaced main's checkpoints on the branch (the leak invariant, in reverse). The scan stays bounded (mainSpineScanLimit, ~60ms regardless of main length — an unbounded scan was multiple seconds on a repo with tens of thousands of commits and runs on every read); when it stops before main's root it falls back to a commit-date frontier — any commit at or before the oldest scanned main commit is treated as main's history. This is the same commit-date boundary git's own rev-list uses for main..HEAD: it keeps the deep-history leak closed (a former main tip merged in beyond the window is older than the frontier, so still pruned) while erring toward exclusion (past the bound it may hide a branch-unique commit older than the frontier rather than risk a leak). First-parent-only semantics are preserved, so merged-in feature commits are still shown, and the scan runs once per command.

Out of scope: the ephemeral (shadow-branch) counterpart

The same "first-parent misses a merge's second parent" shape exists in the temporary / in-progress (shadow-branch) read path (isShadowBranchReachable). Making it merge-aware trades one harm for another — a full-DAG walk leaks a default-branch session's in-progress checkpoints onto a branch that merged the default branch in, while pruning shared-with-main hides the active session on a brand-new branch (checkout -b then start coding, before the first commit). The two are the same topological case and irreducible without persisting the session's origin branch, a feature-sized write-path change. This PR therefore leaves the shadow path at its pre-existing first-parent-only behavior (isShadowBranchReachable is byte-identical to main) and tracks the fix as follow-up #1730. #931 itself is a committed-checkpoint read-path bug.

Testing

go test ./cmd/entire/cli/... ./cmd/entire/cli/strategy/... green; gofmt -s and golangci-lint (0 issues) clean; affected tests also pass under -race.

Traversal scoping is locked down in both directions — merged-in discovery and default-branch exclusion — and every case below is mutation-verified: removing the reachable-from-default prune fails every exclusion assertion; reverting to a first-parent-only walk fails every discovery assertion; restoring the depth limit fails the deep-history leak test.

  • TestGetBranchCheckpoints_NonDefaultTargetFindsMergedCheckpoints — the merged checkpoint is found on a non-default target, and a default-only checkpoint does not leak. The target is anchored at the default branch's tip so that default-only checkpoint is a genuine first-parent ancestor (previously it branched from the base commit, which made the "must not leak" assertion vacuous — it passed even with the prune removed).
  • TestGetBranchCheckpoints_MainMergedIntoFeatureExcludesMainCheckpoint — merging the default branch INTO a feature branch does not surface the default branch's checkpoints (they arrive on the merge's second parent).
  • TestGetBranchCheckpoints_OctopusMergeIntoNonDefaultTarget — an octopus merge (three feature parents) surfaces every merged checkpoint; default-branch checkpoints stay excluded.
  • TestGetBranchCheckpoints_NestedMergeIntoNonDefaultTarget — a transitively merged sub-feature checkpoint (sub-feature → feature A → release) is discovered.
  • TestGetBranchCheckpoints_DeepHistoryDoesNotLeakMainCheckpoint — with main advanced past the scan bound, an old main checkpoint merged in via a former-main-tip does not leak onto the branch (the commit-date frontier fail-safe prunes it). Fails without the fail-safe.
  • TestComputeReachableFromMain_ScanIsBounded — the main first-parent scan visits at most mainSpineScanLimit commits regardless of main length (no O(main) read-path blowup). Fails with an unbounded scan.
  • TestGetBranchCheckpoints_ComputesReachabilityOncePerCall — the scan runs once per getBranchCheckpoints call, not once per checkpoint.

Live before/after — same repo, non-default target release, feature merged via --no-ff

Verified end-to-end via the deterministic vogon hook-replay agent (real session hooks → shadow branch → condensed checkpoint on entire/checkpoints/v1), running a pre-fix binary and the fixed binary against the same repo.

Committed checkpoint (baseline pre-fix → fixed):

>>> entire checkpoint list        (pre-fix)          (fixed)
  branch       release            release            release
  checkpoints  0                  1  ● b7d5fa68…  "create a markdown file …"

Invariant re-checked live: on a feature branch with the default branch merged in (--no-ff), both the branch-point and the merged-in default-branch checkpoints stay excluded — the fixed and pre-fix binaries agree, confirming no leakage was introduced. The shadow (in-progress) path is unchanged from main, so the fresh-branch "checkout -b then start coding" session still shows exactly as before.

Edge matrix (live, isolated scratch repos)

Scenario Result
Non-default target, --no-ff merge commit (the bug) fixed — checkpoint visible
Non-default target, true --ff-only still works
Default branch (main), --no-ff and divergent merge still works (unchanged)
Multiple sessions on feature → ff-merge into main all 3 visible
Target branch has its own checkpoint + merged-in feature both visible
ff-merge performed from a linked worktree visible (list + explain)
Octopus merge (3+ parents) into non-default target all merged checkpoints visible
Nested merge (sub-feature → feature → release) transitive checkpoint visible
Deep history: main advanced >1000 commits past an old merge-main-in main's old checkpoints excluded (no leak)
Invariant: main merged INTO a feature branch main's checkpoints still excluded

Merging a feature branch into a target branch dropped every session
reference (checkpoint links and agent labels) from the target when the
merge produced a merge commit and the target was not the repository's
default branch.

getBranchCheckpoints and getAssociatedCommits scanned the branch with a
first-parent-only walk on every non-default branch. Checkpoints brought
in by a merge live on the merge commit's second parent, which a
first-parent walk never visits, so `entire checkpoint list` and
`entire checkpoint explain` showed nothing on the target branch. The
earlier default-branch fix restored a full DAG walk only for the repo
default branch, leaving every other merge target broken -- a release
branch, or even main when git resolves the default branch elsewhere.

Replace the first-parent walk on non-default branches with a DAG walk
(walkBranchOwnCommits) that follows every parent not reachable from the
default branch. This discovers checkpoints merged in via second parents
while still pruning the default branch's own history at the merge base --
the property that motivated first-parent traversal in the first place, so
"main merged into a feature branch" still excludes main's checkpoints.

True fast-forward merges were already correct (the moved commits and
their trailers become the target's linear history); this covers the
merge-commit case the fast-forward path could not.

Fixes #931
@suhaanthayyil suhaanthayyil changed the title Find checkpoints merged into non-default target branches (#931) fix(explain): find checkpoints merged into non-default target branches Jul 12, 2026
@suhaanthayyil
suhaanthayyil marked this pull request as ready for review July 12, 2026 01:20
@suhaanthayyil
suhaanthayyil requested a review from a team as a code owner July 12, 2026 01:20
Copilot AI review requested due to automatic review settings July 12, 2026 01:20

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Fixes checkpoint discovery on non-default target branches when work is merged via a merge commit (so checkpoint trailers live on non-first-parent ancestry). This restores entire checkpoint list / entire checkpoint explain visibility for merged work on branches like release.

Changes:

  • Replace non-default-branch first-parent traversal with a branch-scoped DAG walk (walkBranchOwnCommits) that follows non-default history including merge second parents while pruning the default branch’s spine.
  • Update getAssociatedCommits and getBranchCheckpoints to use the new walk on non-default branches.
  • Add/adjust tests covering non-default merge-target behavior and updated associated-commit expectations.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.

File Description
cmd/entire/cli/explain.go Introduces walkBranchOwnCommits and uses it to find checkpoint trailers on non-default branches through merge second parents.
cmd/entire/cli/explain_test.go Updates expectations for associated commits and adds a regression test for non-default merge targets.
Comments suppressed due to low confidence (1)

cmd/entire/cli/explain.go:1404

  • getAssociatedCommits now uses walkBranchOwnCommits, which does not guarantee commit order (it’s a BFS over parents). The returned slice is used to pick the author from commits[0] and is also displayed to users, so non-deterministic / non-recency ordering can produce incorrect author attribution and confusing output. Sort the collected commits by committer time (descending) before returning so both code paths are stable.
		err = walkBranchOwnCommits(ctx, repo, head.Hash(), reachableFromMain, commitScanLimit, func(c *object.Commit) error {
			cpID, found := trailers.ParseCheckpoint(c.Message)
			if found && cpID.String() == targetID {
				collectCommit(c)
			}
			return nil
		})

Comment thread cmd/entire/cli/explain.go Outdated
Comment thread cmd/entire/cli/explain_test.go
Strengthen the issue #931 regression tests so the branch-scoped walk
(walkBranchOwnCommits) is verified in both directions — merged-in
discovery and default-branch exclusion — and add adversarial merge
topologies.

- Anchor the "release" target at master's tip in
  TestGetBranchCheckpoints_NonDefaultTargetFindsMergedCheckpoints so the
  master-only checkpoint is a genuine first-parent ancestor. It previously
  branched from the base commit, leaving the "must not leak" assertion
  vacuous (that checkpoint was unreachable regardless of pruning); the
  assertion now fails if the reachable-from-default prune is removed.
- Add TestGetBranchCheckpoints_MainMergedIntoFeatureExcludesMainCheckpoint:
  merging the default branch INTO a feature branch must not surface the
  default branch's checkpoints, which arrive on the merge's second parent.
- Add TestGetBranchCheckpoints_OctopusMergeIntoNonDefaultTarget: an octopus
  merge (three feature parents) surfaces every merged checkpoint.
- Add TestGetBranchCheckpoints_NestedMergeIntoNonDefaultTarget: a
  transitively merged sub-feature checkpoint is discovered.

Each exclusion assertion fails when the prune is removed and each discovery
assertion fails under a first-parent-only walk, so the traversal scoping is
locked down in both directions.
…rge-aware

Two read-path gaps in the issue #931 area, both mutation-verified.

computeReachableFromMain walked only the last MaxCommitTraversalDepth (1000)
commits of main's first-parent chain. A branch that merged main in long ago
-- with main since advanced past that bound -- left the merged-in
former-main-tip outside the reachable set, so walkBranchOwnCommits descended
into main's older history and surfaced main's checkpoints on the branch.
Walk main's entire first-parent chain (no depth bound); this runs only on
the read path (checkpoint list/explain), so the linear scan is acceptable.

isShadowBranchReachable (the ephemeral/shadow-branch path) still used a
first-parent-only walk. A shadow branch whose base commit entered history
via a merge's second parent -- an in-progress session on a feature branch
merged into a non-default target -- was judged unreachable and its temporary
checkpoints dropped, the ephemeral counterpart of #931. Replace it with a
full-DAG reachability walk (isCommitReachable), consistent with the
committed-checkpoint path's merge awareness.

Tests: deep-history leak (main advanced past the old bound); shadow base on
a merge second parent (unit + end-to-end via the ephemeral store). Each
fails under its pre-fix behavior (depth-limited set; first-parent-only
reachability).
…il-safe

computeReachableFromMain walked main's entire first-parent history -- a prior
change removed the depth bound to stop a deep-history leak. That turned a
bounded read-path step into an O(main) traversal (~0.6s at 10k commits, ~5s at
30k) run synchronously on every checkpoint list/explain.

Restore a bounded scan (mainSpineScanLimit) and, when it stops before main's
root, fall back to a commit-date frontier: any commit at or before the oldest
scanned main commit is treated as main's history. This is the same commit-date
boundary git's rev-list uses for main..HEAD; it keeps the deep-history leak
closed (a former main tip merged in beyond the scanned window is older than the
frontier, so it is still pruned) without the unbounded walk. First-parent-only
semantics are unchanged, so merged-in feature checkpoints are still shown. The
scan is ~60ms regardless of main length and runs once per command.

The fail-safe errs toward exclusion: past the bound it may hide a branch-unique
commit older than the frontier rather than risk leaking main's history.

Tests: deep-history no-leak (main advanced past the bound; the frontier
fail-safe prunes the old main checkpoint), scan-is-bounded (at most
mainSpineScanLimit scanned regardless of main length), and once-per-call. The
first fails without the fail-safe; the second fails with an unbounded scan.
The ephemeral (shadow-branch) reachability check used a full-DAG walk with no
default-branch pruning, unlike the committed-checkpoint path. Once a branch
merged the default branch in, a shadow branch based on the default branch's own
history became reachable, so in-progress checkpoints from a session created while
working on the default branch leaked onto an unrelated branch's checkpoint list
-- the false-positive counterpart of the earlier ephemeral false-negative fix.

Make isShadowBranchReachable reuse walkBranchOwnCommits (the committed path's
walk), so the ephemeral and committed paths agree: a base merged in via a merge
commit's second parent is still found (the issue #931 ephemeral fix is
preserved), while a base on the default branch's own history is pruned
(sharedWithMain) and does not leak. The shared-with-main test is computed once in
getBranchCheckpoints and reused by both walks.

Tradeoff (documented in isShadowBranchReachable): the shadow base is only the
session's starting anchor, so a session started at a commit shared with the
default branch -- e.g. a brand-new feature branch before its first own commit --
is treated as shared and its in-progress checkpoints are not shown until the
branch has a commit of its own. This is the safe direction (never leak one
branch's in-progress session data onto another) and cannot be avoided at read
time: the shadow branch records only base commit + worktree, not the branch it
was created on, so a default-branch session and a feature session that start at
the same shared commit are indistinguishable.

Tests: a default-branch session's shadow does not leak onto feature/x after a
merge while the feature session's shadow still shows; the unit reachability test
now asserts a shared base is excluded. Both fail under a non-pruning full-DAG walk.
Issue #931 is a committed-checkpoint read-path bug. Earlier commits on this
branch also changed the ephemeral (shadow-branch) reachability path
(isShadowBranchReachable) -- first to a full-DAG walk, then to reuse
walkBranchOwnCommits. Each swapped one shadow-path harm for another: the
full-DAG walk leaks a default-branch session's in-progress checkpoints onto a
branch that merged the default branch in, while pruning shared-with-main hides
the active session on a brand-new branch (the common "checkout -b then start
coding" workflow, before the first commit). The two harms are irreducible
without session-origin metadata that isn't persisted today, which is a
feature-sized change to the write path and out of scope for #931.

Revert isShadowBranchReachable and getReachableTemporaryCheckpoints to the
first-parent-only behavior that shipped before this branch, so the PR
introduces no shadow-path change (neither the leak nor the fresh-branch
hiding). Tracked as follow-up #1730.

The committed-checkpoint #931 fix is unchanged: walkBranchOwnCommits for
committed checkpoints, the bounded main first-parent scan with a commit-date
frontier, and their tests. This commit also removes the shadow-specific tests
that asserted the reverted behavior.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

fast forward merge lost all session references in target branch

2 participants