arche / commit

commit 9484e2021701f5876a8e3b682e4a2dc29e6b655de147e2ce0f9ddd4c251e7765
change myqjknbh
author dewn <dewn5228@proton.me>
committer dewn <dewn5228@proton.me>
date 2026-03-12 14:43:15
phase public
parents d5b711f9
signature Unsigned
add arche absorb: blame-walk draft chain, fold WC hunks into correct ancestor draft commit
internal/cli/cmd_absorb.go [A]
--- /dev/null
+++ b/internal/cli/cmd_absorb.go
@@ -1,0 +1,555 @@
+package cli
+
+import (
+	"fmt"
+	"os"
+	"path/filepath"
+	"sort"
+	"strings"
+	"time"
+
+	"arche/internal/diff"
+	"arche/internal/merge"
+	"arche/internal/object"
+	"arche/internal/repo"
+	"arche/internal/store"
+	"arche/internal/wc"
+
+	"github.com/spf13/cobra"
+)
+
+type absorbFlatEntry struct {
+	blobID [32]byte
+	mode   object.EntryMode
+}
+
+var absorbCmd = &cobra.Command{
+	Use:   "absorb",
+	Short: "Fold working-copy hunks into the correct ancestor draft commit",
+	Long: `arche absorb inspects each changed hunk in the working copy, blame-walks
+the draft commit chain to find which ancestor last introduced the surrounding
+lines, and folds the hunk into that ancestor commit.
+
+Hunks that cannot be attributed to any draft ancestor are left in the working
+copy unchanged. After a successful absorb the working copy shows only those
+unattributable changes, and the draft stack has been amended in-place with
+ObsoleteMarkers linking old commits to their replacements.
+
+Typical workflow:
+  arche stack list        # see the current stack
+  arche absorb            # fold review-feedback edits into the right commits
+  arche stack push        # re-publish the updated 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 {
+			return fmt.Errorf("no draft commits in stack — nothing to absorb into")
+		}
+
+		headCommit, headID, err := r.HeadCommit()
+		if err != nil {
+			return err
+		}
+
+		headFiles, err := absorbReadTreeFiles(r, headCommit.TreeID)
+		if err != nil {
+			return fmt.Errorf("read HEAD tree: %w", err)
+		}
+		wcw := wc.New(r)
+		wcFiles, err := absorbReadWCFiles(r, wcw)
+		if err != nil {
+			return fmt.Errorf("read WC files: %w", err)
+		}
+
+		type patchEntry struct {
+			path   string
+			hunk   diff.Hunk
+			target int
+		}
+		var patches []patchEntry
+		allPaths := make(map[string]bool)
+		for p := range headFiles {
+			allPaths[p] = true
+		}
+		for p := range wcFiles {
+			allPaths[p] = true
+		}
+
+		for path := range allPaths {
+			old := headFiles[path]
+			neu := wcFiles[path]
+			if old == neu {
+				continue
+			}
+			var status rune
+			switch {
+			case old == "":
+				status = 'A'
+			case neu == "":
+				continue
+			default:
+				status = 'M'
+			}
+			fhd := diff.ComputeFileHunks(path, old, neu, status)
+			for _, h := range fhd.Hunks {
+				t := absorbAttributeHunk(r, chain, path, h)
+				if t < 0 {
+					continue
+				}
+				patches = append(patches, patchEntry{path, h, t})
+			}
+		}
+
+		if len(patches) == 0 {
+			fmt.Println("Nothing to absorb.")
+			return nil
+		}
+
+		type pathHunks = map[string][]diff.Hunk
+		grouped := make(map[int]pathHunks)
+		for _, pe := range patches {
+			if grouped[pe.target] == nil {
+				grouped[pe.target] = make(pathHunks)
+			}
+			grouped[pe.target][pe.path] = append(grouped[pe.target][pe.path], pe.hunk)
+		}
+
+		before, _ := r.CaptureRefState()
+		now := time.Now()
+		remapped := make(map[[32]byte][32]byte)
+		absorbCount := 0
+
+		for i, entry := range chain {
+			curID := entry.commitID
+			if mapped, ok := remapped[curID]; ok {
+				curID = mapped
+			}
+			curCommit, err := r.ReadCommit(curID)
+			if err != nil {
+				return fmt.Errorf("read commit %s: %w", object.FormatChangeID(entry.commit.ChangeID), err)
+			}
+
+			newParentID := object.ZeroID
+			parentChanged := false
+			if len(curCommit.Parents) > 0 {
+				oldP := curCommit.Parents[0]
+				if mapped, ok := remapped[oldP]; ok {
+					newParentID = mapped
+					parentChanged = true
+				} else {
+					newParentID = oldP
+				}
+			}
+
+			fileHunks := grouped[i]
+			hasHunks := len(fileHunks) > 0
+
+			if !hasHunks && !parentChanged {
+				continue
+			}
+
+			amendedTreeID := curCommit.TreeID
+			if hasHunks {
+				commitFiles, err := absorbReadTreeFiles(r, curCommit.TreeID)
+				if err != nil {
+					return err
+				}
+				modifications := make(map[string]string)
+				for path, hunks := range fileHunks {
+					content := commitFiles[path]
+					for _, h := range hunks {
+						if newContent, ok := absorbApplyHunk(content, h); ok {
+							content = newContent
+							absorbCount++
+						}
+					}
+					modifications[path] = content
+				}
+				tx0, err := r.Store.Begin()
+				if err != nil {
+					return err
+				}
+				amendedTreeID, err = absorbPatchTree(r, tx0, curCommit.TreeID, modifications)
+				if err != nil {
+					r.Store.Rollback(tx0)
+					return fmt.Errorf("patch tree for %s: %w", entry.commit.ChangeID, err)
+				}
+				if err := r.Store.Commit(tx0); err != nil {
+					return err
+				}
+			}
+
+			finalTreeID := amendedTreeID
+			if parentChanged {
+				var baseTreeID [32]byte
+				if len(curCommit.Parents) > 0 {
+					if pc, err := r.ReadCommit(curCommit.Parents[0]); err == nil {
+						baseTreeID = pc.TreeID
+					}
+				}
+				newParentCommit, err := r.ReadCommit(newParentID)
+				if err != nil {
+					return fmt.Errorf("read new parent: %w", err)
+				}
+				result, err := merge.Trees(r, baseTreeID, amendedTreeID, newParentCommit.TreeID)
+				if err != nil {
+					return fmt.Errorf("rebase-merge for %s: %w", entry.commit.ChangeID, err)
+				}
+				finalTreeID = result.TreeID
+			}
+
+			var parents [][32]byte
+			if newParentID != object.ZeroID {
+				parents = [][32]byte{newParentID}
+			}
+			newCommit := &object.Commit{
+				TreeID:    finalTreeID,
+				Parents:   parents,
+				ChangeID:  curCommit.ChangeID,
+				Author:    curCommit.Author,
+				Committer: object.Signature{Name: r.Cfg.User.Name, Email: r.Cfg.User.Email, Timestamp: now},
+				Message:   curCommit.Message,
+				Phase:     curCommit.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, curCommit.ChangeID, newCommitID); err != nil {
+				r.Store.Rollback(tx)
+				return err
+			}
+			obs := &object.ObsoleteMarker{
+				Predecessor: curID,
+				Successors:  [][32]byte{newCommitID},
+				Reason:      "absorb",
+				Timestamp:   now.Unix(),
+			}
+			if _, err := repo.WriteObsoleteTx(r.Store, tx, obs); err != nil {
+				r.Store.Rollback(tx)
+				return err
+			}
+			if err := r.Store.Commit(tx); err != nil {
+				return err
+			}
+
+			fmt.Printf("  absorbed into %s — %s\n",
+				object.FormatChangeID(curCommit.ChangeID), bisectFirstLine(curCommit.Message))
+			remapped[entry.commitID] = newCommitID
+			remapped[curID] = newCommitID
+		}
+
+		headEntry := chain[len(chain)-1]
+		newHeadID := headID
+		if mapped, ok := remapped[headID]; ok {
+			newHeadID = mapped
+		} else if mapped, ok := remapped[headEntry.commitID]; ok {
+			newHeadID = mapped
+		}
+		newHeadCommit, err := r.ReadCommit(newHeadID)
+		if err != nil {
+			return err
+		}
+		newHeadChangeID := object.FormatChangeID(newHeadCommit.ChangeID)
+
+		opAfter := buildMergeRefState(newHeadID, newHeadChangeID)
+		tx, err := r.Store.Begin()
+		if err != nil {
+			return err
+		}
+		op := store.Operation{
+			Kind:      "absorb",
+			Timestamp: now.Unix(),
+			Before:    before,
+			After:     opAfter,
+			Metadata:  fmt.Sprintf("absorbed %d hunk(s) into %d commit(s)", absorbCount, len(grouped)),
+		}
+		if _, err := r.Store.InsertOperation(tx, op); err != nil {
+			r.Store.Rollback(tx)
+			return err
+		}
+		for path := range allPaths {
+			_ = r.Store.DeleteWCacheEntry(tx, path)
+		}
+		if err := r.Store.Commit(tx); err != nil {
+			return err
+		}
+
+		if err := r.WriteHead(newHeadChangeID); err != nil {
+			return err
+		}
+
+		fmt.Printf("Absorbed %d hunk(s) into %d commit(s). Working copy retains unattributed changes.\n",
+			absorbCount, len(grouped))
+		return nil
+	},
+}
+
+func absorbAttributeHunk(r *repo.Repo, chain []draftEntry, path string, hunk diff.Hunk) int {
+	var keyLines []string
+	for _, l := range hunk.Lines {
+		if l.Kind == diff.LineEqual || l.Kind == diff.LineRemove {
+			keyLines = append(keyLines, l.Content)
+		}
+	}
+	if len(keyLines) == 0 {
+		return -1
+	}
+
+	for i := len(chain) - 1; i >= 0; i-- {
+		commitContent, _ := absorbFileInTree(r, chain[i].commit.TreeID, path)
+		if commitContent == "" {
+			continue
+		}
+		var parentContent string
+		if i > 0 {
+			parentContent, _ = absorbFileInTree(r, chain[i-1].commit.TreeID, path)
+		} else if len(chain[0].commit.Parents) > 0 {
+			if pc, err := r.ReadCommit(chain[0].commit.Parents[0]); err == nil {
+				parentContent, _ = absorbFileInTree(r, pc.TreeID, path)
+			}
+		}
+		if absorbContainsSeq(commitContent, keyLines) && !absorbContainsSeq(parentContent, keyLines) {
+			return i
+		}
+	}
+	return 0
+}
+
+func absorbFileInTree(r *repo.Repo, treeID [32]byte, path string) (string, error) {
+	blobs := make(map[string][32]byte)
+	if err := diff.FlattenTree(r, treeID, blobs); err != nil {
+		return "", err
+	}
+	id, ok := blobs[path]
+	if !ok {
+		return "", nil
+	}
+	data, err := r.ReadBlob(id)
+	if err != nil {
+		return "", err
+	}
+	return string(data), nil
+}
+
+func absorbContainsSeq(content string, lines []string) bool {
+	if len(lines) == 0 {
+		return true
+	}
+	if content == "" {
+		return false
+	}
+	cl := absorbSplitLines(content)
+	for i := 0; i+len(lines) <= len(cl); i++ {
+		match := true
+		for j, l := range lines {
+			if cl[i+j] != l {
+				match = false
+				break
+			}
+		}
+		if match {
+			return true
+		}
+	}
+	return false
+}
+
+func absorbApplyHunk(content string, hunk diff.Hunk) (string, bool) {
+	if len(hunk.Lines) == 0 {
+		return content, false
+	}
+	lines := absorbSplitLines(content)
+
+	var oldBlock, newBlock []string
+	for _, l := range hunk.Lines {
+		switch l.Kind {
+		case diff.LineEqual:
+			oldBlock = append(oldBlock, l.Content)
+			newBlock = append(newBlock, l.Content)
+		case diff.LineRemove:
+			oldBlock = append(oldBlock, l.Content)
+		case diff.LineAdd:
+			newBlock = append(newBlock, l.Content)
+		}
+	}
+	if len(oldBlock) == 0 {
+		return content, false
+	}
+
+	tryAt := func(start int) (string, bool) {
+		if start < 0 || start+len(oldBlock) > len(lines) {
+			return content, false
+		}
+		for i, ol := range oldBlock {
+			if lines[start+i] != ol {
+				return content, false
+			}
+		}
+		result := make([]string, 0, len(lines)-len(oldBlock)+len(newBlock))
+		result = append(result, lines[:start]...)
+		result = append(result, newBlock...)
+		result = append(result, lines[start+len(oldBlock):]...)
+		return strings.Join(result, ""), true
+	}
+
+	if out, ok := tryAt(hunk.OldStart - 1); ok {
+		return out, true
+	}
+	for i := 0; i+len(oldBlock) <= len(lines); i++ {
+		if out, ok := tryAt(i); ok {
+			return out, true
+		}
+	}
+	return content, false
+}
+
+func absorbPatchTree(r *repo.Repo, tx *store.Tx, baseTreeID [32]byte, modifications map[string]string) ([32]byte, error) {
+	flat := make(map[string]absorbFlatEntry)
+	if err := absorbFlatTree(r, baseTreeID, "", flat); err != nil {
+		return object.ZeroID, err
+	}
+	for path, content := range modifications {
+		if content == "" {
+			delete(flat, path)
+		} else {
+			blobID, err := repo.WriteBlobTx(r.Store, tx, &object.Blob{Content: []byte(content)})
+			if err != nil {
+				return object.ZeroID, err
+			}
+			mode := object.ModeFile
+			if e, ok := flat[path]; ok {
+				mode = e.mode
+			}
+			flat[path] = absorbFlatEntry{blobID, mode}
+		}
+	}
+	return absorbBuildTree(r, tx, flat)
+}
+
+func absorbFlatTree(r *repo.Repo, treeID [32]byte, prefix string, out map[string]absorbFlatEntry) error {
+	if treeID == object.ZeroID {
+		return nil
+	}
+	t, err := r.ReadTree(treeID)
+	if err != nil {
+		return err
+	}
+	for _, e := range t.Entries {
+		rel := e.Name
+		if prefix != "" {
+			rel = prefix + "/" + e.Name
+		}
+		if e.Mode == object.ModeDir {
+			if err := absorbFlatTree(r, e.ObjectID, rel, out); err != nil {
+				return err
+			}
+		} else {
+			out[rel] = absorbFlatEntry{e.ObjectID, e.Mode}
+		}
+	}
+	return nil
+}
+
+func absorbBuildTree(r *repo.Repo, tx *store.Tx, flat map[string]absorbFlatEntry) ([32]byte, error) {
+	type node struct {
+		isFile bool
+		blobID [32]byte
+		mode   object.EntryMode
+		kids   map[string]*node
+	}
+	root := &node{kids: make(map[string]*node)}
+
+	for path, fe := range flat {
+		parts := strings.Split(path, "/")
+		cur := root
+		for i, part := range parts {
+			if i == len(parts)-1 {
+				cur.kids[part] = &node{isFile: true, blobID: fe.blobID, mode: fe.mode}
+			} else {
+				if _, ok := cur.kids[part]; !ok {
+					cur.kids[part] = &node{kids: make(map[string]*node)}
+				}
+				cur = cur.kids[part]
+			}
+		}
+	}
+
+	var writeNode func(*node) ([32]byte, error)
+	writeNode = func(n *node) ([32]byte, error) {
+		var entries []object.TreeEntry
+		for name, child := range n.kids {
+			if child.isFile {
+				entries = append(entries, object.TreeEntry{Name: name, Mode: child.mode, ObjectID: child.blobID})
+			} else {
+				subID, err := writeNode(child)
+				if err != nil {
+					return object.ZeroID, err
+				}
+				entries = append(entries, object.TreeEntry{Name: name, Mode: object.ModeDir, ObjectID: subID})
+			}
+		}
+		sort.Slice(entries, func(i, j int) bool { return entries[i].Name < entries[j].Name })
+		return repo.WriteTreeTx(r.Store, tx, &object.Tree{Entries: entries})
+	}
+	return writeNode(root)
+}
+
+func absorbReadTreeFiles(r *repo.Repo, treeID [32]byte) (map[string]string, error) {
+	blobs := make(map[string][32]byte)
+	if err := diff.FlattenTree(r, treeID, blobs); err != nil {
+		return nil, err
+	}
+	out := make(map[string]string, len(blobs))
+	for p, id := range blobs {
+		data, err := r.ReadBlob(id)
+		if err != nil {
+			return nil, err
+		}
+		out[p] = string(data)
+	}
+	return out, nil
+}
+
+func absorbReadWCFiles(r *repo.Repo, w *wc.WC) (map[string]string, error) {
+	paths, err := w.TrackedPaths()
+	if err != nil {
+		return nil, err
+	}
+	out := make(map[string]string, len(paths))
+	for _, p := range paths {
+		data, err := os.ReadFile(filepath.Join(r.Root, filepath.FromSlash(p)))
+		if err != nil {
+			return nil, err
+		}
+		out[p] = string(data)
+	}
+	return out, nil
+}
+
+func absorbSplitLines(s string) []string {
+	if s == "" {
+		return nil
+	}
+	var lines []string
+	for {
+		i := strings.IndexByte(s, '\n')
+		if i < 0 {
+			lines = append(lines, s)
+			break
+		}
+		lines = append(lines, s[:i+1])
+		s = s[i+1:]
+	}
+	return lines
+}

internal/cli/root.go [M]
--- a/internal/cli/root.go
+++ b/internal/cli/root.go
@@ -1,82 +1,83 @@
 package cli
 
 import (
 	"fmt"
 	"os"
 
 	"arche/internal/repo"
 
 	"github.com/spf13/cobra"
 )
 
 var version = "0.1.0"
 
 func isTerminal(f *os.File) bool {
 	fi, err := f.Stat()
 	if err != nil {
 		return false
 	}
 	return (fi.Mode() & os.ModeCharDevice) != 0
 }
 
 var Root = &cobra.Command{
 	Use:           "arche",
 	Short:         "Arche - a modern distributed version control system",
 	SilenceUsage:  true,
 	SilenceErrors: true,
 	Version:       version,
 }
 
 func init() {
 	Root.AddCommand(
 		initCmd,
 		snapCmd,
 		statusCmd,
 		diffCmd,
 		logCmd,
 		coCmd,
 		undoCmd,
 		opLogCmd,
 		bookmarkCmd,
 		mergeCmd,
 		rebaseCmd,
 		resolveCmd,
 		splitCmd,
 		foldCmd,
 		phaseCmd,
 		syncCmd,
 		uiCmd,
 		serveCmd,
 		hooksCmd,
 		cloneCmd,
 		gitImportCmd,
 		bundleCmd,
 		squashCmd,
 		gcCmd,
 		worktreeCmd,
 		watchCmd,
 		wikiCmd,
 		lockCmd,
 		bisectCmd,
 		explainCmd,
 		stackCmd,
 		grepCmd,
 		shelveCmd,
 		unshelveCmd,
 		graftCmd,
+		absorbCmd,
 	)
 }
 
 func openRepo() *repo.Repo {
 	wd, err := os.Getwd()
 	if err != nil {
 		fmt.Fprintf(os.Stderr, "arche: %v\n", err)
 		os.Exit(1)
 	}
 	r, err := repo.Open(wd)
 	if err != nil {
 		fmt.Fprintf(os.Stderr, "arche: %v\n", err)
 		os.Exit(1)
 	}
 	return r
 }

internal/wc/wc.go [M]
--- a/internal/wc/wc.go
+++ b/internal/wc/wc.go
@@ -1,1101 +1,1104 @@
 package wc
 
 import (
 	"encoding/json"
 	"fmt"
 	"io/fs"
 	"os"
 	"path/filepath"
 	"sort"
 	"strings"
 	"syscall"
 	"time"
 
 	"arche/internal/merge"
 	"arche/internal/object"
 	"arche/internal/repo"
 	"arche/internal/store"
 	"arche/internal/watcher"
 )
 
 func dirtySet(r *repo.Repo) (map[string]bool, error) {
 	if !watcher.IsActive(r.ArcheDir()) {
 		return nil, nil
 	}
 	entries, err := r.Store.ListDirtyWCacheEntries()
 	if err != nil {
 		return nil, err
 	}
 	m := make(map[string]bool, len(entries))
 	for _, e := range entries {
 		m[e.Path] = true
 	}
 	return m, nil
 }
 
 type FileStatus struct {
 	Path   string
 	Status rune
 }
 
 type WC struct {
 	Repo           *repo.Repo
 	SignKey        string
 	NoAutoAdvance  bool
 	AuthorOverride *object.Signature
 }
 
 func New(r *repo.Repo) *WC { return &WC{Repo: r} }
 
 func (wc *WC) maybeSign(c *object.Commit) error {
 	if wc.SignKey == "" {
 		return nil
 	}
 	body := object.CommitBodyForSigning(c)
 	sig, _, err := object.SignCommitBody(body, wc.SignKey)
 	if err != nil {
 		return fmt.Errorf("commit signing: %w", err)
 	}
 	c.CommitSig = sig
 	return nil
 }
 
 func (wc *WC) snapshotIntoTx(tx *store.Tx, headCommit *object.Commit, paths []string, cacheMap map[string]store.WCacheEntry, dirty map[string]bool, message string, now time.Time) (*object.Commit, [32]byte, error) {
 	r := wc.Repo
 
 	var entries []fileEntry
 
 	if err := r.Store.ClearWCache(tx); err != nil {
 		return nil, object.ZeroID, fmt.Errorf("clear wcache: %w", err)
 	}
 
 	for _, rel := range paths {
 		if dirty != nil && !dirty[rel] {
 			if cached, ok := cacheMap[rel]; ok {
 				entries = append(entries, fileEntry{
 					path:   rel,
 					blobID: cached.BlobID,
 					mode:   object.EntryMode(cached.Mode),
 				})
 				if err := r.Store.SetWCacheEntry(tx, cached); err != nil {
 					return nil, object.ZeroID, fmt.Errorf("set wcache: %w", err)
 				}
 				continue
 			}
 		}
 
 		abs := filepath.Join(r.Root, rel)
 		info, err := os.Lstat(abs)
 		if err != nil {
 			continue
 		}
 
 		var blobID [32]byte
 		mode := fileMode(info)
 
 		if cached, ok := cacheMap[rel]; ok {
 			st := info.Sys().(*syscall.Stat_t)
 			inode := st.Ino
 			mtime := info.ModTime().UnixNano()
 			size := info.Size()
 			if cached.Inode == inode && cached.MtimeNs == mtime && cached.Size == size {
 				blobID = cached.BlobID
 			}
 		}
 
 		if blobID == object.ZeroID {
 			content, err := readFileContent(abs, info)
 			if err != nil {
 				return nil, object.ZeroID, err
 			}
 			id, err := repo.WriteBlobTx(r.Store, tx, &object.Blob{Content: content})
 			if err != nil {
 				return nil, object.ZeroID, err
 			}
 			blobID = id
 		}
 
 		st := info.Sys().(*syscall.Stat_t)
 		if err := r.Store.SetWCacheEntry(tx, store.WCacheEntry{
 			Path:    rel,
 			Inode:   st.Ino,
 			MtimeNs: info.ModTime().UnixNano(),
 			Size:    info.Size(),
 			BlobID:  blobID,
 			Mode:    uint8(mode),
 		}); err != nil {
 			return nil, object.ZeroID, fmt.Errorf("set wcache: %w", err)
 		}
 
 		entries = append(entries, fileEntry{path: rel, blobID: blobID, mode: mode})
 	}
 
 	tree, err := buildTree(r, tx, entries)
 	if err != nil {
 		return nil, object.ZeroID, err
 	}
 
 	sig := object.Signature{
 		Name:      r.Cfg.User.Name,
 		Email:     r.Cfg.User.Email,
 		Timestamp: now,
 	}
 
 	c := &object.Commit{
 		TreeID:    tree,
 		Parents:   headCommit.Parents,
 		ChangeID:  headCommit.ChangeID,
 		Author:    headCommit.Author,
 		Committer: sig,
 		Message:   message,
 		Phase:     headCommit.Phase,
 	}
 	if headCommit.Author.Timestamp.IsZero() {
 		c.Author = sig
 	}
 
 	if err := wc.maybeSign(c); err != nil {
 		return nil, object.ZeroID, err
 	}
 
 	commitID, err := repo.WriteCommitTx(r.Store, tx, c)
 	if err != nil {
 		return nil, object.ZeroID, err
 	}
 	if err := r.Store.SetChangeCommit(tx, c.ChangeID, commitID); err != nil {
 		return nil, object.ZeroID, err
 	}
 
 	return c, commitID, nil
 }
 
 func (wc *WC) snapshotInput() (paths []string, cacheMap map[string]store.WCacheEntry, dirty map[string]bool, err error) {
 	r := wc.Repo
 
 	cacheEntries, err := r.Store.ListWCacheEntries()
 	if err != nil {
 		return nil, nil, nil, err
 	}
 	cacheMap = make(map[string]store.WCacheEntry, len(cacheEntries))
 	for _, e := range cacheEntries {
 		cacheMap[e.Path] = e
 	}
 
 	dirty, _ = dirtySet(r)
 
 	if dirty != nil {
 		seen := make(map[string]bool, len(cacheMap)+len(dirty))
 		for p := range cacheMap {
 			seen[p] = true
 			paths = append(paths, p)
 		}
 		for p := range dirty {
 			if !seen[p] {
 				paths = append(paths, p)
 			}
 		}
 	} else {
 		paths, err = wc.trackedPaths()
 		if err != nil {
 			return nil, nil, nil, err
 		}
 	}
 
 	return paths, cacheMap, dirty, nil
 }
 
 func (wc *WC) Snapshot(message string) (*object.Commit, [32]byte, error) {
 	r := wc.Repo
 	now := time.Now()
 
 	head, _, err := r.HeadCommit()
 	if err != nil {
 		return nil, object.ZeroID, err
 	}
 
 	paths, cacheMap, dirty, err := wc.snapshotInput()
 	if err != nil {
 		return nil, object.ZeroID, err
 	}
 
 	tx, err := r.Store.Begin()
 	if err != nil {
 		return nil, object.ZeroID, err
 	}
 
 	c, commitID, err := wc.snapshotIntoTx(tx, head, paths, cacheMap, dirty, message, now)
 	if err != nil {
 		r.Store.Rollback(tx)
 		return nil, object.ZeroID, err
 	}
 	if err := r.Store.Commit(tx); err != nil {
 		return nil, object.ZeroID, err
 	}
 	return c, commitID, nil
 }
 
 func (wc *WC) Snap(message string) (*object.Commit, [32]byte, error) {
 	r := wc.Repo
 	now := time.Now()
 
 	before, err := r.CaptureRefState()
 	if err != nil {
 		return nil, object.ZeroID, err
 	}
 
 	statusBefore, err := wc.Status()
 	if err != nil {
 		return nil, object.ZeroID, err
 	}
 	diffPaths := make(map[string]bool, len(statusBefore))
 	for _, fsEntry := range statusBefore {
 		diffPaths[fsEntry.Path] = true
 	}
 
 	useRestrictedPaths := len(r.Cfg.Hooks.PreSnap) > 0
 	if useRestrictedPaths {
 		if err := RunHooksSequential(r.Root, "pre-snap", r.Cfg.Hooks.PreSnap); err != nil {
 			return nil, object.ZeroID, fmt.Errorf("pre-snap hook failed: %w", err)
 		}
 	}
 
 	head, oldHeadID, err := r.HeadCommit()
 	if err != nil {
 		return nil, object.ZeroID, err
 	}
 
 	type snapshotFn func(tx *store.Tx) (*object.Commit, [32]byte, error)
 	var doSnapshot snapshotFn
 
 	if useRestrictedPaths {
 		headBlobs := make(map[string][32]byte)
 		headModes := make(map[string]object.EntryMode)
 		if err := flattenTree(r, head.TreeID, "", headBlobs); err != nil {
 			return nil, object.ZeroID, err
 		}
 		if err := flattenTreeModes(r, head.TreeID, "", headModes); err != nil {
 			return nil, object.ZeroID, err
 		}
 		doSnapshot = func(tx *store.Tx) (*object.Commit, [32]byte, error) {
 			return wc.snapshotRestrictedPathsIntoTx(tx, head, headBlobs, headModes, diffPaths, message, now)
 		}
 	} else {
 		paths, cacheMap, dirty, err := wc.snapshotInput()
 		if err != nil {
 			return nil, object.ZeroID, err
 		}
 		doSnapshot = func(tx *store.Tx) (*object.Commit, [32]byte, error) {
 			return wc.snapshotIntoTx(tx, head, paths, cacheMap, dirty, message, now)
 		}
 	}
 
 	existingBookmarks, _ := r.Store.ListBookmarks()
 
 	tx, err := r.Store.Begin()
 	if err != nil {
 		return nil, object.ZeroID, err
 	}
 
 	snapped, snappedID, err := doSnapshot(tx)
 	if err != nil {
 		r.Store.Rollback(tx)
 		return nil, object.ZeroID, err
 	}
 
 	if snappedID != oldHeadID {
 		for _, bm := range existingBookmarks {
 			if bm.CommitID == oldHeadID {
 				_ = r.Store.SetBookmark(tx, store.Bookmark{
 					Name:     bm.Name,
 					CommitID: snappedID,
 					Remote:   bm.Remote,
 				})
 			}
 		}
 	}
 
 	newChangeID, err := r.Store.AllocChangeID(tx)
 	if err != nil {
 		r.Store.Rollback(tx)
 		return nil, object.ZeroID, err
 	}
 
 	sig := object.Signature{Name: r.Cfg.User.Name, Email: r.Cfg.User.Email, Timestamp: now}
 	newDraft := &object.Commit{
 		TreeID:    snapped.TreeID,
 		Parents:   [][32]byte{snappedID},
 		ChangeID:  newChangeID,
 		Author:    sig,
 		Committer: sig,
 		Message:   "",
 		Phase:     object.PhaseDraft,
 	}
 
 	newDraftID, err := repo.WriteCommitTx(r.Store, tx, newDraft)
 	if err != nil {
 		r.Store.Rollback(tx)
 		return nil, object.ZeroID, err
 	}
 
 	if err := r.Store.SetChangeCommit(tx, newChangeID, newDraftID); err != nil {
 		r.Store.Rollback(tx)
 		return nil, object.ZeroID, err
 	}
 
 	after := buildRefState(snappedID, object.FormatChangeID(newChangeID))
 	op := store.Operation{
 		Kind:      "snap",
 		Timestamp: now.Unix(),
 		Before:    before,
 		After:     after,
 		Metadata:  "'" + firstLine(snapped.Message) + "'",
 	}
 	if _, err := r.Store.InsertOperation(tx, op); err != nil {
 		r.Store.Rollback(tx)
 		return nil, object.ZeroID, err
 	}
 
 	if err := r.Store.Commit(tx); err != nil {
 		return nil, object.ZeroID, err
 	}
 
 	if err := r.WriteHead(object.FormatChangeID(newChangeID)); err != nil {
 		return nil, object.ZeroID, err
 	}
 
 	if len(r.Cfg.Hooks.PostSnap) > 0 {
 		if err := RunHooksSequential(r.Root, "post-snap", r.Cfg.Hooks.PostSnap); err != nil {
 			fmt.Fprintf(os.Stderr, "arche snap: post-snap hook: %v\n", err)
 		}
 	}
 
 	return snapped, snappedID, nil
 }
 
 func (wc *WC) Status() ([]FileStatus, error) {
 	r := wc.Repo
 	head, _, err := r.HeadCommit()
 	if err != nil {
 		return nil, err
 	}
 
 	headFiles := make(map[string][32]byte)
 	if err := flattenTree(r, head.TreeID, "", headFiles); err != nil {
 		return nil, err
 	}
 
 	wcPaths, err := wc.trackedPaths()
 	if err != nil {
 		return nil, err
 	}
 	wcSet := make(map[string]bool, len(wcPaths))
 	for _, p := range wcPaths {
 		wcSet[p] = true
 	}
 
 	cacheEntries, _ := r.Store.ListWCacheEntries()
 	cacheMap := make(map[string]store.WCacheEntry, len(cacheEntries))
 	for _, e := range cacheEntries {
 		cacheMap[e.Path] = e
 	}
 	dirty, _ := dirtySet(r)
 
 	var out []FileStatus
 
 	for path, headBlobID := range headFiles {
 		if !wcSet[path] {
 			out = append(out, FileStatus{Path: path, Status: 'D'})
 			continue
 		}
 
 		if dirty != nil && !dirty[path] {
 			if cached, ok := cacheMap[path]; ok {
 				if cached.BlobID != headBlobID {
 					out = append(out, FileStatus{Path: path, Status: 'M'})
 				}
 				continue
 			}
 		}
 
 		curBlobID, err := wc.blobIDForPath(path)
 		if err != nil {
 			continue
 		}
 		if curBlobID != headBlobID {
 			out = append(out, FileStatus{Path: path, Status: 'M'})
 		}
 	}
 
 	ignore, _ := loadIgnore(r.Root)
 	for _, path := range wcPaths {
 		if _, inHead := headFiles[path]; !inHead {
 			if ignore.Match(path) {
 				continue
 			}
 			out = append(out, FileStatus{Path: path, Status: 'A'})
 		}
 	}
 
 	sort.Slice(out, func(i, j int) bool { return out[i].Path < out[j].Path })
 	return out, nil
 }
 
 func (wc *WC) materializeDisk(treeID [32]byte) (map[string][32]byte, map[string]object.EntryMode, error) {
 	r := wc.Repo
 
 	wantFiles := make(map[string][32]byte)
 	wantMode := make(map[string]object.EntryMode)
 	if err := flattenTree(r, treeID, "", wantFiles); err != nil {
 		return nil, nil, err
 	}
 
 	if err := flattenTreeModes(r, treeID, "", wantMode); err != nil {
 		return nil, nil, err
 	}
 
 	ignore, _ := loadIgnore(r.Root)
 	err := filepath.WalkDir(r.Root, func(path string, d fs.DirEntry, err error) error {
 		if err != nil {
 			return nil
 		}
 		rel, _ := filepath.Rel(r.Root, path)
 		if rel == "." {
 			return nil
 		}
 		if d.IsDir() {
 			if rel == archeDirName || strings.HasPrefix(rel, archeDirName+string(os.PathSeparator)) {
 				return filepath.SkipDir
 			}
 			return nil
 		}
 		if ignore.Match(rel) {
 			return nil
 		}
 		if _, ok := wantFiles[rel]; !ok {
 			return os.Remove(path)
 		}
 		return nil
 	})
 	if err != nil {
 		return nil, nil, err
 	}
 
 	var conflictPaths []string
 	for relPath, blobID := range wantFiles {
 		abs := filepath.Join(r.Root, relPath)
 		if err := os.MkdirAll(filepath.Dir(abs), 0o755); err != nil {
 			return nil, nil, err
 		}
 		content, err := r.ReadBlob(blobID)
 		if err != nil {
 			if conf, cErr := r.ReadConflict(blobID); cErr == nil {
 				content = renderConflictMarkers(r, conf)
 				conflictPaths = append(conflictPaths, relPath)
 				err = nil
 			}
 		}
 		if err != nil {
 			return nil, nil, err
 		}
 		perm := fs.FileMode(0o644)
 		if wantMode[relPath] == object.ModeExec {
 			perm = 0o755
 		}
 		if err := os.WriteFile(abs, content, perm); err != nil {
 			return nil, nil, err
 		}
 	}
 
 	for _, p := range conflictPaths {
 		delete(wantFiles, p)
 	}
 
 	return wantFiles, wantMode, nil
 }
 
 func renderConflictMarkers(r *repo.Repo, conf *object.Conflict) []byte {
 	readStr := func(id [32]byte) string {
 		if id == object.ZeroID {
 			return ""
 		}
 		b, _ := r.ReadBlob(id)
 		return string(b)
 	}
 	nl := func(s string) string {
 		if len(s) > 0 && s[len(s)-1] != '\n' {
 			return s + "\n"
 		}
 		return s
 	}
 	if conf.Ours.BlobID == object.ZeroID {
 		return []byte(fmt.Sprintf("<<<<<<< ours\n(deleted)\n=======\n%s>>>>>>> theirs\n", nl(readStr(conf.Theirs.BlobID))))
 	}
 	if conf.Theirs.BlobID == object.ZeroID {
 		return []byte(fmt.Sprintf("<<<<<<< ours\n%s=======\n(deleted)\n>>>>>>> theirs\n", nl(readStr(conf.Ours.BlobID))))
 	}
 	return []byte(fmt.Sprintf("<<<<<<< ours\n%s=======\n%s>>>>>>> theirs\n",
 		nl(readStr(conf.Ours.BlobID)),
 		nl(readStr(conf.Theirs.BlobID))))
 }
 
 func (wc *WC) populateWCacheInTx(tx *store.Tx, wantFiles map[string][32]byte) error {
 	r := wc.Repo
 	if err := r.Store.ClearWCache(tx); err != nil {
 		return err
 	}
 	for relPath, blobID := range wantFiles {
 		abs := filepath.Join(r.Root, relPath)
 		info, err := os.Lstat(abs)
 		if err != nil {
 			continue
 		}
 		st, ok := info.Sys().(*syscall.Stat_t)
 		if !ok {
 			continue
 		}
 		_ = r.Store.SetWCacheEntry(tx, store.WCacheEntry{
 			Path:    relPath,
 			Inode:   st.Ino,
 			MtimeNs: info.ModTime().UnixNano(),
 			Size:    info.Size(),
 			BlobID:  blobID,
 			Mode:    uint8(fileMode(info)),
 		})
 	}
 	return nil
 }
 
 func (wc *WC) MaterializeQuiet(treeID [32]byte) error {
 	r := wc.Repo
 
 	wantFiles, _, err := wc.materializeDisk(treeID)
 	if err != nil {
 		return err
 	}
 
 	tx, err := r.Store.Begin()
 	if err != nil {
 		return err
 	}
 	if err := wc.populateWCacheInTx(tx, wantFiles); err != nil {
 		r.Store.Rollback(tx)
 		return err
 	}
 	return r.Store.Commit(tx)
 }
 
 func (wc *WC) Materialize(treeID [32]byte, newChangeID string) error {
 	r := wc.Repo
 
 	before, _ := r.CaptureRefState()
 	now := time.Now()
 
 	wantFiles, _, err := wc.materializeDisk(treeID)
 	if err != nil {
 		return err
 	}
 
 	bare := object.StripChangeIDPrefix(newChangeID)
 	commitID, _ := r.Store.GetChangeCommit(bare)
 	after := buildRefState(commitID, newChangeID)
 
 	tx, err := r.Store.Begin()
 	if err != nil {
 		return err
 	}
 	if err := wc.populateWCacheInTx(tx, wantFiles); err != nil {
 		r.Store.Rollback(tx)
 		return err
 	}
 
 	op := store.Operation{
 		Kind:      "co",
 		Timestamp: now.Unix(),
 		Before:    before,
 		After:     after,
 		Metadata:  "checked out " + newChangeID,
 	}
 	if _, err := r.Store.InsertOperation(tx, op); err != nil {
 		r.Store.Rollback(tx)
 		return err
 	}
 
 	return r.Store.Commit(tx)
 }
 
 const archeDirName = ".arche"
 
 func (wc *WC) trackedPaths() ([]string, error) {
 	r := wc.Repo
 	ignore, _ := loadIgnore(r.Root)
 
 	var paths []string
 	err := filepath.WalkDir(r.Root, func(path string, d fs.DirEntry, err error) error {
 		if err != nil {
 			return nil
 		}
 		rel, _ := filepath.Rel(r.Root, path)
 		if rel == "." {
 			return nil
 		}
 		if d.IsDir() {
 			if rel == archeDirName || strings.HasPrefix(rel, archeDirName+string(os.PathSeparator)) {
 				return filepath.SkipDir
 			}
 			if ignore.MatchDir(rel) {
 				return filepath.SkipDir
 			}
 			return nil
 		}
 		if ignore.Match(rel) {
 			return nil
 		}
 		paths = append(paths, filepath.ToSlash(rel))
 		return nil
 	})
 	return paths, err
 }
 
+func (wc *WC) TrackedPaths() ([]string, error) { return wc.trackedPaths() }
+
 func (wc *WC) blobIDForPath(rel string) ([32]byte, error) {
 	r := wc.Repo
 	abs := filepath.Join(r.Root, rel)
 	info, err := os.Lstat(abs)
 	if err != nil {
 		return object.ZeroID, err
 	}
 	st := info.Sys().(*syscall.Stat_t)
 
 	if cached, _ := r.Store.GetWCacheEntry(rel); cached != nil {
 		if cached.Inode == st.Ino &&
 			cached.MtimeNs == info.ModTime().UnixNano() &&
 			cached.Size == info.Size() {
 			return cached.BlobID, nil
 		}
 	}
 
 	content, err := readFileContent(abs, info)
 	if err != nil {
 		return object.ZeroID, err
 	}
 	b := &object.Blob{Content: content}
 	return object.HashBlob(b), nil
 }
 
 func flattenTree(r *repo.Repo, treeID [32]byte, prefix string, out map[string][32]byte) error {
 	if treeID == object.ZeroID {
 		return nil
 	}
 	t, err := r.ReadTree(treeID)
 	if err != nil {
 		return err
 	}
 	for _, e := range t.Entries {
 		rel := join(prefix, e.Name)
 		switch e.Mode {
 		case object.ModeDir:
 			if err := flattenTree(r, e.ObjectID, rel, out); err != nil {
 				return err
 			}
 		default:
 			out[rel] = e.ObjectID
 		}
 	}
 	return nil
 }
 
 func flattenTreeModes(r *repo.Repo, treeID [32]byte, prefix string, out map[string]object.EntryMode) error {
 	if treeID == object.ZeroID {
 		return nil
 	}
 	t, err := r.ReadTree(treeID)
 	if err != nil {
 		return err
 	}
 	for _, e := range t.Entries {
 		rel := join(prefix, e.Name)
 		switch e.Mode {
 		case object.ModeDir:
 			if err := flattenTreeModes(r, e.ObjectID, rel, out); err != nil {
 				return err
 			}
 		default:
 			out[rel] = e.Mode
 		}
 	}
 	return nil
 }
 
 type fileEntry struct {
 	path   string
 	blobID [32]byte
 	mode   object.EntryMode
 }
 
 func buildTree(r *repo.Repo, tx *store.Tx, entries []fileEntry) ([32]byte, error) {
 	type node struct {
 		isFile   bool
 		blobID   [32]byte
 		mode     object.EntryMode
 		children map[string]*node
 	}
 	root := &node{children: make(map[string]*node)}
 
 	for _, e := range entries {
 		parts := strings.Split(e.path, "/")
 		cur := root
 		for i, part := range parts {
 			if i == len(parts)-1 {
 				cur.children[part] = &node{isFile: true, blobID: e.blobID, mode: e.mode}
 			} else {
 				if _, ok := cur.children[part]; !ok {
 					cur.children[part] = &node{children: make(map[string]*node)}
 				}
 				cur = cur.children[part]
 			}
 		}
 	}
 
 	var writeNode func(n *node) ([32]byte, error)
 	writeNode = func(n *node) ([32]byte, error) {
 		var treeEntries []object.TreeEntry
 		for name, child := range n.children {
 			if child.isFile {
 				treeEntries = append(treeEntries, object.TreeEntry{
 					Name:     name,
 					Mode:     child.mode,
 					ObjectID: child.blobID,
 				})
 			} else {
 				subID, err := writeNode(child)
 				if err != nil {
 					return object.ZeroID, err
 				}
 				treeEntries = append(treeEntries, object.TreeEntry{
 					Name:     name,
 					Mode:     object.ModeDir,
 					ObjectID: subID,
 				})
 			}
 		}
 		sort.Slice(treeEntries, func(i, j int) bool { return treeEntries[i].Name < treeEntries[j].Name })
 		t := &object.Tree{Entries: treeEntries}
 		id, err := repo.WriteTreeTx(r.Store, tx, t)
 		return id, err
 	}
 
 	return writeNode(root)
 }
 
 func fileMode(info os.FileInfo) object.EntryMode {
 	if info.Mode()&0o111 != 0 {
 		return object.ModeExec
 	}
 	if info.Mode()&os.ModeSymlink != 0 {
 		return object.ModeSymlink
 	}
 	return object.ModeFile
 }
 
 func readFileContent(abs string, info os.FileInfo) ([]byte, error) {
 	if info.Mode()&os.ModeSymlink != 0 {
 		target, err := os.Readlink(abs)
 		if err != nil {
 			return nil, err
 		}
 		return []byte(target), nil
 	}
 	return os.ReadFile(abs)
 }
 
 func join(prefix, name string) string {
 	if prefix == "" {
 		return name
 	}
 	return prefix + "/" + name
 }
 
 func buildRefState(commitID [32]byte, changeID string) string {
 	m := map[string]string{
 		"head": changeID,
 		"tip":  fmt.Sprintf("%x", commitID),
 	}
 	b, _ := json.Marshal(m)
 	return string(b)
 }
 
 func firstLine(s string) string {
 	if i := strings.IndexByte(s, '\n'); i >= 0 {
 		return s[:i]
 	}
 	return s
 }
+
 func (wc *WC) SnapshotTree() ([32]byte, error) {
 	r := wc.Repo
 
 	paths, cacheMap, dirty, err := wc.snapshotInput()
 	if err != nil {
 		return object.ZeroID, err
 	}
 
 	tx, err := r.Store.Begin()
 	if err != nil {
 		return object.ZeroID, err
 	}
 
 	var entries []fileEntry
 	for _, rel := range paths {
 		if dirty != nil && !dirty[rel] {
 			if cached, ok := cacheMap[rel]; ok {
 				entries = append(entries, fileEntry{
 					path:   rel,
 					blobID: cached.BlobID,
 					mode:   object.EntryMode(cached.Mode),
 				})
 				continue
 			}
 		}
 
 		abs := filepath.Join(r.Root, rel)
 		info, err := os.Lstat(abs)
 		if err != nil {
 			continue
 		}
 
 		var blobID [32]byte
 		mode := fileMode(info)
 
 		if cached, ok := cacheMap[rel]; ok {
 			st := info.Sys().(*syscall.Stat_t)
 			if cached.Inode == st.Ino && cached.MtimeNs == info.ModTime().UnixNano() && cached.Size == info.Size() {
 				blobID = cached.BlobID
 			}
 		}
 
 		if blobID == object.ZeroID {
 			content, err := readFileContent(abs, info)
 			if err != nil {
 				r.Store.Rollback(tx) //nolint:errcheck
 				return object.ZeroID, err
 			}
 			id, err := repo.WriteBlobTx(r.Store, tx, &object.Blob{Content: content})
 			if err != nil {
 				r.Store.Rollback(tx) //nolint:errcheck
 				return object.ZeroID, err
 			}
 			blobID = id
 		}
 
 		entries = append(entries, fileEntry{path: rel, blobID: blobID, mode: mode})
 	}
 
 	treeID, err := buildTree(r, tx, entries)
 	if err != nil {
 		r.Store.Rollback(tx) //nolint:errcheck
 		return object.ZeroID, err
 	}
 	if err := r.Store.Commit(tx); err != nil {
 		return object.ZeroID, err
 	}
 	return treeID, nil
 }
 
 func (wc *WC) Amend(message string) (*object.Commit, [32]byte, error) {
 	r := wc.Repo
 	now := time.Now()
 
 	head, oldHeadID, err := r.HeadCommit()
 	if err != nil {
 		return nil, object.ZeroID, err
 	}
 	if head.Phase == object.PhasePublic {
 		return nil, object.ZeroID, fmt.Errorf("cannot amend a public commit; use --force-rewrite if you are sure")
 	}
 
 	before, err := r.CaptureRefState()
 	if err != nil {
 		return nil, object.ZeroID, err
 	}
 
 	if message == "" {
 		message = head.Message
 	}
 
 	paths, cacheMap, dirty, err := wc.snapshotInput()
 	if err != nil {
 		return nil, object.ZeroID, err
 	}
 
 	tx, err := r.Store.Begin()
 	if err != nil {
 		return nil, object.ZeroID, err
 	}
 
 	amended, amendedID, err := wc.snapshotIntoTx(tx, head, paths, cacheMap, dirty, message, now)
 	if err != nil {
 		r.Store.Rollback(tx)
 		return nil, object.ZeroID, err
 	}
 
 	if oldHeadID != amendedID {
 		obs := &object.ObsoleteMarker{
 			Predecessor: oldHeadID,
 			Successors:  [][32]byte{amendedID},
 			Reason:      "amend",
 			Timestamp:   now.Unix(),
 		}
 		if _, err := repo.WriteObsoleteTx(r.Store, tx, obs); err != nil {
 			r.Store.Rollback(tx)
 			return nil, object.ZeroID, err
 		}
 	}
 
 	after := buildRefState(amendedID, object.FormatChangeID(amended.ChangeID))
 	op := store.Operation{
 		Kind:      "amend",
 		Timestamp: now.Unix(),
 		Before:    before,
 		After:     after,
 		Metadata:  "'" + firstLine(amended.Message) + "'",
 	}
 	if _, err := r.Store.InsertOperation(tx, op); err != nil {
 		r.Store.Rollback(tx)
 		return nil, object.ZeroID, err
 	}
 
 	if err := r.Store.Commit(tx); err != nil {
 		return nil, object.ZeroID, err
 	}
 
 	if oldHeadID != amendedID {
 		if err := wc.autoRebaseDownstream(oldHeadID, amendedID, head.ChangeID, now); err != nil {
 			fmt.Fprintf(os.Stderr, "arche: warning: downstream rebase failed: %v\n", err)
 		}
 	}
 
 	return amended, amendedID, nil
 }
 
 func (wc *WC) autoRebaseDownstream(oldParentID, newParentID [32]byte, headChangeID string, now time.Time) error {
 	r := wc.Repo
 
 	allChanges, err := r.Store.ListChanges()
 	if err != nil {
 		return err
 	}
 
 	type draftEntry struct {
 		id       [32]byte
 		changeID string
 		commit   *object.Commit
 	}
 
 	children := make(map[[32]byte][]draftEntry)
 	for _, ch := range allChanges {
 		if ch.CommitID == object.ZeroID {
 			continue
 		}
 		c, err := r.ReadCommit(ch.CommitID)
 		if err != nil || c == nil {
 			continue
 		}
 		if c.Phase != object.PhaseDraft {
 			continue
 		}
 		if c.ChangeID == headChangeID {
 			continue
 		}
 		if len(c.Parents) == 0 {
 			continue
 		}
 		d := draftEntry{id: ch.CommitID, changeID: ch.Name, commit: c}
 		children[c.Parents[0]] = append(children[c.Parents[0]], d)
 	}
 
 	type rebaseTask struct {
 		entry     draftEntry
 		newParent [32]byte
 	}
 	var tasks []rebaseTask
 	queue := []struct {
 		oldID [32]byte
 		newID [32]byte
 	}{{oldParentID, newParentID}}
 
 	for len(queue) > 0 {
 		cur := queue[0]
 		queue = queue[1:]
 		for _, child := range children[cur.oldID] {
 			tasks = append(tasks, rebaseTask{entry: child, newParent: cur.newID})
 			queue = append(queue, struct{ oldID, newID [32]byte }{child.id, child.id})
 		}
 	}
 
 	remapped := map[[32]byte][32]byte{oldParentID: newParentID}
 
 	for _, task := range tasks {
 		oldFirst := task.entry.commit.Parents[0]
 		newParent, ok := remapped[oldFirst]
 		if !ok {
 			newParent = oldFirst
 		}
 
 		var baseTreeID [32]byte
 		if pc, err2 := r.ReadCommit(oldFirst); err2 == nil {
 			baseTreeID = pc.TreeID
 		}
 		newParentCommit, err := r.ReadCommit(newParent)
 		if err != nil {
 			return fmt.Errorf("read new parent for %s: %w", object.FormatChangeID(task.entry.changeID), err)
 		}
 
 		result, err := merge.Trees(r, baseTreeID, task.entry.commit.TreeID, newParentCommit.TreeID)
 		if err != nil {
 			return fmt.Errorf("merge for %s: %w", object.FormatChangeID(task.entry.changeID), err)
 		}
 
 		newCommit := &object.Commit{
 			TreeID:    result.TreeID,
 			Parents:   [][32]byte{newParent},
 			ChangeID:  task.entry.changeID,
 			Author:    task.entry.commit.Author,
 			Committer: object.Signature{Name: r.Cfg.User.Name, Email: r.Cfg.User.Email, Timestamp: now},
 			Message:   task.entry.commit.Message,
 			Phase:     task.entry.commit.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, task.entry.changeID, newCommitID); err != nil {
 			r.Store.Rollback(tx)
 			return err
 		}
 		obs := &object.ObsoleteMarker{
 			Predecessor: task.entry.id,
 			Successors:  [][32]byte{newCommitID},
 			Reason:      "amend",
 			Timestamp:   now.Unix(),
 		}
 		if _, err := repo.WriteObsoleteTx(r.Store, tx, obs); err != nil {
 			r.Store.Rollback(tx)
 			return err
 		}
 		if err := r.Store.Commit(tx); err != nil {
 			return err
 		}
 
 		remapped[task.entry.id] = newCommitID
 		conflictNote := ""
 		if len(result.Conflicts) > 0 {
 			conflictNote = fmt.Sprintf(" (%d conflict(s))", len(result.Conflicts))
 		}
 		fmt.Printf("  auto-rebased %s%s\n", object.FormatChangeID(task.entry.changeID), conflictNote)
 	}
 	return nil
 }