--- a/internal/cli/cmd_stack.go
+++ b/internal/cli/cmd_stack.go
@@ -1,332 +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
+, stackLandCmd
)
}
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
},
}