--- /dev/null
+++ b/internal/cli/cmd_graft.go
@@ -1,0 +1,158 @@
+package cli
+
+import (
+ "fmt"
+ "time"
+
+ "arche/internal/merge"
+ "arche/internal/object"
+ "arche/internal/repo"
+ "arche/internal/store"
+ "arche/internal/wc"
+
+ "github.com/spf13/cobra"
+)
+
+var graftCmd = &cobra.Command{
+ Use: "graft <id>",
+ Short: "Copy a commit onto the current HEAD, preserving its change ID",
+ Long: `arche graft copies the changes introduced by <id> onto the current HEAD.
+The grafted commit carries the same change ID, author, and message as the
+original. An ObsoleteMarker links the original commit to the grafted copy,
+making the relationship visible in 'arche explain' and forge stack views.
+
+Unlike 'arche rebase', graft does not move the original commit — it copies it.
+This makes graft suitable for backporting a fix to a release branch without
+modifying the main-line history.`,
+ Args: cobra.ExactArgs(1),
+ RunE: func(cmd *cobra.Command, args []string) error {
+ r := openRepo()
+ defer r.Close()
+
+ srcID, err := resolveRef(r, args[0])
+ if err != nil {
+ return err
+ }
+
+ src, err := r.ReadCommit(srcID)
+ if err != nil {
+ return fmt.Errorf("read source commit: %w", err)
+ }
+
+ _, headID, err := r.HeadCommit()
+ if err != nil {
+ return err
+ }
+
+ if srcID == headID {
+ fmt.Println("Nothing to graft: source is already the current HEAD.")
+ return nil
+ }
+
+ headCommit, err := r.ReadCommit(headID)
+ if err != nil {
+ return err
+ }
+
+ var baseTreeID [32]byte
+ if len(src.Parents) > 0 {
+ if pc, perr := r.ReadCommit(src.Parents[0]); perr == nil {
+ baseTreeID = pc.TreeID
+ }
+ }
+
+ result, err := merge.Trees(r, baseTreeID, src.TreeID, headCommit.TreeID)
+ if err != nil {
+ return fmt.Errorf("graft merge: %w", err)
+ }
+
+ before, _ := r.CaptureRefState()
+ now := time.Now()
+
+ newCommit := &object.Commit{
+ TreeID: result.TreeID,
+ Parents: [][32]byte{headID},
+ ChangeID: src.ChangeID,
+ Author: src.Author,
+ Committer: object.Signature{Name: r.Cfg.User.Name, Email: r.Cfg.User.Email, Timestamp: now},
+ Message: src.Message,
+ Phase: object.PhaseDraft,
+ }
+
+ 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, src.ChangeID, newCommitID); err != nil {
+ r.Store.Rollback(tx)
+ return err
+ }
+
+ obs := &object.ObsoleteMarker{
+ Predecessor: srcID,
+ Successors: [][32]byte{newCommitID},
+ Reason: "graft",
+ Timestamp: now.Unix(),
+ }
+ if _, err := repo.WriteObsoleteTx(r.Store, tx, obs); err != nil {
+ r.Store.Rollback(tx)
+ return err
+ }
+
+ newChangeID := object.FormatChangeID(src.ChangeID)
+ opAfter := buildMergeRefState(newCommitID, newChangeID)
+ op := store.Operation{
+ Kind: "graft",
+ Timestamp: now.Unix(),
+ Before: before,
+ After: opAfter,
+ Metadata: fmt.Sprintf("grafted %s onto %x", newChangeID, headID[:6]),
+ }
+ if _, err := r.Store.InsertOperation(tx, op); err != nil {
+ r.Store.Rollback(tx)
+ return err
+ }
+
+ if err := r.Store.ClearAllConflicts(tx); err != nil {
+ r.Store.Rollback(tx)
+ return err
+ }
+ for _, cp := range result.Conflicts {
+ if err := r.Store.AddConflict(tx, cp); err != nil {
+ r.Store.Rollback(tx)
+ return err
+ }
+ }
+
+ if err := r.Store.Commit(tx); err != nil {
+ return err
+ }
+
+ w := wc.New(r)
+ if err := w.Materialize(result.TreeID, newChangeID); err != nil {
+ return err
+ }
+ if err := r.WriteHead(newChangeID); err != nil {
+ return err
+ }
+
+ if len(result.Conflicts) > 0 {
+ fmt.Printf("Graft paused at %s (%d conflict(s)):\n", newChangeID, 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
+ }
+
+ fmt.Printf("Grafted %s — %s\n", newChangeID, bisectFirstLine(src.Message))
+ return nil
+ },
+}