1package cli
2
3import (
4 "bufio"
5 "fmt"
6 "os"
7 "strings"
8 "time"
9
10 "arche/internal/object"
11 "arche/internal/repo"
12 "arche/internal/store"
13 "arche/internal/tui"
14 "arche/internal/wc"
15
16 "github.com/spf13/cobra"
17)
18
19var splitCmd = &cobra.Command{
20 Use: "split",
21 Short: "Split the current working-copy changes into two commits using interactive hunk selection",
22 Long: `Interactively select which hunks belong to the first commit; the remaining
23changes become a second draft commit chained on top.`,
24 RunE: func(cmd *cobra.Command, args []string) error {
25 r := openRepo()
26 defer r.Close()
27
28 w := wc.New(r)
29
30 head, headID, err := r.HeadCommit()
31 if err != nil {
32 return err
33 }
34
35 before, _ := r.CaptureRefState()
36
37 diffs, err := w.ComputeWorkingDiffs()
38 if err != nil {
39 return err
40 }
41 if len(diffs) == 0 {
42 fmt.Println("Nothing to split: working copy has no changes.")
43 return nil
44 }
45
46 var items []tui.HunkItem
47 for _, fhd := range diffs {
48 for hi, h := range fhd.Hunks {
49 items = append(items, tui.HunkItem{
50 FilePath: fhd.Path,
51 HunkIdx: hi,
52 TotalHunksInFile: len(fhd.Hunks),
53 Hunk: h,
54 })
55 }
56 }
57
58 sel, err := tui.RunHunkSelector(items, "include in first commit")
59 if err != nil {
60 return err
61 }
62 if sel.Cancelled {
63 fmt.Fprintln(os.Stderr, "Split cancelled.")
64 return nil
65 }
66
67 perFile := make(map[string][]bool)
68 idx := 0
69 for _, fhd := range diffs {
70 n := len(fhd.Hunks)
71 perFile[fhd.Path] = sel.Selected[idx : idx+n]
72 idx += n
73 }
74
75 anyFirst := false
76 for _, bs := range perFile {
77 for _, b := range bs {
78 if b {
79 anyFirst = true
80 break
81 }
82 }
83 }
84 if !anyFirst {
85 return fmt.Errorf("no hunks selected for first commit – split aborted")
86 }
87
88 sc := bufio.NewScanner(os.Stdin)
89
90 msg1 := strings.TrimSpace(head.Message)
91 if msg1 == "" {
92 msg1 = "change (1/2)"
93 } else {
94 msg1 += " (1/2)"
95 }
96 fmt.Printf("Message for first commit [%s]: ", msg1)
97 sc.Scan()
98 if t := strings.TrimSpace(sc.Text()); t != "" {
99 msg1 = t
100 }
101
102 msg2 := strings.TrimSpace(head.Message)
103 if msg2 == "" {
104 msg2 = "change (2/2)"
105 } else {
106 msg2 += " (2/2)"
107 }
108 fmt.Printf("Message for second commit [%s]: ", msg2)
109 sc.Scan()
110 if t := strings.TrimSpace(sc.Text()); t != "" {
111 msg2 = t
112 }
113
114 c1, c1ID, err := w.SnapFirstOfSplit(msg1, diffs, perFile)
115 if err != nil {
116 return fmt.Errorf("snap first commit: %w", err)
117 }
118
119 c2, c2ID, err := w.SnapRemaining(msg2, c1ID)
120 if err != nil {
121 return fmt.Errorf("snap second commit: %w", err)
122 }
123
124 now := time.Now()
125 tx, err := r.Store.Begin()
126 if err != nil {
127 return err
128 }
129 obs := &object.ObsoleteMarker{
130 Predecessor: headID,
131 Successors: [][32]byte{c1ID, c2ID},
132 Reason: "split",
133 }
134 if _, err := repo.WriteObsoleteTx(r.Store, tx, obs); err != nil {
135 r.Store.Rollback(tx)
136 return err
137 }
138 after := fmt.Sprintf(`{"head":%q,"tip1":%q,"tip2":%q}`,
139 object.FormatChangeID(c2.ChangeID),
140 fmt.Sprintf("%x", c1ID),
141 fmt.Sprintf("%x", c2ID),
142 )
143 op := store.Operation{
144 Kind: "split", Timestamp: now.Unix(), Before: before, After: after,
145 Metadata: "split into 2 commits",
146 }
147 if _, err := r.Store.InsertOperation(tx, op); err != nil {
148 r.Store.Rollback(tx)
149 return err
150 }
151 if err := r.Store.Commit(tx); err != nil {
152 return err
153 }
154
155 fmt.Printf("Split into:\n ch:%s %s\n ch:%s %s\n",
156 c1.ChangeID, c1.Message,
157 c2.ChangeID, c2.Message,
158 )
159 return nil
160 },
161}