Artifact 24de4ccd98728952d762fbd858bc0607ad1867376f9c118a7a7b107e61c8176b:
- File
internal/checkout/revert_test.go
— part of check-in
[50d1a7f72b]
at
2026-10-03 17:38:39
on branch trunk
— fix(checkout): revert undoes a pending rename (#242) (#249)
* fix(checkout): revert undoes a pending rename (#242)
Revert-all selected rows by content and removal, so a rename recorded by fossil mv (origname set, chnged 0) was skipped; a renamed and edited file had its committed content written under the new name, leaving the rename pending. A named revert only matched the current name.
Like fossil's revert, a pending rename is now undone: the file under the new name is removed, the row takes its old name back (UPDATE OR REPLACE, as fossil does), and the committed content is written there. Revert-all includes renamed rows, and a named revert answers to either the new or the old name. A renamed file a merge added is still removed outright, as before and as in fossil.
removeRegularFile is shared by the rename undo and removeMergeAdded, so neither deletes a directory the user made at that path.
* fix(checkout): plan a revert before changing anything (#242)
Undoing a rename with UPDATE OR REPLACE let a swap destroy files: reverting a.txt and b.txt exchanged through fossil mv deleted the other row, the loop then removed a file it had just restored from its stale row list, and the next restore failed half way, leaving both files gone from disk. A new file added under the old name was overwritten the same way. A named revert of the old name also stopped at the first match.
Revert now plans, checks, then acts. planRevert decides every target row's action (restore, un-manage, remove a merge-added file, undo a rename) before anything changes; checkRenameUndoFree refuses the whole revert when undoing a rename would land on a name another tracked row holds, since go-libfossil has no undo copy to recover an overwritten file (fossil saves one); revertFile carries out the planned action. The rename undo is a plain UPDATE, which fails rather than replace a row. A name selects every row it matches, new name or old. The callback reports a rename under the name the file gets back.
Swaps and chains, which fossil completes via its undo store, are #248.
* fix(checkout): revert named paths as one; refuse untracked file at old name (#242)
Review of the planned revert found two remaining ways to lose work:
- Several named paths were reverted one at a time, so in `revert b.txt z.txt` the edit to b.txt was discarded before z.txt's rename undo was refused. revertPaths now gathers every named row (new or old name) and plans and checks them as one revert: a refusal reverts none of them. - Undoing a rename restored the committed content over an untracked file at the old name. checkRenameUndoFree now also refuses when a file on disk there differs from the committed content (wouldClobber), for the same reason it refuses a tracked one: there is no undo copy.
* docs(changelog): note revert's untracked-file and multi-path rules (#242) (user: [email protected], size: 13103) [annotate] [blame] [check-ins using]
package checkout import ( "testing" "github.com/danmestas/go-libfossil/simio" ) func TestRevertModified(t *testing.T) { r, cleanup := newTestRepoWithCheckin(t) defer cleanup() dir := t.TempDir() co, err := Create(r, dir, CreateOpts{}) if err != nil { t.Fatal(err) } defer co.Close() rid, _, _ := co.Version() mem := simio.NewMemStorage() co.env = &simio.Env{Storage: mem, Clock: simio.RealClock{}, Rand: simio.CryptoRand{}} co.dir = "/checkout" // Extract files if err := co.Extract(rid, ExtractOpts{}); err != nil { t.Fatal(err) } // Modify hello.txt in MemStorage if err := mem.WriteFile("/checkout/hello.txt", []byte("modified content\n"), 0644); err != nil { t.Fatal(err) } // Scan to detect the change (with hash flag to detect content changes) if err := co.ScanChanges(ScanHash); err != nil { t.Fatal(err) } // Verify it's marked as changed var chnged int err = co.db.QueryRow("SELECT chnged FROM vfile WHERE vid=? AND pathname='hello.txt'", int64(rid)).Scan(&chnged) if err != nil { t.Fatal("vfile row not found:", err) } if chnged == 0 { t.Fatal("expected chnged > 0 after modification") } // Revert the file err = co.Revert(RevertOpts{Paths: []string{"hello.txt"}}) if err != nil { t.Fatal(err) } // Verify content is restored data, err := mem.ReadFile("/checkout/hello.txt") if err != nil { t.Fatal("file not found after revert:", err) } if string(data) != "hello world\n" { t.Fatalf("content = %q, want %q", data, "hello world\n") } // Verify vfile.chnged reset to 0 err = co.db.QueryRow("SELECT chnged FROM vfile WHERE vid=? AND pathname='hello.txt'", int64(rid)).Scan(&chnged) if err != nil { t.Fatal(err) } if chnged != 0 { t.Fatalf("chnged = %d, want 0 after revert", chnged) } } func TestRevertDeleted(t *testing.T) { r, cleanup := newTestRepoWithCheckin(t) defer cleanup() dir := t.TempDir() co, err := Create(r, dir, CreateOpts{}) if err != nil { t.Fatal(err) } defer co.Close() rid, _, _ := co.Version() mem := simio.NewMemStorage() co.env = &simio.Env{Storage: mem, Clock: simio.RealClock{}, Rand: simio.CryptoRand{}} co.dir = "/checkout" // Extract files if err := co.Extract(rid, ExtractOpts{}); err != nil { t.Fatal(err) } // Unmanage hello.txt (marks deleted=1) err = co.Unmanage(UnmanageOpts{Paths: []string{"hello.txt"}}) if err != nil { t.Fatal(err) } // Verify deleted=1 var deleted int err = co.db.QueryRow("SELECT deleted FROM vfile WHERE vid=? AND pathname='hello.txt'", int64(rid)).Scan(&deleted) if err != nil { t.Fatal(err) } if deleted != 1 { t.Fatalf("deleted = %d, want 1 after unmanage", deleted) } // Revert the file err = co.Revert(RevertOpts{Paths: []string{"hello.txt"}}) if err != nil { t.Fatal(err) } // Verify deleted reset to 0 err = co.db.QueryRow("SELECT deleted FROM vfile WHERE vid=? AND pathname='hello.txt'", int64(rid)).Scan(&deleted) if err != nil { t.Fatal(err) } if deleted != 0 { t.Fatalf("deleted = %d, want 0 after revert", deleted) } // Verify content is restored data, err := mem.ReadFile("/checkout/hello.txt") if err != nil { t.Fatal("file not found after revert:", err) } if string(data) != "hello world\n" { t.Fatalf("content = %q, want %q", data, "hello world\n") } } func TestRevertNewlyAdded(t *testing.T) { r, cleanup := newTestRepoWithCheckin(t) defer cleanup() dir := t.TempDir() co, err := Create(r, dir, CreateOpts{}) if err != nil { t.Fatal(err) } defer co.Close() rid, _, _ := co.Version() mem := simio.NewMemStorage() co.env = &simio.Env{Storage: mem, Clock: simio.RealClock{}, Rand: simio.CryptoRand{}} co.dir = "/checkout" // Extract files if err := co.Extract(rid, ExtractOpts{}); err != nil { t.Fatal(err) } // Add a new file if err := mem.WriteFile("/checkout/new.txt", []byte("new file"), 0644); err != nil { t.Fatal(err) } if _, err := co.Manage(ManageOpts{Paths: []string{"new.txt"}}); err != nil { t.Fatal(err) } // Verify vfile row exists with rid=0 var vRid int err = co.db.QueryRow("SELECT rid FROM vfile WHERE vid=? AND pathname='new.txt'", int64(rid)).Scan(&vRid) if err != nil { t.Fatal("vfile row not found:", err) } if vRid != 0 { t.Fatalf("rid = %d, want 0 (newly added)", vRid) } // Revert the newly added file err = co.Revert(RevertOpts{Paths: []string{"new.txt"}}) if err != nil { t.Fatal(err) } // Verify vfile row is deleted var count int err = co.db.QueryRow("SELECT count(*) FROM vfile WHERE vid=? AND pathname='new.txt'", int64(rid)).Scan(&count) if err != nil { t.Fatal(err) } if count != 0 { t.Fatalf("expected 0 rows after revert, got %d", count) } // The file was never committed: revert un-manages it but keeps it on // disk, as fossil does, rather than destroying the only copy (#234). data, err := mem.ReadFile("/checkout/new.txt") if err != nil { t.Fatal("new.txt should stay on disk after revert:", err) } if string(data) != "new file" { t.Fatalf("new.txt = %q after revert, want its content kept", data) } } func TestRevertAll(t *testing.T) { r, cleanup := newTestRepoWithCheckin(t) defer cleanup() dir := t.TempDir() co, err := Create(r, dir, CreateOpts{}) if err != nil { t.Fatal(err) } defer co.Close() rid, _, _ := co.Version() mem := simio.NewMemStorage() co.env = &simio.Env{Storage: mem, Clock: simio.RealClock{}, Rand: simio.CryptoRand{}} co.dir = "/checkout" // Extract files if err := co.Extract(rid, ExtractOpts{}); err != nil { t.Fatal(err) } // Modify hello.txt if err := mem.WriteFile("/checkout/hello.txt", []byte("modified 1\n"), 0644); err != nil { t.Fatal(err) } // Modify src/main.go if err := mem.WriteFile("/checkout/src/main.go", []byte("modified 2\n"), 0644); err != nil { t.Fatal(err) } // Add a new file if err := mem.WriteFile("/checkout/new.txt", []byte("new"), 0644); err != nil { t.Fatal(err) } if _, err := co.Manage(ManageOpts{Paths: []string{"new.txt"}}); err != nil { t.Fatal(err) } // Scan to detect changes (with hash flag to detect content changes) if err := co.ScanChanges(ScanHash); err != nil { t.Fatal(err) } // Verify we have changes hasChanges, err := co.HasChanges() if err != nil { t.Fatal(err) } if !hasChanges { t.Fatal("expected changes before revert") } // Revert all (empty Paths) err = co.Revert(RevertOpts{}) if err != nil { t.Fatal(err) } // Verify all files are restored data1, err := mem.ReadFile("/checkout/hello.txt") if err != nil { t.Fatal("hello.txt not found after revert:", err) } if string(data1) != "hello world\n" { t.Fatalf("hello.txt = %q, want %q", data1, "hello world\n") } data2, err := mem.ReadFile("/checkout/src/main.go") if err != nil { t.Fatal("src/main.go not found after revert:", err) } if string(data2) != "package main\n" { t.Fatalf("src/main.go = %q, want %q", data2, "package main\n") } // new.txt is un-managed but kept on disk (#234). if _, err := mem.ReadFile("/checkout/new.txt"); err != nil { t.Fatal("new.txt should stay on disk after revert:", err) } // Verify no changes remain hasChanges, err = co.HasChanges() if err != nil { t.Fatal(err) } if hasChanges { t.Fatal("expected no changes after revert all") } } func TestRevertCallback(t *testing.T) { r, cleanup := newTestRepoWithCheckin(t) defer cleanup() dir := t.TempDir() co, err := Create(r, dir, CreateOpts{}) if err != nil { t.Fatal(err) } defer co.Close() rid, _, _ := co.Version() mem := simio.NewMemStorage() co.env = &simio.Env{Storage: mem, Clock: simio.RealClock{}, Rand: simio.CryptoRand{}} co.dir = "/checkout" // Extract and modify a file if err := co.Extract(rid, ExtractOpts{}); err != nil { t.Fatal(err) } if err := mem.WriteFile("/checkout/hello.txt", []byte("modified"), 0644); err != nil { t.Fatal(err) } if err := co.ScanChanges(ScanHash); err != nil { t.Fatal(err) } // Revert with callback var callbackName string var callbackChange RevertChange err = co.Revert(RevertOpts{ Paths: []string{"hello.txt"}, Callback: func(name string, change RevertChange) error { callbackName = name callbackChange = change return nil }, }) if err != nil { t.Fatal(err) } if callbackName != "hello.txt" { t.Fatalf("callback name = %q, want %q", callbackName, "hello.txt") } if callbackChange != RevertContents { t.Fatalf("callback change = %v, want RevertContents", callbackChange) } } func TestRevertCallbackUnmanage(t *testing.T) { r, cleanup := newTestRepoWithCheckin(t) defer cleanup() dir := t.TempDir() co, err := Create(r, dir, CreateOpts{}) if err != nil { t.Fatal(err) } defer co.Close() rid, _, _ := co.Version() mem := simio.NewMemStorage() co.env = &simio.Env{Storage: mem, Clock: simio.RealClock{}, Rand: simio.CryptoRand{}} co.dir = "/checkout" // Extract, add a new file, then revert it if err := co.Extract(rid, ExtractOpts{}); err != nil { t.Fatal(err) } if err := mem.WriteFile("/checkout/new.txt", []byte("new"), 0644); err != nil { t.Fatal(err) } if _, err := co.Manage(ManageOpts{Paths: []string{"new.txt"}}); err != nil { t.Fatal(err) } // Revert with callback var callbackName string var callbackChange RevertChange err = co.Revert(RevertOpts{ Paths: []string{"new.txt"}, Callback: func(name string, change RevertChange) error { callbackName = name callbackChange = change return nil }, }) if err != nil { t.Fatal(err) } if callbackName != "new.txt" { t.Fatalf("callback name = %q, want %q", callbackName, "new.txt") } if callbackChange != RevertUnmanage { t.Fatalf("callback change = %v, want RevertUnmanage", callbackChange) } } func TestRevertNotFound(t *testing.T) { r, cleanup := newTestRepoWithCheckin(t) defer cleanup() dir := t.TempDir() co, err := Create(r, dir, CreateOpts{}) if err != nil { t.Fatal(err) } defer co.Close() rid, _, _ := co.Version() mem := simio.NewMemStorage() co.env = &simio.Env{Storage: mem, Clock: simio.RealClock{}, Rand: simio.CryptoRand{}} co.dir = "/checkout" // Extract files if err := co.Extract(rid, ExtractOpts{}); err != nil { t.Fatal(err) } // Revert a file that doesn't exist — should not error (no-op) err = co.Revert(RevertOpts{Paths: []string{"nonexistent.txt"}}) if err != nil { t.Fatal("expected no error for nonexistent file, got:", err) } } func TestRevertNoChanges(t *testing.T) { r, cleanup := newTestRepoWithCheckin(t) defer cleanup() dir := t.TempDir() co, err := Create(r, dir, CreateOpts{}) if err != nil { t.Fatal(err) } defer co.Close() rid, _, _ := co.Version() mem := simio.NewMemStorage() co.env = &simio.Env{Storage: mem, Clock: simio.RealClock{}, Rand: simio.CryptoRand{}} co.dir = "/checkout" // Extract files but don't modify them if err := co.Extract(rid, ExtractOpts{}); err != nil { t.Fatal(err) } // Revert a clean file — should not error (no-op) err = co.Revert(RevertOpts{Paths: []string{"hello.txt"}}) if err != nil { t.Fatal("expected no error for clean file, got:", err) } } // TestRevertUnscannedEdit pins that Revert reverts an edit no scan has // recorded yet: it refreshes change flags from disk first, as fossil's revert // checks the disk (#234). func TestRevertUnscannedEdit(t *testing.T) { r, cleanup := newTestRepoWithCheckin(t) defer cleanup() co, err := Create(r, t.TempDir(), CreateOpts{}) if err != nil { t.Fatal(err) } defer co.Close() rid := mustVersion(t, co) mem := simio.NewMemStorage() co.env = &simio.Env{Storage: mem, Clock: simio.RealClock{}, Rand: simio.CryptoRand{}} co.dir = "/checkout" if err := co.Extract(rid, ExtractOpts{}); err != nil { t.Fatal(err) } if err := mem.WriteFile("/checkout/hello.txt", []byte("edited\n"), 0o644); err != nil { t.Fatal(err) } if err := co.Revert(RevertOpts{Paths: []string{"hello.txt"}}); err != nil { t.Fatal(err) } data, err := mem.ReadFile("/checkout/hello.txt") if err != nil { t.Fatal(err) } if string(data) != "hello world\n" { t.Fatalf("hello.txt = %q after revert, want the committed content", data) } } // TestRevertReportsRename pins that undoing a rename reaches the callback as // RevertRename, under the name the file is given back. func TestRevertReportsRename(t *testing.T) { r, cleanup := newTestRepoWithCheckin(t) defer cleanup() co, err := Create(r, t.TempDir(), CreateOpts{}) if err != nil { t.Fatal(err) } defer co.Close() rid := mustVersion(t, co) co.env = &simio.Env{ Storage: simio.NewMemStorage(), Clock: simio.RealClock{}, Rand: simio.CryptoRand{}, } co.dir = "/checkout" if err := co.Extract(rid, ExtractOpts{}); err != nil { t.Fatal(err) } if err := co.Rename(RenameOpts{From: "hello.txt", To: "moved.txt", DoFsMove: true}); err != nil { t.Fatal(err) } got := map[string]RevertChange{} cb := func(name string, change RevertChange) error { got[name] = change return nil } if err := co.Revert(RevertOpts{Callback: cb}); err != nil { t.Fatal(err) } if len(got) != 1 || got["hello.txt"] != RevertRename { t.Fatalf("callback saw %v, want hello.txt: RevertRename", got) } }