arche / commit

commit d5b711f9b81e9cf3b53f97759ab966e94782ac5f90b1ebb7555ab49f8f4e06f1
change bgsdaavv
author dewn <dewn5228@proton.me>
committer dewn <dewn5228@proton.me>
date 2026-03-12 14:36:49
phase public
parents b8d98184
signature Unsigned
add stack land: promote draft change to public, optional --bookmark flag
internal/cli/cmd_stack.go [M]
--- 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
 	},
 }

internal/cli/cmd_stack_land.go [A]
--- /dev/null
+++ b/internal/cli/cmd_stack_land.go
@@ -1,0 +1,154 @@
+package cli
+
+import (
+	"fmt"
+	"time"
+
+	"arche/internal/object"
+	"arche/internal/store"
+	"arche/internal/wc"
+
+	"github.com/spf13/cobra"
+)
+
+var stackLandBookmark string
+
+var stackLandCmd = &cobra.Command{
+	Use:   "land [change-id]",
+	Short: "Promote a draft change to public (land it)",
+	Long: `arche stack land promotes a draft change to public phase, marking it as
+permanently landed. If no change-id is given, the oldest (bottom-most) draft
+in the current stack is landed.
+
+The canonical commit for the change is set to public phase. Any draft commits
+higher in the stack remain draft and are still parented to the now-public commit
+— run 'arche stack rebase' afterwards if any re-chaining is needed.
+
+Use --bookmark to advance (or create) a named bookmark to the landed commit,
+which makes it visible as a branch tip on the forge and to other clones.`,
+	Args: cobra.MaximumNArgs(1),
+	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 {
+			return fmt.Errorf("no draft commits in stack — nothing to land")
+		}
+
+		var targetChangeID string
+		var targetCommitID [32]byte
+
+		if len(args) == 1 {
+			resolvedID, rerr := resolveRef(r, args[0])
+			if rerr != nil {
+				return rerr
+			}
+			c, cerr := r.ReadCommit(resolvedID)
+			if cerr != nil {
+				return fmt.Errorf("read commit: %w", cerr)
+			}
+
+			found := false
+			for _, e := range chain {
+				if e.commit.ChangeID == c.ChangeID {
+					found = true
+					break
+				}
+			}
+			if !found {
+				return fmt.Errorf("%s is not in the current draft stack", args[0])
+			}
+
+			canonID, cerr2 := r.Store.GetChangeCommit(c.ChangeID)
+			if cerr2 != nil || canonID == object.ZeroID {
+				canonID = resolvedID
+			}
+			targetCommitID = canonID
+			targetChangeID = c.ChangeID
+		} else {
+			e := chain[0]
+			canonID, cerr := r.Store.GetChangeCommit(e.commit.ChangeID)
+			if cerr != nil || canonID == object.ZeroID {
+				canonID = e.commitID
+			}
+			targetCommitID = canonID
+			targetChangeID = e.commit.ChangeID
+		}
+
+		targetCommit, err := r.ReadCommit(targetCommitID)
+		if err != nil {
+			return fmt.Errorf("read target commit: %w", err)
+		}
+
+		before, _ := r.CaptureRefState()
+		now := time.Now()
+
+		tx, err := r.Store.Begin()
+		if err != nil {
+			return err
+		}
+
+		if err := r.Store.SetPhase(tx, targetCommitID, object.PhasePublic); err != nil {
+			r.Store.Rollback(tx)
+			return fmt.Errorf("set phase: %w", err)
+		}
+
+		if stackLandBookmark != "" {
+			bm := store.Bookmark{Name: stackLandBookmark, CommitID: targetCommitID}
+			if err := r.Store.SetBookmark(tx, bm); err != nil {
+				r.Store.Rollback(tx)
+				return fmt.Errorf("set bookmark %q: %w", stackLandBookmark, err)
+			}
+		}
+
+		changeIDFmt := object.FormatChangeID(targetChangeID)
+		opAfter := buildMergeRefState(targetCommitID, changeIDFmt)
+		op := store.Operation{
+			Kind:      "stack-land",
+			Timestamp: now.Unix(),
+			Before:    before,
+			After:     opAfter,
+			Metadata:  fmt.Sprintf("landed %s", changeIDFmt),
+		}
+		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("Landed %s — %s\n", changeIDFmt, bisectFirstLine(targetCommit.Message))
+		if stackLandBookmark != "" {
+			fmt.Printf("Bookmark %q → %x\n", stackLandBookmark, targetCommitID[:6])
+		}
+
+		_, headID, herr := r.HeadCommit()
+		if herr == nil && headID == targetCommitID {
+			w := wc.New(r)
+			_ = w.Materialize(targetCommit.TreeID, changeIDFmt)
+		}
+
+		remaining := 0
+		for _, e := range chain {
+			if e.commit.ChangeID != targetChangeID {
+				remaining++
+			}
+		}
+		if remaining > 0 {
+			fmt.Printf("%d draft commit(s) remain above the landed change.\n", remaining)
+			fmt.Println("Run 'arche stack rebase' if any re-chaining is needed.")
+		}
+
+		return nil
+	},
+}
+
+func init() {
+	stackLandCmd.Flags().StringVar(&stackLandBookmark, "bookmark", "", "create or advance this bookmark to the landed commit")
+}