1package cli
2
3import (
4 "encoding/json"
5 "fmt"
6 "os"
7 "os/exec"
8 "path/filepath"
9 "strings"
10
11 "arche/internal/object"
12 "arche/internal/repo"
13 "arche/internal/wc"
14
15 "github.com/spf13/cobra"
16)
17
18type bisectState struct {
19 OriginalChangeID string `json:"original_change_id"`
20 GoodCommits [][32]byte `json:"good_commits"`
21 BadCommit [32]byte `json:"bad_commit"`
22 BadCommitSet bool `json:"bad_commit_set"`
23}
24
25func bisectStatePath(archeDir string) string { return filepath.Join(archeDir, "bisect.json") }
26func bisectLogPath(archeDir string) string { return filepath.Join(archeDir, "bisect.log") }
27
28func loadBisectState(archeDir string) (*bisectState, error) {
29 data, err := os.ReadFile(bisectStatePath(archeDir))
30 if err != nil {
31 if os.IsNotExist(err) {
32 return nil, nil
33 }
34 return nil, err
35 }
36 var s bisectState
37 if err := json.Unmarshal(data, &s); err != nil {
38 return nil, err
39 }
40 return &s, nil
41}
42
43func saveBisectState(archeDir string, s *bisectState) error {
44 data, err := json.MarshalIndent(s, "", " ")
45 if err != nil {
46 return err
47 }
48 return os.WriteFile(bisectStatePath(archeDir), data, 0o644)
49}
50
51func clearBisectState(archeDir string) { _ = os.Remove(bisectStatePath(archeDir)) }
52
53func appendBisectLog(archeDir, line string) {
54 f, err := os.OpenFile(bisectLogPath(archeDir), os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
55 if err != nil {
56 return
57 }
58 defer f.Close()
59 fmt.Fprintln(f, line)
60}
61
62func bisectAncestors(r *repo.Repo, startID [32]byte) [][32]byte {
63 seen := make(map[[32]byte]bool)
64 var order [][32]byte
65 queue := [][32]byte{startID}
66 for len(queue) > 0 {
67 id := queue[0]
68 queue = queue[1:]
69 if seen[id] {
70 continue
71 }
72 seen[id] = true
73 order = append(order, id)
74 c, err := r.ReadCommit(id)
75 if err != nil {
76 break
77 }
78 for _, p := range c.Parents {
79 if !seen[p] {
80 queue = append(queue, p)
81 }
82 }
83 }
84 for i, j := 0, len(order)-1; i < j; i, j = i+1, j-1 {
85 order[i], order[j] = order[j], order[i]
86 }
87 return order
88}
89
90func bisectCandidates(r *repo.Repo, goodCommits [][32]byte, badCommit [32]byte) [][32]byte {
91 allFromBad := bisectAncestors(r, badCommit)
92
93 goodSet := make(map[[32]byte]bool)
94 for _, g := range goodCommits {
95 for _, a := range bisectAncestors(r, g) {
96 goodSet[a] = true
97 }
98 }
99
100 var out [][32]byte
101 for _, id := range allFromBad {
102 if !goodSet[id] {
103 out = append(out, id)
104 }
105 }
106 return out
107}
108
109func bisectDoStep(r *repo.Repo, s *bisectState) ([32]byte, error) {
110 if !s.BadCommitSet || len(s.GoodCommits) == 0 {
111 return object.ZeroID, nil
112 }
113
114 candidates := bisectCandidates(r, s.GoodCommits, s.BadCommit)
115 if len(candidates) == 0 {
116 return object.ZeroID, fmt.Errorf("bisect range is empty — good and bad commits may not share an ancestry path")
117 }
118 if len(candidates) == 1 {
119 c, err := r.ReadCommit(candidates[0])
120 if err != nil {
121 return object.ZeroID, err
122 }
123 appendBisectLog(r.ArcheDir(), fmt.Sprintf("found ch:%s", c.ChangeID))
124 fmt.Printf("\nFound: ch:%s — %q\n", c.ChangeID, c.Message)
125 fmt.Printf("Bisect log written to %s\n", bisectLogPath(r.ArcheDir()))
126 clearBisectState(r.ArcheDir())
127 return candidates[0], nil
128 }
129
130 mid := candidates[len(candidates)/2]
131 midC, err := r.ReadCommit(mid)
132 if err != nil {
133 return object.ZeroID, err
134 }
135
136 w := wc.New(r)
137 if err := w.Materialize(midC.TreeID, object.FormatChangeID(midC.ChangeID)); err != nil {
138 return object.ZeroID, fmt.Errorf("materialize: %w", err)
139 }
140 if err := r.WriteHead(object.FormatChangeID(midC.ChangeID)); err != nil {
141 return object.ZeroID, err
142 }
143
144 fmt.Printf("Bisecting: %d revisions remain. Checking out ch:%s — %s\n",
145 len(candidates), midC.ChangeID, bisectFirstLine(midC.Message))
146 return object.ZeroID, nil
147}
148
149var bisectCmd = &cobra.Command{
150 Use: "bisect",
151 Short: "Binary search commit history to find a regression",
152 Long: `arche bisect performs a binary search through commit history to find the
153first change that introduced a regression. Operates on stable change IDs
154(survive rebase and amend) rather than content hashes.
155
156Subcommands: start, good, bad, reset, run`,
157}
158
159var bisectStartCmd = &cobra.Command{
160 Use: "start",
161 Short: "Begin a bisect session",
162 RunE: func(cmd *cobra.Command, args []string) error {
163 r := openRepo()
164 defer r.Close()
165
166 if s, _ := loadBisectState(r.ArcheDir()); s != nil {
167 return fmt.Errorf("bisect already in progress — run 'arche bisect reset' first")
168 }
169
170 _, headID, err := r.HeadCommit()
171 if err != nil {
172 return err
173 }
174 headC, err := r.ReadCommit(headID)
175 if err != nil {
176 return err
177 }
178
179 s := &bisectState{OriginalChangeID: headC.ChangeID}
180 if err := saveBisectState(r.ArcheDir(), s); err != nil {
181 return err
182 }
183 _ = os.Remove(bisectLogPath(r.ArcheDir()))
184 fmt.Printf("Bisect started at ch:%s\n", headC.ChangeID)
185 fmt.Println("Mark the bad commit with 'arche bisect bad' and a known-good commit with 'arche bisect good <change-id>'.")
186 return nil
187 },
188}
189
190var bisectBadCmd = &cobra.Command{
191 Use: "bad [change-id]",
192 Short: "Mark a commit as bad (broken)",
193 Args: cobra.MaximumNArgs(1),
194 RunE: func(cmd *cobra.Command, args []string) error {
195 r := openRepo()
196 defer r.Close()
197
198 s, err := loadBisectState(r.ArcheDir())
199 if err != nil {
200 return err
201 }
202 if s == nil {
203 return fmt.Errorf("no bisect in progress — run 'arche bisect start' first")
204 }
205
206 var targetID [32]byte
207 if len(args) > 0 {
208 targetID, err = resolveRef(r, args[0])
209 } else {
210 _, targetID, err = r.HeadCommit()
211 }
212 if err != nil {
213 return err
214 }
215
216 c, err := r.ReadCommit(targetID)
217 if err != nil {
218 return err
219 }
220
221 s.BadCommit = targetID
222 s.BadCommitSet = true
223 if err := saveBisectState(r.ArcheDir(), s); err != nil {
224 return err
225 }
226 appendBisectLog(r.ArcheDir(), fmt.Sprintf("bad ch:%s", c.ChangeID))
227 fmt.Printf("Marked bad: ch:%s — %s\n", c.ChangeID, bisectFirstLine(c.Message))
228
229 _, err = bisectDoStep(r, s)
230 return err
231 },
232}
233
234var bisectGoodCmd = &cobra.Command{
235 Use: "good [change-id]",
236 Short: "Mark a commit as good (working)",
237 Args: cobra.MaximumNArgs(1),
238 RunE: func(cmd *cobra.Command, args []string) error {
239 r := openRepo()
240 defer r.Close()
241
242 s, err := loadBisectState(r.ArcheDir())
243 if err != nil {
244 return err
245 }
246 if s == nil {
247 return fmt.Errorf("no bisect in progress — run 'arche bisect start' first")
248 }
249
250 var targetID [32]byte
251 if len(args) > 0 {
252 targetID, err = resolveRef(r, args[0])
253 } else {
254 _, targetID, err = r.HeadCommit()
255 }
256 if err != nil {
257 return err
258 }
259
260 c, err := r.ReadCommit(targetID)
261 if err != nil {
262 return err
263 }
264
265 s.GoodCommits = append(s.GoodCommits, targetID)
266 if err := saveBisectState(r.ArcheDir(), s); err != nil {
267 return err
268 }
269 appendBisectLog(r.ArcheDir(), fmt.Sprintf("good ch:%s", c.ChangeID))
270 fmt.Printf("Marked good: ch:%s — %s\n", c.ChangeID, bisectFirstLine(c.Message))
271
272 _, err = bisectDoStep(r, s)
273 return err
274 },
275}
276
277var bisectResetCmd = &cobra.Command{
278 Use: "reset",
279 Short: "End bisect and restore the original working copy",
280 RunE: func(cmd *cobra.Command, args []string) error {
281 r := openRepo()
282 defer r.Close()
283
284 s, err := loadBisectState(r.ArcheDir())
285 if err != nil {
286 return err
287 }
288 if s == nil {
289 fmt.Println("No bisect in progress.")
290 return nil
291 }
292
293 if s.OriginalChangeID != "" {
294 origID, resErr := resolveRef(r, "ch:"+s.OriginalChangeID)
295 if resErr == nil {
296 origC, rdErr := r.ReadCommit(origID)
297 if rdErr == nil {
298 w := wc.New(r)
299 _ = w.Materialize(origC.TreeID, object.FormatChangeID(origC.ChangeID))
300 _ = r.WriteHead(object.FormatChangeID(origC.ChangeID))
301 }
302 }
303 }
304
305 clearBisectState(r.ArcheDir())
306 fmt.Printf("Bisect reset. Restored to ch:%s\n", s.OriginalChangeID)
307 return nil
308 },
309}
310
311var bisectRunCmd = &cobra.Command{
312 Use: "run <command> [args...]",
313 Short: "Automate bisect: run command at each candidate commit",
314 Long: "Exit code 0 = good, non-zero = bad. Arche restores the original working copy when done.",
315 Args: cobra.MinimumNArgs(1),
316 DisableFlagParsing: true,
317 RunE: func(cmd *cobra.Command, args []string) error {
318 r := openRepo()
319 defer r.Close()
320
321 s, err := loadBisectState(r.ArcheDir())
322 if err != nil {
323 return err
324 }
325 if s == nil {
326 return fmt.Errorf("no bisect in progress — run 'arche bisect start' first")
327 }
328 if !s.BadCommitSet || len(s.GoodCommits) == 0 {
329 return fmt.Errorf("set good and bad commits before running automated bisect")
330 }
331
332 candidates := bisectCandidates(r, s.GoodCommits, s.BadCommit)
333 if len(candidates) == 0 {
334 return fmt.Errorf("bisect range is empty")
335 }
336
337 w := wc.New(r)
338 var found [32]byte
339 lo, hi := 0, len(candidates)
340 for lo < hi {
341 mid := (lo + hi) / 2
342 midID := candidates[mid]
343 midC, cErr := r.ReadCommit(midID)
344 if cErr != nil {
345 return cErr
346 }
347 if err := w.Materialize(midC.TreeID, object.FormatChangeID(midC.ChangeID)); err != nil {
348 return err
349 }
350 if err := r.WriteHead(object.FormatChangeID(midC.ChangeID)); err != nil {
351 return err
352 }
353 fmt.Printf("Bisecting: testing ch:%s — %s\n", midC.ChangeID, bisectFirstLine(midC.Message))
354
355 testCmd := exec.Command(args[0], args[1:]...) //nolint:gosec
356 testCmd.Stdout = os.Stdout
357 testCmd.Stderr = os.Stderr
358 testCmd.Dir = r.Root
359 runErr := testCmd.Run()
360 isGood := runErr == nil
361 mark := "bad"
362 if isGood {
363 mark = "good"
364 }
365 appendBisectLog(r.ArcheDir(), fmt.Sprintf("%s ch:%s", mark, midC.ChangeID))
366 fmt.Printf(" → %s\n", mark)
367
368 if isGood {
369 lo = mid + 1
370 } else {
371 found = midID
372 hi = mid
373 }
374 }
375
376 origID, resErr := resolveRef(r, "ch:"+s.OriginalChangeID)
377 if resErr == nil {
378 origC, _ := r.ReadCommit(origID)
379 if origC != nil {
380 _ = w.Materialize(origC.TreeID, object.FormatChangeID(origC.ChangeID))
381 _ = r.WriteHead(object.FormatChangeID(origC.ChangeID))
382 }
383 }
384 clearBisectState(r.ArcheDir())
385
386 if found != (object.ZeroID) {
387 foundC, _ := r.ReadCommit(found)
388 if foundC != nil {
389 fmt.Printf("\nFound: ch:%s — %q\n", foundC.ChangeID, foundC.Message)
390 fmt.Printf("Bisect log written to %s\n", bisectLogPath(r.ArcheDir()))
391 return nil
392 }
393 }
394 fmt.Println("Bisect complete — no bad commit found in range.")
395 return nil
396 },
397}
398
399func bisectFirstLine(s string) string {
400 if i := strings.IndexByte(s, '\n'); i >= 0 {
401 return s[:i]
402 }
403 return s
404}
405
406func init() {
407 bisectCmd.AddCommand(bisectStartCmd, bisectBadCmd, bisectGoodCmd, bisectResetCmd, bisectRunCmd)
408}