1package cli
2
3import (
4 "fmt"
5 "time"
6
7 "arche/internal/merge"
8 "arche/internal/object"
9 "arche/internal/repo"
10 "arche/internal/store"
11 "arche/internal/wc"
12
13 "github.com/spf13/cobra"
14)
15
16var graftCmd = &cobra.Command{
17 Use: "graft <id>",
18 Short: "Copy a commit onto the current HEAD, preserving its change ID",
19 Long: `arche graft copies the changes introduced by <id> onto the current HEAD.
20The grafted commit carries the same change ID, author, and message as the
21original. An ObsoleteMarker links the original commit to the grafted copy,
22making the relationship visible in 'arche explain' and forge stack views.
23
24Unlike 'arche rebase', graft does not move the original commit — it copies it.
25This makes graft suitable for backporting a fix to a release branch without
26modifying the main-line history.`,
27 Args: cobra.ExactArgs(1),
28 RunE: func(cmd *cobra.Command, args []string) error {
29 r := openRepo()
30 defer r.Close()
31
32 srcID, err := resolveRef(r, args[0])
33 if err != nil {
34 return err
35 }
36
37 src, err := r.ReadCommit(srcID)
38 if err != nil {
39 return fmt.Errorf("read source commit: %w", err)
40 }
41
42 _, headID, err := r.HeadCommit()
43 if err != nil {
44 return err
45 }
46
47 if srcID == headID {
48 fmt.Println("Nothing to graft: source is already the current HEAD.")
49 return nil
50 }
51
52 headCommit, err := r.ReadCommit(headID)
53 if err != nil {
54 return err
55 }
56
57 var baseTreeID [32]byte
58 if len(src.Parents) > 0 {
59 if pc, perr := r.ReadCommit(src.Parents[0]); perr == nil {
60 baseTreeID = pc.TreeID
61 }
62 }
63
64 result, err := merge.Trees(r, baseTreeID, src.TreeID, headCommit.TreeID)
65 if err != nil {
66 return fmt.Errorf("graft merge: %w", err)
67 }
68
69 before, _ := r.CaptureRefState()
70 now := time.Now()
71
72 newCommit := &object.Commit{
73 TreeID: result.TreeID,
74 Parents: [][32]byte{headID},
75 ChangeID: src.ChangeID,
76 Author: src.Author,
77 Committer: object.Signature{Name: r.Cfg.User.Name, Email: r.Cfg.User.Email, Timestamp: now},
78 Message: src.Message,
79 Phase: object.PhaseDraft,
80 }
81
82 tx, err := r.Store.Begin()
83 if err != nil {
84 return err
85 }
86
87 newCommitID, err := repo.WriteCommitTx(r.Store, tx, newCommit)
88 if err != nil {
89 r.Store.Rollback(tx)
90 return err
91 }
92
93 if err := r.Store.SetChangeCommit(tx, src.ChangeID, newCommitID); err != nil {
94 r.Store.Rollback(tx)
95 return err
96 }
97
98 obs := &object.ObsoleteMarker{
99 Predecessor: srcID,
100 Successors: [][32]byte{newCommitID},
101 Reason: "graft",
102 Timestamp: now.Unix(),
103 }
104 if _, err := repo.WriteObsoleteTx(r.Store, tx, obs); err != nil {
105 r.Store.Rollback(tx)
106 return err
107 }
108
109 newChangeID := object.FormatChangeID(src.ChangeID)
110 opAfter := buildMergeRefState(newCommitID, newChangeID)
111 op := store.Operation{
112 Kind: "graft",
113 Timestamp: now.Unix(),
114 Before: before,
115 After: opAfter,
116 Metadata: fmt.Sprintf("grafted %s onto %x", newChangeID, headID[:6]),
117 }
118 if _, err := r.Store.InsertOperation(tx, op); err != nil {
119 r.Store.Rollback(tx)
120 return err
121 }
122
123 if err := r.Store.ClearAllConflicts(tx); err != nil {
124 r.Store.Rollback(tx)
125 return err
126 }
127 for _, cp := range result.Conflicts {
128 if err := r.Store.AddConflict(tx, cp); err != nil {
129 r.Store.Rollback(tx)
130 return err
131 }
132 }
133
134 if err := r.Store.Commit(tx); err != nil {
135 return err
136 }
137
138 w := wc.New(r)
139 if err := w.Materialize(result.TreeID, newChangeID); err != nil {
140 return err
141 }
142 if err := r.WriteHead(newChangeID); err != nil {
143 return err
144 }
145
146 if len(result.Conflicts) > 0 {
147 fmt.Printf("Graft paused at %s (%d conflict(s)):\n", newChangeID, len(result.Conflicts))
148 for _, p := range result.Conflicts {
149 fmt.Printf(" conflict: %s\n", p)
150 }
151 fmt.Println("Resolve conflicts, then run 'arche resolve <path>' and 'arche snap'.")
152 return nil
153 }
154
155 fmt.Printf("Grafted %s — %s\n", newChangeID, bisectFirstLine(src.Message))
156 return nil
157 },
158}