1package cli
2
3import (
4 "fmt"
5 "os"
6 "strings"
7 "time"
8
9 "arche/internal/merge"
10 "arche/internal/object"
11 "arche/internal/repo"
12 "arche/internal/store"
13 "arche/internal/syncpkg"
14 "arche/internal/wc"
15
16 "github.com/spf13/cobra"
17)
18
19var stackCmd = &cobra.Command{
20 Use: "stack",
21 Short: "Manage stacked changes",
22}
23
24var stackPushCmd = &cobra.Command{
25 Use: "push [remote]",
26 Short: "Publish the draft stack as per-change bookmarks for review",
27 Long: `arche stack push publishes the chain of draft commits from the current HEAD
28back to the nearest public ancestor as individual bookmarks on the remote.
29
30Each draft change gets a bookmark named stack/<change-id-prefix>, grouped as a
31reviewable unit on the forge. When you amend a change and run stack push again
32all affected downstream bookmarks are updated in a single operation.
33
34Only draft commits are included. The first public (or secret) ancestor marks
35the base of the stack.`,
36 Args: cobra.MaximumNArgs(1),
37 RunE: func(cmd *cobra.Command, args []string) error {
38 r := openRepo()
39 defer r.Close()
40
41 remoteName := "origin"
42 if len(args) == 1 {
43 remoteName = args[0]
44 }
45
46 rc := findRemote(r.Cfg.Remotes, remoteName)
47 if rc == nil {
48 return fmt.Errorf("no remote named %q; add one to .arche/config.toml:\n\n [[remote]]\n name = %q\n url = \"http://host:8765\"\n token = \"secret\"", remoteName, remoteName)
49 }
50
51 chain, err := collectDraftChain(r)
52 if err != nil {
53 return err
54 }
55 if len(chain) == 0 {
56 return fmt.Errorf("no draft commits in stack — HEAD is already public")
57 }
58
59 tx, err := r.Store.Begin()
60 if err != nil {
61 return err
62 }
63
64 var bmNames []string
65 for _, e := range chain {
66 name := "stack/" + e.commit.ChangeID[:8]
67 bm := store.Bookmark{Name: name, CommitID: e.commitID}
68 if setErr := r.Store.SetBookmark(tx, bm); setErr != nil {
69 r.Store.Rollback(tx)
70 return fmt.Errorf("set bookmark %q: %w", name, setErr)
71 }
72 bmNames = append(bmNames, name)
73 }
74
75 if err := r.Store.Commit(tx); err != nil {
76 return err
77 }
78
79 fmt.Printf("Pushing stack of %d change(s) to %s (%s):\n\n", len(chain), remoteName, rc.URL)
80 for i, e := range chain {
81 name := bmNames[i]
82 fmt.Printf(" ch:%s → %s — %s\n", e.commit.ChangeID, name, bisectFirstLine(e.commit.Message))
83 }
84 fmt.Println()
85
86 client := syncpkg.NewClient(r, rc.URL, rc.Token)
87 pushOpts := syncpkg.PushOptions{}
88 if err := client.PushWith(pushOpts); err != nil {
89 fmt.Fprintf(os.Stderr, "arche stack push: push failed: %v\n", err)
90 return err
91 }
92
93 fmt.Printf("Stack pushed: %s\n", strings.Join(bmNames, ", "))
94 return nil
95 },
96}
97
98type draftEntry struct {
99 commitID [32]byte
100 commit *object.Commit
101}
102
103func collectDraftChain(r *repo.Repo) ([]draftEntry, error) {
104 _, headID, err := r.HeadCommit()
105 if err != nil {
106 return nil, err
107 }
108
109 var chain []draftEntry
110 cur := headID
111 for {
112 c, err := r.ReadCommit(cur)
113 if err != nil {
114 break
115 }
116 phase, _ := r.Store.GetPhase(cur)
117 if phase != object.PhaseDraft {
118 break
119 }
120 chain = append(chain, draftEntry{commitID: cur, commit: c})
121 if len(c.Parents) == 0 {
122 break
123 }
124 cur = c.Parents[0]
125 }
126
127 for i, j := 0, len(chain)-1; i < j; i, j = i+1, j-1 {
128 chain[i], chain[j] = chain[j], chain[i]
129 }
130 return chain, nil
131}
132
133func init() {
134 stackCmd.AddCommand(stackPushCmd, stackListCmd, stackRebaseCmd, stackLandCmd)
135}
136
137var stackListCmd = &cobra.Command{
138 Use: "list",
139 Short: "List the current draft stack",
140 RunE: func(cmd *cobra.Command, args []string) error {
141 r := openRepo()
142 defer r.Close()
143
144 chain, err := collectDraftChain(r)
145 if err != nil {
146 return err
147 }
148 if len(chain) == 0 {
149 fmt.Println("No draft commits in stack — HEAD is already public.")
150 return nil
151 }
152
153 _, headID, err := r.HeadCommit()
154 if err != nil {
155 return err
156 }
157
158 fmt.Printf("Stack (%d change(s)):\n", len(chain))
159 for _, e := range chain {
160 marker := " "
161 if e.commitID == headID {
162 marker = "@ "
163 }
164 fmt.Printf("%sch:%-8s %s\n", marker, e.commit.ChangeID[:8], bisectFirstLine(e.commit.Message))
165 }
166 return nil
167 },
168}
169
170var stackRebaseCmd = &cobra.Command{
171 Use: "rebase",
172 Short: "Re-chain the draft stack, rebasing any stale or mis-parented commits",
173 Long: `arche stack rebase walks the draft commit chain from the current HEAD and
174ensures each commit is parented to the canonical (latest-amended) version of the
175previous change. Use this after a failed auto-rebase or to clean up a stack that
176has been imported or manually edited.`,
177 RunE: func(cmd *cobra.Command, args []string) error {
178 r := openRepo()
179 defer r.Close()
180
181 chain, err := collectDraftChain(r)
182 if err != nil {
183 return err
184 }
185 if len(chain) == 0 {
186 fmt.Println("No draft commits in stack — nothing to rebase.")
187 return nil
188 }
189
190 _, headID, err := r.HeadCommit()
191 if err != nil {
192 return err
193 }
194
195 before, _ := r.CaptureRefState()
196 now := time.Now()
197
198 newParentID := chain[0].commit.Parents[0]
199 rebaseCount := 0
200 newTipID := headID
201 newTipChangeID := object.FormatChangeID(chain[len(chain)-1].commit.ChangeID)
202
203 for _, entry := range chain {
204 canonicalID, cerr := r.Store.GetChangeCommit(entry.commit.ChangeID)
205 if cerr != nil || canonicalID == object.ZeroID {
206 canonicalID = entry.commitID
207 }
208
209 canonicalCommit, cerr := r.ReadCommit(canonicalID)
210 if cerr != nil {
211 return fmt.Errorf("read canonical for %s: %w", object.FormatChangeID(entry.commit.ChangeID), cerr)
212 }
213
214 if len(canonicalCommit.Parents) > 0 && canonicalCommit.Parents[0] == newParentID {
215 newParentID = canonicalID
216 if entry.commitID == headID || canonicalID == headID {
217 newTipID = canonicalID
218 newTipChangeID = object.FormatChangeID(entry.commit.ChangeID)
219 }
220 continue
221 }
222
223 var baseTreeID [32]byte
224 if len(canonicalCommit.Parents) > 0 {
225 if pc, perr := r.ReadCommit(canonicalCommit.Parents[0]); perr == nil {
226 baseTreeID = pc.TreeID
227 }
228 }
229 newParentCommit, nperr := r.ReadCommit(newParentID)
230 if nperr != nil {
231 return fmt.Errorf("read new parent: %w", nperr)
232 }
233 result, merr := merge.Trees(r, baseTreeID, canonicalCommit.TreeID, newParentCommit.TreeID)
234 if merr != nil {
235 return fmt.Errorf("merge for %s: %w", object.FormatChangeID(entry.commit.ChangeID), merr)
236 }
237
238 newCommit := &object.Commit{
239 TreeID: result.TreeID,
240 Parents: [][32]byte{newParentID},
241 ChangeID: canonicalCommit.ChangeID,
242 Author: canonicalCommit.Author,
243 Committer: object.Signature{Name: r.Cfg.User.Name, Email: r.Cfg.User.Email, Timestamp: now},
244 Message: canonicalCommit.Message,
245 Phase: canonicalCommit.Phase,
246 }
247
248 tx, err := r.Store.Begin()
249 if err != nil {
250 return err
251 }
252 newCommitID, err := repo.WriteCommitTx(r.Store, tx, newCommit)
253 if err != nil {
254 r.Store.Rollback(tx)
255 return err
256 }
257 if err := r.Store.SetChangeCommit(tx, canonicalCommit.ChangeID, newCommitID); err != nil {
258 r.Store.Rollback(tx)
259 return err
260 }
261 obs := &object.ObsoleteMarker{
262 Predecessor: canonicalID,
263 Successors: [][32]byte{newCommitID},
264 Reason: "stack-rebase",
265 Timestamp: now.Unix(),
266 }
267 if _, err := repo.WriteObsoleteTx(r.Store, tx, obs); err != nil {
268 r.Store.Rollback(tx)
269 return err
270 }
271 opAfter := buildMergeRefState(newCommitID, object.FormatChangeID(canonicalCommit.ChangeID))
272 op := store.Operation{
273 Kind: "stack-rebase-step",
274 Timestamp: now.Unix(),
275 Before: before,
276 After: opAfter,
277 Metadata: fmt.Sprintf("rebased %s", object.FormatChangeID(canonicalCommit.ChangeID)),
278 }
279 if _, err := r.Store.InsertOperation(tx, op); err != nil {
280 r.Store.Rollback(tx)
281 return err
282 }
283 if err := r.Store.Commit(tx); err != nil {
284 return err
285 }
286
287 fmt.Printf(" rebased %s — %s\n", object.FormatChangeID(canonicalCommit.ChangeID), bisectFirstLine(canonicalCommit.Message))
288 rebaseCount++
289 newParentID = newCommitID
290
291 if entry.commitID == headID || canonicalID == headID {
292 newTipID = newCommitID
293 newTipChangeID = object.FormatChangeID(canonicalCommit.ChangeID)
294 }
295
296 if len(result.Conflicts) > 0 {
297 w := wc.New(r)
298 if err := w.Materialize(result.TreeID, newTipChangeID); err != nil {
299 return err
300 }
301 if err := r.WriteHead(newTipChangeID); err != nil {
302 return err
303 }
304 fmt.Printf("Stack rebase paused at %s (%d conflict(s)):\n", newTipChangeID, len(result.Conflicts))
305 for _, p := range result.Conflicts {
306 fmt.Printf(" conflict: %s\n", p)
307 }
308 fmt.Println("Resolve conflicts, then run 'arche resolve <path>' and 'arche snap'.")
309 return nil
310 }
311 }
312
313 if rebaseCount == 0 {
314 fmt.Println("Stack is already up to date.")
315 return nil
316 }
317
318 finalCommit, err := r.ReadCommit(newTipID)
319 if err != nil {
320 return err
321 }
322 w := wc.New(r)
323 if err := w.Materialize(finalCommit.TreeID, newTipChangeID); err != nil {
324 return err
325 }
326 if err := r.WriteHead(newTipChangeID); err != nil {
327 return err
328 }
329 fmt.Printf("Stack rebase complete: %d change(s) rebased, now at %s\n", rebaseCount, newTipChangeID)
330 return nil
331 },
332}