@@ -4,7 +4,6 @@ package cmd
44import (
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+
107119func 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}
0 commit comments