Artifact 4a84b6b40627544c2250886958aa68a10cfcf0ac1b639ddb66616f685e5b7dc0:
- File
cli/repo_stage_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: 16897) [annotate] [blame] [check-ins using]
package cli_test // Tests for the CLI's add, rm, rename and revert commands (#234). Each runs a // command against a checkout the fossil binary created, then asks fossil // itself what the checkout looks like, so a command that writes vfile in a // way fossil reads differently fails here. import ( "os" "os/exec" "path/filepath" "strings" "testing" "github.com/danmestas/go-libfossil/cli" libdb "github.com/danmestas/go-libfossil/db" "github.com/danmestas/go-libfossil/testutil" ) // fossilCheckout creates a repository and checkout with the fossil binary, // with a.txt and b.txt committed, and returns the binary, repository path // and checkout directory. func fossilCheckout(t *testing.T) (bin, repoPath, ckDir string) { t.Helper() bin = testutil.RequireFossilBin(t) dir := t.TempDir() repoPath = filepath.Join(dir, "test.fossil") ckDir = filepath.Join(dir, "ck") if err := os.Mkdir(ckDir, 0o755); err != nil { t.Fatal(err) } runFossil(t, bin, dir, "init", repoPath) runFossil(t, bin, ckDir, "open", repoPath) writeCkFile(t, ckDir, "a.txt", "one\n") writeCkFile(t, ckDir, "b.txt", "two\n") runFossil(t, bin, ckDir, "add", "a.txt", "b.txt") runFossil(t, bin, ckDir, "commit", "-m", "seed") return bin, repoPath, ckDir } func runFossil(t *testing.T, bin, dir string, args ...string) string { t.Helper() cmd := exec.Command(bin, args...) cmd.Dir = dir out, err := cmd.CombinedOutput() if err != nil { t.Fatalf("fossil %s: %v\n%s", strings.Join(args, " "), err, out) } return string(out) } func writeCkFile(t *testing.T, dir, name, content string) { t.Helper() if err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0o644); err != nil { t.Fatal(err) } } func readCkFile(t *testing.T, dir, name string) string { t.Helper() data, err := os.ReadFile(filepath.Join(dir, name)) if err != nil { t.Fatal(err) } return string(data) } func fossilChanges(t *testing.T, bin, ckDir string) string { t.Helper() return strings.TrimSpace(runFossil(t, bin, ckDir, "changes")) } func TestRepoAddNewFile(t *testing.T) { bin, repoPath, ckDir := fossilCheckout(t) writeCkFile(t, ckDir, "c.txt", "three\n") cmd := &cli.RepoAddCmd{Files: []string{filepath.Join(ckDir, "c.txt")}, Dir: ckDir} if err := cmd.Run(&cli.Globals{Repo: repoPath}); err != nil { t.Fatalf("add: %v", err) } if got := fossilChanges(t, bin, ckDir); got != "ADDED c.txt" { t.Fatalf("fossil changes = %q, want c.txt ADDED", got) } } // Adding a file that is already tracked and unchanged must not mark it // edited: fossil's add leaves it alone. func TestRepoAddTrackedFileIsNoChange(t *testing.T) { bin, repoPath, ckDir := fossilCheckout(t) cmd := &cli.RepoAddCmd{Files: []string{filepath.Join(ckDir, "a.txt")}, Dir: ckDir} if err := cmd.Run(&cli.Globals{Repo: repoPath}); err != nil { t.Fatalf("add: %v", err) } if got := fossilChanges(t, bin, ckDir); got != "" { t.Fatalf("fossil changes = %q after re-adding a tracked file, want none", got) } // fossil's own scan would clear a stray chnged=1, so check the row // itself: re-adding must not mark the file edited. ckdb, err := libdb.OpenSQL(filepath.Join(ckDir, ".fslckout"), libdb.OpenConfig{}, nil) if err != nil { t.Fatal(err) } defer ckdb.Close() var chnged int if err := ckdb.QueryRow( "SELECT chnged FROM vfile WHERE pathname='a.txt'", ).Scan(&chnged); err != nil { t.Fatal(err) } if chnged != 0 { t.Fatalf("a.txt chnged = %d after re-adding, want 0", chnged) } } func TestRepoRmMarksDeleted(t *testing.T) { bin, repoPath, ckDir := fossilCheckout(t) cmd := &cli.RepoRmCmd{Files: []string{"a.txt"}, Dir: ckDir} if err := cmd.Run(&cli.Globals{Repo: repoPath}); err != nil { t.Fatalf("rm: %v", err) } if got := fossilChanges(t, bin, ckDir); got != "DELETED a.txt" { t.Fatalf("fossil changes = %q, want a.txt DELETED", got) } } // Rename moves the file on disk as well as in the checkout, like // `fossil mv --hard`. Recording the new name while the file stays under the // old one left the checkout tracking a file that is not there. func TestRepoRenameMovesFile(t *testing.T) { bin, repoPath, ckDir := fossilCheckout(t) cmd := &cli.RepoRenameCmd{From: "a.txt", To: "z.txt", Dir: ckDir} if err := cmd.Run(&cli.Globals{Repo: repoPath}); err != nil { t.Fatalf("rename: %v", err) } if got := readCkFile(t, ckDir, "z.txt"); got != "one\n" { t.Fatalf("z.txt = %q, want a.txt's content", got) } if _, err := os.Stat(filepath.Join(ckDir, "a.txt")); !os.IsNotExist(err) { t.Fatalf("a.txt still on disk after rename (stat err %v)", err) } got := fossilChanges(t, bin, ckDir) if !strings.Contains(got, "RENAMED") || !strings.Contains(got, "z.txt") { t.Fatalf("fossil changes = %q, want z.txt RENAMED", got) } } // Revert restores the committed content, as fossil's revert does, rather than // only clearing the checkout's change flags over an edited file. func TestRepoRevertRestoresContent(t *testing.T) { bin, repoPath, ckDir := fossilCheckout(t) writeCkFile(t, ckDir, "a.txt", "edited\n") writeCkFile(t, ckDir, "b.txt", "edited too\n") one := &cli.RepoRevertCmd{Files: []string{"a.txt"}, Dir: ckDir} if err := one.Run(&cli.Globals{Repo: repoPath}); err != nil { t.Fatalf("revert a.txt: %v", err) } if got := readCkFile(t, ckDir, "a.txt"); got != "one\n" { t.Fatalf("a.txt = %q after revert, want the committed content", got) } all := &cli.RepoRevertCmd{Dir: ckDir} if err := all.Run(&cli.Globals{Repo: repoPath}); err != nil { t.Fatalf("revert all: %v", err) } if got := readCkFile(t, ckDir, "b.txt"); got != "two\n" { t.Fatalf("b.txt = %q after revert all, want the committed content", got) } if got := fossilChanges(t, bin, ckDir); got != "" { t.Fatalf("fossil changes = %q after revert, want none", got) } } // fossilCheckoutRelative is fossilCheckout with the checkout opened by a // relative repository path, as `fossil open ../test.fossil` stores it. func fossilCheckoutRelative(t *testing.T) (bin, ckDir string) { t.Helper() bin = testutil.RequireFossilBin(t) dir := t.TempDir() ckDir = filepath.Join(dir, "ck") if err := os.Mkdir(ckDir, 0o755); err != nil { t.Fatal(err) } runFossil(t, bin, dir, "init", "test.fossil") runFossil(t, bin, ckDir, "open", "../test.fossil") writeCkFile(t, ckDir, "a.txt", "one\n") writeCkFile(t, ckDir, "b.txt", "two\n") runFossil(t, bin, ckDir, "add", "a.txt", "b.txt") runFossil(t, bin, ckDir, "commit", "-m", "seed") return bin, ckDir } // Without -R the commands find the repository the checkout records, even // when fossil stored it as a path relative to the checkout and the command // runs from another directory (#234 review). func TestRepoRmWithoutRepoFlag(t *testing.T) { bin, ckDir := fossilCheckoutRelative(t) cmd := &cli.RepoRmCmd{Files: []string{"a.txt"}, Dir: ckDir} if err := cmd.Run(&cli.Globals{}); err != nil { t.Fatalf("rm without -R: %v", err) } if got := fossilChanges(t, bin, ckDir); got != "DELETED a.txt" { t.Fatalf("fossil changes = %q, want a.txt DELETED", got) } } // Rename refuses to move onto a file already on disk, as fossil mv --hard // does, and leaves everything as it was: that file may be the only copy. func TestRepoRenameRefusesExistingTarget(t *testing.T) { bin, repoPath, ckDir := fossilCheckout(t) writeCkFile(t, ckDir, "u.txt", "mine\n") cmd := &cli.RepoRenameCmd{From: "a.txt", To: "u.txt", Dir: ckDir} if err := cmd.Run(&cli.Globals{Repo: repoPath}); err == nil { t.Fatal("rename onto an existing file succeeded") } if got := readCkFile(t, ckDir, "u.txt"); got != "mine\n" { t.Fatalf("u.txt = %q, the existing file was overwritten", got) } if got := readCkFile(t, ckDir, "a.txt"); got != "one\n" { t.Fatalf("a.txt = %q, want it untouched", got) } if got := fossilChanges(t, bin, ckDir); got != "" { t.Fatalf("fossil changes = %q after a refused rename, want none", got) } } // Revert restores a file that is missing from disk, named or not, as fossil // does. func TestRepoRevertRestoresMissingFile(t *testing.T) { _, repoPath, ckDir := fossilCheckout(t) for _, name := range []string{"a.txt", "b.txt"} { if err := os.Remove(filepath.Join(ckDir, name)); err != nil { t.Fatal(err) } } named := &cli.RepoRevertCmd{Files: []string{"a.txt"}, Dir: ckDir} if err := named.Run(&cli.Globals{Repo: repoPath}); err != nil { t.Fatalf("revert a.txt: %v", err) } all := &cli.RepoRevertCmd{Dir: ckDir} if err := all.Run(&cli.Globals{Repo: repoPath}); err != nil { t.Fatalf("revert all: %v", err) } if got := readCkFile(t, ckDir, "a.txt"); got != "one\n" { t.Fatalf("a.txt = %q, want it restored", got) } if got := readCkFile(t, ckDir, "b.txt"); got != "two\n" { t.Fatalf("b.txt = %q, want it restored", got) } } // Revert of everything undoes a pending merge completely: the merged file's // content, its merged-in version, and the merge record, as fossil's revert // does. Otherwise the next scan sees the file changed again and the next // commit records a merge parent. func TestRepoRevertDropsPendingMerge(t *testing.T) { bin, repoPath, ckDir := fossilCheckout(t) writeCkFile(t, ckDir, "a.txt", "one\nbranch\n") runFossil(t, bin, ckDir, "commit", "-m", "branch", "--branch", "feat") runFossil(t, bin, ckDir, "update", "trunk") runFossil(t, bin, ckDir, "merge", "feat") cmd := &cli.RepoRevertCmd{Dir: ckDir} if err := cmd.Run(&cli.Globals{Repo: repoPath}); err != nil { t.Fatalf("revert: %v", err) } if got := readCkFile(t, ckDir, "a.txt"); got != "one\n" { t.Fatalf("a.txt = %q, want trunk's content", got) } if got := fossilChanges(t, bin, ckDir); got != "" { t.Fatalf("fossil changes = %q after revert, want none (merge dropped)", got) } } // A file a merge added is not in the checked-out version: reverting the merge // removes it, as fossil does (DELETE), instead of keeping it tracked so the // next commit would include it. func TestRepoRevertRemovesFileAddedByMerge(t *testing.T) { bin, repoPath, ckDir := fossilCheckout(t) runFossil(t, bin, ckDir, "branch", "new", "feat", "trunk") runFossil(t, bin, ckDir, "update", "feat") writeCkFile(t, ckDir, "new.txt", "from feat\n") runFossil(t, bin, ckDir, "add", "new.txt") runFossil(t, bin, ckDir, "commit", "-m", "add new.txt") runFossil(t, bin, ckDir, "update", "trunk") runFossil(t, bin, ckDir, "merge", "feat") cmd := &cli.RepoRevertCmd{Dir: ckDir} if err := cmd.Run(&cli.Globals{Repo: repoPath}); err != nil { t.Fatalf("revert: %v", err) } if _, err := os.Stat(filepath.Join(ckDir, "new.txt")); !os.IsNotExist(err) { t.Fatalf("new.txt still on disk after reverting the merge that added it (stat err %v)", err) } if got := fossilChanges(t, bin, ckDir); got != "" { t.Fatalf("fossil changes = %q after revert, want none", got) } if got := runFossil(t, bin, ckDir, "ls"); strings.Contains(got, "new.txt") { t.Fatalf("new.txt still tracked after revert:\n%s", got) } } // A renamed and edited file is not in the checked-out version under its new // name, but it is a committed file: revert must never delete it. (Moving it // back to its old name is #242.) func TestRepoRevertKeepsRenamedEditedFile(t *testing.T) { bin, repoPath, ckDir := fossilCheckout(t) runFossil(t, bin, ckDir, "mv", "--hard", "a.txt", "z.txt") writeCkFile(t, ckDir, "z.txt", "edited\n") cmd := &cli.RepoRevertCmd{Dir: ckDir} if err := cmd.Run(&cli.Globals{Repo: repoPath}); err != nil { t.Fatalf("revert: %v", err) } kept := false for _, name := range []string{"a.txt", "z.txt"} { data, err := os.ReadFile(filepath.Join(ckDir, name)) if err == nil && string(data) == "one\n" { kept = true } } if !kept { t.Fatal("revert deleted a renamed committed file instead of restoring it") } } // Revert undoes a pending rename, as fossil's revert does: the file goes back // to its old name with its committed content, the new name is gone, and the // checkout is clean (#242). Naming either the new or the old name works. func TestRepoRevertUndoesRename(t *testing.T) { for _, tc := range []struct { name string files []string edit bool }{ {"revert all, pure rename", nil, false}, {"revert all, renamed and edited", nil, true}, {"by new name", []string{"z.txt"}, true}, {"by old name", []string{"a.txt"}, false}, } { t.Run(tc.name, func(t *testing.T) { bin, repoPath, ckDir := fossilCheckout(t) runFossil(t, bin, ckDir, "mv", "--hard", "a.txt", "z.txt") if tc.edit { writeCkFile(t, ckDir, "z.txt", "edited\n") } cmd := &cli.RepoRevertCmd{Files: tc.files, Dir: ckDir} if err := cmd.Run(&cli.Globals{Repo: repoPath}); err != nil { t.Fatalf("revert: %v", err) } if got := readCkFile(t, ckDir, "a.txt"); got != "one\n" { t.Fatalf("a.txt = %q, want the committed content back under the old name", got) } if _, err := os.Stat(filepath.Join(ckDir, "z.txt")); !os.IsNotExist(err) { t.Fatalf("z.txt still on disk after reverting the rename (stat err %v)", err) } if got := fossilChanges(t, bin, ckDir); got != "" { t.Fatalf("fossil changes = %q after revert, want none", got) } }) } } // Undoing a rename must never land on a name another tracked file holds: // a swap (a.txt and b.txt exchanged) or a new add at the old name. Revert // refuses up front and leaves every file and the checkout as they were (#242 // review). go-libfossil has no undo copy to fall back on, unlike fossil. func TestRepoRevertRefusesRenameOntoTrackedName(t *testing.T) { cases := []struct { name string setup func(t *testing.T, bin, ckDir string) files []string check map[string]string // file -> content that must survive }{ { name: "swap", setup: func(t *testing.T, bin, ckDir string) { runFossil(t, bin, ckDir, "mv", "--hard", "a.txt", "tmp.txt") runFossil(t, bin, ckDir, "mv", "--hard", "b.txt", "a.txt") runFossil(t, bin, ckDir, "mv", "--hard", "tmp.txt", "b.txt") }, check: map[string]string{"a.txt": "two\n", "b.txt": "one\n"}, }, { name: "add at the old name, revert all", setup: func(t *testing.T, bin, ckDir string) { runFossil(t, bin, ckDir, "mv", "--hard", "a.txt", "z.txt") writeCkFile(t, ckDir, "a.txt", "brand new\n") runFossil(t, bin, ckDir, "add", "a.txt") }, check: map[string]string{"a.txt": "brand new\n", "z.txt": "one\n"}, }, { name: "add at the old name, revert by old name", setup: func(t *testing.T, bin, ckDir string) { runFossil(t, bin, ckDir, "mv", "--hard", "a.txt", "z.txt") writeCkFile(t, ckDir, "a.txt", "brand new\n") runFossil(t, bin, ckDir, "add", "a.txt") }, files: []string{"a.txt"}, check: map[string]string{"a.txt": "brand new\n", "z.txt": "one\n"}, }, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { bin, repoPath, ckDir := fossilCheckout(t) tc.setup(t, bin, ckDir) before := fossilChanges(t, bin, ckDir) cmd := &cli.RepoRevertCmd{Files: tc.files, Dir: ckDir} if err := cmd.Run(&cli.Globals{Repo: repoPath}); err == nil { t.Fatal("revert succeeded where undoing a rename collides with a tracked name") } for name, want := range tc.check { if got := readCkFile(t, ckDir, name); got != want { t.Fatalf("%s = %q after a refused revert, want %q", name, got, want) } } if got := fossilChanges(t, bin, ckDir); got != before { t.Fatalf("fossil changes after a refused revert = %q, want unchanged %q", got, before) } }) } } // Reverting several paths is planned and checked as one revert: when one of // them must be refused, none of them is reverted (#242 review). func TestRepoRevertSeveralPathsRefusesWhole(t *testing.T) { bin, repoPath, ckDir := fossilCheckout(t) writeCkFile(t, ckDir, "b.txt", "edited\n") runFossil(t, bin, ckDir, "mv", "--hard", "a.txt", "z.txt") writeCkFile(t, ckDir, "a.txt", "brand new\n") runFossil(t, bin, ckDir, "add", "a.txt") cmd := &cli.RepoRevertCmd{Files: []string{"b.txt", "z.txt"}, Dir: ckDir} if err := cmd.Run(&cli.Globals{Repo: repoPath}); err == nil { t.Fatal("revert succeeded where z.txt's rename cannot be undone") } if got := readCkFile(t, ckDir, "b.txt"); got != "edited\n" { t.Fatalf("b.txt = %q, a refused revert reverted part of the request", got) } } // An untracked file at a renamed file's old name would be overwritten by // undoing the rename; with no undo copy, revert refuses instead (#242 // review). func TestRepoRevertRefusesRenameOntoUntrackedFile(t *testing.T) { bin, repoPath, ckDir := fossilCheckout(t) runFossil(t, bin, ckDir, "mv", "--hard", "a.txt", "z.txt") writeCkFile(t, ckDir, "a.txt", "mine, untracked\n") cmd := &cli.RepoRevertCmd{Dir: ckDir} if err := cmd.Run(&cli.Globals{Repo: repoPath}); err == nil { t.Fatal("revert succeeded over an untracked file at the old name") } if got := readCkFile(t, ckDir, "a.txt"); got != "mine, untracked\n" { t.Fatalf("a.txt = %q, the untracked file was overwritten", got) } }