Skip to content

Commit 8da1dd4

Browse files
committed
refactor(doctor): replace hand-rolled INI parsing with git config --comment
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.
1 parent 48d5685 commit 8da1dd4

4 files changed

Lines changed: 191 additions & 156 deletions

File tree

cmd/doctor.go

Lines changed: 141 additions & 151 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@ package cmd
44
import (
55
"fmt"
66
"os"
7-
"strings"
87
"time"
98

109
"github.com/boneskull/gh-stack/internal/config"
@@ -68,42 +67,55 @@ func runDoctor(cmd *cobra.Command, args []string) error {
6867
fmt.Println(s.Bold("Stack Health Report"))
6968
fmt.Println()
7069

71-
var healthy, issues int
70+
var issueCount int
7271
for _, branch := range branches {
7372
result := checkBranch(g, cfg, s, branch, doctorFixFlag)
74-
if result.healthy {
75-
fmt.Printf("%s %s %s\n", s.SuccessIcon(), s.Branch(result.name), s.Muted("(healthy)"))
76-
healthy++
77-
} else if result.fixed {
78-
fmt.Printf("%s %s: %s\n", s.SuccessIcon(), s.Branch(result.name), result.fixMsg)
79-
healthy++
80-
} else {
81-
fmt.Printf("%s %s\n", s.FailureIcon(), s.Branch(result.name))
82-
for _, issue := range result.issues {
83-
fmt.Printf(" %s\n", issue)
84-
}
85-
issues++
73+
if !printResult(s, result) {
74+
issueCount++
8675
}
8776
}
8877

8978
fmt.Println()
90-
if issues > 0 {
91-
noun := "issue"
92-
if issues > 1 {
93-
noun = "issues"
94-
}
95-
fmt.Printf("%d %s found.", issues, noun)
96-
if !doctorFixFlag {
97-
fmt.Printf(" Run 'gh stack doctor --fix' to repair.")
98-
}
99-
fmt.Println()
100-
} else {
101-
fmt.Println(s.SuccessMessage("All branches healthy."))
102-
}
79+
printSummary(s, issueCount, doctorFixFlag)
10380

10481
return nil
10582
}
10683

84+
// printResult prints the outcome of a single branch check.
85+
// Returns true if the branch is healthy (or was fixed), false if issues remain.
86+
func printResult(s *style.Style, r branchResult) bool {
87+
if r.healthy {
88+
fmt.Printf("%s %s %s\n", s.SuccessIcon(), s.Branch(r.name), s.Muted("(healthy)"))
89+
return true
90+
}
91+
if r.fixed {
92+
fmt.Printf("%s %s: %s\n", s.SuccessIcon(), s.Branch(r.name), r.fixMsg)
93+
return true
94+
}
95+
fmt.Printf("%s %s\n", s.FailureIcon(), s.Branch(r.name))
96+
for _, issue := range r.issues {
97+
fmt.Printf(" %s\n", issue)
98+
}
99+
return false
100+
}
101+
102+
// printSummary prints the final summary line after all branches have been checked.
103+
func printSummary(s *style.Style, issueCount int, fix bool) {
104+
if issueCount == 0 {
105+
fmt.Println(s.SuccessMessage("All branches healthy."))
106+
return
107+
}
108+
noun := "issue"
109+
if issueCount > 1 {
110+
noun = "issues"
111+
}
112+
fmt.Printf("%d %s found.", issueCount, noun)
113+
if !fix {
114+
fmt.Printf(" Run 'gh stack doctor --fix' to repair.")
115+
}
116+
fmt.Println()
117+
}
118+
107119
func checkBranch(g *git.Git, cfg *config.Config, s *style.Style, branch string, fix bool) branchResult {
108120
result := branchResult{name: branch}
109121

@@ -113,26 +125,35 @@ func checkBranch(g *git.Git, cfg *config.Config, s *style.Style, branch string,
113125
return result
114126
}
115127

116-
// If not fixing, format issues with styling and return.
117128
if !fix {
118-
for _, iss := range issues {
119-
switch iss.Kind {
120-
case health.KindBranchMissing, health.KindParentMissing, health.KindNoForkPoint:
121-
result.issues = append(result.issues, s.Error(iss.Message))
122-
default:
123-
result.issues = append(result.issues, iss.Message)
124-
}
125-
}
129+
result.issues = formatIssues(s, issues)
126130
return result
127131
}
128132

129-
// Attempt to fix: dispatch based on the issue kind.
133+
return fixBranch(g, cfg, s, branch, issues)
134+
}
135+
136+
// formatIssues converts health issues into styled display strings.
137+
func formatIssues(s *style.Style, issues []health.Issue) []string {
138+
out := make([]string, 0, len(issues))
139+
for _, iss := range issues {
140+
switch iss.Kind {
141+
case health.KindBranchMissing, health.KindParentMissing, health.KindNoForkPoint:
142+
out = append(out, s.Error(iss.Message))
143+
default:
144+
out = append(out, iss.Message)
145+
}
146+
}
147+
return out
148+
}
149+
150+
// fixBranch attempts to repair the first issue found by health.CheckBranch.
151+
func fixBranch(g *git.Git, cfg *config.Config, s *style.Style, branch string, issues []health.Issue) branchResult {
152+
result := branchResult{name: branch}
153+
130154
kind := issues[0].Kind
131155
if !issues[0].Fixable && kind != health.KindDrift {
132-
// Unfixable issues (missing branch, missing parent, check failures)
133-
for _, iss := range issues {
134-
result.issues = append(result.issues, s.Error(iss.Message))
135-
}
156+
result.issues = formatIssues(s, issues)
136157
return result
137158
}
138159

@@ -141,64 +162,67 @@ func checkBranch(g *git.Git, cfg *config.Config, s *style.Style, branch string,
141162

142163
switch kind {
143164
case health.KindNoForkPoint:
144-
newFP, fixErr := computeForkPoint(g, parent, branch)
145-
if fixErr != nil {
146-
result.issues = append(result.issues, fmt.Sprintf("No fork point stored and could not compute one: %v", fixErr))
147-
return result
148-
}
149-
if setErr := cfg.SetForkPoint(branch, newFP); setErr != nil {
150-
result.issues = append(result.issues, fmt.Sprintf("Failed to set fork point: %v", setErr))
151-
return result
152-
}
153-
result.fixed = true
154-
result.fixMsg = fmt.Sprintf("set fork point to %s", git.AbbrevSHA(newFP))
155-
156-
case health.KindForkPointMissing:
157-
newFP, fixErr := computeForkPoint(g, parent, branch)
158-
if fixErr != nil {
159-
result.issues = append(result.issues, fmt.Sprintf("Stored fork point %s does not exist and could not compute replacement: %v", git.AbbrevSHA(storedFP), fixErr))
160-
return result
161-
}
162-
if setErr := setForkPointWithComment(g, cfg, branch, storedFP, newFP); setErr != nil {
163-
result.issues = append(result.issues, fmt.Sprintf("Failed to set fork point: %v", setErr))
164-
return result
165-
}
166-
result.fixed = true
167-
result.fixMsg = fmt.Sprintf("updated fork point %s \u2192 %s", git.AbbrevSHA(storedFP), git.AbbrevSHA(newFP))
168-
169-
case health.KindForkPointNotAncestor:
170-
newFP, fixErr := computeForkPoint(g, parent, branch)
171-
if fixErr != nil {
172-
result.issues = append(result.issues, fmt.Sprintf("Stored fork point %s is not an ancestor of %s and could not compute replacement: %v", git.AbbrevSHA(storedFP), branch, fixErr))
173-
return result
174-
}
175-
if setErr := setForkPointWithComment(g, cfg, branch, storedFP, newFP); setErr != nil {
176-
result.issues = append(result.issues, fmt.Sprintf("Failed to set fork point: %v", setErr))
177-
return result
178-
}
179-
result.fixed = true
180-
result.fixMsg = fmt.Sprintf("updated fork point %s \u2192 %s", git.AbbrevSHA(storedFP), git.AbbrevSHA(newFP))
181-
165+
return fixMissingForkPoint(g, cfg, branch, parent)
166+
case health.KindForkPointMissing, health.KindForkPointNotAncestor:
167+
return fixStaleForkPoint(g, cfg, s, branch, parent, storedFP, issues[0].Message)
182168
case health.KindDrift:
183-
mergeBase, err := g.GetMergeBase(parent, branch)
184-
if err != nil {
185-
result.issues = append(result.issues, fmt.Sprintf("Failed to compute merge-base for fix: %v", err))
186-
return result
187-
}
188-
if setErr := setForkPointWithComment(g, cfg, branch, storedFP, mergeBase); setErr != nil {
189-
result.issues = append(result.issues, fmt.Sprintf("Failed to set fork point: %v", setErr))
190-
return result
191-
}
192-
result.fixed = true
193-
result.fixMsg = fmt.Sprintf("updated fork point %s \u2192 %s", git.AbbrevSHA(storedFP), git.AbbrevSHA(mergeBase))
194-
169+
return fixDrift(g, cfg, s, branch, parent, storedFP)
195170
default:
196-
// Shouldn't happen, but surface issues if it does
197171
for _, iss := range issues {
198172
result.issues = append(result.issues, iss.Message)
199173
}
174+
return result
175+
}
176+
}
177+
178+
// fixMissingForkPoint computes and sets a fork point when none is stored.
179+
func fixMissingForkPoint(g *git.Git, cfg *config.Config, branch, parent string) branchResult {
180+
result := branchResult{name: branch}
181+
newFP, err := computeForkPoint(g, parent, branch)
182+
if err != nil {
183+
result.issues = append(result.issues, fmt.Sprintf("No fork point stored and could not compute one: %v", err))
184+
return result
185+
}
186+
if err := cfg.SetForkPoint(branch, newFP); err != nil {
187+
result.issues = append(result.issues, fmt.Sprintf("Failed to set fork point: %v", err))
188+
return result
200189
}
190+
result.fixed = true
191+
result.fixMsg = fmt.Sprintf("set fork point to %s", git.AbbrevSHA(newFP))
192+
return result
193+
}
201194

195+
// fixStaleForkPoint recomputes and replaces a fork point that is missing or not an ancestor.
196+
func fixStaleForkPoint(g *git.Git, cfg *config.Config, s *style.Style, branch, parent, storedFP, context string) branchResult {
197+
result := branchResult{name: branch}
198+
newFP, err := computeForkPoint(g, parent, branch)
199+
if err != nil {
200+
result.issues = append(result.issues, fmt.Sprintf("%s and could not compute replacement: %v", context, err))
201+
return result
202+
}
203+
if err := setForkPointWithComment(s, cfg, branch, storedFP, newFP); err != nil {
204+
result.issues = append(result.issues, fmt.Sprintf("Failed to set fork point: %v", err))
205+
return result
206+
}
207+
result.fixed = true
208+
result.fixMsg = fmt.Sprintf("updated fork point %s \u2192 %s", git.AbbrevSHA(storedFP), git.AbbrevSHA(newFP))
209+
return result
210+
}
211+
212+
// fixDrift replaces a stored fork point that doesn't match the computed merge-base.
213+
func fixDrift(g *git.Git, cfg *config.Config, s *style.Style, branch, parent, storedFP string) branchResult {
214+
result := branchResult{name: branch}
215+
mergeBase, err := g.GetMergeBase(parent, branch)
216+
if err != nil {
217+
result.issues = append(result.issues, fmt.Sprintf("Failed to compute merge-base for fix: %v", err))
218+
return result
219+
}
220+
if err := setForkPointWithComment(s, cfg, branch, storedFP, mergeBase); err != nil {
221+
result.issues = append(result.issues, fmt.Sprintf("Failed to set fork point: %v", err))
222+
return result
223+
}
224+
result.fixed = true
225+
result.fixMsg = fmt.Sprintf("updated fork point %s \u2192 %s", git.AbbrevSHA(storedFP), git.AbbrevSHA(mergeBase))
202226
return result
203227
}
204228

@@ -211,70 +235,36 @@ func computeForkPoint(g *git.Git, parent, branch string) (string, error) {
211235
return g.GetMergeBase(parent, branch)
212236
}
213237

214-
// setForkPointWithComment updates the fork point and inserts a comment preserving the old value.
215-
// The comment is best-effort; if it fails, the fork point is still updated.
216-
func setForkPointWithComment(g *git.Git, cfg *config.Config, branch, oldSHA, newSHA string) error {
217-
if err := cfg.SetForkPoint(branch, newSHA); err != nil {
218-
return err
238+
// setForkPointWithComment updates the fork point and records the old value
239+
// as an inline git config comment for provenance. Falls back to a plain
240+
// SetForkPoint if the git version doesn't support --comment (requires 2.45+).
241+
func setForkPointWithComment(s *style.Style, cfg *config.Config, branch, oldSHA, newSHA string) error {
242+
comment := fmt.Sprintf("replaces %s (%s)",
243+
git.AbbrevSHA(oldSHA),
244+
time.Now().UTC().Format(time.RFC3339),
245+
)
246+
err := cfg.SetForkPointWithComment(branch, newSHA, comment)
247+
if err == nil {
248+
return nil
219249
}
220-
commentForkPointChange(g, branch, oldSHA)
221-
return nil
222-
}
223250

224-
// commentForkPointChange inserts a comment above the stackForkPoint line in the git config
225-
// recording the previous value and a timestamp. This preserves provenance so old fork
226-
// points can be recovered if a fix goes wrong. Best-effort: errors are silently ignored.
227-
func commentForkPointChange(g *git.Git, branch, oldSHA string) {
228-
configPath, err := g.GetConfigPath()
229-
if err != nil {
230-
return
231-
}
232-
info, err := os.Stat(configPath)
233-
if err != nil {
234-
return
235-
}
236-
data, err := os.ReadFile(configPath)
237-
if err != nil {
238-
return
251+
// --comment likely unsupported; fall back to plain set and warn once.
252+
if setErr := cfg.SetForkPoint(branch, newSHA); setErr != nil {
253+
return setErr
239254
}
255+
warnOldGit(s)
256+
return nil
257+
}
240258

241-
lines := strings.Split(string(data), "\n")
242-
sectionHeader := fmt.Sprintf("[branch %q]", branch)
243-
inSection := false
244-
modified := false
245-
var result []string
246-
247-
for _, line := range lines {
248-
trimmed := strings.TrimSpace(line)
249-
250-
if trimmed == sectionHeader {
251-
inSection = true
252-
result = append(result, line)
253-
continue
254-
}
255-
256-
// New section starts; we've left the target section
257-
if inSection && strings.HasPrefix(trimmed, "[") {
258-
inSection = false
259-
}
260-
261-
// Insert comment before the stackForkPoint line
262-
if inSection && strings.HasPrefix(strings.ToLower(trimmed), "stackforkpoint") {
263-
indent := line[:len(line)-len(strings.TrimLeft(line, " \t"))]
264-
timestamp := time.Now().UTC().Format(time.RFC3339)
265-
result = append(result,
266-
fmt.Sprintf("%s# doctor fix %s replaces previous value of:", indent, timestamp),
267-
fmt.Sprintf("%s# %s", indent, oldSHA),
268-
)
269-
modified = true
270-
}
271-
272-
result = append(result, line)
273-
}
259+
var oldGitWarned bool
274260

275-
if !modified {
261+
// warnOldGit prints a one-time warning that the user's git version is too old
262+
// for inline config comments.
263+
func warnOldGit(s *style.Style) {
264+
if oldGitWarned {
276265
return
277266
}
278-
//nolint:errcheck // best-effort provenance
279-
_ = os.WriteFile(configPath, []byte(strings.Join(result, "\n")), info.Mode())
267+
oldGitWarned = true
268+
fmt.Printf("%s git config --comment not supported; fix provenance will be missing.\n", s.WarningIcon())
269+
fmt.Println(s.Muted(" gh-stack works best with git 2.45 or newer."))
280270
}

e2e/doctor_test.go

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -136,17 +136,20 @@ func TestDoctorFixUpdatesConfig(t *testing.T) {
136136
t.Errorf("expected new fork point %s to match merge-base %s", newFP, mergeBase)
137137
}
138138

139-
// Verify provenance comment was written to the config file
139+
// Verify inline provenance comment was written to the config value line.
140+
// git config --comment (Git 2.45+) appends "# replaces <abbrev> (<timestamp>)"
141+
// on the same line as the stackForkPoint value.
140142
configData, err := os.ReadFile(filepath.Join(env.WorkDir, ".git", "config"))
141143
if err != nil {
142144
t.Fatalf("failed to read .git/config: %v", err)
143145
}
144146
configStr := string(configData)
145-
if !strings.Contains(configStr, "# doctor fix") {
146-
t.Error("expected provenance comment '# doctor fix ...' in .git/config")
147+
if !strings.Contains(configStr, "# replaces") {
148+
t.Error("expected inline provenance comment '# replaces ...' in .git/config")
147149
}
148-
if !strings.Contains(configStr, "# "+originalFP) {
149-
t.Errorf("expected old fork point %s preserved in config comment", originalFP)
150+
abbrevOld := originalFP[:7]
151+
if !strings.Contains(configStr, abbrevOld) {
152+
t.Errorf("expected abbreviated old fork point %s in config comment", abbrevOld)
150153
}
151154
}
152155

internal/config/config.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,13 @@ func (c *Config) SetForkPoint(branch, sha string) error {
116116
return exec.Command("git", "-C", c.repoPath, "config", key, sha).Run()
117117
}
118118

119+
// SetForkPointWithComment stores the fork point SHA with an inline comment.
120+
// Requires Git 2.45+. The comment appears after the value on the same line.
121+
func (c *Config) SetForkPointWithComment(branch, sha, comment string) error {
122+
key := "branch." + branch + ".stackForkPoint"
123+
return exec.Command("git", "-C", c.repoPath, "config", "--comment", comment, key, sha).Run()
124+
}
125+
119126
// RemoveForkPoint removes the stored fork point for a branch.
120127
func (c *Config) RemoveForkPoint(branch string) error {
121128
key := "branch." + branch + ".stackForkPoint"

0 commit comments

Comments
 (0)