package sidecar import ( "fmt" "os" "os/exec" "path/filepath" "strconv" "strings" "sync" "testing" ) func TestAppendCreatesPrivateUnrelatedSidecar(t *testing.T) { repo := testRepo(t) // Store must be independent of a user's restrictive auto-track setting. testJJ(t, repo, "config", "set", "--repo", "snapshot.auto-track", `"none()"`) testJJ(t, repo, "config", "set", "--repo", "git.private-commits", `"description(glob:'secret:*')"`) before := testJJ(t, repo, "status") store, err := Open(repo) if err != nil { t.Fatal(err) } if err := store.Append(func(current []byte) ([]byte, error) { if len(current) != 0 { t.Fatalf("new store contained %q", current) } return []byte("one\n"), nil }); err != nil { t.Fatal(err) } if err := store.Append(func(current []byte) ([]byte, error) { if string(current) != "one\n" { t.Fatalf("second append saw %q", current) } return []byte("two\n"), nil }); err != nil { t.Fatal(err) } got, err := store.Read() if err != nil { t.Fatal(err) } if string(got) != "one\ntwo\n" { t.Fatalf("sidecar log = %q", got) } if after := testJJ(t, repo, "status"); after != before { t.Fatalf("working copy changed:\n--- before\n%s--- after\n%s", before, after) } if _, err := os.Stat(filepath.Join(repo, LogFile)); !os.IsNotExist(err) { t.Fatalf("log materialized in the project working copy: %v", err) } alias := strings.TrimSpace(testJJ(t, repo, "config", "get", "revset-aliases."+Alias)) if alias == "" { t.Fatal("repo-local sidecar alias was not configured") } private := testJJ(t, repo, "config", "get", "git.private-commits") if !containsRevsetName(private, Alias) { t.Fatalf("private commits = %q, missing %s", private, Alias) } if !strings.Contains(private, "secret:*") { t.Fatalf("private commits = %q, previous setting was overwritten", private) } parent := strings.TrimSpace(testJJ(t, repo, "log", "-G", "-r", "exactly("+Alias+", 1)", "-T", "parents.map(|p| p.commit_id()).join(\"\")")) if parent != strings.Repeat("0", 40) { t.Fatalf("sidecar parent = %q, want virtual root", parent) } } func TestLegacyLogIsReadThenImportedOnFirstAppend(t *testing.T) { repo := testRepo(t) data := []byte("legacy\n") if err := os.WriteFile(filepath.Join(repo, legacy.logFile), data, 0o644); err != nil { t.Fatal(err) } store, err := Open(repo) if err != nil { t.Fatal(err) } if got, err := store.Read(); err != nil || string(got) != string(data) { t.Fatalf("pre-migration read = %q, %v", got, err) } if err := store.Append(func(current []byte) ([]byte, error) { if string(current) != string(data) { t.Fatalf("migration callback saw %q", current) } return []byte("sidecar\n"), nil }); err != nil { t.Fatal(err) } if got, err := store.Read(); err != nil || string(got) != "legacy\nsidecar\n" { t.Fatalf("migrated log = %q, %v", got, err) } } // A repository written by the pre-rebrand tool must keep working, and must stop // carrying the old name once notevi has touched it. func TestPreRebrandSidecarIsRenamedInPlace(t *testing.T) { repo := testRepo(t) rev := writeLegacySidecar(t, repo, "old\n") store, err := Open(repo) if err != nil { t.Fatal(err) } if got, err := store.Read(); err != nil || string(got) != "old\n" { t.Fatalf("read of an un-migrated sidecar = %q, %v", got, err) } migrated, err := store.Migrate() if err != nil { t.Fatal(err) } if !migrated { t.Fatal("Migrate reported nothing to do for a legacy sidecar") } if got := strings.TrimSpace(testJJ(t, repo, "config", "get", "revset-aliases."+Alias)); got != rev { t.Fatalf("alias %s = %q, want the original change %q", Alias, got, rev) } if out, err := exec.Command("jj", "--ignore-working-copy", "-R", repo, "config", "get", "revset-aliases."+legacy.alias).CombinedOutput(); err == nil { t.Fatalf("legacy alias %s survived: %s", legacy.alias, out) } private := testJJ(t, repo, "config", "get", "git.private-commits") if !containsRevsetName(private, Alias) || containsRevsetName(private, legacy.alias) { t.Fatalf("private commits = %q, want %s and not %s", private, Alias, legacy.alias) } files := testJJ(t, repo, "file", "list", "-r", "exactly("+Alias+", 1)") if !strings.Contains(files, LogFile) || strings.Contains(files, legacy.logFile) { t.Fatalf("sidecar files = %q, want only %s", files, LogFile) } subject := strings.TrimSpace(testJJ(t, repo, "log", "-G", "-r", "exactly("+Alias+", 1)", "-T", "description")) if subject != Description { t.Fatalf("sidecar description = %q, want %q", subject, Description) } // The trace survived the rename and the store keeps appending to it. if err := store.Append(func(current []byte) ([]byte, error) { if string(current) != "old\n" { t.Fatalf("append after migration saw %q", current) } return []byte("new\n"), nil }); err != nil { t.Fatal(err) } if got, err := store.Read(); err != nil || string(got) != "old\nnew\n" { t.Fatalf("migrated log = %q, %v", got, err) } if again, err := store.Migrate(); err != nil || again { t.Fatalf("second Migrate = %v, %v; want no-op", again, err) } } // A real trace is hundreds of KB. jj silently declines to auto-track a *new* // file in the run working copy past a size limit it does not take from the // command line, so a rename that writes the whole log under a name the change // has never held loses it — with jj reporting success. Small fixtures do not // catch that; this one does. func TestLargeLogSurvivesRenameAndAppend(t *testing.T) { repo := testRepo(t) var seed strings.Builder for i := range 5000 { fmt.Fprintf(&seed, `{"time":"2026-07-26T10:00:00Z","op":"read","file":"src/%d.rs","start":1,"end":400}`+"\n", i) } if seed.Len() < 256<<10 { t.Fatalf("fixture is only %d bytes; too small to exercise the limit", seed.Len()) } writeLegacySidecar(t, repo, seed.String()) store, err := Open(repo) if err != nil { t.Fatal(err) } if _, err := store.Migrate(); err != nil { t.Fatal(err) } got, err := store.Read() if err != nil { t.Fatal(err) } if string(got) != seed.String() { t.Fatalf("renamed log is %d bytes, want %d", len(got), seed.Len()) } if err := store.Append(func([]byte) ([]byte, error) { return []byte(`{"op":"note"}`), nil }); err != nil { t.Fatal(err) } if got, err = store.Read(); err != nil || len(got) <= seed.Len() { t.Fatalf("append after a large rename left %d bytes, %v", len(got), err) } } // writeLegacySidecar builds the sidecar exactly as the pre-rebrand tool did and // returns its change id. func writeLegacySidecar(t *testing.T, repo, content string) string { t.Helper() testJJ(t, repo, "new", "--no-edit", "root()", "-m", legacy.description) rev := strings.TrimSpace(testJJ(t, repo, "log", "-G", "-T", "change_id", "-r", fmt.Sprintf("root()+ & subject(exact:%q)", legacy.description))) tmp := filepath.Join(t.TempDir(), "seed") if err := os.WriteFile(tmp, []byte(content), 0o644); err != nil { t.Fatal(err) } // Touch first, then fill: the same two steps the store uses, so a large // fixture really lands in the sidecar instead of quietly seeding nothing. testJJ(t, repo, "--config", `snapshot.auto-track="all()"`, "run", "--clean", "--root", "-r", "exactly("+rev+", 1)", "--", "sh", "-c", ": > "+legacy.logFile) testJJ(t, repo, "--config", `snapshot.auto-track="all()"`, "run", "--clean", "--root", "-r", "exactly("+rev+", 1)", "--", "sh", "-c", "cat \"$1\" >> "+legacy.logFile, "sh", tmp) if got := testJJ(t, repo, "file", "show", "-r", "exactly("+rev+", 1)", "--", legacy.logFile); got != content { t.Fatalf("legacy fixture is %d bytes in the sidecar, want %d", len(got), len(content)) } testJJ(t, repo, "config", "set", "--repo", "revset-aliases."+legacy.alias, strconv.Quote(rev)) testJJ(t, repo, "config", "set", "--repo", "git.private-commits", strconv.Quote("(none()) | "+legacy.alias)) return rev } func TestConcurrentAppendsAreSerialized(t *testing.T) { repo := testRepo(t) store, err := Open(repo) if err != nil { t.Fatal(err) } const writers = 6 errCh := make(chan error, writers) var wg sync.WaitGroup for range writers { wg.Add(1) go func() { defer wg.Done() errCh <- store.Append(func(current []byte) ([]byte, error) { id := len(strings.Fields(string(current))) + 1 return []byte(fmt.Sprintf("%d\n", id)), nil }) }() } wg.Wait() close(errCh) for err := range errCh { if err != nil { t.Fatal(err) } } got, err := store.Read() if err != nil { t.Fatal(err) } if string(got) != "1\n2\n3\n4\n5\n6\n" { t.Fatalf("serialized appends = %q", got) } } func testRepo(t *testing.T) string { t.Helper() if _, err := exec.LookPath("jj"); err != nil { t.Skip("jj not installed") } home := t.TempDir() t.Setenv("HOME", home) t.Setenv("JJ_USER", "test") t.Setenv("JJ_EMAIL", "test@example.com") repo := filepath.Join(home, "repo") if err := os.Mkdir(repo, 0o755); err != nil { t.Fatal(err) } cmd := exec.Command("jj", "git", "init") cmd.Dir = repo if out, err := cmd.CombinedOutput(); err != nil { t.Fatalf("jj git init: %v\n%s", err, out) } return repo } func testJJ(t *testing.T, repo string, args ...string) string { t.Helper() cmd := exec.Command("jj", append([]string{"--ignore-working-copy", "--no-pager"}, args...)...) cmd.Dir = repo out, err := cmd.CombinedOutput() if err != nil { t.Fatalf("jj %s: %v\n%s", strings.Join(args, " "), err, out) } return string(out) }