gh stack doctor: detect and fix stale fork points#50
gh stack doctor: detect and fix stale fork points#50cds-amal wants to merge 15 commits intoboneskull:mainfrom
gh stack doctor: detect and fix stale fork points#50Conversation
There was a problem hiding this comment.
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 doctorcommand with a--fixmode to detect and repair stale/missing/dangling/non-ancestor fork points. - Extend the internal git wrapper with
IsAncestorandGetForkPointhelpers. - 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.
|
I love the co-pilot reviews; checks should surface issues! I should look into those. |
|
I wonder if we shouldn't actually try these fixes automatically prior to |
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.
056cc7b to
8da1dd4
Compare
Neat idear. I refactored the health check so it will be composable. |
There was a problem hiding this comment.
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).
|
@cds-amal If you would be so kind, there's a conflict... |
gh stack doctor: detect and fix stale fork pointsThe problem
This one came out of a private Slack conversation. Regular git operations like
rebaseandresetdon'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 leavesstackForkPointuntouched. The stored value quietly goes stale, and the nextrestackpicks an unexpected replay range and produces avoidable conflicts. There's currently no way to detect this drift before it bites you.What
doctordoesgh stack doctorwalks every tracked branch and runs a series of health checks, ordered by severity:merge-basewith parent (fixable if drifted)For the fixable cases,
--fixcomputes a new fork point (trying the reflog-awaremerge-base --fork-pointfirst, falling back to plainmerge-base) and writes it back to config.The output looks like this:
And with
--fix:Design decisions worth mentioning
Intentionally skips
tree.Build(): The doctor command iteratescfg.ListTrackedBranches()directly and looks up parents viacfg.GetParent(). This matters becausetree.Buildsilently 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[]Issuevalues (withKind,Message,Fixablefields) and has no dependency on the style package or fix logic. The doctor command is a thin wrapper that callshealth.CheckBranchfor 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 throughcomputeForkPoint, which tries the reflog-awaregit merge-base --fork-pointfirst. But--fork-pointcan return a different SHA than plainmerge-base, so the nextdoctorrun 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-pointdepends on reflogs, and reflogs expire (90 days by default). On a long-lived branch where the reflogs have been pruned,--fork-pointfails silently with exit code 1 and we fall back to plainmerge-base. This is whycomputeForkPointexists as a separate helper rather than just calling--fork-pointdirectly.Fixes preserve provenance via
git config --comment: When--fixreplaces a fork point, it usesgit config --comment(Git 2.45+) to record the abbreviated old SHA and a timestamp as an inline comment on the same line:This is best-effort: if the user's git is too old for
--comment, the fix falls back to a plaingit config setand 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
internal/git/git.goIsAncestor(ancestor, descendant)andGetForkPoint(parent, branch)internal/git/git_test.gointernal/health/health.goCheckBranchAPI with structuredIssuetype andIssueKindenuminternal/health/health_test.gointernal/config/config.goSetForkPointWithComment(usesgit config --comment)internal/config/config_test.goSetForkPointWithComment(verifies inline comment in raw config)cmd/doctor.go--fixflag; delegates validation tohealth.CheckBranch, keeps fix/repair logic locale2e/doctor_test.goe2e/helpers_test.gogit inittogit init -b mainso E2E tests don't depend on the user's globalinit.defaultBranchconfigThe
helpers_test.gofixWhile working on this I noticed the E2E tests were failing on my machine because
NewTestEnvused baregit init, which picks up whateverinit.defaultBranchis set globally. Every existing E2E test hardcodes"main", so if your global config saysmaster(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
I think that basically covers it!