arche / internal/cli/cmd_absorb.go

commit a22ffc45
  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}