arche / internal/cli/cmd_bisect.go

commit 154431fd
  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}