arche / commit

commit 5b54645525627211538ed08bcfd9787093ced0f37f55fa0e1d7c79508da0fb45
change khtgzbcx
author dewn <dewn5228@proton.me>
committer dewn <dewn5228@proton.me>
date 2026-03-12 06:06:24
phase public
parents f1002bb7
signature Unsigned
add stack list and stack rebase: canonicalize draft chain, rebase stale commits via 3-way merge
internal/cli/cmd_stack.go [M]
--- a/internal/cli/cmd_stack.go
+++ b/internal/cli/cmd_stack.go
@@ -1,132 +1,332 @@
 package cli
 
 import (
 	"fmt"
 	"os"
 	"strings"
+	"time"
+
+	"arche/internal/merge"
 
 	"arche/internal/object"
 	"arche/internal/repo"
 	"arche/internal/store"
 	"arche/internal/syncpkg"
+	"arche/internal/wc"
 
 	"github.com/spf13/cobra"
 )
 
 var stackCmd = &cobra.Command{
 	Use:   "stack",
 	Short: "Manage stacked changes",
 }
 
 var stackPushCmd = &cobra.Command{
 	Use:   "push [remote]",
 	Short: "Publish the draft stack as per-change bookmarks for review",
 	Long: `arche stack push publishes the chain of draft commits from the current HEAD
 back to the nearest public ancestor as individual bookmarks on the remote.
 
 Each draft change gets a bookmark named stack/<change-id-prefix>, grouped as a
 reviewable unit on the forge. When you amend a change and run stack push again
 all affected downstream bookmarks are updated in a single operation.
 
 Only draft commits are included. The first public (or secret) ancestor marks
 the base of the stack.`,
 	Args: cobra.MaximumNArgs(1),
 	RunE: func(cmd *cobra.Command, args []string) error {
 		r := openRepo()
 		defer r.Close()
 
 		remoteName := "origin"
 		if len(args) == 1 {
 			remoteName = args[0]
 		}
 
 		rc := findRemote(r.Cfg.Remotes, remoteName)
 		if rc == nil {
 			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)
 		}
 
 		chain, err := collectDraftChain(r)
 		if err != nil {
 			return err
 		}
 		if len(chain) == 0 {
 			return fmt.Errorf("no draft commits in stack — HEAD is already public")
 		}
 
 		tx, err := r.Store.Begin()
 		if err != nil {
 			return err
 		}
 
 		var bmNames []string
 		for _, e := range chain {
 			name := "stack/" + e.commit.ChangeID[:8]
 			bm := store.Bookmark{Name: name, CommitID: e.commitID}
 			if setErr := r.Store.SetBookmark(tx, bm); setErr != nil {
 				r.Store.Rollback(tx)
 				return fmt.Errorf("set bookmark %q: %w", name, setErr)
 			}
 			bmNames = append(bmNames, name)
 		}
 
 		if err := r.Store.Commit(tx); err != nil {
 			return err
 		}
 
 		fmt.Printf("Pushing stack of %d change(s) to %s (%s):\n\n", len(chain), remoteName, rc.URL)
 		for i, e := range chain {
 			name := bmNames[i]
 			fmt.Printf("  ch:%s → %s — %s\n", e.commit.ChangeID, name, bisectFirstLine(e.commit.Message))
 		}
 		fmt.Println()
 
 		client := syncpkg.NewClient(r, rc.URL, rc.Token)
 		pushOpts := syncpkg.PushOptions{}
 		if err := client.PushWith(pushOpts); err != nil {
 			fmt.Fprintf(os.Stderr, "arche stack push: push failed: %v\n", err)
 			return err
 		}
 
 		fmt.Printf("Stack pushed: %s\n", strings.Join(bmNames, ", "))
 		return nil
 	},
 }
 
 type draftEntry struct {
 	commitID [32]byte
 	commit   *object.Commit
 }
 
 func collectDraftChain(r *repo.Repo) ([]draftEntry, error) {
 	_, headID, err := r.HeadCommit()
 	if err != nil {
 		return nil, err
 	}
 
 	var chain []draftEntry
 	cur := headID
 	for {
 		c, err := r.ReadCommit(cur)
 		if err != nil {
 			break
 		}
 		phase, _ := r.Store.GetPhase(cur)
 		if phase != object.PhaseDraft {
 			break
 		}
 		chain = append(chain, draftEntry{commitID: cur, commit: c})
 		if len(c.Parents) == 0 {
 			break
 		}
 		cur = c.Parents[0]
 	}
 
 	for i, j := 0, len(chain)-1; i < j; i, j = i+1, j-1 {
 		chain[i], chain[j] = chain[j], chain[i]
 	}
 	return chain, nil
 }
 
 func init() {
 	stackCmd.AddCommand(stackPushCmd
-)
+, stackListCmd, stackRebaseCmd)
+}
+
+var stackListCmd = &cobra.Command{
+	Use:   "list",
+	Short: "List the current draft stack",
+	RunE: func(cmd *cobra.Command, args []string) error {
+		r := openRepo()
+		defer r.Close()
+
+		chain, err := collectDraftChain(r)
+		if err != nil {
+			return err
+		}
+		if len(chain) == 0 {
+			fmt.Println("No draft commits in stack — HEAD is already public.")
+			return nil
+		}
+
+		_, headID, err := r.HeadCommit()
+		if err != nil {
+			return err
+		}
+
+		fmt.Printf("Stack (%d change(s)):\n", len(chain))
+		for _, e := range chain {
+			marker := "  "
+			if e.commitID == headID {
+				marker = "@ "
+			}
+			fmt.Printf("%sch:%-8s  %s\n", marker, e.commit.ChangeID[:8], bisectFirstLine(e.commit.Message))
+		}
+		return nil
+	},
+}
+
+var stackRebaseCmd = &cobra.Command{
+	Use:   "rebase",
+	Short: "Re-chain the draft stack, rebasing any stale or mis-parented commits",
+	Long: `arche stack rebase walks the draft commit chain from the current HEAD and
+ensures each commit is parented to the canonical (latest-amended) version of the
+previous change. Use this after a failed auto-rebase or to clean up a stack that
+has been imported or manually edited.`,
+	RunE: func(cmd *cobra.Command, args []string) error {
+		r := openRepo()
+		defer r.Close()
+
+		chain, err := collectDraftChain(r)
+		if err != nil {
+			return err
+		}
+		if len(chain) == 0 {
+			fmt.Println("No draft commits in stack — nothing to rebase.")
+			return nil
+		}
+
+		_, headID, err := r.HeadCommit()
+		if err != nil {
+			return err
+		}
+
+		before, _ := r.CaptureRefState()
+		now := time.Now()
+
+		newParentID := chain[0].commit.Parents[0]
+		rebaseCount := 0
+		newTipID := headID
+		newTipChangeID := object.FormatChangeID(chain[len(chain)-1].commit.ChangeID)
+
+		for _, entry := range chain {
+			canonicalID, cerr := r.Store.GetChangeCommit(entry.commit.ChangeID)
+			if cerr != nil || canonicalID == object.ZeroID {
+				canonicalID = entry.commitID
+			}
+
+			canonicalCommit, cerr := r.ReadCommit(canonicalID)
+			if cerr != nil {
+				return fmt.Errorf("read canonical for %s: %w", object.FormatChangeID(entry.commit.ChangeID), cerr)
+			}
+
+			if len(canonicalCommit.Parents) > 0 && canonicalCommit.Parents[0] == newParentID {
+				newParentID = canonicalID
+				if entry.commitID == headID || canonicalID == headID {
+					newTipID = canonicalID
+					newTipChangeID = object.FormatChangeID(entry.commit.ChangeID)
+				}
+				continue
+			}
+
+			var baseTreeID [32]byte
+			if len(canonicalCommit.Parents) > 0 {
+				if pc, perr := r.ReadCommit(canonicalCommit.Parents[0]); perr == nil {
+					baseTreeID = pc.TreeID
+				}
+			}
+			newParentCommit, nperr := r.ReadCommit(newParentID)
+			if nperr != nil {
+				return fmt.Errorf("read new parent: %w", nperr)
+			}
+			result, merr := merge.Trees(r, baseTreeID, canonicalCommit.TreeID, newParentCommit.TreeID)
+			if merr != nil {
+				return fmt.Errorf("merge for %s: %w", object.FormatChangeID(entry.commit.ChangeID), merr)
+			}
+
+			newCommit := &object.Commit{
+				TreeID:    result.TreeID,
+				Parents:   [][32]byte{newParentID},
+				ChangeID:  canonicalCommit.ChangeID,
+				Author:    canonicalCommit.Author,
+				Committer: object.Signature{Name: r.Cfg.User.Name, Email: r.Cfg.User.Email, Timestamp: now},
+				Message:   canonicalCommit.Message,
+				Phase:     canonicalCommit.Phase,
+			}
+
+			tx, err := r.Store.Begin()
+			if err != nil {
+				return err
+			}
+			newCommitID, err := repo.WriteCommitTx(r.Store, tx, newCommit)
+			if err != nil {
+				r.Store.Rollback(tx)
+				return err
+			}
+			if err := r.Store.SetChangeCommit(tx, canonicalCommit.ChangeID, newCommitID); err != nil {
+				r.Store.Rollback(tx)
+				return err
+			}
+			obs := &object.ObsoleteMarker{
+				Predecessor: canonicalID,
+				Successors:  [][32]byte{newCommitID},
+				Reason:      "stack-rebase",
+				Timestamp:   now.Unix(),
+			}
+			if _, err := repo.WriteObsoleteTx(r.Store, tx, obs); err != nil {
+				r.Store.Rollback(tx)
+				return err
+			}
+			opAfter := buildMergeRefState(newCommitID, object.FormatChangeID(canonicalCommit.ChangeID))
+			op := store.Operation{
+				Kind:      "stack-rebase-step",
+				Timestamp: now.Unix(),
+				Before:    before,
+				After:     opAfter,
+				Metadata:  fmt.Sprintf("rebased %s", object.FormatChangeID(canonicalCommit.ChangeID)),
+			}
+			if _, err := r.Store.InsertOperation(tx, op); err != nil {
+				r.Store.Rollback(tx)
+				return err
+			}
+			if err := r.Store.Commit(tx); err != nil {
+				return err
+			}
+
+			fmt.Printf("  rebased %s — %s\n", object.FormatChangeID(canonicalCommit.ChangeID), bisectFirstLine(canonicalCommit.Message))
+			rebaseCount++
+			newParentID = newCommitID
+
+			if entry.commitID == headID || canonicalID == headID {
+				newTipID = newCommitID
+				newTipChangeID = object.FormatChangeID(canonicalCommit.ChangeID)
+			}
+
+			if len(result.Conflicts) > 0 {
+				w := wc.New(r)
+				if err := w.Materialize(result.TreeID, newTipChangeID); err != nil {
+					return err
+				}
+				if err := r.WriteHead(newTipChangeID); err != nil {
+					return err
+				}
+				fmt.Printf("Stack rebase paused at %s (%d conflict(s)):\n", newTipChangeID, len(result.Conflicts))
+				for _, p := range result.Conflicts {
+					fmt.Printf("  conflict: %s\n", p)
+				}
+				fmt.Println("Resolve conflicts, then run 'arche resolve <path>' and 'arche snap'.")
+				return nil
+			}
+		}
+
+		if rebaseCount == 0 {
+			fmt.Println("Stack is already up to date.")
+			return nil
+		}
+
+		finalCommit, err := r.ReadCommit(newTipID)
+		if err != nil {
+			return err
+		}
+		w := wc.New(r)
+		if err := w.Materialize(finalCommit.TreeID, newTipChangeID); err != nil {
+			return err
+		}
+		if err := r.WriteHead(newTipChangeID); err != nil {
+			return err
+		}
+		fmt.Printf("Stack rebase complete: %d change(s) rebased, now at %s\n", rebaseCount, newTipChangeID)
+		return nil
+	},