Skip to content

Comments

gh stack doctor: detect and fix stale fork points#50

Open
cds-amal wants to merge 15 commits intoboneskull:mainfrom
cds-amal:feat/whats-up-doc
Open

gh stack doctor: detect and fix stale fork points#50
cds-amal wants to merge 15 commits intoboneskull:mainfrom
cds-amal:feat/whats-up-doc

Conversation

@cds-amal
Copy link

@cds-amal cds-amal commented Feb 14, 2026

gh stack doctor: detect and fix stale fork points

The problem

This one came out of a private Slack conversation. Regular git operations like rebase and reset don't know about the extra state gh-stack stores in .git/config, so when you manually rebase an intermediate branch, git does its thing but leaves stackForkPoint untouched. The stored value quietly goes stale, and the next restack picks an unexpected replay range and produces avoidable conflicts. There's currently no way to detect this drift before it bites you.

What doctor does

gh stack doctor walks every tracked branch and runs a series of health checks, ordered by severity:

  1. Branch exists locally (unfixable if missing)
  2. Parent branch exists locally (unfixable if missing)
  3. Fork point is stored in config (fixable if missing)
  4. Fork point commit exists in the repo (fixable if dangling)
  5. Fork point is an ancestor of the branch (fixable if not)
  6. Drift detection: stored fork point matches the current merge-base with parent (fixable if drifted)

For the fixable cases, --fix computes a new fork point (trying the reflog-aware merge-base --fork-point first, falling back to plain merge-base) and writes it back to config.

The output looks like this:

Stack Health Report

✓ feature-a (healthy)
✗ feature-b
    Stored parent: feature-a
    Stored fork:   abc1234
    Actual fork:   def5678
    Drift detected: stored fork point does not match merge-base

1 issue found. Run 'gh stack doctor --fix' to repair.

And with --fix:

✓ feature-a (healthy)
✓ feature-b: updated fork point abc1234 → def5678

Design decisions worth mentioning

Intentionally skips tree.Build(): The doctor command iterates cfg.ListTrackedBranches() directly and looks up parents via cfg.GetParent(). This matters because tree.Build silently drops branches with missing parents, which is exactly the kind of thing doctor needs to catch.

Health checks are composable: The pure validation logic lives in internal/health.CheckBranch(), which returns structured []Issue values (with Kind, Message, Fixable fields) and has no dependency on the style package or fix logic. The doctor command is a thin wrapper that calls health.CheckBranch for diagnosis, then layers styling and repair on top. This means future commands (cascade, sync) can run the same checks as a pre-flight before rebasing without pulling in doctor's concerns.

Drift fix uses merge-base directly: The drift check (6) compares the stored fork point against GetMergeBase. Early on, the fix path went through computeForkPoint, which tries the reflog-aware git merge-base --fork-point first. But --fork-point can return a different SHA than plain merge-base, so the next doctor run would still report drift. The fix now just uses the merge-base we already computed for the comparison.

The fancier reflog-aware path is still used for checks 3 through 5 (missing, dangling, non-ancestor) where we don't have a known-good merge-base to compare against. But even there, it's a "try first, fall back" situation: --fork-point depends on reflogs, and reflogs expire (90 days by default). On a long-lived branch where the reflogs have been pruned, --fork-point fails silently with exit code 1 and we fall back to plain merge-base. This is why computeForkPoint exists as a separate helper rather than just calling --fork-point directly.

Fixes preserve provenance via git config --comment: When --fix replaces a fork point, it uses git config --comment (Git 2.45+) to record the abbreviated old SHA and a timestamp as an inline comment on the same line:

[branch "feature-b"]
	stackParent = feature-a
	stackForkPoint = def5678901234567890abcdef1234567890abcdef # replaces abc1234 (2026-02-13T20:15:00Z)

This is best-effort: if the user's git is too old for --comment, the fix falls back to a plain git config set and emits a one-time warning that gh-stack works best with git 2.45 or newer. The fix itself still goes through either way; you just lose the audit trail on older git versions.

Changes

File What
internal/git/git.go Added IsAncestor(ancestor, descendant) and GetForkPoint(parent, branch)
internal/git/git_test.go Unit tests for both new methods
internal/health/health.go New package: reusable CheckBranch API with structured Issue type and IssueKind enum
internal/health/health_test.go 8 unit tests: healthy, missing branch, missing parent, missing trunk, no fork point, nonexistent fork point, not-ancestor, drift
internal/config/config.go Added SetForkPointWithComment (uses git config --comment)
internal/config/config_test.go Unit test for SetForkPointWithComment (verifies inline comment in raw config)
cmd/doctor.go New command with --fix flag; delegates validation to health.CheckBranch, keeps fix/repair logic local
e2e/doctor_test.go 7 E2E tests: healthy stack, drift detection + fix, missing branch, missing parent, missing trunk, config update verification, not-initialized error
e2e/helpers_test.go Changed git init to git init -b main so E2E tests don't depend on the user's global init.defaultBranch config

The helpers_test.go fix

While working on this I noticed the E2E tests were failing on my machine because NewTestEnv used bare git init, which picks up whatever init.defaultBranch is set globally. Every existing E2E test hardcodes "main", so if your global config says master (like mine does), every single E2E test fails. The fix is one line: git init -b main. This is technically unrelated to doctor but it was blocking me from running the test suite, so here it is.

Testing

go test -v -run "TestCheckBranch" ./internal/health/                 # health API unit tests
go test -v -run "TestSetForkPointWithComment" ./internal/config/     # config comment test
go test -v -run "TestIsAncestor|TestGetForkPoint" ./internal/git/    # git method unit tests
go test -v -run "TestDoctor" ./e2e/                                  # E2E tests
make test                                                            # full suite

I think that basically covers it!

