1package cli
2
3import (
4 "fmt"
5 "os"
6 "path/filepath"
7 "sort"
8 "strings"
9 "time"
10
11 "arche/internal/diff"
12 "arche/internal/merge"
13 "arche/internal/object"
14 "arche/internal/repo"
15 "arche/internal/store"
16 "arche/internal/wc"
17
18 "github.com/spf13/cobra"
19)
20
21type absorbFlatEntry struct {
22 blobID [32]byte
23 mode object.EntryMode
24}
25
26var absorbCmd = &cobra.Command{
27 Use: "absorb",
28 Short: "Fold working-copy hunks into the correct ancestor draft commit",
29 Long: `arche absorb inspects each changed hunk in the working copy, blame-walks
30the draft commit chain to find which ancestor last introduced the surrounding
31lines, and folds the hunk into that ancestor commit.
32
33Hunks that cannot be attributed to any draft ancestor are left in the working
34copy unchanged. After a successful absorb the working copy shows only those
35unattributable changes, and the draft stack has been amended in-place with
36ObsoleteMarkers linking old commits to their replacements.
37
38Typical workflow:
39 arche stack list # see the current stack
40 arche absorb # fold review-feedback edits into the right commits
41 arche stack push # re-publish the updated stack`,
42 RunE: func(cmd *cobra.Command, args []string) error {
43 r := openRepo()
44 defer r.Close()
45
46 chain, err := collectDraftChain(r)
47 if err != nil {
48 return err
49 }
50 if len(chain) == 0 {
51 return fmt.Errorf("no draft commits in stack — nothing to absorb into")
52 }
53
54 headCommit, headID, err := r.HeadCommit()
55 if err != nil {
56 return err
57 }
58
59 headFiles, err := absorbReadTreeFiles(r, headCommit.TreeID)
60 if err != nil {
61 return fmt.Errorf("read HEAD tree: %w", err)
62 }
63 wcw := wc.New(r)
64 wcFiles, err := absorbReadWCFiles(r, wcw)
65 if err != nil {
66 return fmt.Errorf("read WC files: %w", err)
67 }
68
69 type patchEntry struct {
70 path string
71 hunk diff.Hunk
72 target int
73 }
74 var patches []patchEntry
75 allPaths := make(map[string]bool)
76 for p := range headFiles {
77 allPaths[p] = true
78 }
79 for p := range wcFiles {
80 allPaths[p] = true
81 }
82
83 for path := range allPaths {
84 old := headFiles[path]
85 neu := wcFiles[path]
86 if old == neu {
87 continue
88 }
89 var status rune
90 switch {
91 case old == "":
92 status = 'A'
93 case neu == "":
94 continue
95 default:
96 status = 'M'
97 }
98 fhd := diff.ComputeFileHunks(path, old, neu, status)
99 for _, h := range fhd.Hunks {
100 t := absorbAttributeHunk(r, chain, path, h)
101 if t < 0 {
102 continue
103 }
104 patches = append(patches, patchEntry{path, h, t})
105 }
106 }
107
108 if len(patches) == 0 {
109 fmt.Println("Nothing to absorb.")
110 return nil
111 }
112
113 type pathHunks = map[string][]diff.Hunk
114 grouped := make(map[int]pathHunks)
115 for _, pe := range patches {
116 if grouped[pe.target] == nil {
117 grouped[pe.target] = make(pathHunks)
118 }
119 grouped[pe.target][pe.path] = append(grouped[pe.target][pe.path], pe.hunk)
120 }
121
122 before, _ := r.CaptureRefState()
123 now := time.Now()
124 remapped := make(map[[32]byte][32]byte)
125 absorbCount := 0
126
127 for i, entry := range chain {
128 curID := entry.commitID
129 if mapped, ok := remapped[curID]; ok {
130 curID = mapped
131 }
132 curCommit, err := r.ReadCommit(curID)
133 if err != nil {
134 return fmt.Errorf("read commit %s: %w", object.FormatChangeID(entry.commit.ChangeID), err)
135 }
136
137 newParentID := object.ZeroID
138 parentChanged := false
139 if len(curCommit.Parents) > 0 {
140 oldP := curCommit.Parents[0]
141 if mapped, ok := remapped[oldP]; ok {
142 newParentID = mapped
143 parentChanged = true
144 } else {
145 newParentID = oldP
146 }
147 }
148
149 fileHunks := grouped[i]
150 hasHunks := len(fileHunks) > 0
151
152 if !hasHunks && !parentChanged {
153 continue
154 }
155
156 amendedTreeID := curCommit.TreeID
157 if hasHunks {
158 commitFiles, err := absorbReadTreeFiles(r, curCommit.TreeID)
159 if err != nil {
160 return err
161 }
162 modifications := make(map[string]string)
163 for path, hunks := range fileHunks {
164 content := commitFiles[path]
165 for _, h := range hunks {
166 if newContent, ok := absorbApplyHunk(content, h); ok {
167 content = newContent
168 absorbCount++
169 }
170 }
171 modifications[path] = content
172 }
173 tx0, err := r.Store.Begin()
174 if err != nil {
175 return err
176 }
177 amendedTreeID, err = absorbPatchTree(r, tx0, curCommit.TreeID, modifications)
178 if err != nil {
179 r.Store.Rollback(tx0)
180 return fmt.Errorf("patch tree for %s: %w", entry.commit.ChangeID, err)
181 }
182 if err := r.Store.Commit(tx0); err != nil {
183 return err
184 }
185 }
186
187 finalTreeID := amendedTreeID
188 if parentChanged {
189 var baseTreeID [32]byte
190 if len(curCommit.Parents) > 0 {
191 if pc, err := r.ReadCommit(curCommit.Parents[0]); err == nil {
192 baseTreeID = pc.TreeID
193 }
194 }
195 newParentCommit, err := r.ReadCommit(newParentID)
196 if err != nil {
197 return fmt.Errorf("read new parent: %w", err)
198 }
199 result, err := merge.Trees(r, baseTreeID, amendedTreeID, newParentCommit.TreeID)
200 if err != nil {
201 return fmt.Errorf("rebase-merge for %s: %w", entry.commit.ChangeID, err)
202 }
203 finalTreeID = result.TreeID
204 }
205
206 var parents [][32]byte
207 if newParentID != object.ZeroID {
208 parents = [][32]byte{newParentID}
209 }
210 newCommit := &object.Commit{
211 TreeID: finalTreeID,
212 Parents: parents,
213 ChangeID: curCommit.ChangeID,
214 Author: curCommit.Author,
215 Committer: object.Signature{Name: r.Cfg.User.Name, Email: r.Cfg.User.Email, Timestamp: now},
216 Message: curCommit.Message,
217 Phase: curCommit.Phase,
218 }
219 tx, err := r.Store.Begin()
220 if err != nil {
221 return err
222 }
223 newCommitID, err := repo.WriteCommitTx(r.Store, tx, newCommit)
224 if err != nil {
225 r.Store.Rollback(tx)
226 return err
227 }
228 if err := r.Store.SetChangeCommit(tx, curCommit.ChangeID, newCommitID); err != nil {
229 r.Store.Rollback(tx)
230 return err
231 }
232 obs := &object.ObsoleteMarker{
233 Predecessor: curID,
234 Successors: [][32]byte{newCommitID},
235 Reason: "absorb",
236 Timestamp: now.Unix(),
237 }
238 if _, err := repo.WriteObsoleteTx(r.Store, tx, obs); err != nil {
239 r.Store.Rollback(tx)
240 return err
241 }
242 if err := r.Store.Commit(tx); err != nil {
243 return err
244 }
245
246 fmt.Printf(" absorbed into %s — %s\n",
247 object.FormatChangeID(curCommit.ChangeID), bisectFirstLine(curCommit.Message))
248 remapped[entry.commitID] = newCommitID
249 remapped[curID] = newCommitID
250 }
251
252 headEntry := chain[len(chain)-1]
253 newHeadID := headID
254 if mapped, ok := remapped[headID]; ok {
255 newHeadID = mapped
256 } else if mapped, ok := remapped[headEntry.commitID]; ok {
257 newHeadID = mapped
258 }
259 newHeadCommit, err := r.ReadCommit(newHeadID)
260 if err != nil {
261 return err
262 }
263 newHeadChangeID := object.FormatChangeID(newHeadCommit.ChangeID)
264
265 opAfter := buildMergeRefState(newHeadID, newHeadChangeID)
266 tx, err := r.Store.Begin()
267 if err != nil {
268 return err
269 }
270 op := store.Operation{
271 Kind: "absorb",
272 Timestamp: now.Unix(),
273 Before: before,
274 After: opAfter,
275 Metadata: fmt.Sprintf("absorbed %d hunk(s) into %d commit(s)", absorbCount, len(grouped)),
276 }
277 if _, err := r.Store.InsertOperation(tx, op); err != nil {
278 r.Store.Rollback(tx)
279 return err
280 }
281 for path := range allPaths {
282 _ = r.Store.DeleteWCacheEntry(tx, path)
283 }
284 if err := r.Store.Commit(tx); err != nil {
285 return err
286 }
287
288 if err := r.WriteHead(newHeadChangeID); err != nil {
289 return err
290 }
291
292 fmt.Printf("Absorbed %d hunk(s) into %d commit(s). Working copy retains unattributed changes.\n",
293 absorbCount, len(grouped))
294 return nil
295 },
296}
297
298func absorbAttributeHunk(r *repo.Repo, chain []draftEntry, path string, hunk diff.Hunk) int {
299 var keyLines []string
300 for _, l := range hunk.Lines {
301 if l.Kind == diff.LineEqual || l.Kind == diff.LineRemove {
302 keyLines = append(keyLines, l.Content)
303 }
304 }
305 if len(keyLines) == 0 {
306 return -1
307 }
308
309 for i := len(chain) - 1; i >= 0; i-- {
310 commitContent, _ := absorbFileInTree(r, chain[i].commit.TreeID, path)
311 if commitContent == "" {
312 continue
313 }
314 var parentContent string
315 if i > 0 {
316 parentContent, _ = absorbFileInTree(r, chain[i-1].commit.TreeID, path)
317 } else if len(chain[0].commit.Parents) > 0 {
318 if pc, err := r.ReadCommit(chain[0].commit.Parents[0]); err == nil {
319 parentContent, _ = absorbFileInTree(r, pc.TreeID, path)
320 }
321 }
322 if absorbContainsSeq(commitContent, keyLines) && !absorbContainsSeq(parentContent, keyLines) {
323 return i
324 }
325 }
326 return 0
327}
328
329func absorbFileInTree(r *repo.Repo, treeID [32]byte, path string) (string, error) {
330 blobs := make(map[string][32]byte)
331 if err := diff.FlattenTree(r, treeID, blobs); err != nil {
332 return "", err
333 }
334 id, ok := blobs[path]
335 if !ok {
336 return "", nil
337 }
338 data, err := r.ReadBlob(id)
339 if err != nil {
340 return "", err
341 }
342 return string(data), nil
343}
344
345func absorbContainsSeq(content string, lines []string) bool {
346 if len(lines) == 0 {
347 return true
348 }
349 if content == "" {
350 return false
351 }
352 cl := absorbSplitLines(content)
353 for i := 0; i+len(lines) <= len(cl); i++ {
354 match := true
355 for j, l := range lines {
356 if cl[i+j] != l {
357 match = false
358 break
359 }
360 }
361 if match {
362 return true
363 }
364 }
365 return false
366}
367
368func absorbApplyHunk(content string, hunk diff.Hunk) (string, bool) {
369 if len(hunk.Lines) == 0 {
370 return content, false
371 }
372 lines := absorbSplitLines(content)
373
374 var oldBlock, newBlock []string
375 for _, l := range hunk.Lines {
376 switch l.Kind {
377 case diff.LineEqual:
378 oldBlock = append(oldBlock, l.Content)
379 newBlock = append(newBlock, l.Content)
380 case diff.LineRemove:
381 oldBlock = append(oldBlock, l.Content)
382 case diff.LineAdd:
383 newBlock = append(newBlock, l.Content)
384 }
385 }
386 if len(oldBlock) == 0 {
387 return content, false
388 }
389
390 tryAt := func(start int) (string, bool) {
391 if start < 0 || start+len(oldBlock) > len(lines) {
392 return content, false
393 }
394 for i, ol := range oldBlock {
395 if lines[start+i] != ol {
396 return content, false
397 }
398 }
399 result := make([]string, 0, len(lines)-len(oldBlock)+len(newBlock))
400 result = append(result, lines[:start]...)
401 result = append(result, newBlock...)
402 result = append(result, lines[start+len(oldBlock):]...)
403 return strings.Join(result, ""), true
404 }
405
406 if out, ok := tryAt(hunk.OldStart - 1); ok {
407 return out, true
408 }
409 for i := 0; i+len(oldBlock) <= len(lines); i++ {
410 if out, ok := tryAt(i); ok {
411 return out, true
412 }
413 }
414 return content, false
415}
416
417func absorbPatchTree(r *repo.Repo, tx *store.Tx, baseTreeID [32]byte, modifications map[string]string) ([32]byte, error) {
418 flat := make(map[string]absorbFlatEntry)
419 if err := absorbFlatTree(r, baseTreeID, "", flat); err != nil {
420 return object.ZeroID, err
421 }
422 for path, content := range modifications {
423 if content == "" {
424 delete(flat, path)
425 } else {
426 blobID, err := repo.WriteBlobTx(r.Store, tx, &object.Blob{Content: []byte(content)})
427 if err != nil {
428 return object.ZeroID, err
429 }
430 mode := object.ModeFile
431 if e, ok := flat[path]; ok {
432 mode = e.mode
433 }
434 flat[path] = absorbFlatEntry{blobID, mode}
435 }
436 }
437 return absorbBuildTree(r, tx, flat)
438}
439
440func absorbFlatTree(r *repo.Repo, treeID [32]byte, prefix string, out map[string]absorbFlatEntry) error {
441 if treeID == object.ZeroID {
442 return nil
443 }
444 t, err := r.ReadTree(treeID)
445 if err != nil {
446 return err
447 }
448 for _, e := range t.Entries {
449 rel := e.Name
450 if prefix != "" {
451 rel = prefix + "/" + e.Name
452 }
453 if e.Mode == object.ModeDir {
454 if err := absorbFlatTree(r, e.ObjectID, rel, out); err != nil {
455 return err
456 }
457 } else {
458 out[rel] = absorbFlatEntry{e.ObjectID, e.Mode}
459 }
460 }
461 return nil
462}
463
464func absorbBuildTree(r *repo.Repo, tx *store.Tx, flat map[string]absorbFlatEntry) ([32]byte, error) {
465 type node struct {
466 isFile bool
467 blobID [32]byte
468 mode object.EntryMode
469 kids map[string]*node
470 }
471 root := &node{kids: make(map[string]*node)}
472
473 for path, fe := range flat {
474 parts := strings.Split(path, "/")
475 cur := root
476 for i, part := range parts {
477 if i == len(parts)-1 {
478 cur.kids[part] = &node{isFile: true, blobID: fe.blobID, mode: fe.mode}
479 } else {
480 if _, ok := cur.kids[part]; !ok {
481 cur.kids[part] = &node{kids: make(map[string]*node)}
482 }
483 cur = cur.kids[part]
484 }
485 }
486 }
487
488 var writeNode func(*node) ([32]byte, error)
489 writeNode = func(n *node) ([32]byte, error) {
490 var entries []object.TreeEntry
491 for name, child := range n.kids {
492 if child.isFile {
493 entries = append(entries, object.TreeEntry{Name: name, Mode: child.mode, ObjectID: child.blobID})
494 } else {
495 subID, err := writeNode(child)
496 if err != nil {
497 return object.ZeroID, err
498 }
499 entries = append(entries, object.TreeEntry{Name: name, Mode: object.ModeDir, ObjectID: subID})
500 }
501 }
502 sort.Slice(entries, func(i, j int) bool { return entries[i].Name < entries[j].Name })
503 return repo.WriteTreeTx(r.Store, tx, &object.Tree{Entries: entries})
504 }
505 return writeNode(root)
506}
507
508func absorbReadTreeFiles(r *repo.Repo, treeID [32]byte) (map[string]string, error) {
509 blobs := make(map[string][32]byte)
510 if err := diff.FlattenTree(r, treeID, blobs); err != nil {
511 return nil, err
512 }
513 out := make(map[string]string, len(blobs))
514 for p, id := range blobs {
515 data, err := r.ReadBlob(id)
516 if err != nil {
517 return nil, err
518 }
519 out[p] = string(data)
520 }
521 return out, nil
522}
523
524func absorbReadWCFiles(r *repo.Repo, w *wc.WC) (map[string]string, error) {
525 paths, err := w.TrackedPaths()
526 if err != nil {
527 return nil, err
528 }
529 out := make(map[string]string, len(paths))
530 for _, p := range paths {
531 data, err := os.ReadFile(filepath.Join(r.Root, filepath.FromSlash(p)))
532 if err != nil {
533 return nil, err
534 }
535 out[p] = string(data)
536 }
537 return out, nil
538}
539
540func absorbSplitLines(s string) []string {
541 if s == "" {
542 return nil
543 }
544 var lines []string
545 for {
546 i := strings.IndexByte(s, '\n')
547 if i < 0 {
548 lines = append(lines, s)
549 break
550 }
551 lines = append(lines, s[:i+1])
552 s = s[i+1:]
553 }
554 return lines
555}