arche / internal/cli/cmd_stack.go

commit a22ffc45
  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}