Copilot AI review requested due to automatic review settings February 14, 2026 03:49
@cds-amal cds-amal removed their assignment Feb 14, 2026
Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Adds a new gh stack doctor command to detect drift between stored stackForkPoint metadata and the repo’s actual history, and optionally repair fixable issues by recomputing/writing fork points back to .git/config.

Changes:

  • Add gh stack doctor command with a --fix mode to detect and repair stale/missing/dangling/non-ancestor fork points.
  • Extend the internal git wrapper with IsAncestor and GetForkPoint helpers.
  • Add unit + E2E coverage for the new git helpers and doctor behaviors; make E2E setup deterministic by initializing repos with main.

Reviewed changes

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

Show a summary per file
File Description
cmd/doctor.go Implements doctor checks/fixes and best-effort provenance comments in .git/config.
internal/git/git.go Adds IsAncestor and GetForkPoint methods used by doctor.
internal/git/git_test.go Adds unit tests for the new git helpers.
e2e/doctor_test.go Adds E2E tests covering healthy stacks, drift detection/fix, missing branches/parents, config comment verification, and “not initialized”.
e2e/helpers_test.go Makes E2E git init always create main to avoid dependence on user global git config.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

@cds-amal
Copy link
Author

I love the co-pilot reviews; checks should surface issues! I should look into those.

@boneskull boneskull added the enhancement New feature or request label Feb 14, 2026
@boneskull
Copy link
Owner

I wonder if we shouldn't actually try these fixes automatically prior to restack operations... I'll look into this more later.

NewTestEnv used bare `git init` which inherits the user's global
init.defaultBranch setting. Every E2E test hardcodes "main", so
the suite fails on systems defaulting to "master". Pass `-b main`
to make it deterministic.
IsAncestor wraps `git merge-base --is-ancestor`. GetForkPoint wraps
`git merge-base --fork-point` for reflog-aware divergence detection;
callers fall back to GetMergeBase when reflogs have expired.
Diagnoses and optionally repairs stale fork points caused by manual
rebases or resets. Walks tracked branches directly (skipping
tree.Build so missing-parent cases are caught) and checks six
conditions from fatal to cosmetic. --fix updates the stored fork
point and writes a provenance comment into .git/config preserving
the old value.
Covers healthy stack, drift detection + fix, missing branch, missing
parent, config update verification with provenance comment check,
and not-initialized error.
IsAncestor was treating all non-zero exits from `git merge-base
--is-ancestor` as "not ancestor". Exit code 1 means "not ancestor",
but other codes (e.g., 128) indicate real failures like invalid refs
or repo corruption. Now only exit code 1 returns (false, nil);
other failures propagate as errors.
The comment claimed the error was specifically about expired reflogs
(exit code 1), but the wrapper doesn't inspect exit codes; it just
returns whatever error g.run() gives back.
If `git merge-base --is-ancestor` fails for reasons other than "not
ancestor" (invalid refs, repo corruption), doctor now reports it as
an issue instead of misinterpreting it as a fork-point problem that
--fix might incorrectly "repair".
When GetMergeBase fails (unrelated histories, bad refs), doctor was
silently marking the branch healthy and skipping drift detection.
Now it reports the failure as an issue.
commentForkPointChange assumed .git/config at a hardcoded path,
which breaks in linked worktrees where .git is a file. Now uses
git rev-parse --git-path config via a new GetConfigPath method.
commentForkPointChange was writing .git/config with hardcoded 0644,
which could loosen permissions if the file was e.g. 0600. Now
preserves the existing file mode via os.Stat. Also skips the write
entirely when no provenance comment was inserted.
The parent existence check was skipping trunk, so a deleted or
renamed trunk branch wouldn't be reported. Now checks
g.BranchExists(trunk) when the parent is trunk.
Consistent with other simple issue messages in checkBranch that
use the style package for formatting.
…rnal/health

Move the pure validation logic out of doctor's checkBranch into a new
health.CheckBranch API that returns structured []Issue values (with Kind,
Message, Fixable fields) instead of styled strings. This makes the
health checks composable so future commands (cascade, sync) can run them
as pre-flight validation before rebasing without pulling in style or
fix logic.

Doctor becomes a thin wrapper: call health.CheckBranch for diagnosis,
then layer styling and repair logic on top based on issue kind.
…omment

Use git config --comment (Git 2.45+) to write fork-point metadata with
an inline note (extending our provenance trail so fixes are auditable),
instead of manually rewriting .git/config. If --comment isn't supported,
fall back to a plain git config set and emit a one-time warning.

Also reduces cyclomatic complexity across all of doctor.go by extracting
printResult, printSummary, fixBranch, fixMissingForkPoint,
fixStaleForkPoint, and fixDrift helpers. Peak complexity drops from
21 to 8.
Copilot AI review requested due to automatic review settings February 14, 2026 23:17
@cds-amal
Copy link
Author

I wonder if we shouldn't actually try these fixes automatically prior to restack operations... I'll look into this more later.

Neat idear. I refactored the health check so it will be composable.

Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

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


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

- Check errors in test setup helpers instead of silently discarding them
  (health_test.go, git_test.go)
- Skip provenance-comment assertions on git < 2.45 instead of failing
  (config_test.go, e2e/doctor_test.go)
- Discriminate "unknown option" from real errors in setForkPointWithComment
  so permissions/locking failures propagate instead of triggering the
  old-git warning (cmd/doctor.go, internal/config/config.go)
- Delete unused GetConfigPath (dead code left over from the
  commentForkPointChange removal)

Note: PR.md also updated to clarify abbreviated SHA in provenance
comments (not included in this commit; will be added manually).
@boneskull
Copy link
Owner

@cds-amal If you would be so kind, there's a conflict...

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants