Artifact 6863d33e893dc5f46d93752026ee5b0d7a62a28e43917ff1e17c8b4cb104fc71:
- File
checkout_update_test.go
— part of check-in
[6e6969bf97]
at
2026-10-18 21:59:04
on branch main
— fix(checkout): keep uncommitted changes across Update, as fossil does (#450)
* fix(checkout): keep uncommitted changes across Update, as fossil does
Update compared only committed versions: it wrote the target over a locally edited file, deleted an edited file the target removed, and merged the current and target check-ins with their ancestor while ignoring the working file.
It now follows fossil's update (src/update.c): scan the checkout, then for each file take the target's content when there is no local change, merge a local edit three ways (the checkout's version as base, the working file as local, the target as other) with conflicts listed in Conflicted, keep an edited file the target deletes as an untracked file, keep pending adds and removals, restore a missing file, and copy an untracked file the target adds over to NAME-original first. A dry run writes nothing. Files written with the target's content record the mtime of the write. Files are matched by name; a target-side rename is not followed.
The two tests that relied on merging two branches' committed changes now make the clashing edit locally. A fossil 2.28 interop test runs the same edits through fossil update and compares files, fossil changes and Status.
Tracked in #443
* fix(checkout): make Update safe on binary files, failures and dry runs
A binary file (a NUL byte, or a line over 32,767 bytes, as fossil's line diff refuses) is no longer line-merged. Update saves the working copy as NAME-original and writes the target, as fossil's update does; Merge keeps the checkout's copy, as fossil's merge does. Both used to splice the versions and report a clean merge. merge.IsText is fossil's test.
Update refuses before writing anything when a file it must write or delete has a directory or a read-only file in the way, moves the checkout database to the target in one transaction, and in a dry run leaves the database untouched (the scan's findings stay in memory). Every target file gets the target's executable bit, as fossil's checkout_set_all_exe does, so a bit-only change reaches the disk.
UpdateResult.Unmerged lists files left to reconcile without markers: edited files the target deletes, files added both locally and by the target, and binary conflicts. The interop test now covers a binary conflict against fossil 2.28.
Tracked in #443
* fix(checkout): check real write access before Update writes anything
The pre-write check read the owner-write bit: it missed an unwritable directory (a remove failed after a merge had been written), files owned by others, and wrongly refused for root. It now asks the storage, via an optional CheckWritable: OSStorage uses access(2) with W_OK on unix and a read-only-attribute check elsewhere (js/wasm, Windows). Every step needs its directory writable; a write or merge over an existing file needs the file writable too.
A read-only untracked file where the target adds one is backed up to NAME-original, deleted and replaced, as fossil does, instead of refused.
Merge keeps a binary file whatever its strategy, as fossil's merge does. setVVar's precondition now catches a nil *sql.DB or *sql.Tx inside the interface.
Tracked in #443 (user: [email protected], size: 7677) [annotate] [blame] [check-ins using] [more...]
package libfossil_test import ( "os" "path/filepath" "strings" "testing" "github.com/danmestas/go-libfossil" ) // updateFixture creates a repo, commits two revs via Repo.Commit, then // creates a checkout (initialized to tip = rid2). Returns the second RID // so callers can pass it as a deliberate Update target. func updateFixture(t *testing.T) (*libfossil.Repo, *libfossil.Checkout, int64) { t.Helper() dir := t.TempDir() repoPath := filepath.Join(dir, "u.fossil") repo, err := libfossil.Create(repoPath, libfossil.CreateOpts{}) if err != nil { t.Fatalf("Create: %v", err) } t.Cleanup(func() { _ = repo.Close() }) // First commit (no parent — genesis). rid1, _, err := repo.Commit(libfossil.CommitOpts{ Comment: "first", User: "test", Files: []libfossil.FileToCommit{{Name: "a.txt", Content: []byte("v1")}}, }) if err != nil { t.Fatalf("first Commit: %v", err) } // Second commit (parent = rid1). This becomes the tip. rid2, _, err := repo.Commit(libfossil.CommitOpts{ Comment: "second", User: "test", ParentID: rid1, Files: []libfossil.FileToCommit{{Name: "a.txt", Content: []byte("v2")}}, }) if err != nil { t.Fatalf("second Commit: %v", err) } // CreateCheckout (NOT OpenCheckout — the dir is fresh) initializes the // working tree to the tip checkin (rid2). checkoutDir := filepath.Join(dir, "wt") checkout, err := repo.CreateCheckout(checkoutDir, libfossil.CheckoutCreateOpts{}) if err != nil { t.Fatalf("CreateCheckout: %v", err) } t.Cleanup(func() { _ = checkout.Close() }) return repo, checkout, rid2 } func TestCheckoutUpdate_TargetRID(t *testing.T) { _, checkout, rid := updateFixture(t) result, err := checkout.Update(libfossil.UpdateOpts{TargetRID: rid}) if err != nil { t.Fatalf("Update: %v", err) } if len(result.Conflicted) != 0 { t.Fatalf("expected clean update, got Conflicted=%v", result.Conflicted) } } func TestCheckoutUpdate_ZeroTargetRIDIsTipUpdate(t *testing.T) { // TargetRID=0 means the newest open descendant leaf on the checkout's branch. _, checkout, _ := updateFixture(t) result, err := checkout.Update(libfossil.UpdateOpts{TargetRID: 0}) if err != nil { t.Fatalf("Update(0): %v", err) } if len(result.Conflicted) != 0 { t.Fatalf("expected clean update, got Conflicted=%v", result.Conflicted) } } func TestCheckoutUpdate_NonexistentRIDErrors(t *testing.T) { _, checkout, _ := updateFixture(t) result, err := checkout.Update(libfossil.UpdateOpts{TargetRID: 999999}) if err == nil { t.Fatal("expected error for missing RID, got nil") } // A genuine failure must be distinguishable from a conflicted success: // no marker paths are reported for a failed update. if len(result.Conflicted) != 0 { t.Fatalf("expected empty Conflicted on failure, got %v", result.Conflicted) } } // TestCheckoutUpdate_CleanReportsWrittenPaths verifies the clean-success // outcome: err == nil, Conflicted empty, and the modified file's path shows // up in FilesWritten (not just a count). updateFixture's checkout starts at // tip (rid2), so this pins the checkout back to rid1 first via Extract to // force Update(TargetRID: rid2) to do real work. func TestCheckoutUpdate_CleanReportsWrittenPaths(t *testing.T) { dir := t.TempDir() repoPath := filepath.Join(dir, "clean.fossil") repo, err := libfossil.Create(repoPath, libfossil.CreateOpts{}) if err != nil { t.Fatalf("Create: %v", err) } t.Cleanup(func() { _ = repo.Close() }) rid1, _, err := repo.Commit(libfossil.CommitOpts{ Comment: "first", User: "test", Files: []libfossil.FileToCommit{{Name: "a.txt", Content: []byte("v1")}}, }) if err != nil { t.Fatalf("first Commit: %v", err) } rid2, _, err := repo.Commit(libfossil.CommitOpts{ Comment: "second", User: "test", ParentID: rid1, Files: []libfossil.FileToCommit{{Name: "a.txt", Content: []byte("v2")}}, }) if err != nil { t.Fatalf("second Commit: %v", err) } checkoutDir := filepath.Join(dir, "wt") checkout, err := repo.CreateCheckout(checkoutDir, libfossil.CheckoutCreateOpts{}) if err != nil { t.Fatalf("CreateCheckout: %v", err) } t.Cleanup(func() { _ = checkout.Close() }) // Pin the checkout to rid1 so Update(TargetRID: rid2) has real work to do. if err := checkout.Extract(rid1, libfossil.ExtractOpts{Force: true}); err != nil { t.Fatalf("Extract rid1: %v", err) } result, err := checkout.Update(libfossil.UpdateOpts{TargetRID: rid2}) if err != nil { t.Fatalf("Update: %v", err) } if len(result.Conflicted) != 0 { t.Fatalf("expected clean update, got Conflicted=%v", result.Conflicted) } found := false for _, p := range result.FilesWritten { if p == "a.txt" { found = true } } if !found { t.Fatalf("expected FilesWritten to contain a.txt, got %v", result.FilesWritten) } } // TestCheckoutUpdate_ConflictedSurfacesPaths builds a fork at the public // API level — two commits from the same parent — checks out one with an // uncommitted edit to the line the other changed, and updates across it. The merge cannot resolve the // overlapping edit, so it writes conflict markers into the working file. // This is a SUCCESSFUL update (err == nil): the caller must be able to see // which paths now contain markers via Conflicted. func TestCheckoutUpdate_ConflictedSurfacesPaths(t *testing.T) { dir := t.TempDir() repoPath := filepath.Join(dir, "c.fossil") repo, err := libfossil.Create(repoPath, libfossil.CreateOpts{}) if err != nil { t.Fatalf("Create: %v", err) } t.Cleanup(func() { _ = repo.Close() }) ridBase, _, err := repo.Commit(libfossil.CommitOpts{ Comment: "base", User: "test", Files: []libfossil.FileToCommit{{Name: "conflict.txt", Content: []byte("original\n")}}, }) if err != nil { t.Fatalf("base Commit: %v", err) } ridA, _, err := repo.Commit(libfossil.CommitOpts{ Comment: "branch A", User: "test", ParentID: ridBase, Files: []libfossil.FileToCommit{{Name: "conflict.txt", Content: []byte("local version\n")}}, }) if err != nil { t.Fatalf("branch A Commit: %v", err) } ridB, _, err := repo.Commit(libfossil.CommitOpts{ Comment: "branch B", User: "test", ParentID: ridBase, Files: []libfossil.FileToCommit{{Name: "conflict.txt", Content: []byte("remote version\n")}}, }) if err != nil { t.Fatalf("branch B Commit: %v", err) } // CreateCheckout initializes to tip, which (last write wins in the // leaf table) is branch A here since it was committed first among the // two leaves and branch B follows — extract explicitly to branch A to // pin the starting point regardless of tip resolution. checkoutDir := filepath.Join(dir, "wt") checkout, err := repo.CreateCheckout(checkoutDir, libfossil.CheckoutCreateOpts{}) if err != nil { t.Fatalf("CreateCheckout: %v", err) } t.Cleanup(func() { _ = checkout.Close() }) if err := checkout.Extract(ridA, libfossil.ExtractOpts{Force: true}); err != nil { t.Fatalf("Extract ridA: %v", err) } // An uncommitted edit to the line branch B changed: update merges the // working file, as fossil's update does. if err := os.WriteFile(filepath.Join(checkoutDir, "conflict.txt"), []byte("my edit\n"), 0o644); err != nil { t.Fatal(err) } result, err := checkout.Update(libfossil.UpdateOpts{TargetRID: ridB}) if err != nil { t.Fatalf("conflicted Update must still succeed (err==nil): %v", err) } if len(result.Conflicted) != 1 || result.Conflicted[0] != "conflict.txt" { t.Fatalf("expected Conflicted=[conflict.txt], got %v", result.Conflicted) } data, err := os.ReadFile(filepath.Join(checkoutDir, "conflict.txt")) if err != nil { t.Fatal(err) } if !strings.Contains(string(data), "<<<<<<<") { t.Fatalf("expected conflict markers written to disk, got %q", data) } }