--- 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
+ },