arche / internal/wc/wc.go

commit a22ffc45
   1package wc
   2
   3import (
   4	"encoding/json"
   5	"fmt"
   6	"io/fs"
   7	"os"
   8	"path/filepath"
   9	"sort"
  10	"strings"
  11	"syscall"
  12	"time"
  13
  14	"arche/internal/merge"
  15	"arche/internal/object"
  16	"arche/internal/repo"
  17	"arche/internal/store"
  18	"arche/internal/watcher"
  19)
  20
  21func dirtySet(r *repo.Repo) (map[string]bool, error) {
  22	if !watcher.IsActive(r.ArcheDir(), r.Store) {
  23		return nil, nil
  24	}
  25	entries, err := r.Store.ListDirtyWCacheEntries()
  26	if err != nil {
  27		return nil, err
  28	}
  29	m := make(map[string]bool, len(entries))
  30	for _, e := range entries {
  31		m[e.Path] = true
  32	}
  33	return m, nil
  34}
  35
  36type FileStatus struct {
  37	Path   string
  38	Status rune
  39}
  40
  41type WC struct {
  42	Repo           *repo.Repo
  43	SignKey        string
  44	NoAutoAdvance  bool
  45	AuthorOverride *object.Signature
  46}
  47
  48func New(r *repo.Repo) *WC { return &WC{Repo: r} }
  49
  50func (wc *WC) maybeSign(c *object.Commit) error {
  51	if wc.SignKey == "" {
  52		return nil
  53	}
  54	body := object.CommitBodyForSigning(c)
  55	sig, _, err := object.SignCommitBody(body, wc.SignKey)
  56	if err != nil {
  57		return fmt.Errorf("commit signing: %w", err)
  58	}
  59	c.CommitSig = sig
  60	return nil
  61}
  62
  63func (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) {
  64	r := wc.Repo
  65
  66	var entries []fileEntry
  67
  68	if err := r.Store.ClearWCache(tx); err != nil {
  69		return nil, object.ZeroID, fmt.Errorf("clear wcache: %w", err)
  70	}
  71
  72	for _, rel := range paths {
  73		if dirty != nil && !dirty[rel] {
  74			if cached, ok := cacheMap[rel]; ok {
  75				entries = append(entries, fileEntry{
  76					path:   rel,
  77					blobID: cached.BlobID,
  78					mode:   object.EntryMode(cached.Mode),
  79				})
  80				if err := r.Store.SetWCacheEntry(tx, cached); err != nil {
  81					return nil, object.ZeroID, fmt.Errorf("set wcache: %w", err)
  82				}
  83				continue
  84			}
  85		}
  86
  87		abs := filepath.Join(r.Root, rel)
  88		info, err := os.Lstat(abs)
  89		if err != nil {
  90			continue
  91		}
  92
  93		var blobID [32]byte
  94		mode := fileMode(info)
  95
  96		if cached, ok := cacheMap[rel]; ok {
  97			st := info.Sys().(*syscall.Stat_t)
  98			inode := st.Ino
  99			mtime := info.ModTime().UnixNano()
 100			size := info.Size()
 101			if cached.Inode == inode && cached.MtimeNs == mtime && cached.Size == size {
 102				blobID = cached.BlobID
 103			}
 104		}
 105
 106		if blobID == object.ZeroID {
 107			content, err := readFileContent(abs, info)
 108			if err != nil {
 109				return nil, object.ZeroID, err
 110			}
 111			id, err := repo.WriteBlobTx(r.Store, tx, &object.Blob{Content: content})
 112			if err != nil {
 113				return nil, object.ZeroID, err
 114			}
 115			blobID = id
 116		}
 117
 118		st := info.Sys().(*syscall.Stat_t)
 119		if err := r.Store.SetWCacheEntry(tx, store.WCacheEntry{
 120			Path:    rel,
 121			Inode:   st.Ino,
 122			MtimeNs: info.ModTime().UnixNano(),
 123			Size:    info.Size(),
 124			BlobID:  blobID,
 125			Mode:    uint8(mode),
 126		}); err != nil {
 127			return nil, object.ZeroID, fmt.Errorf("set wcache: %w", err)
 128		}
 129
 130		entries = append(entries, fileEntry{path: rel, blobID: blobID, mode: mode})
 131	}
 132
 133	tree, err := buildTree(r, tx, entries)
 134	if err != nil {
 135		return nil, object.ZeroID, err
 136	}
 137
 138	sig := object.Signature{
 139		Name:      r.Cfg.User.Name,
 140		Email:     r.Cfg.User.Email,
 141		Timestamp: now,
 142	}
 143
 144	c := &object.Commit{
 145		TreeID:    tree,
 146		Parents:   headCommit.Parents,
 147		ChangeID:  headCommit.ChangeID,
 148		Author:    headCommit.Author,
 149		Committer: sig,
 150		Message:   message,
 151		Phase:     headCommit.Phase,
 152	}
 153	if headCommit.Author.Timestamp.IsZero() {
 154		c.Author = sig
 155	}
 156
 157	if err := wc.maybeSign(c); err != nil {
 158		return nil, object.ZeroID, err
 159	}
 160
 161	commitID, err := repo.WriteCommitTx(r.Store, tx, c)
 162	if err != nil {
 163		return nil, object.ZeroID, err
 164	}
 165	if err := r.Store.SetChangeCommit(tx, c.ChangeID, commitID); err != nil {
 166		return nil, object.ZeroID, err
 167	}
 168
 169	return c, commitID, nil
 170}
 171
 172func (wc *WC) snapshotInput() (paths []string, cacheMap map[string]store.WCacheEntry, dirty map[string]bool, err error) {
 173	r := wc.Repo
 174
 175	cacheEntries, err := r.Store.ListWCacheEntries()
 176	if err != nil {
 177		return nil, nil, nil, err
 178	}
 179	cacheMap = make(map[string]store.WCacheEntry, len(cacheEntries))
 180	for _, e := range cacheEntries {
 181		cacheMap[e.Path] = e
 182	}
 183
 184	dirty, _ = dirtySet(r)
 185
 186	if dirty != nil {
 187		seen := make(map[string]bool, len(cacheMap)+len(dirty))
 188		for p := range cacheMap {
 189			seen[p] = true
 190			paths = append(paths, p)
 191		}
 192		for p := range dirty {
 193			if !seen[p] {
 194				paths = append(paths, p)
 195			}
 196		}
 197	} else {
 198		paths, err = wc.trackedPaths()
 199		if err != nil {
 200			return nil, nil, nil, err
 201		}
 202	}
 203
 204	return paths, cacheMap, dirty, nil
 205}
 206
 207func (wc *WC) Snapshot(message string) (*object.Commit, [32]byte, error) {
 208	r := wc.Repo
 209	now := time.Now()
 210
 211	head, _, err := r.HeadCommit()
 212	if err != nil {
 213		return nil, object.ZeroID, err
 214	}
 215
 216	paths, cacheMap, dirty, err := wc.snapshotInput()
 217	if err != nil {
 218		return nil, object.ZeroID, err
 219	}
 220
 221	tx, err := r.Store.Begin()
 222	if err != nil {
 223		return nil, object.ZeroID, err
 224	}
 225
 226	c, commitID, err := wc.snapshotIntoTx(tx, head, paths, cacheMap, dirty, message, now)
 227	if err != nil {
 228		r.Store.Rollback(tx)
 229		return nil, object.ZeroID, err
 230	}
 231	if err := r.Store.Commit(tx); err != nil {
 232		return nil, object.ZeroID, err
 233	}
 234	return c, commitID, nil
 235}
 236
 237func (wc *WC) Snap(message string) (*object.Commit, [32]byte, error) {
 238	r := wc.Repo
 239	now := time.Now()
 240
 241	before, err := r.CaptureRefState()
 242	if err != nil {
 243		return nil, object.ZeroID, err
 244	}
 245
 246	statusBefore, err := wc.Status()
 247	if err != nil {
 248		return nil, object.ZeroID, err
 249	}
 250	diffPaths := make(map[string]bool, len(statusBefore))
 251	for _, fsEntry := range statusBefore {
 252		diffPaths[fsEntry.Path] = true
 253	}
 254
 255	useRestrictedPaths := len(r.Cfg.Hooks.PreSnap) > 0
 256	if useRestrictedPaths {
 257		if err := RunHooksSequential(r.Root, "pre-snap", r.Cfg.Hooks.PreSnap); err != nil {
 258			return nil, object.ZeroID, fmt.Errorf("pre-snap hook failed: %w", err)
 259		}
 260	}
 261
 262	head, oldHeadID, err := r.HeadCommit()
 263	if err != nil {
 264		return nil, object.ZeroID, err
 265	}
 266
 267	type snapshotFn func(tx *store.Tx) (*object.Commit, [32]byte, error)
 268	var doSnapshot snapshotFn
 269
 270	if useRestrictedPaths {
 271		headBlobs := make(map[string][32]byte)
 272		headModes := make(map[string]object.EntryMode)
 273		if err := flattenTree(r, head.TreeID, "", headBlobs); err != nil {
 274			return nil, object.ZeroID, err
 275		}
 276		if err := flattenTreeModes(r, head.TreeID, "", headModes); err != nil {
 277			return nil, object.ZeroID, err
 278		}
 279		doSnapshot = func(tx *store.Tx) (*object.Commit, [32]byte, error) {
 280			return wc.snapshotRestrictedPathsIntoTx(tx, head, headBlobs, headModes, diffPaths, message, now)
 281		}
 282	} else {
 283		paths, cacheMap, dirty, err := wc.snapshotInput()
 284		if err != nil {
 285			return nil, object.ZeroID, err
 286		}
 287		doSnapshot = func(tx *store.Tx) (*object.Commit, [32]byte, error) {
 288			return wc.snapshotIntoTx(tx, head, paths, cacheMap, dirty, message, now)
 289		}
 290	}
 291
 292	existingBookmarks, _ := r.Store.ListBookmarks()
 293
 294	tx, err := r.Store.Begin()
 295	if err != nil {
 296		return nil, object.ZeroID, err
 297	}
 298
 299	snapped, snappedID, err := doSnapshot(tx)
 300	if err != nil {
 301		r.Store.Rollback(tx)
 302		return nil, object.ZeroID, err
 303	}
 304
 305	if snappedID != oldHeadID {
 306		for _, bm := range existingBookmarks {
 307			if bm.CommitID == oldHeadID {
 308				_ = r.Store.SetBookmark(tx, store.Bookmark{
 309					Name:     bm.Name,
 310					CommitID: snappedID,
 311					Remote:   bm.Remote,
 312				})
 313			}
 314		}
 315	}
 316
 317	newChangeID, err := r.Store.AllocChangeID(tx)
 318	if err != nil {
 319		r.Store.Rollback(tx)
 320		return nil, object.ZeroID, err
 321	}
 322
 323	sig := object.Signature{Name: r.Cfg.User.Name, Email: r.Cfg.User.Email, Timestamp: now}
 324	newDraft := &object.Commit{
 325		TreeID:    snapped.TreeID,
 326		Parents:   [][32]byte{snappedID},
 327		ChangeID:  newChangeID,
 328		Author:    sig,
 329		Committer: sig,
 330		Message:   "",
 331		Phase:     object.PhaseDraft,
 332	}
 333
 334	newDraftID, err := repo.WriteCommitTx(r.Store, tx, newDraft)
 335	if err != nil {
 336		r.Store.Rollback(tx)
 337		return nil, object.ZeroID, err
 338	}
 339
 340	if err := r.Store.SetChangeCommit(tx, newChangeID, newDraftID); err != nil {
 341		r.Store.Rollback(tx)
 342		return nil, object.ZeroID, err
 343	}
 344
 345	after := buildRefState(snappedID, object.FormatChangeID(newChangeID))
 346	op := store.Operation{
 347		Kind:      "snap",
 348		Timestamp: now.Unix(),
 349		Before:    before,
 350		After:     after,
 351		Metadata:  "'" + firstLine(snapped.Message) + "'",
 352	}
 353	if _, err := r.Store.InsertOperation(tx, op); err != nil {
 354		r.Store.Rollback(tx)
 355		return nil, object.ZeroID, err
 356	}
 357
 358	if err := r.Store.Commit(tx); err != nil {
 359		return nil, object.ZeroID, err
 360	}
 361
 362	if err := r.WriteHead(object.FormatChangeID(newChangeID)); err != nil {
 363		return nil, object.ZeroID, err
 364	}
 365
 366	if len(r.Cfg.Hooks.PostSnap) > 0 {
 367		if err := RunHooksSequential(r.Root, "post-snap", r.Cfg.Hooks.PostSnap); err != nil {
 368			fmt.Fprintf(os.Stderr, "arche snap: post-snap hook: %v\n", err)
 369		}
 370	}
 371
 372	return snapped, snappedID, nil
 373}
 374
 375func (wc *WC) Status() ([]FileStatus, error) {
 376	r := wc.Repo
 377	head, _, err := r.HeadCommit()
 378	if err != nil {
 379		return nil, err
 380	}
 381
 382	headFiles := make(map[string][32]byte)
 383	if err := flattenTree(r, head.TreeID, "", headFiles); err != nil {
 384		return nil, err
 385	}
 386
 387	wcPaths, err := wc.trackedPaths()
 388	if err != nil {
 389		return nil, err
 390	}
 391	wcSet := make(map[string]bool, len(wcPaths))
 392	for _, p := range wcPaths {
 393		wcSet[p] = true
 394	}
 395
 396	cacheEntries, _ := r.Store.ListWCacheEntries()
 397	cacheMap := make(map[string]store.WCacheEntry, len(cacheEntries))
 398	for _, e := range cacheEntries {
 399		cacheMap[e.Path] = e
 400	}
 401	dirty, _ := dirtySet(r)
 402
 403	var out []FileStatus
 404
 405	for path, headBlobID := range headFiles {
 406		if !wcSet[path] {
 407			out = append(out, FileStatus{Path: path, Status: 'D'})
 408			continue
 409		}
 410
 411		if dirty != nil && !dirty[path] {
 412			if cached, ok := cacheMap[path]; ok {
 413				if cached.BlobID != headBlobID {
 414					out = append(out, FileStatus{Path: path, Status: 'M'})
 415				}
 416				continue
 417			}
 418		}
 419
 420		curBlobID, err := wc.blobIDForPath(path)
 421		if err != nil {
 422			continue
 423		}
 424		if curBlobID != headBlobID {
 425			out = append(out, FileStatus{Path: path, Status: 'M'})
 426		}
 427	}
 428
 429	ignore, _ := loadIgnore(r.Root)
 430	for _, path := range wcPaths {
 431		if _, inHead := headFiles[path]; !inHead {
 432			if ignore.Match(path) {
 433				continue
 434			}
 435			out = append(out, FileStatus{Path: path, Status: 'A'})
 436		}
 437	}
 438
 439	sort.Slice(out, func(i, j int) bool { return out[i].Path < out[j].Path })
 440	return out, nil
 441}
 442
 443func (wc *WC) materializeDisk(treeID [32]byte) (map[string][32]byte, map[string]object.EntryMode, error) {
 444	r := wc.Repo
 445
 446	wantFiles := make(map[string][32]byte)
 447	wantMode := make(map[string]object.EntryMode)
 448	if err := flattenTree(r, treeID, "", wantFiles); err != nil {
 449		return nil, nil, err
 450	}
 451
 452	if err := flattenTreeModes(r, treeID, "", wantMode); err != nil {
 453		return nil, nil, err
 454	}
 455
 456	ignore, _ := loadIgnore(r.Root)
 457	err := filepath.WalkDir(r.Root, func(path string, d fs.DirEntry, err error) error {
 458		if err != nil {
 459			return nil
 460		}
 461		rel, _ := filepath.Rel(r.Root, path)
 462		if rel == "." {
 463			return nil
 464		}
 465		if d.IsDir() {
 466			if rel == archeDirName || strings.HasPrefix(rel, archeDirName+string(os.PathSeparator)) {
 467				return filepath.SkipDir
 468			}
 469			if ignore.MatchDir(rel) {
 470				return filepath.SkipDir
 471			}
 472			return nil
 473		}
 474		if ignore.Match(rel) {
 475			return nil
 476		}
 477		if _, ok := wantFiles[rel]; !ok {
 478			return os.Remove(path)
 479		}
 480		return nil
 481	})
 482	if err != nil {
 483		return nil, nil, err
 484	}
 485
 486	var conflictPaths []string
 487	for relPath, blobID := range wantFiles {
 488		abs := filepath.Join(r.Root, relPath)
 489		if err := os.MkdirAll(filepath.Dir(abs), 0o755); err != nil {
 490			return nil, nil, err
 491		}
 492		content, err := r.ReadBlob(blobID)
 493		if err != nil {
 494			if conf, cErr := r.ReadConflict(blobID); cErr == nil {
 495				content = renderConflictMarkers(r, conf)
 496				conflictPaths = append(conflictPaths, relPath)
 497				err = nil
 498			}
 499		}
 500		if err != nil {
 501			return nil, nil, err
 502		}
 503		perm := fs.FileMode(0o644)
 504		if wantMode[relPath] == object.ModeExec {
 505			perm = 0o755
 506		}
 507		if err := os.WriteFile(abs, content, perm); err != nil {
 508			return nil, nil, err
 509		}
 510	}
 511
 512	for _, p := range conflictPaths {
 513		delete(wantFiles, p)
 514	}
 515
 516	return wantFiles, wantMode, nil
 517}
 518
 519func renderConflictMarkers(r *repo.Repo, conf *object.Conflict) []byte {
 520	readStr := func(id [32]byte) string {
 521		if id == object.ZeroID {
 522			return ""
 523		}
 524		b, _ := r.ReadBlob(id)
 525		return string(b)
 526	}
 527	nl := func(s string) string {
 528		if len(s) > 0 && s[len(s)-1] != '\n' {
 529			return s + "\n"
 530		}
 531		return s
 532	}
 533	if conf.Ours.BlobID == object.ZeroID {
 534		return []byte(fmt.Sprintf("<<<<<<< ours\n(deleted)\n=======\n%s>>>>>>> theirs\n", nl(readStr(conf.Theirs.BlobID))))
 535	}
 536	if conf.Theirs.BlobID == object.ZeroID {
 537		return []byte(fmt.Sprintf("<<<<<<< ours\n%s=======\n(deleted)\n>>>>>>> theirs\n", nl(readStr(conf.Ours.BlobID))))
 538	}
 539	return []byte(fmt.Sprintf("<<<<<<< ours\n%s=======\n%s>>>>>>> theirs\n",
 540		nl(readStr(conf.Ours.BlobID)),
 541		nl(readStr(conf.Theirs.BlobID))))
 542}
 543
 544func (wc *WC) populateWCacheInTx(tx *store.Tx, wantFiles map[string][32]byte) error {
 545	r := wc.Repo
 546	if err := r.Store.ClearWCache(tx); err != nil {
 547		return err
 548	}
 549	for relPath, blobID := range wantFiles {
 550		abs := filepath.Join(r.Root, relPath)
 551		info, err := os.Lstat(abs)
 552		if err != nil {
 553			continue
 554		}
 555		st, ok := info.Sys().(*syscall.Stat_t)
 556		if !ok {
 557			continue
 558		}
 559		_ = r.Store.SetWCacheEntry(tx, store.WCacheEntry{
 560			Path:    relPath,
 561			Inode:   st.Ino,
 562			MtimeNs: info.ModTime().UnixNano(),
 563			Size:    info.Size(),
 564			BlobID:  blobID,
 565			Mode:    uint8(fileMode(info)),
 566		})
 567	}
 568	return nil
 569}
 570
 571func (wc *WC) MaterializeQuiet(treeID [32]byte) error {
 572	r := wc.Repo
 573
 574	wantFiles, _, err := wc.materializeDisk(treeID)
 575	if err != nil {
 576		return err
 577	}
 578
 579	tx, err := r.Store.Begin()
 580	if err != nil {
 581		return err
 582	}
 583	if err := wc.populateWCacheInTx(tx, wantFiles); err != nil {
 584		r.Store.Rollback(tx)
 585		return err
 586	}
 587	return r.Store.Commit(tx)
 588}
 589
 590func (wc *WC) Materialize(treeID [32]byte, newChangeID string) error {
 591	r := wc.Repo
 592
 593	before, _ := r.CaptureRefState()
 594	now := time.Now()
 595
 596	wantFiles, _, err := wc.materializeDisk(treeID)
 597	if err != nil {
 598		return err
 599	}
 600
 601	bare := object.StripChangeIDPrefix(newChangeID)
 602	commitID, _ := r.Store.GetChangeCommit(bare)
 603	after := buildRefState(commitID, newChangeID)
 604
 605	tx, err := r.Store.Begin()
 606	if err != nil {
 607		return err
 608	}
 609	if err := wc.populateWCacheInTx(tx, wantFiles); err != nil {
 610		r.Store.Rollback(tx)
 611		return err
 612	}
 613
 614	op := store.Operation{
 615		Kind:      "co",
 616		Timestamp: now.Unix(),
 617		Before:    before,
 618		After:     after,
 619		Metadata:  "checked out " + newChangeID,
 620	}
 621	if _, err := r.Store.InsertOperation(tx, op); err != nil {
 622		r.Store.Rollback(tx)
 623		return err
 624	}
 625
 626	return r.Store.Commit(tx)
 627}
 628
 629const archeDirName = ".arche"
 630
 631func (wc *WC) trackedPaths() ([]string, error) {
 632	r := wc.Repo
 633	ignore, _ := loadIgnore(r.Root)
 634
 635	var paths []string
 636	err := filepath.WalkDir(r.Root, func(path string, d fs.DirEntry, err error) error {
 637		if err != nil {
 638			return nil
 639		}
 640		rel, _ := filepath.Rel(r.Root, path)
 641		if rel == "." {
 642			return nil
 643		}
 644		if d.IsDir() {
 645			if rel == archeDirName || strings.HasPrefix(rel, archeDirName+string(os.PathSeparator)) {
 646				return filepath.SkipDir
 647			}
 648			if ignore.MatchDir(rel) {
 649				return filepath.SkipDir
 650			}
 651			return nil
 652		}
 653		if ignore.Match(rel) {
 654			return nil
 655		}
 656		paths = append(paths, filepath.ToSlash(rel))
 657		return nil
 658	})
 659	return paths, err
 660}
 661
 662func (wc *WC) TrackedPaths() ([]string, error) { return wc.trackedPaths() }
 663
 664func (wc *WC) blobIDForPath(rel string) ([32]byte, error) {
 665	r := wc.Repo
 666	abs := filepath.Join(r.Root, rel)
 667	info, err := os.Lstat(abs)
 668	if err != nil {
 669		return object.ZeroID, err
 670	}
 671	st := info.Sys().(*syscall.Stat_t)
 672
 673	if cached, _ := r.Store.GetWCacheEntry(rel); cached != nil {
 674		if cached.Inode == st.Ino &&
 675			cached.MtimeNs == info.ModTime().UnixNano() &&
 676			cached.Size == info.Size() {
 677			return cached.BlobID, nil
 678		}
 679	}
 680
 681	content, err := readFileContent(abs, info)
 682	if err != nil {
 683		return object.ZeroID, err
 684	}
 685	b := &object.Blob{Content: content}
 686	return object.HashBlob(b), nil
 687}
 688
 689func flattenTree(r *repo.Repo, treeID [32]byte, prefix string, out map[string][32]byte) error {
 690	if treeID == object.ZeroID {
 691		return nil
 692	}
 693	t, err := r.ReadTree(treeID)
 694	if err != nil {
 695		return err
 696	}
 697	for _, e := range t.Entries {
 698		rel := join(prefix, e.Name)
 699		switch e.Mode {
 700		case object.ModeDir:
 701			if err := flattenTree(r, e.ObjectID, rel, out); err != nil {
 702				return err
 703			}
 704		default:
 705			out[rel] = e.ObjectID
 706		}
 707	}
 708	return nil
 709}
 710
 711func flattenTreeModes(r *repo.Repo, treeID [32]byte, prefix string, out map[string]object.EntryMode) error {
 712	if treeID == object.ZeroID {
 713		return nil
 714	}
 715	t, err := r.ReadTree(treeID)
 716	if err != nil {
 717		return err
 718	}
 719	for _, e := range t.Entries {
 720		rel := join(prefix, e.Name)
 721		switch e.Mode {
 722		case object.ModeDir:
 723			if err := flattenTreeModes(r, e.ObjectID, rel, out); err != nil {
 724				return err
 725			}
 726		default:
 727			out[rel] = e.Mode
 728		}
 729	}
 730	return nil
 731}
 732
 733type fileEntry struct {
 734	path   string
 735	blobID [32]byte
 736	mode   object.EntryMode
 737}
 738
 739func buildTree(r *repo.Repo, tx *store.Tx, entries []fileEntry) ([32]byte, error) {
 740	type node struct {
 741		isFile   bool
 742		blobID   [32]byte
 743		mode     object.EntryMode
 744		children map[string]*node
 745	}
 746	root := &node{children: make(map[string]*node)}
 747
 748	for _, e := range entries {
 749		parts := strings.Split(e.path, "/")
 750		cur := root
 751		for i, part := range parts {
 752			if i == len(parts)-1 {
 753				cur.children[part] = &node{isFile: true, blobID: e.blobID, mode: e.mode}
 754			} else {
 755				if _, ok := cur.children[part]; !ok {
 756					cur.children[part] = &node{children: make(map[string]*node)}
 757				}
 758				cur = cur.children[part]
 759			}
 760		}
 761	}
 762
 763	var writeNode func(n *node) ([32]byte, error)
 764	writeNode = func(n *node) ([32]byte, error) {
 765		var treeEntries []object.TreeEntry
 766		for name, child := range n.children {
 767			if child.isFile {
 768				treeEntries = append(treeEntries, object.TreeEntry{
 769					Name:     name,
 770					Mode:     child.mode,
 771					ObjectID: child.blobID,
 772				})
 773			} else {
 774				subID, err := writeNode(child)
 775				if err != nil {
 776					return object.ZeroID, err
 777				}
 778				treeEntries = append(treeEntries, object.TreeEntry{
 779					Name:     name,
 780					Mode:     object.ModeDir,
 781					ObjectID: subID,
 782				})
 783			}
 784		}
 785		sort.Slice(treeEntries, func(i, j int) bool { return treeEntries[i].Name < treeEntries[j].Name })
 786		t := &object.Tree{Entries: treeEntries}
 787		id, err := repo.WriteTreeTx(r.Store, tx, t)
 788		return id, err
 789	}
 790
 791	return writeNode(root)
 792}
 793
 794func fileMode(info os.FileInfo) object.EntryMode {
 795	if info.Mode()&0o111 != 0 {
 796		return object.ModeExec
 797	}
 798	if info.Mode()&os.ModeSymlink != 0 {
 799		return object.ModeSymlink
 800	}
 801	return object.ModeFile
 802}
 803
 804func readFileContent(abs string, info os.FileInfo) ([]byte, error) {
 805	if info.Mode()&os.ModeSymlink != 0 {
 806		target, err := os.Readlink(abs)
 807		if err != nil {
 808			return nil, err
 809		}
 810		return []byte(target), nil
 811	}
 812	return os.ReadFile(abs)
 813}
 814
 815func join(prefix, name string) string {
 816	if prefix == "" {
 817		return name
 818	}
 819	return prefix + "/" + name
 820}
 821
 822func buildRefState(commitID [32]byte, changeID string) string {
 823	m := map[string]string{
 824		"head": changeID,
 825		"tip":  fmt.Sprintf("%x", commitID),
 826	}
 827	b, _ := json.Marshal(m)
 828	return string(b)
 829}
 830
 831func firstLine(s string) string {
 832	if i := strings.IndexByte(s, '\n'); i >= 0 {
 833		return s[:i]
 834	}
 835	return s
 836}
 837
 838func (wc *WC) SnapshotTree() ([32]byte, error) {
 839	r := wc.Repo
 840
 841	paths, cacheMap, dirty, err := wc.snapshotInput()
 842	if err != nil {
 843		return object.ZeroID, err
 844	}
 845
 846	tx, err := r.Store.Begin()
 847	if err != nil {
 848		return object.ZeroID, err
 849	}
 850
 851	var entries []fileEntry
 852	for _, rel := range paths {
 853		if dirty != nil && !dirty[rel] {
 854			if cached, ok := cacheMap[rel]; ok {
 855				entries = append(entries, fileEntry{
 856					path:   rel,
 857					blobID: cached.BlobID,
 858					mode:   object.EntryMode(cached.Mode),
 859				})
 860				continue
 861			}
 862		}
 863
 864		abs := filepath.Join(r.Root, rel)
 865		info, err := os.Lstat(abs)
 866		if err != nil {
 867			continue
 868		}
 869
 870		var blobID [32]byte
 871		mode := fileMode(info)
 872
 873		if cached, ok := cacheMap[rel]; ok {
 874			st := info.Sys().(*syscall.Stat_t)
 875			if cached.Inode == st.Ino && cached.MtimeNs == info.ModTime().UnixNano() && cached.Size == info.Size() {
 876				blobID = cached.BlobID
 877			}
 878		}
 879
 880		if blobID == object.ZeroID {
 881			content, err := readFileContent(abs, info)
 882			if err != nil {
 883				r.Store.Rollback(tx) //nolint:errcheck
 884				return object.ZeroID, err
 885			}
 886			id, err := repo.WriteBlobTx(r.Store, tx, &object.Blob{Content: content})
 887			if err != nil {
 888				r.Store.Rollback(tx) //nolint:errcheck
 889				return object.ZeroID, err
 890			}
 891			blobID = id
 892		}
 893
 894		entries = append(entries, fileEntry{path: rel, blobID: blobID, mode: mode})
 895	}
 896
 897	treeID, err := buildTree(r, tx, entries)
 898	if err != nil {
 899		r.Store.Rollback(tx) //nolint:errcheck
 900		return object.ZeroID, err
 901	}
 902	if err := r.Store.Commit(tx); err != nil {
 903		return object.ZeroID, err
 904	}
 905	return treeID, nil
 906}
 907
 908func (wc *WC) Amend(message string) (*object.Commit, [32]byte, error) {
 909	r := wc.Repo
 910	now := time.Now()
 911
 912	head, oldHeadID, err := r.HeadCommit()
 913	if err != nil {
 914		return nil, object.ZeroID, err
 915	}
 916	if head.Phase == object.PhasePublic {
 917		return nil, object.ZeroID, fmt.Errorf("cannot amend a public commit; use --force-rewrite if you are sure")
 918	}
 919
 920	before, err := r.CaptureRefState()
 921	if err != nil {
 922		return nil, object.ZeroID, err
 923	}
 924
 925	if message == "" {
 926		message = head.Message
 927	}
 928
 929	paths, cacheMap, dirty, err := wc.snapshotInput()
 930	if err != nil {
 931		return nil, object.ZeroID, err
 932	}
 933
 934	tx, err := r.Store.Begin()
 935	if err != nil {
 936		return nil, object.ZeroID, err
 937	}
 938
 939	amended, amendedID, err := wc.snapshotIntoTx(tx, head, paths, cacheMap, dirty, message, now)
 940	if err != nil {
 941		r.Store.Rollback(tx)
 942		return nil, object.ZeroID, err
 943	}
 944
 945	if oldHeadID != amendedID {
 946		obs := &object.ObsoleteMarker{
 947			Predecessor: oldHeadID,
 948			Successors:  [][32]byte{amendedID},
 949			Reason:      "amend",
 950			Timestamp:   now.Unix(),
 951		}
 952		if _, err := repo.WriteObsoleteTx(r.Store, tx, obs); err != nil {
 953			r.Store.Rollback(tx)
 954			return nil, object.ZeroID, err
 955		}
 956	}
 957
 958	after := buildRefState(amendedID, object.FormatChangeID(amended.ChangeID))
 959	op := store.Operation{
 960		Kind:      "amend",
 961		Timestamp: now.Unix(),
 962		Before:    before,
 963		After:     after,
 964		Metadata:  "'" + firstLine(amended.Message) + "'",
 965	}
 966	if _, err := r.Store.InsertOperation(tx, op); err != nil {
 967		r.Store.Rollback(tx)
 968		return nil, object.ZeroID, err
 969	}
 970
 971	if err := r.Store.Commit(tx); err != nil {
 972		return nil, object.ZeroID, err
 973	}
 974
 975	if oldHeadID != amendedID {
 976		if err := wc.autoRebaseDownstream(oldHeadID, amendedID, head.ChangeID, now); err != nil {
 977			fmt.Fprintf(os.Stderr, "arche: warning: downstream rebase failed: %v\n", err)
 978		}
 979	}
 980
 981	return amended, amendedID, nil
 982}
 983
 984func (wc *WC) autoRebaseDownstream(oldParentID, newParentID [32]byte, headChangeID string, now time.Time) error {
 985	r := wc.Repo
 986
 987	allChanges, err := r.Store.ListChanges()
 988	if err != nil {
 989		return err
 990	}
 991
 992	type draftEntry struct {
 993		id       [32]byte
 994		changeID string
 995		commit   *object.Commit
 996	}
 997
 998	children := make(map[[32]byte][]draftEntry)
 999	for _, ch := range allChanges {
1000		if ch.CommitID == object.ZeroID {
1001			continue
1002		}
1003		c, err := r.ReadCommit(ch.CommitID)
1004		if err != nil || c == nil {
1005			continue
1006		}
1007		if c.Phase != object.PhaseDraft {
1008			continue
1009		}
1010		if c.ChangeID == headChangeID {
1011			continue
1012		}
1013		if len(c.Parents) == 0 {
1014			continue
1015		}
1016		d := draftEntry{id: ch.CommitID, changeID: ch.Name, commit: c}
1017		children[c.Parents[0]] = append(children[c.Parents[0]], d)
1018	}
1019
1020	type rebaseTask struct {
1021		entry     draftEntry
1022		newParent [32]byte
1023	}
1024	var tasks []rebaseTask
1025	queue := []struct {
1026		oldID [32]byte
1027		newID [32]byte
1028	}{{oldParentID, newParentID}}
1029
1030	for len(queue) > 0 {
1031		cur := queue[0]
1032		queue = queue[1:]
1033		for _, child := range children[cur.oldID] {
1034			tasks = append(tasks, rebaseTask{entry: child, newParent: cur.newID})
1035			queue = append(queue, struct{ oldID, newID [32]byte }{child.id, child.id})
1036		}
1037	}
1038
1039	remapped := map[[32]byte][32]byte{oldParentID: newParentID}
1040
1041	for _, task := range tasks {
1042		oldFirst := task.entry.commit.Parents[0]
1043		newParent, ok := remapped[oldFirst]
1044		if !ok {
1045			newParent = oldFirst
1046		}
1047
1048		var baseTreeID [32]byte
1049		if pc, err2 := r.ReadCommit(oldFirst); err2 == nil {
1050			baseTreeID = pc.TreeID
1051		}
1052		newParentCommit, err := r.ReadCommit(newParent)
1053		if err != nil {
1054			return fmt.Errorf("read new parent for %s: %w", object.FormatChangeID(task.entry.changeID), err)
1055		}
1056
1057		result, err := merge.Trees(r, baseTreeID, task.entry.commit.TreeID, newParentCommit.TreeID)
1058		if err != nil {
1059			return fmt.Errorf("merge for %s: %w", object.FormatChangeID(task.entry.changeID), err)
1060		}
1061
1062		newCommit := &object.Commit{
1063			TreeID:    result.TreeID,
1064			Parents:   [][32]byte{newParent},
1065			ChangeID:  task.entry.changeID,
1066			Author:    task.entry.commit.Author,
1067			Committer: object.Signature{Name: r.Cfg.User.Name, Email: r.Cfg.User.Email, Timestamp: now},
1068			Message:   task.entry.commit.Message,
1069			Phase:     task.entry.commit.Phase,
1070		}
1071
1072		tx, err := r.Store.Begin()
1073		if err != nil {
1074			return err
1075		}
1076		newCommitID, err := repo.WriteCommitTx(r.Store, tx, newCommit)
1077		if err != nil {
1078			r.Store.Rollback(tx)
1079			return err
1080		}
1081		if err := r.Store.SetChangeCommit(tx, task.entry.changeID, newCommitID); err != nil {
1082			r.Store.Rollback(tx)
1083			return err
1084		}
1085		obs := &object.ObsoleteMarker{
1086			Predecessor: task.entry.id,
1087			Successors:  [][32]byte{newCommitID},
1088			Reason:      "amend",
1089			Timestamp:   now.Unix(),
1090		}
1091		if _, err := repo.WriteObsoleteTx(r.Store, tx, obs); err != nil {
1092			r.Store.Rollback(tx)
1093			return err
1094		}
1095		if err := r.Store.Commit(tx); err != nil {
1096			return err
1097		}
1098
1099		remapped[task.entry.id] = newCommitID
1100		conflictNote := ""
1101		if len(result.Conflicts) > 0 {
1102			conflictNote = fmt.Sprintf(" (%d conflict(s))", len(result.Conflicts))
1103		}
1104		fmt.Printf("  auto-rebased %s%s\n", object.FormatChangeID(task.entry.changeID), conflictNote)
1105	}
1106	return nil
1107}