mirror of
https://gh-proxy.org/https://github.com/klinsmaya/multica.git
synced 2026-11-10 18:36:45 +08:00
* fix(daemon): keep an existing checkout's work when repo checkout runs again Re-running `multica repo checkout` on a path that already held a checkout ran git reset --hard and git clean -fd and moved it to a new branch from the default branch; isolated checkouts also deleted earlier agent/* branches. Uncommitted changes, untracked files, and unpushed commits were lost with output that still read as a normal checkout. An existing checkout that holds work, or is already on this task's branch, is now kept exactly as it is and only its remote refs are fetched. The result reports that it was kept, with its branch and uncommitted/unpushed counts, and the CLI says so. --fresh restores the old start-over behavior. Isolated-mode pruning keeps agent/* branches with unpushed commits, and a linked worktree holding work is no longer removed to migrate it. MUL-7284 Co-authored-by: multica-agent <[email protected]> * fix(daemon): carry a migrated linked worktree's unpushed branch into the isolated checkout --fresh migration of a linked worktree to an isolated checkout removed the worktree and seeded a new clone whose copied branches are all deleted, so a branch holding unpushed commits stayed only in the shared cache: out of the agent's reach and dropped by the next GC. Carry that one branch into the new checkout (no other cache branch), imported before the task branch so a same-named task branch moves to a timestamped name. Align the agent-facing --fresh wording: it discards uncommitted and untracked files and starts a new branch; commits stay on the old branch, but push any still needed first, since a linked worktree's old branch lives in the shared cache that GC prunes. MUL-7284 Co-authored-by: multica-agent <[email protected]> --------- Co-authored-by: J <[email protected]> Co-authored-by: multica-agent <[email protected]>
2803 lines
101 KiB
Go
2803 lines
101 KiB
Go
package repocache
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io/fs"
|
|
"log/slog"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func testLogger() *slog.Logger {
|
|
return slog.Default()
|
|
}
|
|
|
|
func TestGitEnv(t *testing.T) {
|
|
t.Parallel()
|
|
env := gitEnv()
|
|
|
|
// Must contain GIT_TERMINAL_PROMPT=0.
|
|
found := false
|
|
for _, entry := range env {
|
|
if entry == "GIT_TERMINAL_PROMPT=0" {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
t.Error("gitEnv() must include GIT_TERMINAL_PROMPT=0")
|
|
}
|
|
|
|
// Must contain HOME from the current environment.
|
|
home := os.Getenv("HOME")
|
|
if home == "" {
|
|
t.Skip("HOME not set in test environment")
|
|
}
|
|
foundHome := false
|
|
for _, entry := range env {
|
|
if entry == "HOME="+home {
|
|
foundHome = true
|
|
break
|
|
}
|
|
}
|
|
if !foundHome {
|
|
t.Error("gitEnv() must include HOME from os.Environ()")
|
|
}
|
|
|
|
// Must set safe.directory=* via GIT_CONFIG env vars.
|
|
envHas := func(env []string, want string) bool {
|
|
for _, e := range env {
|
|
if e == want {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
if !envHas(env, "GIT_CONFIG_KEY_0=safe.directory") {
|
|
t.Error("gitEnv() must include GIT_CONFIG_KEY_0=safe.directory (no pre-existing config)")
|
|
}
|
|
if !envHas(env, "GIT_CONFIG_VALUE_0=*") {
|
|
t.Error("gitEnv() must include GIT_CONFIG_VALUE_0=*")
|
|
}
|
|
}
|
|
|
|
func TestGitEnvPreservesExistingConfig(t *testing.T) {
|
|
// GIT_CONFIG_COUNT env vars are process-wide; cannot use t.Setenv in
|
|
// parallel tests, so run sequentially.
|
|
t.Setenv("GIT_CONFIG_COUNT", "2")
|
|
t.Setenv("GIT_CONFIG_KEY_0", "url.https://github.com/.insteadOf")
|
|
t.Setenv("GIT_CONFIG_VALUE_0", "gh:")
|
|
t.Setenv("GIT_CONFIG_KEY_1", "http.extraHeader")
|
|
t.Setenv("GIT_CONFIG_VALUE_1", "Authorization: Bearer tok")
|
|
|
|
env := gitEnv()
|
|
|
|
envHas := func(want string) bool {
|
|
for _, e := range env {
|
|
if e == want {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// safe.directory must be appended at index 2 (next available).
|
|
if !envHas("GIT_CONFIG_COUNT=3") {
|
|
t.Error("expected GIT_CONFIG_COUNT=3")
|
|
}
|
|
if !envHas("GIT_CONFIG_KEY_2=safe.directory") {
|
|
t.Error("expected GIT_CONFIG_KEY_2=safe.directory")
|
|
}
|
|
if !envHas("GIT_CONFIG_VALUE_2=*") {
|
|
t.Error("expected GIT_CONFIG_VALUE_2=*")
|
|
}
|
|
|
|
// Original entries must still be present.
|
|
if !envHas("GIT_CONFIG_KEY_0=url.https://github.com/.insteadOf") {
|
|
t.Error("existing GIT_CONFIG_KEY_0 was lost")
|
|
}
|
|
if !envHas("GIT_CONFIG_VALUE_0=gh:") {
|
|
t.Error("existing GIT_CONFIG_VALUE_0 was lost")
|
|
}
|
|
if !envHas("GIT_CONFIG_KEY_1=http.extraHeader") {
|
|
t.Error("existing GIT_CONFIG_KEY_1 was lost")
|
|
}
|
|
}
|
|
|
|
func TestRunGitOutputTimesOut(t *testing.T) {
|
|
_, err := runGitOutputWithTimeout(0, "--version")
|
|
if !errors.Is(err, context.DeadlineExceeded) {
|
|
t.Fatalf("runGitOutputWithTimeout error = %v, want deadline exceeded", err)
|
|
}
|
|
if !strings.Contains(err.Error(), "timed out after 0s") {
|
|
t.Fatalf("runGitOutputWithTimeout error = %v, want timeout context", err)
|
|
}
|
|
}
|
|
|
|
func TestNewGitCommandUsesStableWorkingDirectory(t *testing.T) {
|
|
cmd := newGitCommand("--version")
|
|
if cmd.Dir == "" {
|
|
t.Fatal("newGitCommand Dir is empty; Git would inherit the daemon working directory")
|
|
}
|
|
if !filepath.IsAbs(cmd.Dir) {
|
|
t.Fatalf("newGitCommand Dir = %q, want an absolute path", cmd.Dir)
|
|
}
|
|
if info, err := os.Stat(cmd.Dir); err != nil {
|
|
t.Fatalf("newGitCommand Dir = %q is unavailable: %v", cmd.Dir, err)
|
|
} else if !info.IsDir() {
|
|
t.Fatalf("newGitCommand Dir = %q is not a directory", cmd.Dir)
|
|
}
|
|
}
|
|
|
|
func TestSyncSurvivesDeletedProcessWorkingDirectory(t *testing.T) {
|
|
sourceRepo := createTestRepo(t)
|
|
cacheRoot := t.TempDir()
|
|
originalCWD, err := os.Getwd()
|
|
if err != nil {
|
|
t.Fatalf("get original working directory: %v", err)
|
|
}
|
|
deletedRoot := t.TempDir()
|
|
deletedCWD := filepath.Join(deletedRoot, "worktree")
|
|
if err := os.Mkdir(deletedCWD, 0o755); err != nil {
|
|
t.Fatalf("create disposable working directory: %v", err)
|
|
}
|
|
if err := os.Chdir(deletedCWD); err != nil {
|
|
t.Fatalf("chdir to disposable working directory: %v", err)
|
|
}
|
|
defer func() {
|
|
if err := os.Chdir(originalCWD); err != nil {
|
|
t.Errorf("restore working directory: %v", err)
|
|
}
|
|
}()
|
|
if err := os.RemoveAll(deletedRoot); err != nil {
|
|
t.Skipf("platform does not allow removing the process working directory: %v", err)
|
|
}
|
|
|
|
cache := New(cacheRoot, testLogger())
|
|
if err := cache.Sync("ws-deleted-cwd", []RepoInfo{{URL: sourceRepo}}); err != nil {
|
|
t.Fatalf("Sync with deleted process working directory failed: %v", err)
|
|
}
|
|
if cachedPath := cache.Lookup("ws-deleted-cwd", sourceRepo); !isBareRepo(cachedPath) {
|
|
t.Fatalf("expected synced bare repo, got %q", cachedPath)
|
|
}
|
|
}
|
|
|
|
func TestRepoMaintenanceYieldsToForeground(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
cache := New(t.TempDir(), testLogger())
|
|
const barePath = "/cache/repo.git"
|
|
entered := make(chan struct{})
|
|
maintenanceDone := make(chan error, 1)
|
|
go func() {
|
|
ran, err := cache.WithRepoMaintenance(context.Background(), barePath, func(ctx context.Context) error {
|
|
close(entered)
|
|
<-ctx.Done()
|
|
return context.Cause(ctx)
|
|
})
|
|
if !ran && err == nil {
|
|
err = errors.New("maintenance did not run")
|
|
}
|
|
maintenanceDone <- err
|
|
}()
|
|
<-entered
|
|
|
|
foregroundDone := make(chan error, 1)
|
|
go func() {
|
|
foregroundDone <- cache.WithRepoLockContext(context.Background(), barePath, func() error { return nil })
|
|
}()
|
|
|
|
select {
|
|
case err := <-foregroundDone:
|
|
if err != nil {
|
|
t.Fatalf("foreground lock: %v", err)
|
|
}
|
|
case <-time.After(time.Second):
|
|
t.Fatal("foreground work did not preempt maintenance")
|
|
}
|
|
if err := <-maintenanceDone; !errors.Is(err, ErrMaintenancePreempted) {
|
|
t.Fatalf("maintenance error = %v, want preemption", err)
|
|
}
|
|
activity := cache.Activity()
|
|
if activity.MaintenanceActive != 0 || activity.ForegroundWaiters != 0 {
|
|
t.Fatalf("activity after release = %+v, want zero", activity)
|
|
}
|
|
}
|
|
|
|
func TestCancelMaintenanceStopsMaintenanceWithoutARepoCheckout(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
cache := New(t.TempDir(), testLogger())
|
|
const barePath = "/cache/reused-worktree.git"
|
|
entered := make(chan struct{})
|
|
done := make(chan error, 1)
|
|
go func() {
|
|
ran, err := cache.WithRepoMaintenance(context.Background(), barePath, func(ctx context.Context) error {
|
|
close(entered)
|
|
<-ctx.Done()
|
|
return context.Cause(ctx)
|
|
})
|
|
if !ran && err == nil {
|
|
err = errors.New("maintenance did not run")
|
|
}
|
|
done <- err
|
|
}()
|
|
<-entered
|
|
|
|
cache.CancelMaintenance()
|
|
select {
|
|
case err := <-done:
|
|
if !errors.Is(err, ErrMaintenancePreempted) {
|
|
t.Fatalf("maintenance error = %v, want preemption", err)
|
|
}
|
|
case <-time.After(time.Second):
|
|
t.Fatal("maintenance did not stop after task-level cancellation")
|
|
}
|
|
|
|
if got := cache.Activity().MaintenanceActive; got != 0 {
|
|
t.Fatalf("maintenance active after cancellation = %d, want 0", got)
|
|
}
|
|
}
|
|
|
|
func TestCancelMaintenanceWaitsForCleanupBarrier(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
cache := New(t.TempDir(), testLogger())
|
|
const barePath = "/cache/cleanup-barrier.git"
|
|
entered := make(chan struct{})
|
|
cancelled := make(chan struct{})
|
|
releaseCleanup := make(chan struct{})
|
|
maintenanceDone := make(chan struct{})
|
|
go func() {
|
|
_, _ = cache.WithRepoMaintenance(context.Background(), barePath, func(ctx context.Context) error {
|
|
close(entered)
|
|
<-ctx.Done()
|
|
close(cancelled)
|
|
<-releaseCleanup
|
|
return context.Cause(ctx)
|
|
})
|
|
close(maintenanceDone)
|
|
}()
|
|
<-entered
|
|
|
|
cancelDone := make(chan struct{})
|
|
go func() {
|
|
cache.CancelMaintenance()
|
|
close(cancelDone)
|
|
}()
|
|
<-cancelled
|
|
select {
|
|
case <-cancelDone:
|
|
t.Fatal("CancelMaintenance returned before maintenance cleanup released the repo")
|
|
case <-time.After(20 * time.Millisecond):
|
|
}
|
|
|
|
close(releaseCleanup)
|
|
select {
|
|
case <-cancelDone:
|
|
case <-time.After(time.Second):
|
|
t.Fatal("CancelMaintenance did not return after maintenance cleanup completed")
|
|
}
|
|
<-maintenanceDone
|
|
}
|
|
|
|
func TestCreateWorktreeContextReturnsBusyAfterBoundedLockWait(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
root := t.TempDir()
|
|
cache := New(root, testLogger())
|
|
const (
|
|
workspaceID = "ws-1"
|
|
repoURL = "https://github.com/org/repo.git"
|
|
)
|
|
barePath := cache.BarePath(workspaceID, repoURL)
|
|
if err := os.MkdirAll(barePath, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(barePath, "HEAD"), []byte("ref: refs/heads/main\n"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
lock := cache.lockForRepo(barePath)
|
|
lock.Lock()
|
|
defer lock.Unlock()
|
|
|
|
_, err := cache.CreateWorktreeContext(context.Background(), WorktreeParams{
|
|
WorkspaceID: workspaceID,
|
|
RepoURL: repoURL,
|
|
WorkDir: t.TempDir(),
|
|
LockWaitTimeout: 20 * time.Millisecond,
|
|
})
|
|
if !errors.Is(err, ErrRepoBusy) {
|
|
t.Fatalf("CreateWorktreeContext error = %v, want ErrRepoBusy", err)
|
|
}
|
|
}
|
|
|
|
func TestCreateWorktreeContextCancelsRunningGitProcessTree(t *testing.T) {
|
|
binDir := t.TempDir()
|
|
marker := filepath.Join(t.TempDir(), "git-descendant-survived")
|
|
script := filepath.Join(binDir, "git")
|
|
body := "#!/bin/sh\n(sleep 0.5; echo leaked > \"$MULTICA_TEST_GIT_MARKER\") &\nwait\n"
|
|
if err := os.WriteFile(script, []byte(body), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH"))
|
|
t.Setenv("MULTICA_TEST_GIT_MARKER", marker)
|
|
|
|
root := t.TempDir()
|
|
cache := New(root, testLogger())
|
|
const (
|
|
workspaceID = "ws-1"
|
|
repoURL = "https://github.com/org/repo.git"
|
|
)
|
|
barePath := cache.BarePath(workspaceID, repoURL)
|
|
if err := os.MkdirAll(barePath, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(barePath, "HEAD"), []byte("ref: refs/heads/main\n"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
|
|
defer cancel()
|
|
started := time.Now()
|
|
_, err := cache.CreateWorktreeContext(ctx, WorktreeParams{
|
|
WorkspaceID: workspaceID,
|
|
RepoURL: repoURL,
|
|
WorkDir: t.TempDir(),
|
|
})
|
|
if !errors.Is(err, context.DeadlineExceeded) {
|
|
t.Fatalf("CreateWorktreeContext error = %v, want deadline exceeded", err)
|
|
}
|
|
if elapsed := time.Since(started); elapsed > time.Second {
|
|
t.Fatalf("CreateWorktreeContext took %s after cancellation", elapsed)
|
|
}
|
|
|
|
time.Sleep(600 * time.Millisecond)
|
|
if _, err := os.Stat(marker); !os.IsNotExist(err) {
|
|
t.Fatalf("Git descendant survived checkout cancellation, stat error = %v", err)
|
|
}
|
|
}
|
|
|
|
func TestBareDirName(t *testing.T) {
|
|
t.Parallel()
|
|
tests := []struct {
|
|
input, want string
|
|
}{
|
|
{"https://github.com/org/my-repo.git", "github.com+org+my-repo.git"},
|
|
{"https://github.com/org/my-repo", "github.com+org+my-repo.git"},
|
|
{"[email protected]:org/my-repo.git", "github.com+org+my-repo.git"},
|
|
{"[email protected]:org/my-repo", "github.com+org+my-repo.git"},
|
|
{"https://github.com/org/repo/", "github.com+org+repo.git"},
|
|
{"ssh://[email protected]:22/group/sub/repo.git", "gitlab.example.com%3A22+group+sub+repo.git"},
|
|
// Basename collision: two repos sharing the basename must produce
|
|
// distinct dirs (the original bug).
|
|
{"ssh://[email protected]:22/relisty/app.git", "gitlab.example.com%3A22+relisty+app.git"},
|
|
{"ssh://[email protected]:22/listbridge/app.git", "gitlab.example.com%3A22+listbridge+app.git"},
|
|
{"my-repo", "my-repo.git"},
|
|
{"", "repo.git"},
|
|
}
|
|
for _, tt := range tests {
|
|
if got := bareDirName(tt.input); got != tt.want {
|
|
t.Errorf("bareDirName(%q) = %q, want %q", tt.input, got, tt.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestBareDirNameDistinctsSegmentBoundaryColliders covers the collision class
|
|
// that a naive path-flattening-with-dashes scheme would miss: two repos whose
|
|
// path segments differ only at a segment boundary flatten to the same string
|
|
// once slashes become dashes. The '+' separator can't appear inside a
|
|
// GitHub/GitLab path segment, so the boundary stays visible in the output.
|
|
func TestBareDirNameDistinctsSegmentBoundaryColliders(t *testing.T) {
|
|
t.Parallel()
|
|
pairs := [][2]string{
|
|
{"[email protected]:foo/bar-baz.git", "[email protected]:foo-bar/baz.git"},
|
|
{"https://github.com/foo/bar-baz.git", "https://github.com/foo-bar/baz.git"},
|
|
}
|
|
for _, p := range pairs {
|
|
a, b := bareDirName(p[0]), bareDirName(p[1])
|
|
if a == b {
|
|
t.Errorf("bareDirName collision: %q and %q both → %q", p[0], p[1], a)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestBareDirNameDistinctsSameRepoNameAcrossHosts covers the cross-host
|
|
// collision class: the same path-with-namespace on different hosts must
|
|
// produce distinct cache dirs so an agent configured for host A can't be
|
|
// served the clone from host B.
|
|
func TestBareDirNameDistinctsSameRepoNameAcrossHosts(t *testing.T) {
|
|
t.Parallel()
|
|
pairs := [][2]string{
|
|
{"[email protected]:org/repo.git", "[email protected]:org/repo.git"},
|
|
{"https://github.com/org/repo.git", "https://gitlab.example.com/org/repo.git"},
|
|
{"ssh://[email protected]/org/repo.git", "ssh://[email protected]/org/repo.git"},
|
|
}
|
|
for _, p := range pairs {
|
|
a, b := bareDirName(p[0]), bareDirName(p[1])
|
|
if a == b {
|
|
t.Errorf("bareDirName collision across hosts: %q and %q both → %q", p[0], p[1], a)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestBareDirNameDistinctsHostPortFromDashedHostname covers the lossy-port
|
|
// encoding regression: a naive ':' -> '-' rewrite would collapse
|
|
// `host:port` onto a hostname that literally contains the same dash pattern,
|
|
// silently reintroducing the wrong-remote bug. We URL-encode ':' to '%3A'
|
|
// so host+port is lossless — and '%' is forbidden in valid hostnames so the
|
|
// marker can never come from a legal literal hostname.
|
|
func TestBareDirNameDistinctsHostPortFromDashedHostname(t *testing.T) {
|
|
t.Parallel()
|
|
pairs := [][2]string{
|
|
// Host-with-port vs a literal hostname that looks like `host-port`.
|
|
{"ssh://[email protected]:22/org/repo.git", "[email protected]:org/repo.git"},
|
|
// Same again but across the URL and scp-style forms, explicit ports
|
|
// swapped to ensure we don't rely on order.
|
|
{"ssh://[email protected]:443/a/b.git", "[email protected]:a/b.git"},
|
|
}
|
|
for _, p := range pairs {
|
|
a, b := bareDirName(p[0]), bareDirName(p[1])
|
|
if a == b {
|
|
t.Errorf("bareDirName collision between host:port and host-port: %q and %q both → %q", p[0], p[1], a)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestIsBareRepo(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// A directory with a HEAD file should be detected as bare.
|
|
dir := t.TempDir()
|
|
os.WriteFile(filepath.Join(dir, "HEAD"), []byte("ref: refs/heads/main\n"), 0o644)
|
|
if !isBareRepo(dir) {
|
|
t.Error("expected bare repo to be detected")
|
|
}
|
|
|
|
// An empty directory should not.
|
|
emptyDir := t.TempDir()
|
|
if isBareRepo(emptyDir) {
|
|
t.Error("expected empty dir to not be detected as bare repo")
|
|
}
|
|
}
|
|
|
|
// createTestRepo creates a local git repo with an initial commit and returns its path.
|
|
func createTestRepo(t *testing.T) string {
|
|
t.Helper()
|
|
return createTestRepoAt(t, t.TempDir())
|
|
}
|
|
|
|
// createTestRepoAt initializes a git repo at the given directory (which
|
|
// must already exist). Used to craft repo URLs at paths chosen by the test
|
|
// — e.g. to reproduce collision classes in name derivation.
|
|
func createTestRepoAt(t *testing.T, dir string) string {
|
|
t.Helper()
|
|
for _, args := range [][]string{
|
|
{"init", dir},
|
|
{"-C", dir, "commit", "--allow-empty", "-m", "initial"},
|
|
} {
|
|
cmd := exec.Command("git", args...)
|
|
cmd.Env = append(os.Environ(),
|
|
"GIT_AUTHOR_NAME=test", "[email protected]",
|
|
"GIT_COMMITTER_NAME=test", "[email protected]",
|
|
)
|
|
if out, err := cmd.CombinedOutput(); err != nil {
|
|
t.Skipf("git setup failed: %s: %v", out, err)
|
|
}
|
|
}
|
|
return dir
|
|
}
|
|
|
|
func TestSyncAndLookup(t *testing.T) {
|
|
t.Parallel()
|
|
sourceRepo := createTestRepo(t)
|
|
cacheRoot := t.TempDir()
|
|
|
|
cache := New(cacheRoot, testLogger())
|
|
|
|
// Sync should clone the repo.
|
|
err := cache.Sync("ws-123", []RepoInfo{
|
|
{URL: sourceRepo},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Sync failed: %v", err)
|
|
}
|
|
|
|
// Lookup should find the cached repo.
|
|
path := cache.Lookup("ws-123", sourceRepo)
|
|
if path == "" {
|
|
t.Fatal("expected to find cached repo")
|
|
}
|
|
if !isBareRepo(path) {
|
|
t.Fatalf("expected bare repo at %s", path)
|
|
}
|
|
|
|
// Lookup for unknown URL should return empty.
|
|
if got := cache.Lookup("ws-123", "https://github.com/org/unknown"); got != "" {
|
|
t.Fatalf("expected empty for unknown URL, got %q", got)
|
|
}
|
|
|
|
// Lookup for unknown workspace should return empty.
|
|
if got := cache.Lookup("ws-999", sourceRepo); got != "" {
|
|
t.Fatalf("expected empty for unknown workspace, got %q", got)
|
|
}
|
|
}
|
|
|
|
// TestSyncKeepsDistinctCachesForSegmentBoundaryColliders proves that two
|
|
// URLs differing only at a path-segment boundary don't share a bare cache
|
|
// and don't silently reuse each other's origin. Both conditions would have
|
|
// failed under a plain slashes-to-dashes flattening scheme: the two URLs
|
|
// in this test produce the same dash-joined key even though they point at
|
|
// different source repositories.
|
|
func TestSyncKeepsDistinctCachesForSegmentBoundaryColliders(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// Build two real source repos under a shared parent. Their filesystem
|
|
// paths are used directly as URLs (git accepts local paths as remote
|
|
// URLs). The path pair ".../foo/bar-baz" and ".../foo-bar/baz" would
|
|
// flatten to the same string under slashes-to-dashes — that's the
|
|
// class of collision we want to rule out.
|
|
parent := t.TempDir()
|
|
srcA := filepath.Join(parent, "foo", "bar-baz")
|
|
srcB := filepath.Join(parent, "foo-bar", "baz")
|
|
if err := os.MkdirAll(srcA, 0o755); err != nil {
|
|
t.Fatalf("mkdir srcA: %v", err)
|
|
}
|
|
if err := os.MkdirAll(srcB, 0o755); err != nil {
|
|
t.Fatalf("mkdir srcB: %v", err)
|
|
}
|
|
createTestRepoAt(t, srcA)
|
|
createTestRepoAt(t, srcB)
|
|
// Distinct content so a silent-reuse bug would produce the wrong file
|
|
// in the wrong cache.
|
|
if err := os.WriteFile(filepath.Join(srcA, "A.txt"), []byte("A\n"), 0o644); err != nil {
|
|
t.Fatalf("write A: %v", err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(srcB, "B.txt"), []byte("B\n"), 0o644); err != nil {
|
|
t.Fatalf("write B: %v", err)
|
|
}
|
|
runGitAuthored(t, srcA, "add", ".")
|
|
runGitAuthored(t, srcA, "commit", "-m", "A-content")
|
|
runGitAuthored(t, srcB, "add", ".")
|
|
runGitAuthored(t, srcB, "commit", "-m", "B-content")
|
|
|
|
cache := New(t.TempDir(), testLogger())
|
|
if err := cache.Sync("ws-1", []RepoInfo{{URL: srcA}, {URL: srcB}}); err != nil {
|
|
t.Fatalf("Sync failed: %v", err)
|
|
}
|
|
|
|
pathA := cache.Lookup("ws-1", srcA)
|
|
pathB := cache.Lookup("ws-1", srcB)
|
|
if pathA == "" || pathB == "" {
|
|
t.Fatalf("missing cache entry: A=%q B=%q", pathA, pathB)
|
|
}
|
|
if pathA == pathB {
|
|
t.Fatalf("collider URLs share a bare cache path: %s", pathA)
|
|
}
|
|
|
|
// Each bare cache must carry the origin URL of the repo it was
|
|
// cloned from — not the other one's. A silent-reuse bug would have
|
|
// both caches pointing at whichever URL won the race in Sync.
|
|
if got := gitConfigGet(t, pathA, "remote.origin.url"); got != srcA {
|
|
t.Errorf("cacheA origin.url = %q, want %q", got, srcA)
|
|
}
|
|
if got := gitConfigGet(t, pathB, "remote.origin.url"); got != srcB {
|
|
t.Errorf("cacheB origin.url = %q, want %q", got, srcB)
|
|
}
|
|
|
|
// And each cache's content must reflect the right source.
|
|
if !cachedRepoHasFile(t, pathA, "A.txt") {
|
|
t.Errorf("cacheA (%s) should contain A.txt from srcA", pathA)
|
|
}
|
|
if !cachedRepoHasFile(t, pathB, "B.txt") {
|
|
t.Errorf("cacheB (%s) should contain B.txt from srcB", pathB)
|
|
}
|
|
}
|
|
|
|
// gitConfigGet reads a git config value from repoPath. Fails the test if
|
|
// the key is missing or the command errors.
|
|
func gitConfigGet(t *testing.T, repoPath, key string) string {
|
|
t.Helper()
|
|
out, err := exec.Command("git", "-C", repoPath, "config", "--get", key).Output()
|
|
if err != nil {
|
|
t.Fatalf("git config --get %s in %s: %v", key, repoPath, err)
|
|
}
|
|
return strings.TrimSpace(string(out))
|
|
}
|
|
|
|
// cachedRepoHasFile returns true if the bare cache at barePath exposes a
|
|
// file named filename anywhere in its remote-tracking default branch.
|
|
// Walks refs/remotes/origin/* since a bare clone stores fetched heads
|
|
// there under the modern refspec.
|
|
func cachedRepoHasFile(t *testing.T, barePath, filename string) bool {
|
|
t.Helper()
|
|
ref := getRemoteDefaultBranch(barePath)
|
|
if ref == "" {
|
|
return false
|
|
}
|
|
out, err := exec.Command("git", "-C", barePath, "ls-tree", "-r", "--name-only", ref).Output()
|
|
if err != nil {
|
|
t.Fatalf("git ls-tree %s in %s: %v", ref, barePath, err)
|
|
}
|
|
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
|
|
if strings.TrimSpace(line) == filename {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func TestSyncFetchesExisting(t *testing.T) {
|
|
t.Parallel()
|
|
sourceRepo := createTestRepo(t)
|
|
cacheRoot := t.TempDir()
|
|
|
|
cache := New(cacheRoot, testLogger())
|
|
|
|
// First sync: clone.
|
|
if err := cache.Sync("ws-1", []RepoInfo{{URL: sourceRepo}}); err != nil {
|
|
t.Fatalf("first sync failed: %v", err)
|
|
}
|
|
|
|
// Record the remote-tracking default head in the cache. Under the modern
|
|
// refspec layout, fetches write to refs/remotes/origin/*, not the bare
|
|
// repo's own refs/heads/*, so reading the bare HEAD would return the
|
|
// fossil snapshot from initial clone.
|
|
barePath := cache.Lookup("ws-1", sourceRepo)
|
|
oldHead := gitRefCommit(t, barePath, getRemoteDefaultBranch(barePath))
|
|
|
|
// Add a commit to source.
|
|
addEmptyCommit(t, sourceRepo, "second")
|
|
sourceHead := gitHead(t, sourceRepo)
|
|
if sourceHead == oldHead {
|
|
t.Fatal("source HEAD should differ after new commit")
|
|
}
|
|
|
|
// Second sync: should fetch (not re-clone).
|
|
if err := cache.Sync("ws-1", []RepoInfo{{URL: sourceRepo}}); err != nil {
|
|
t.Fatalf("second sync failed: %v", err)
|
|
}
|
|
|
|
// Verify the cache remote-tracking ref was updated.
|
|
newHead := gitRefCommit(t, barePath, getRemoteDefaultBranch(barePath))
|
|
if newHead == oldHead {
|
|
t.Fatal("expected cache remote-tracking head to be updated after fetch")
|
|
}
|
|
if newHead != sourceHead {
|
|
t.Fatalf("expected cache head %s to match source head %s", newHead, sourceHead)
|
|
}
|
|
}
|
|
|
|
func gitHead(t *testing.T, repoPath string) string {
|
|
t.Helper()
|
|
cmd := exec.Command("git", "-C", repoPath, "rev-parse", "HEAD")
|
|
out, err := cmd.Output()
|
|
if err != nil {
|
|
t.Fatalf("git rev-parse HEAD failed in %s: %v", repoPath, err)
|
|
}
|
|
return strings.TrimSpace(string(out))
|
|
}
|
|
|
|
func TestWorktreeFromCache(t *testing.T) {
|
|
t.Parallel()
|
|
sourceRepo := createTestRepo(t)
|
|
cacheRoot := t.TempDir()
|
|
|
|
cache := New(cacheRoot, testLogger())
|
|
if err := cache.Sync("ws-1", []RepoInfo{{URL: sourceRepo}}); err != nil {
|
|
t.Fatalf("sync failed: %v", err)
|
|
}
|
|
|
|
barePath := cache.Lookup("ws-1", sourceRepo)
|
|
if barePath == "" {
|
|
t.Fatal("expected cached repo")
|
|
}
|
|
|
|
// Create a worktree from the bare cache — this is the actual use case.
|
|
worktreeDir := filepath.Join(t.TempDir(), "work")
|
|
cmd := exec.Command("git", "-C", barePath, "worktree", "add", "-b", "test-branch", worktreeDir, "HEAD")
|
|
if out, err := cmd.CombinedOutput(); err != nil {
|
|
t.Fatalf("worktree add failed: %s: %v", out, err)
|
|
}
|
|
defer exec.Command("git", "-C", barePath, "worktree", "remove", "--force", worktreeDir).Run()
|
|
|
|
// Verify worktree exists and is on the right branch.
|
|
cmd = exec.Command("git", "-C", worktreeDir, "branch", "--show-current")
|
|
out, err := cmd.Output()
|
|
if err != nil {
|
|
t.Fatalf("show branch failed: %v", err)
|
|
}
|
|
if got := trimLine(string(out)); got != "test-branch" {
|
|
t.Fatalf("expected branch 'test-branch', got %q", got)
|
|
}
|
|
}
|
|
|
|
func TestCreateWorktree(t *testing.T) {
|
|
t.Parallel()
|
|
sourceRepo := createTestRepo(t)
|
|
cacheRoot := t.TempDir()
|
|
|
|
cache := New(cacheRoot, testLogger())
|
|
if err := cache.Sync("ws-1", []RepoInfo{{URL: sourceRepo}}); err != nil {
|
|
t.Fatalf("sync failed: %v", err)
|
|
}
|
|
|
|
workDir := t.TempDir()
|
|
result, err := cache.CreateWorktree(WorktreeParams{
|
|
WorkspaceID: "ws-1",
|
|
RepoURL: sourceRepo,
|
|
WorkDir: workDir,
|
|
AgentName: "Code Reviewer",
|
|
TaskID: "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("CreateWorktree failed: %v", err)
|
|
}
|
|
|
|
// Verify the worktree was created.
|
|
if _, err := os.Stat(result.Path); os.IsNotExist(err) {
|
|
t.Fatalf("worktree path does not exist: %s", result.Path)
|
|
}
|
|
|
|
// Verify branch name format.
|
|
if !strings.HasPrefix(result.BranchName, "agent/code-reviewer/") {
|
|
t.Errorf("expected branch to start with 'agent/code-reviewer/', got %q", result.BranchName)
|
|
}
|
|
|
|
// Verify the worktree is on the correct branch.
|
|
cmd := exec.Command("git", "-C", result.Path, "branch", "--show-current")
|
|
out, err := cmd.Output()
|
|
if err != nil {
|
|
t.Fatalf("show branch failed: %v", err)
|
|
}
|
|
if got := strings.TrimSpace(string(out)); got != result.BranchName {
|
|
t.Errorf("expected branch %q, got %q", result.BranchName, got)
|
|
}
|
|
}
|
|
|
|
func TestCreateWorktreeWithIsolatedGitMetadata(t *testing.T) {
|
|
t.Parallel()
|
|
sourceRepo := createTestRepo(t)
|
|
cache := New(t.TempDir(), testLogger())
|
|
if err := cache.Sync("ws-1", []RepoInfo{{URL: sourceRepo}}); err != nil {
|
|
t.Fatalf("sync failed: %v", err)
|
|
}
|
|
|
|
barePath := cache.Lookup("ws-1", sourceRepo)
|
|
baseRef := getRemoteDefaultBranch(barePath)
|
|
baseCommit := gitRefCommit(t, barePath, baseRef)
|
|
workDir := t.TempDir()
|
|
result, err := cache.CreateWorktree(WorktreeParams{
|
|
WorkspaceID: "ws-1",
|
|
RepoURL: sourceRepo,
|
|
WorkDir: workDir,
|
|
AgentName: "Linux Codex",
|
|
TaskID: "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
|
|
IsolatedGitMetadata: true,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("CreateWorktree failed: %v", err)
|
|
}
|
|
|
|
gitInfo, err := os.Stat(filepath.Join(result.Path, ".git"))
|
|
if err != nil || !gitInfo.IsDir() {
|
|
t.Fatalf("isolated checkout .git must be a directory, info=%v err=%v", gitInfo, err)
|
|
}
|
|
if !isIsolatedCheckout(result.Path) {
|
|
t.Fatal("isolated checkout marker missing")
|
|
}
|
|
if _, err := os.Stat(filepath.Join(result.Path, ".git", "objects", "info", "alternates")); !os.IsNotExist(err) {
|
|
t.Fatalf("isolated checkout must not depend on cache alternates, err=%v", err)
|
|
}
|
|
|
|
origin, err := runGitOutput("-C", result.Path, "remote", "get-url", "origin")
|
|
if err != nil {
|
|
t.Fatalf("get origin URL: %v", err)
|
|
}
|
|
if got := strings.TrimSpace(string(origin)); got != sourceRepo {
|
|
t.Fatalf("origin URL = %q, want %q", got, sourceRepo)
|
|
}
|
|
if got := gitHead(t, result.Path); got != baseCommit {
|
|
t.Fatalf("checkout HEAD = %s, want %s", got, baseCommit)
|
|
}
|
|
if err := runGit("-C", result.Path, "rev-parse", "--verify", baseRef); err != nil {
|
|
t.Fatalf("isolated checkout missing cache ref %s: %v", baseRef, err)
|
|
}
|
|
if err := runGit("-C", barePath, "show-ref", "--verify", "refs/heads/"+result.BranchName); err == nil {
|
|
t.Fatalf("isolated branch %s leaked into shared bare cache", result.BranchName)
|
|
}
|
|
|
|
if err := os.WriteFile(filepath.Join(result.Path, "isolated.txt"), []byte("writable\n"), 0o644); err != nil {
|
|
t.Fatalf("write isolated file: %v", err)
|
|
}
|
|
runGitAuthored(t, result.Path, "add", "isolated.txt")
|
|
runGitAuthored(t, result.Path, "commit", "-m", "isolated commit")
|
|
if got := gitHead(t, result.Path); got == baseCommit {
|
|
t.Fatal("git commit did not advance isolated checkout")
|
|
}
|
|
if got := gitRefCommit(t, barePath, baseRef); got != baseCommit {
|
|
t.Fatalf("isolated commit mutated shared cache base: got %s, want %s", got, baseCommit)
|
|
}
|
|
}
|
|
|
|
func TestCreateIsolatedCheckoutImportsFetchedTipFromShallowCache(t *testing.T) {
|
|
t.Parallel()
|
|
root := t.TempDir()
|
|
originPath := filepath.Join(root, "origin.git")
|
|
workPath := filepath.Join(root, "work")
|
|
cachePath := filepath.Join(root, "cache.git")
|
|
checkoutPath := filepath.Join(root, "checkout")
|
|
|
|
if err := runGit("init", "--bare", originPath); err != nil {
|
|
t.Fatalf("init bare origin: %v", err)
|
|
}
|
|
if err := os.Mkdir(workPath, 0o755); err != nil {
|
|
t.Fatalf("create work repo directory: %v", err)
|
|
}
|
|
createTestRepoAt(t, workPath)
|
|
branch := currentBranchName(t, workPath)
|
|
runGitAuthored(t, workPath, "remote", "add", "origin", originPath)
|
|
runGitAuthored(t, workPath, "push", "-u", "origin", branch)
|
|
if err := runGit("-C", originPath, "symbolic-ref", "HEAD", "refs/heads/"+branch); err != nil {
|
|
t.Fatalf("set origin HEAD: %v", err)
|
|
}
|
|
|
|
originURL := "file://" + originPath
|
|
if out, err := runGitCombinedOutput("clone", "--bare", "--depth=1", originURL, cachePath); err != nil {
|
|
t.Fatalf("create shallow cache: %s: %v", strings.TrimSpace(string(out)), err)
|
|
}
|
|
if out, err := runGitOutput("-C", cachePath, "rev-parse", "--is-shallow-repository"); err != nil {
|
|
t.Fatalf("inspect shallow cache: %v", err)
|
|
} else if got := strings.TrimSpace(string(out)); got != "true" {
|
|
t.Fatalf("test cache is shallow = %q, want true", got)
|
|
}
|
|
|
|
oldTip := gitHead(t, workPath)
|
|
if err := runGit("-C", cachePath, "config", "remote.origin.fetch", modernFetchRefspec); err != nil {
|
|
t.Fatalf("set cache fetch refspec: %v", err)
|
|
}
|
|
|
|
addEmptyCommit(t, workPath, "advance origin")
|
|
newTip := gitHead(t, workPath)
|
|
runGitAuthored(t, workPath, "push", "origin", branch)
|
|
if err := runGitFetch(cachePath); err != nil {
|
|
t.Fatalf("fetch updated origin into shallow cache: %v", err)
|
|
}
|
|
|
|
baseRef := "refs/remotes/origin/" + branch
|
|
if got := gitRefCommit(t, cachePath, "refs/heads/"+branch); got != oldTip {
|
|
t.Fatalf("cache local head = %s, want stale tip %s", got, oldTip)
|
|
}
|
|
if got := gitRefCommit(t, cachePath, baseRef); got != newTip {
|
|
t.Fatalf("cache remote-tracking head = %s, want fetched tip %s", got, newTip)
|
|
}
|
|
if err := runGit("-C", cachePath, "cat-file", "-e", newTip+"^{commit}"); err != nil {
|
|
t.Fatalf("fetched tip is missing from shallow cache: %v", err)
|
|
}
|
|
|
|
const taskBranch = "agent/test/shallow-cache"
|
|
actualBranch, err := createIsolatedCheckout(
|
|
cachePath,
|
|
originURL,
|
|
checkoutPath,
|
|
taskBranch,
|
|
baseRef,
|
|
newTip,
|
|
"",
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("create isolated checkout from shallow cache: %v", err)
|
|
}
|
|
if actualBranch != taskBranch {
|
|
t.Fatalf("isolated branch = %q, want %q", actualBranch, taskBranch)
|
|
}
|
|
if got := gitHead(t, checkoutPath); got != newTip {
|
|
t.Fatalf("isolated checkout HEAD = %s, want fetched tip %s", got, newTip)
|
|
}
|
|
}
|
|
|
|
func TestCreateWorktreeReusesIsolatedGitMetadata(t *testing.T) {
|
|
t.Parallel()
|
|
sourceRepo := createTestRepo(t)
|
|
cache := New(t.TempDir(), testLogger())
|
|
if err := cache.Sync("ws-1", []RepoInfo{{URL: sourceRepo}}); err != nil {
|
|
t.Fatalf("initial sync failed: %v", err)
|
|
}
|
|
|
|
workDir := t.TempDir()
|
|
checkout := func(taskID string, fresh bool) *WorktreeResult {
|
|
t.Helper()
|
|
result, err := cache.CreateWorktree(WorktreeParams{
|
|
WorkspaceID: "ws-1",
|
|
RepoURL: sourceRepo,
|
|
WorkDir: workDir,
|
|
AgentName: "Linux Codex",
|
|
TaskID: taskID,
|
|
IsolatedGitMetadata: true,
|
|
Fresh: fresh,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("CreateWorktree(task %s, fresh=%v) failed: %v", taskID, fresh, err)
|
|
}
|
|
return result
|
|
}
|
|
localHeads := func(path string) string {
|
|
t.Helper()
|
|
heads, err := runGitOutput("-C", path, "for-each-ref", "--format=%(refname)", "refs/heads/")
|
|
if err != nil {
|
|
t.Fatalf("list local heads: %v", err)
|
|
}
|
|
return strings.TrimSpace(string(heads))
|
|
}
|
|
|
|
first := checkout("11111111-1111-1111-1111-111111111111", false)
|
|
addEmptyCommit(t, first.Path, "unpublished agent work")
|
|
const userBranch = "feature/keep-me"
|
|
runGitAuthored(t, first.Path, "checkout", "-b", userBranch)
|
|
addEmptyCommit(t, first.Path, "unpublished feature work")
|
|
userCommit := gitHead(t, first.Path)
|
|
|
|
addEmptyCommit(t, sourceRepo, "upstream advance")
|
|
wantHead := gitHead(t, sourceRepo)
|
|
if err := cache.Sync("ws-1", []RepoInfo{{URL: sourceRepo}}); err != nil {
|
|
t.Fatalf("refresh sync failed: %v", err)
|
|
}
|
|
|
|
// Unpushed commits on HEAD keep the checkout exactly as it is.
|
|
kept := checkout("22222222-2222-2222-2222-222222222222", false)
|
|
if kept.Kept != KeptLocalWork || kept.BranchName != userBranch || kept.UnpushedCommits != 2 {
|
|
t.Fatalf("result = %+v, want %s kept with its 2 unpushed commits", kept, userBranch)
|
|
}
|
|
if got := gitHead(t, kept.Path); got != userCommit {
|
|
t.Fatalf("kept checkout HEAD = %s, want %s", got, userCommit)
|
|
}
|
|
|
|
// Fresh moves it to a new branch from the refreshed upstream, but an
|
|
// earlier task's agent/* branch holding unpushed commits is not pruned.
|
|
second := checkout("22222222-2222-2222-2222-222222222222", true)
|
|
if second.Path != first.Path {
|
|
t.Fatalf("reused checkout path = %q, want %q", second.Path, first.Path)
|
|
}
|
|
if second.BranchName == first.BranchName {
|
|
t.Fatalf("reused checkout kept old branch %q", first.BranchName)
|
|
}
|
|
if got := gitHead(t, second.Path); got != wantHead {
|
|
t.Fatalf("reused checkout HEAD = %s, want refreshed upstream %s", got, wantHead)
|
|
}
|
|
wantHeads := "refs/heads/" + first.BranchName + "\nrefs/heads/" + second.BranchName + "\nrefs/heads/" + userBranch
|
|
if got := localHeads(second.Path); got != wantHeads {
|
|
t.Fatalf("reused checkout local heads = %q, want %q", got, wantHeads)
|
|
}
|
|
|
|
// Once pushed, the earlier task's branch is pruned like the second task's,
|
|
// which never left upstream: reuse must not accumulate agent/* branches.
|
|
// User-created branches are never pruned.
|
|
runGitAuthored(t, second.Path, "push", "origin", first.BranchName)
|
|
third := checkout("33333333-3333-3333-3333-333333333333", false)
|
|
if third.Kept != "" || third.BranchName == second.BranchName {
|
|
t.Fatalf("result = %+v, want the clean checkout moved to a new branch", third)
|
|
}
|
|
wantHeads = "refs/heads/" + third.BranchName + "\nrefs/heads/" + userBranch
|
|
if got := localHeads(third.Path); got != wantHeads {
|
|
t.Fatalf("reused checkout local heads = %q, want %q", got, wantHeads)
|
|
}
|
|
if got := gitRefCommit(t, third.Path, "refs/heads/"+userBranch); got != userCommit {
|
|
t.Fatalf("preserved user branch commit = %s, want %s", got, userCommit)
|
|
}
|
|
}
|
|
|
|
func TestCreateWorktreeMigratesLinkedWorktreeToIsolatedMetadata(t *testing.T) {
|
|
t.Parallel()
|
|
sourceRepo := createTestRepo(t)
|
|
cache := New(t.TempDir(), testLogger())
|
|
if err := cache.Sync("ws-1", []RepoInfo{{URL: sourceRepo}}); err != nil {
|
|
t.Fatalf("sync failed: %v", err)
|
|
}
|
|
|
|
workDir := t.TempDir()
|
|
linked, err := cache.CreateWorktree(WorktreeParams{
|
|
WorkspaceID: "ws-1",
|
|
RepoURL: sourceRepo,
|
|
WorkDir: workDir,
|
|
AgentName: "Linux Codex",
|
|
TaskID: "11111111-1111-1111-1111-111111111111",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("linked CreateWorktree failed: %v", err)
|
|
}
|
|
if !isGitWorktree(linked.Path) {
|
|
t.Fatal("test setup did not create a linked worktree")
|
|
}
|
|
|
|
isolated, err := cache.CreateWorktree(WorktreeParams{
|
|
WorkspaceID: "ws-1",
|
|
RepoURL: sourceRepo,
|
|
WorkDir: workDir,
|
|
AgentName: "Linux Codex",
|
|
TaskID: "22222222-2222-2222-2222-222222222222",
|
|
IsolatedGitMetadata: true,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("isolated CreateWorktree failed: %v", err)
|
|
}
|
|
if isolated.Path != linked.Path {
|
|
t.Fatalf("migrated checkout path = %q, want %q", isolated.Path, linked.Path)
|
|
}
|
|
if !isIsolatedCheckout(isolated.Path) {
|
|
t.Fatal("linked worktree was not migrated to isolated metadata")
|
|
}
|
|
// The linked branch held nothing unpushed, so nothing is carried over.
|
|
heads, err := runGitOutput("-C", isolated.Path, "for-each-ref", "--format=%(refname)", "refs/heads/")
|
|
if err != nil {
|
|
t.Fatalf("list local heads: %v", err)
|
|
}
|
|
if got := strings.TrimSpace(string(heads)); got != "refs/heads/"+isolated.BranchName {
|
|
t.Fatalf("migrated checkout local heads = %q, want only %s", got, isolated.BranchName)
|
|
}
|
|
|
|
barePath := cache.Lookup("ws-1", sourceRepo)
|
|
out, err := runGitOutput("-C", barePath, "worktree", "list", "--porcelain")
|
|
if err != nil {
|
|
t.Fatalf("list cache worktrees: %v", err)
|
|
}
|
|
if strings.Contains(string(out), "worktree "+linked.Path+"\n") {
|
|
t.Fatalf("shared cache retained migrated worktree admin entry:\n%s", out)
|
|
}
|
|
}
|
|
|
|
func TestLocalCloneArgsOnlyDisablesHardlinksOnWindows(t *testing.T) {
|
|
t.Parallel()
|
|
const barePath, checkoutPath = "/cache/repo.git", "/task/repo"
|
|
|
|
for _, tt := range []struct {
|
|
goos string
|
|
wantNoHardlnk bool
|
|
}{
|
|
{goos: "windows", wantNoHardlnk: true},
|
|
{goos: "linux"},
|
|
{goos: "darwin"},
|
|
} {
|
|
t.Run(tt.goos, func(t *testing.T) {
|
|
t.Parallel()
|
|
args := localCloneArgs(tt.goos, barePath, checkoutPath)
|
|
got := false
|
|
for _, arg := range args {
|
|
if arg == "--no-hardlinks" {
|
|
got = true
|
|
}
|
|
}
|
|
if got != tt.wantNoHardlnk {
|
|
t.Fatalf("localCloneArgs(%q) --no-hardlinks = %v, want %v (args: %v)", tt.goos, got, tt.wantNoHardlnk, args)
|
|
}
|
|
// Inserting a flag must never displace the trailing operands.
|
|
if src, dst := args[len(args)-2], args[len(args)-1]; src != barePath || dst != checkoutPath {
|
|
t.Fatalf("localCloneArgs(%q) operands = %q %q, want %q %q", tt.goos, src, dst, barePath, checkoutPath)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestIsolatedCheckoutCloneWithoutHardlinksIsIndependent runs the exact clone
|
|
// invocation Windows Codex tasks now use (multica-ai/multica#6449) and proves
|
|
// it still yields a usable checkout whose object files are private copies
|
|
// rather than links back into the daemon-owned cache. Kept platform-agnostic
|
|
// so Linux/macOS CI guards the Windows-only flag.
|
|
func TestIsolatedCheckoutCloneWithoutHardlinksIsIndependent(t *testing.T) {
|
|
t.Parallel()
|
|
sourceRepo := createTestRepo(t)
|
|
cache := New(t.TempDir(), testLogger())
|
|
if err := cache.Sync("ws-1", []RepoInfo{{URL: sourceRepo}}); err != nil {
|
|
t.Fatalf("sync failed: %v", err)
|
|
}
|
|
barePath := cache.Lookup("ws-1", sourceRepo)
|
|
baseCommit := gitRefCommit(t, barePath, getRemoteDefaultBranch(barePath))
|
|
checkoutPath := filepath.Join(t.TempDir(), "repo")
|
|
|
|
if out, err := runGitCombinedOutput(localCloneArgs("windows", barePath, checkoutPath)...); err != nil {
|
|
t.Fatalf("windows-shaped local clone failed: %s: %v", strings.TrimSpace(string(out)), err)
|
|
}
|
|
if out, err := runGitCombinedOutput("-C", checkoutPath, "checkout", "--detach", baseCommit); err != nil {
|
|
t.Fatalf("checkout base commit: %s: %v", strings.TrimSpace(string(out)), err)
|
|
}
|
|
if got := gitHead(t, checkoutPath); got != baseCommit {
|
|
t.Fatalf("checkout HEAD = %s, want %s", got, baseCommit)
|
|
}
|
|
if _, err := os.Stat(filepath.Join(checkoutPath, ".git", "objects", "info", "alternates")); !os.IsNotExist(err) {
|
|
t.Fatalf("clone must not borrow cache objects via alternates, err=%v", err)
|
|
}
|
|
|
|
assertObjectsAreNotHardLinked(t, barePath, checkoutPath)
|
|
}
|
|
|
|
// assertObjectsAreNotHardLinked fails if an object file present in both the
|
|
// cache and the checkout is the same underlying file. os.SameFile compares
|
|
// dev+inode on Unix and the volume serial + file index on Windows, either of
|
|
// which identifies a hard link. Both "nothing in the cache" and "nothing in
|
|
// common" are failures, so the assertion cannot pass vacuously.
|
|
func assertObjectsAreNotHardLinked(t *testing.T, barePath, checkoutPath string) {
|
|
t.Helper()
|
|
cacheObjects := filepath.Join(barePath, "objects")
|
|
checkoutObjects := filepath.Join(checkoutPath, ".git", "objects")
|
|
|
|
names := objectFiles(t, cacheObjects)
|
|
if len(names) == 0 {
|
|
t.Fatal("cache has no object files; assertion would pass vacuously")
|
|
}
|
|
compared := 0
|
|
for _, rel := range names {
|
|
cloned, err := os.Stat(filepath.Join(checkoutObjects, rel))
|
|
if err != nil {
|
|
continue // repacked differently in the checkout; nothing to compare
|
|
}
|
|
cached, err := os.Stat(filepath.Join(cacheObjects, rel))
|
|
if err != nil {
|
|
t.Fatalf("stat cache object %s: %v", rel, err)
|
|
}
|
|
if os.SameFile(cached, cloned) {
|
|
t.Errorf("object %s is hard-linked to the shared cache", rel)
|
|
}
|
|
compared++
|
|
}
|
|
if compared == 0 {
|
|
t.Fatal("no cache object was found in the checkout; assertion would pass vacuously")
|
|
}
|
|
}
|
|
|
|
// objectFiles lists regular files under a Git objects directory, relative to
|
|
// it, skipping the bookkeeping subdirectory that never holds object data.
|
|
func objectFiles(t *testing.T, objectsDir string) []string {
|
|
t.Helper()
|
|
var found []string
|
|
err := filepath.WalkDir(objectsDir, func(path string, entry fs.DirEntry, err error) error {
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if entry.IsDir() {
|
|
if entry.Name() == "info" {
|
|
return filepath.SkipDir
|
|
}
|
|
return nil
|
|
}
|
|
rel, err := filepath.Rel(objectsDir, path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
found = append(found, rel)
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("walk %s: %v", objectsDir, err)
|
|
}
|
|
return found
|
|
}
|
|
|
|
func TestCreateWorktreeExcludesOpenCodeSkills(t *testing.T) {
|
|
t.Parallel()
|
|
sourceRepo := createTestRepo(t)
|
|
cacheRoot := t.TempDir()
|
|
|
|
cache := New(cacheRoot, testLogger())
|
|
if err := cache.Sync("ws-1", []RepoInfo{{URL: sourceRepo}}); err != nil {
|
|
t.Fatalf("sync failed: %v", err)
|
|
}
|
|
|
|
workDir := t.TempDir()
|
|
result, err := cache.CreateWorktree(WorktreeParams{
|
|
WorkspaceID: "ws-1",
|
|
RepoURL: sourceRepo,
|
|
WorkDir: workDir,
|
|
AgentName: "OpenCode",
|
|
TaskID: "opencode-exclude-test",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("CreateWorktree failed: %v", err)
|
|
}
|
|
|
|
exclude := gitInfoExclude(t, result.Path)
|
|
if !strings.Contains(exclude, ".opencode\n") {
|
|
t.Fatalf("expected .git/info/exclude to contain .opencode, got:\n%s", exclude)
|
|
}
|
|
if !strings.Contains(exclude, ".codeartsdoer\n") {
|
|
t.Fatalf("expected .git/info/exclude to contain .codeartsdoer, got:\n%s", exclude)
|
|
}
|
|
if strings.Contains(exclude, ".config/opencode") {
|
|
t.Fatalf("expected .git/info/exclude to not contain stale .config/opencode, got:\n%s", exclude)
|
|
}
|
|
}
|
|
|
|
// TestCreateWorktreeExcludesAgentSidecars is the regression guard for
|
|
// PR #5224's review feedback: once the daemon started writing
|
|
// .codebuddy/skills/ and CODEBUDDY.md into the task workdir (instead of
|
|
// reusing Claude's .claude/CLAUDE.md, which were already excluded), the
|
|
// repo-cache worktree needed the new CodeBuddy sidecar paths added to
|
|
// .git/info/exclude too — otherwise these daemon-injected files show up in
|
|
// `git status` and risk being committed by the agent.
|
|
func TestCreateWorktreeExcludesAgentSidecars(t *testing.T) {
|
|
t.Parallel()
|
|
sourceRepo := createTestRepo(t)
|
|
cacheRoot := t.TempDir()
|
|
|
|
cache := New(cacheRoot, testLogger())
|
|
if err := cache.Sync("ws-1", []RepoInfo{{URL: sourceRepo}}); err != nil {
|
|
t.Fatalf("sync failed: %v", err)
|
|
}
|
|
|
|
workDir := t.TempDir()
|
|
result, err := cache.CreateWorktree(WorktreeParams{
|
|
WorkspaceID: "ws-1",
|
|
RepoURL: sourceRepo,
|
|
WorkDir: workDir,
|
|
AgentName: "CodeBuddy",
|
|
TaskID: "codebuddy-exclude-test",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("CreateWorktree failed: %v", err)
|
|
}
|
|
|
|
exclude := gitInfoExclude(t, result.Path)
|
|
if !strings.Contains(exclude, ".codebuddy\n") {
|
|
t.Fatalf("expected .git/info/exclude to contain .codebuddy, got:\n%s", exclude)
|
|
}
|
|
if !strings.Contains(exclude, "CODEBUDDY.md\n") {
|
|
t.Fatalf("expected .git/info/exclude to contain CODEBUDDY.md, got:\n%s", exclude)
|
|
}
|
|
for _, pattern := range []string{".pi\n", ".omp\n"} {
|
|
if !strings.Contains(exclude, pattern) {
|
|
t.Fatalf("expected .git/info/exclude to contain %q, got:\n%s", pattern, exclude)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestCreateWorktreeDoesNotExcludeReasonixProjectConfig guards the layering
|
|
// that makes a `reasonix.toml` exclude wrong. The daemon writes that file at
|
|
// the WorkDir, and a managed checkout is a directory *inside* the WorkDir, so
|
|
// this exclude list — which only reaches the checkout's .git/info/exclude —
|
|
// could never hide the daemon's copy. All it would do is make a project config
|
|
// an agent legitimately creates inside the repository invisible to git status
|
|
// for every provider.
|
|
func TestCreateWorktreeDoesNotExcludeReasonixProjectConfig(t *testing.T) {
|
|
t.Parallel()
|
|
sourceRepo := createTestRepo(t)
|
|
cacheRoot := t.TempDir()
|
|
|
|
cache := New(cacheRoot, testLogger())
|
|
if err := cache.Sync("ws-1", []RepoInfo{{URL: sourceRepo}}); err != nil {
|
|
t.Fatalf("sync failed: %v", err)
|
|
}
|
|
|
|
workDir := t.TempDir()
|
|
result, err := cache.CreateWorktree(WorktreeParams{
|
|
WorkspaceID: "ws-1",
|
|
RepoURL: sourceRepo,
|
|
WorkDir: workDir,
|
|
AgentName: "Reasonix",
|
|
TaskID: "reasonix-exclude-test",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("CreateWorktree failed: %v", err)
|
|
}
|
|
|
|
// The daemon's sidecar is a sibling of the checkout, not a file in it.
|
|
if filepath.Dir(result.Path) != workDir {
|
|
t.Fatalf("checkout %q is not a child of the work dir %q", result.Path, workDir)
|
|
}
|
|
if strings.Contains(gitInfoExclude(t, result.Path), "reasonix.toml") {
|
|
t.Fatalf("reasonix.toml is excluded inside the checkout, hiding a project config the agent may create:\n%s", gitInfoExclude(t, result.Path))
|
|
}
|
|
|
|
configPath := filepath.Join(result.Path, "reasonix.toml")
|
|
if err := os.WriteFile(configPath, []byte("[permissions]\n"), 0o644); err != nil {
|
|
t.Fatalf("write project config: %v", err)
|
|
}
|
|
// git check-ignore exits 1 when the path is NOT ignored — the outcome this
|
|
// test wants, so the agent can still commit the file it wrote.
|
|
cmd := exec.Command("git", "-C", result.Path, "check-ignore", "-q", "reasonix.toml")
|
|
if err := cmd.Run(); err == nil {
|
|
t.Fatal("git ignores a repository reasonix.toml created inside the checkout")
|
|
}
|
|
}
|
|
|
|
func gitInfoExclude(t *testing.T, worktreePath string) string {
|
|
t.Helper()
|
|
cmd := exec.Command("git", "-C", worktreePath, "rev-parse", "--git-dir")
|
|
out, err := cmd.Output()
|
|
if err != nil {
|
|
t.Fatalf("git rev-parse --git-dir failed in %s: %v", worktreePath, err)
|
|
}
|
|
gitDir := strings.TrimSpace(string(out))
|
|
if !filepath.IsAbs(gitDir) {
|
|
gitDir = filepath.Join(worktreePath, gitDir)
|
|
}
|
|
data, err := os.ReadFile(filepath.Join(gitDir, "info", "exclude"))
|
|
if err != nil {
|
|
t.Fatalf("read .git/info/exclude failed: %v", err)
|
|
}
|
|
return string(data)
|
|
}
|
|
|
|
func TestCreateWorktreeNotCached(t *testing.T) {
|
|
t.Parallel()
|
|
cacheRoot := t.TempDir()
|
|
cache := New(cacheRoot, testLogger())
|
|
|
|
_, err := cache.CreateWorktree(WorktreeParams{
|
|
WorkspaceID: "ws-1",
|
|
RepoURL: "https://github.com/org/nonexistent",
|
|
WorkDir: t.TempDir(),
|
|
AgentName: "Agent",
|
|
TaskID: "test-task-id",
|
|
})
|
|
if err == nil {
|
|
t.Fatal("expected error for uncached repo")
|
|
}
|
|
if !strings.Contains(err.Error(), "not found in cache") {
|
|
t.Errorf("expected 'not found in cache' error, got: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestCreateWorktreeWithRequestedBranchRef(t *testing.T) {
|
|
t.Parallel()
|
|
sourceRepo := createTestRepo(t)
|
|
defaultHead := gitHead(t, sourceRepo)
|
|
|
|
runGitAuthored(t, sourceRepo, "checkout", "-b", "review-branch")
|
|
if err := os.WriteFile(filepath.Join(sourceRepo, "review.txt"), []byte("review\n"), 0o644); err != nil {
|
|
t.Fatalf("write review file: %v", err)
|
|
}
|
|
runGitAuthored(t, sourceRepo, "add", ".")
|
|
runGitAuthored(t, sourceRepo, "commit", "-m", "review branch commit")
|
|
reviewHead := gitHead(t, sourceRepo)
|
|
if reviewHead == defaultHead {
|
|
t.Fatal("test setup failed: review branch did not advance")
|
|
}
|
|
|
|
cache := New(t.TempDir(), testLogger())
|
|
if err := cache.Sync("ws-1", []RepoInfo{{URL: sourceRepo}}); err != nil {
|
|
t.Fatalf("sync failed: %v", err)
|
|
}
|
|
|
|
result, err := cache.CreateWorktree(WorktreeParams{
|
|
WorkspaceID: "ws-1",
|
|
RepoURL: sourceRepo,
|
|
WorkDir: t.TempDir(),
|
|
Ref: "review-branch",
|
|
AgentName: "Reviewer",
|
|
TaskID: "review-task-id",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("CreateWorktree failed: %v", err)
|
|
}
|
|
|
|
if got := gitHead(t, result.Path); got != reviewHead {
|
|
t.Fatalf("worktree HEAD = %s, want requested branch head %s", got, reviewHead)
|
|
}
|
|
if _, err := os.Stat(filepath.Join(result.Path, "review.txt")); err != nil {
|
|
t.Fatalf("requested branch file missing: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestCreateWorktreeWithRequestedCommitRef(t *testing.T) {
|
|
t.Parallel()
|
|
sourceRepo := createTestRepo(t)
|
|
firstCommit := gitHead(t, sourceRepo)
|
|
addEmptyCommit(t, sourceRepo, "second commit")
|
|
|
|
cache := New(t.TempDir(), testLogger())
|
|
if err := cache.Sync("ws-1", []RepoInfo{{URL: sourceRepo}}); err != nil {
|
|
t.Fatalf("sync failed: %v", err)
|
|
}
|
|
|
|
result, err := cache.CreateWorktree(WorktreeParams{
|
|
WorkspaceID: "ws-1",
|
|
RepoURL: sourceRepo,
|
|
WorkDir: t.TempDir(),
|
|
Ref: firstCommit,
|
|
AgentName: "Reviewer",
|
|
TaskID: "commit-task-id",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("CreateWorktree failed: %v", err)
|
|
}
|
|
|
|
if got := gitHead(t, result.Path); got != firstCommit {
|
|
t.Fatalf("worktree HEAD = %s, want requested commit %s", got, firstCommit)
|
|
}
|
|
}
|
|
|
|
func TestCreateWorktreeWithRequestedTagRef(t *testing.T) {
|
|
t.Parallel()
|
|
sourceRepo := createTestRepo(t)
|
|
taggedCommit := gitHead(t, sourceRepo)
|
|
runGitAuthored(t, sourceRepo, "tag", "v1")
|
|
// Advance the default branch past the tag so worktree HEAD == taggedCommit
|
|
// can only be true if the tag was actually resolved (vs falling back to
|
|
// the default branch tip).
|
|
addEmptyCommit(t, sourceRepo, "post-tag commit")
|
|
|
|
cache := New(t.TempDir(), testLogger())
|
|
if err := cache.Sync("ws-1", []RepoInfo{{URL: sourceRepo}}); err != nil {
|
|
t.Fatalf("sync failed: %v", err)
|
|
}
|
|
|
|
result, err := cache.CreateWorktree(WorktreeParams{
|
|
WorkspaceID: "ws-1",
|
|
RepoURL: sourceRepo,
|
|
WorkDir: t.TempDir(),
|
|
Ref: "v1",
|
|
AgentName: "Reviewer",
|
|
TaskID: "tag-task-id",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("CreateWorktree failed: %v", err)
|
|
}
|
|
|
|
if got := gitHead(t, result.Path); got != taggedCommit {
|
|
t.Fatalf("worktree HEAD = %s, want tagged commit %s", got, taggedCommit)
|
|
}
|
|
}
|
|
|
|
func TestCreateWorktreeWithUnknownRequestedRef(t *testing.T) {
|
|
t.Parallel()
|
|
sourceRepo := createTestRepo(t)
|
|
cache := New(t.TempDir(), testLogger())
|
|
if err := cache.Sync("ws-1", []RepoInfo{{URL: sourceRepo}}); err != nil {
|
|
t.Fatalf("sync failed: %v", err)
|
|
}
|
|
|
|
_, err := cache.CreateWorktree(WorktreeParams{
|
|
WorkspaceID: "ws-1",
|
|
RepoURL: sourceRepo,
|
|
WorkDir: t.TempDir(),
|
|
Ref: "missing-ref",
|
|
AgentName: "Reviewer",
|
|
TaskID: "missing-ref-task-id",
|
|
})
|
|
if err == nil {
|
|
t.Fatal("expected unknown ref error")
|
|
}
|
|
if !strings.Contains(err.Error(), "cannot resolve requested ref") {
|
|
t.Fatalf("expected requested ref error, got: %v", err)
|
|
}
|
|
}
|
|
|
|
func trimLine(s string) string {
|
|
return strings.TrimSpace(s)
|
|
}
|
|
|
|
// gitRefCommit resolves a git ref to its commit SHA in repoPath.
|
|
func gitRefCommit(t *testing.T, repoPath, ref string) string {
|
|
t.Helper()
|
|
if ref == "" {
|
|
t.Fatalf("empty ref in %s", repoPath)
|
|
}
|
|
cmd := exec.Command("git", "-C", repoPath, "rev-parse", ref)
|
|
out, err := cmd.Output()
|
|
if err != nil {
|
|
t.Fatalf("git rev-parse %s failed in %s: %v", ref, repoPath, err)
|
|
}
|
|
return strings.TrimSpace(string(out))
|
|
}
|
|
|
|
// addEmptyCommit adds an empty commit on the current branch of repoPath.
|
|
func addEmptyCommit(t *testing.T, repoPath, message string) {
|
|
t.Helper()
|
|
cmd := exec.Command("git", "-C", repoPath, "commit", "--allow-empty", "-m", message)
|
|
cmd.Env = append(os.Environ(),
|
|
"GIT_AUTHOR_NAME=test", "[email protected]",
|
|
"GIT_COMMITTER_NAME=test", "[email protected]",
|
|
)
|
|
if out, err := cmd.CombinedOutput(); err != nil {
|
|
t.Fatalf("git commit failed in %s: %s: %v", repoPath, out, err)
|
|
}
|
|
}
|
|
|
|
// runGitAuthored runs `git -C repoPath <args...>` with the test author env set.
|
|
func runGitAuthored(t *testing.T, repoPath string, args ...string) {
|
|
t.Helper()
|
|
full := append([]string{"-C", repoPath}, args...)
|
|
cmd := exec.Command("git", full...)
|
|
cmd.Env = append(os.Environ(),
|
|
"GIT_AUTHOR_NAME=test", "[email protected]",
|
|
"GIT_COMMITTER_NAME=test", "[email protected]",
|
|
)
|
|
if out, err := cmd.CombinedOutput(); err != nil {
|
|
t.Fatalf("git %v in %s: %s: %v", args, repoPath, out, err)
|
|
}
|
|
}
|
|
|
|
// TestCreateWorktreeFetchesDespiteAgentBranchOnRemote reproduces the original
|
|
// stale-cache bug. Under the legacy mirror refspec (+refs/heads/*:refs/heads/*)
|
|
// the sequence below would break on the second CreateWorktree because `git
|
|
// fetch` tries to overwrite refs/heads/agent/... which is locked by the first
|
|
// worktree, and the whole fetch aborts — silently discarding the main-branch
|
|
// update too. Under the modern remote-tracking refspec, fetched heads land in
|
|
// refs/remotes/origin/* and no longer collide with worktree-locked refs.
|
|
func TestCreateWorktreeFetchesDespiteAgentBranchOnRemote(t *testing.T) {
|
|
t.Parallel()
|
|
sourceRepo := createTestRepo(t)
|
|
// Capture the default branch BEFORE any detach/commit/checkout dance — we
|
|
// need its name later to add new commits to the correct branch.
|
|
defaultBranch := currentBranchName(t, sourceRepo)
|
|
|
|
// Put source repo on a detached HEAD so the first worktree's agent branch
|
|
// can be pushed back to it as a regular update (non-bare repos refuse to
|
|
// push to the currently checked-out branch).
|
|
runGitAuthored(t, sourceRepo, "checkout", "--detach", "HEAD")
|
|
|
|
cacheRoot := t.TempDir()
|
|
cache := New(cacheRoot, testLogger())
|
|
if err := cache.Sync("ws-1", []RepoInfo{{URL: sourceRepo}}); err != nil {
|
|
t.Fatalf("sync failed: %v", err)
|
|
}
|
|
|
|
// First worktree creates refs/heads/agent/... inside the bare cache.
|
|
workDir1 := t.TempDir()
|
|
result1, err := cache.CreateWorktree(WorktreeParams{
|
|
WorkspaceID: "ws-1",
|
|
RepoURL: sourceRepo,
|
|
WorkDir: workDir1,
|
|
AgentName: "agent",
|
|
TaskID: "t1111111-0000-0000-0000-000000000000",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("first CreateWorktree failed: %v", err)
|
|
}
|
|
|
|
// Simulate the agent pushing its branch back to origin (i.e. opening a PR).
|
|
// Now sourceRepo has refs/heads/agent/... matching the locked ref in the
|
|
// bare cache, which is the condition that triggered the legacy bug.
|
|
if err := os.WriteFile(filepath.Join(result1.Path, "hello.txt"), []byte("hi\n"), 0o644); err != nil {
|
|
t.Fatalf("write file: %v", err)
|
|
}
|
|
runGitAuthored(t, result1.Path, "add", ".")
|
|
runGitAuthored(t, result1.Path, "commit", "-m", "first task")
|
|
runGitAuthored(t, result1.Path, "push", "origin", result1.BranchName)
|
|
|
|
// Add a new commit to source's default branch (not the agent branch we
|
|
// just pushed). Then re-detach so future pushes to other branches still work.
|
|
runGitAuthored(t, sourceRepo, "checkout", defaultBranch)
|
|
addEmptyCommit(t, sourceRepo, "new commit on default branch")
|
|
sourceHead := gitRefCommit(t, sourceRepo, "refs/heads/"+defaultBranch)
|
|
runGitAuthored(t, sourceRepo, "checkout", "--detach", "HEAD")
|
|
|
|
// Second worktree: CreateWorktree fetches first. Under the legacy refspec
|
|
// this fetch would fail (refusing to fetch into locked refs/heads/agent/...)
|
|
// and the worktree would be based on the stale snapshot. Under the modern
|
|
// refspec this succeeds and the new worktree sees sourceHead.
|
|
workDir2 := t.TempDir()
|
|
result2, err := cache.CreateWorktree(WorktreeParams{
|
|
WorkspaceID: "ws-1",
|
|
RepoURL: sourceRepo,
|
|
WorkDir: workDir2,
|
|
AgentName: "agent",
|
|
TaskID: "t2222222-0000-0000-0000-000000000000",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("second CreateWorktree failed: %v", err)
|
|
}
|
|
|
|
if got := gitHead(t, result2.Path); got != sourceHead {
|
|
t.Fatalf("second worktree HEAD = %s, want %s (remote default head after new commit)", got, sourceHead)
|
|
}
|
|
}
|
|
|
|
// currentBranchName returns the branch name that HEAD points at in repoPath.
|
|
// Fails the test if HEAD is detached.
|
|
func currentBranchName(t *testing.T, repoPath string) string {
|
|
t.Helper()
|
|
out, err := exec.Command("git", "-C", repoPath, "symbolic-ref", "--short", "HEAD").Output()
|
|
if err != nil {
|
|
t.Fatalf("symbolic-ref --short HEAD in %s: %v", repoPath, err)
|
|
}
|
|
name := strings.TrimSpace(string(out))
|
|
if name == "" {
|
|
t.Fatalf("empty branch name in %s", repoPath)
|
|
}
|
|
return name
|
|
}
|
|
|
|
// TestEnsureRemoteTrackingLayoutMigratesLegacyCache verifies that a cache
|
|
// created with the legacy mirror refspec is migrated in place on next use:
|
|
// the refspec is rewritten to the modern remote-tracking layout and
|
|
// refs/remotes/origin/* gets backfilled so getRemoteDefaultBranch can resolve
|
|
// the remote default.
|
|
func TestEnsureRemoteTrackingLayoutMigratesLegacyCache(t *testing.T) {
|
|
t.Parallel()
|
|
sourceRepo := createTestRepo(t)
|
|
cacheRoot := t.TempDir()
|
|
cache := New(cacheRoot, testLogger())
|
|
if err := cache.Sync("ws-1", []RepoInfo{{URL: sourceRepo}}); err != nil {
|
|
t.Fatalf("sync failed: %v", err)
|
|
}
|
|
barePath := cache.Lookup("ws-1", sourceRepo)
|
|
|
|
// Reset to the legacy mirror refspec to simulate a cache created by an
|
|
// older version of the daemon.
|
|
if err := setFetchRefspec(barePath, "+refs/heads/*:refs/heads/*"); err != nil {
|
|
t.Fatalf("set legacy refspec: %v", err)
|
|
}
|
|
// Wipe any refs/remotes/origin/* that may have been populated by the initial clone.
|
|
_ = exec.Command("git", "-C", barePath, "update-ref", "-d", "refs/remotes/origin/HEAD").Run()
|
|
if err := exec.Command("sh", "-c", "rm -rf '"+filepath.Join(barePath, "refs", "remotes")+"'").Run(); err != nil {
|
|
t.Fatalf("wipe refs/remotes: %v", err)
|
|
}
|
|
|
|
// Sanity check: we've successfully forced the cache into legacy state.
|
|
if cur, _ := readFetchRefspec(barePath); cur != "+refs/heads/*:refs/heads/*" {
|
|
t.Fatalf("precondition failed: refspec is %q, want legacy mirror", cur)
|
|
}
|
|
|
|
// ensureRemoteTrackingLayout should migrate: rewrite refspec, backfill
|
|
// refs/remotes/origin/*, and set origin HEAD.
|
|
if err := ensureRemoteTrackingLayout(barePath); err != nil {
|
|
t.Fatalf("ensureRemoteTrackingLayout failed: %v", err)
|
|
}
|
|
|
|
cur, err := readFetchRefspec(barePath)
|
|
if err != nil {
|
|
t.Fatalf("read refspec after migration: %v", err)
|
|
}
|
|
if cur != modernFetchRefspec {
|
|
t.Errorf("refspec = %q, want %q", cur, modernFetchRefspec)
|
|
}
|
|
|
|
// getRemoteDefaultBranch should now return a refs/remotes/origin/<branch>.
|
|
ref := getRemoteDefaultBranch(barePath)
|
|
if !strings.HasPrefix(ref, "refs/remotes/origin/") {
|
|
t.Errorf("getRemoteDefaultBranch = %q, want refs/remotes/origin/*", ref)
|
|
}
|
|
}
|
|
|
|
// TestCreateWorktreePathCollisionDoesNotLeakBranch verifies the secondary bug
|
|
// fix: when the worktree path already exists as a non-worktree (e.g. a plain
|
|
// directory), createWorktree must fail cleanly without leaking a branch into
|
|
// the bare repo. Previously the "already exists" retry logic would
|
|
// misclassify path collisions as branch collisions and create a second
|
|
// timestamp-suffixed branch before hitting the same path error.
|
|
func TestCreateWorktreePathCollisionDoesNotLeakBranch(t *testing.T) {
|
|
t.Parallel()
|
|
sourceRepo := createTestRepo(t)
|
|
cacheRoot := t.TempDir()
|
|
cache := New(cacheRoot, testLogger())
|
|
if err := cache.Sync("ws-1", []RepoInfo{{URL: sourceRepo}}); err != nil {
|
|
t.Fatalf("sync failed: %v", err)
|
|
}
|
|
barePath := cache.Lookup("ws-1", sourceRepo)
|
|
|
|
// Pre-create the target worktree path as a plain non-empty directory.
|
|
workDir := t.TempDir()
|
|
dirName := repoNameFromURL(sourceRepo)
|
|
worktreePath := filepath.Join(workDir, dirName)
|
|
if err := os.MkdirAll(worktreePath, 0o755); err != nil {
|
|
t.Fatalf("pre-create worktree path: %v", err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(worktreePath, "stray.txt"), []byte("x"), 0o644); err != nil {
|
|
t.Fatalf("write stray file: %v", err)
|
|
}
|
|
|
|
_, err := cache.CreateWorktree(WorktreeParams{
|
|
WorkspaceID: "ws-1",
|
|
RepoURL: sourceRepo,
|
|
WorkDir: workDir,
|
|
AgentName: "agent",
|
|
TaskID: "t1111111-0000-0000-0000-000000000000",
|
|
})
|
|
if err == nil {
|
|
t.Fatal("expected CreateWorktree to fail when path exists as non-worktree")
|
|
}
|
|
|
|
// No agent/* branches should have been created in the bare repo as a
|
|
// side effect of the failed call.
|
|
out, runErr := exec.Command("git", "-C", barePath, "for-each-ref", "--format=%(refname)", "refs/heads/agent").Output()
|
|
if runErr != nil {
|
|
t.Fatalf("for-each-ref failed: %v", runErr)
|
|
}
|
|
if leaked := strings.TrimSpace(string(out)); leaked != "" {
|
|
t.Errorf("branch leaked into bare repo after path-collision failure:\n%s", leaked)
|
|
}
|
|
}
|
|
|
|
// TestGetRemoteDefaultBranchScansForCustomDefault verifies fallback (3) of
|
|
// getRemoteDefaultBranch: when the cache has refs/remotes/origin/<custom>
|
|
// (e.g. develop, trunk) but no refs/remotes/origin/HEAD and no main/master,
|
|
// the function picks the custom branch instead of returning empty.
|
|
func TestGetRemoteDefaultBranchScansForCustomDefault(t *testing.T) {
|
|
t.Parallel()
|
|
sourceRepo := createTestRepo(t)
|
|
cacheRoot := t.TempDir()
|
|
cache := New(cacheRoot, testLogger())
|
|
if err := cache.Sync("ws-1", []RepoInfo{{URL: sourceRepo}}); err != nil {
|
|
t.Fatalf("sync failed: %v", err)
|
|
}
|
|
barePath := cache.Lookup("ws-1", sourceRepo)
|
|
|
|
// Resolve the existing default branch's commit so we can repoint a
|
|
// custom-named ref at it, then wipe the standard refs to force the
|
|
// fallback path.
|
|
existing := getRemoteDefaultBranch(barePath)
|
|
if existing == "" {
|
|
t.Fatalf("precondition: cache should have a default branch right after sync")
|
|
}
|
|
commit := gitRefCommit(t, barePath, existing)
|
|
|
|
// Create refs/remotes/origin/develop pointing at that commit.
|
|
runGitAuthored(t, barePath, "update-ref", "refs/remotes/origin/develop", commit)
|
|
// Now wipe origin/HEAD (symbolic-ref -d removes the symref file itself)
|
|
// and the common defaults so steps 1 and 2 of the resolver miss and we
|
|
// fall through to the for-each-ref scan.
|
|
_ = exec.Command("git", "-C", barePath, "symbolic-ref", "-d", "refs/remotes/origin/HEAD").Run()
|
|
_ = exec.Command("git", "-C", barePath, "update-ref", "-d", "refs/remotes/origin/main").Run()
|
|
_ = exec.Command("git", "-C", barePath, "update-ref", "-d", "refs/remotes/origin/master").Run()
|
|
|
|
got := getRemoteDefaultBranch(barePath)
|
|
if got != "refs/remotes/origin/develop" {
|
|
t.Fatalf("getRemoteDefaultBranch = %q, want refs/remotes/origin/develop", got)
|
|
}
|
|
}
|
|
|
|
// TestGetRemoteDefaultBranchFallsBackToBareHead verifies fallback (5):
|
|
// a legacy / migration-pending cache that has no refs/remotes/origin/* at all
|
|
// but still has its bare HEAD pointing at refs/heads/<branch> (the snapshot
|
|
// from the original mirror clone) should resolve to that local head instead
|
|
// of failing. This protects against transient backfill-fetch failures during
|
|
// the legacy → modern refspec migration. Gated on refs/remotes/origin/* being
|
|
// completely empty — with any modern remote-tracking refs present, the
|
|
// resolver refuses to reach back into the stale bare heads.
|
|
func TestGetRemoteDefaultBranchFallsBackToBareHead(t *testing.T) {
|
|
t.Parallel()
|
|
sourceRepo := createTestRepo(t)
|
|
cacheRoot := t.TempDir()
|
|
cache := New(cacheRoot, testLogger())
|
|
if err := cache.Sync("ws-1", []RepoInfo{{URL: sourceRepo}}); err != nil {
|
|
t.Fatalf("sync failed: %v", err)
|
|
}
|
|
barePath := cache.Lookup("ws-1", sourceRepo)
|
|
|
|
// Force the cache into a state that mimics "legacy mirror clone whose
|
|
// post-migration backfill fetch failed":
|
|
// - bare HEAD still points at refs/heads/<default>
|
|
// - refs/remotes/origin/* is empty
|
|
if err := exec.Command("sh", "-c", "rm -rf '"+filepath.Join(barePath, "refs", "remotes")+"'").Run(); err != nil {
|
|
t.Fatalf("wipe refs/remotes: %v", err)
|
|
}
|
|
|
|
// Sanity: origin/* is gone, HEAD is still a symbolic ref to refs/heads/*.
|
|
if out, err := exec.Command("git", "-C", barePath, "for-each-ref", "refs/remotes/origin/").Output(); err == nil && strings.TrimSpace(string(out)) != "" {
|
|
t.Fatalf("precondition failed: refs/remotes/origin/* should be empty, got %s", out)
|
|
}
|
|
|
|
got := getRemoteDefaultBranch(barePath)
|
|
if !strings.HasPrefix(got, "refs/heads/") {
|
|
t.Fatalf("getRemoteDefaultBranch = %q, want refs/heads/* fallback", got)
|
|
}
|
|
|
|
// And the resolved ref must actually exist — verifying bareHeadBranch's
|
|
// rev-parse guard kicked in correctly.
|
|
if err := exec.Command("git", "-C", barePath, "rev-parse", "--verify", got).Run(); err != nil {
|
|
t.Fatalf("resolved ref %q does not exist: %v", got, err)
|
|
}
|
|
}
|
|
|
|
// TestGitFetchRefreshesOriginHeadAfterDefaultChange verifies that an
|
|
// already-modern cache picks up a remote default-branch change. Plain `git
|
|
// fetch` never refreshes refs/remotes/origin/HEAD on its own, so without
|
|
// gitFetch's explicit `git remote set-head origin --auto` call the resolver
|
|
// would keep returning the original default branch forever after the
|
|
// upstream flipped (e.g. master → main on a long-lived repo). This guards
|
|
// against the "already-modern cache never refreshes origin/HEAD" regression.
|
|
func TestGitFetchRefreshesOriginHeadAfterDefaultChange(t *testing.T) {
|
|
t.Parallel()
|
|
sourceRepo := createTestRepo(t)
|
|
initialBranch := currentBranchName(t, sourceRepo)
|
|
|
|
cacheRoot := t.TempDir()
|
|
cache := New(cacheRoot, testLogger())
|
|
if err := cache.Sync("ws-1", []RepoInfo{{URL: sourceRepo}}); err != nil {
|
|
t.Fatalf("sync failed: %v", err)
|
|
}
|
|
barePath := cache.Lookup("ws-1", sourceRepo)
|
|
|
|
// Precondition: cache is already modern and origin/HEAD points at the
|
|
// source's initial default branch.
|
|
if got := getRemoteDefaultBranch(barePath); got != "refs/remotes/origin/"+initialBranch {
|
|
t.Fatalf("precondition: getRemoteDefaultBranch = %q, want refs/remotes/origin/%s", got, initialBranch)
|
|
}
|
|
|
|
// Flip the source's default: create a new branch, commit on it, stay
|
|
// checked out on it so the source's HEAD reflects the new default. A
|
|
// subsequent `git ls-remote` against the source advertises this new
|
|
// HEAD, which is what set-head --auto consumes.
|
|
runGitAuthored(t, sourceRepo, "checkout", "-b", "new-default")
|
|
addEmptyCommit(t, sourceRepo, "new-default commit")
|
|
|
|
// Fetch via the cache's code path. Without the set-head call, origin/HEAD
|
|
// would still point at the old default here.
|
|
if err := gitFetch(barePath); err != nil {
|
|
t.Fatalf("gitFetch failed: %v", err)
|
|
}
|
|
|
|
// refs/remotes/origin/HEAD must now point at the new default branch.
|
|
out, err := exec.Command("git", "-C", barePath, "symbolic-ref", "refs/remotes/origin/HEAD").Output()
|
|
if err != nil {
|
|
t.Fatalf("symbolic-ref origin/HEAD after fetch: %v", err)
|
|
}
|
|
if got := strings.TrimSpace(string(out)); got != "refs/remotes/origin/new-default" {
|
|
t.Fatalf("origin/HEAD after fetch = %q, want refs/remotes/origin/new-default", got)
|
|
}
|
|
|
|
// And getRemoteDefaultBranch must resolve through step 1 (verified
|
|
// origin/HEAD) to the new default — not through step 2 where origin/main
|
|
// or origin/master could accidentally match the old branch.
|
|
if got := getRemoteDefaultBranch(barePath); got != "refs/remotes/origin/new-default" {
|
|
t.Fatalf("getRemoteDefaultBranch after fetch = %q, want refs/remotes/origin/new-default", got)
|
|
}
|
|
}
|
|
|
|
// TestGetRemoteDefaultBranchUsesBareHeadHintForCustomDefault verifies step 3
|
|
// of the resolver: when the cache has a non-standard default branch name
|
|
// (trunk, develop, …) and `git remote set-head origin --auto` didn't
|
|
// populate refs/remotes/origin/HEAD, the resolver must use the bare repo's
|
|
// own HEAD as a hint to pick refs/remotes/origin/<same name> — NOT fall
|
|
// through to a refname-order scan that would pick the wrong branch.
|
|
func TestGetRemoteDefaultBranchUsesBareHeadHintForCustomDefault(t *testing.T) {
|
|
t.Parallel()
|
|
sourceRepo := createTestRepo(t)
|
|
cacheRoot := t.TempDir()
|
|
cache := New(cacheRoot, testLogger())
|
|
if err := cache.Sync("ws-1", []RepoInfo{{URL: sourceRepo}}); err != nil {
|
|
t.Fatalf("sync failed: %v", err)
|
|
}
|
|
barePath := cache.Lookup("ws-1", sourceRepo)
|
|
|
|
existing := getRemoteDefaultBranch(barePath)
|
|
if existing == "" {
|
|
t.Fatalf("precondition: cache should have a default branch right after sync")
|
|
}
|
|
commit := gitRefCommit(t, barePath, existing)
|
|
|
|
// Simulate a custom default branch: create refs/heads/trunk in the bare
|
|
// repo and point HEAD at it. `git clone --bare` would do the equivalent
|
|
// when the remote's default was "trunk", so this matches real-world
|
|
// state for such remotes.
|
|
runGitAuthored(t, barePath, "update-ref", "refs/heads/trunk", commit)
|
|
runGitAuthored(t, barePath, "symbolic-ref", "HEAD", "refs/heads/trunk")
|
|
|
|
// Populate two refs/remotes/origin/* entries. "feature-alpha" is
|
|
// alphabetically earlier than "trunk" — a refname-order scan (the old
|
|
// bug) would return feature-alpha, not trunk.
|
|
runGitAuthored(t, barePath, "update-ref", "refs/remotes/origin/trunk", commit)
|
|
runGitAuthored(t, barePath, "update-ref", "refs/remotes/origin/feature-alpha", commit)
|
|
|
|
// Knock out the ahead-of-step-3 fallbacks so resolution must rely on
|
|
// the bare-HEAD hint.
|
|
_ = exec.Command("git", "-C", barePath, "symbolic-ref", "-d", "refs/remotes/origin/HEAD").Run()
|
|
_ = exec.Command("git", "-C", barePath, "update-ref", "-d", "refs/remotes/origin/main").Run()
|
|
_ = exec.Command("git", "-C", barePath, "update-ref", "-d", "refs/remotes/origin/master").Run()
|
|
|
|
got := getRemoteDefaultBranch(barePath)
|
|
if got != "refs/remotes/origin/trunk" {
|
|
t.Fatalf("getRemoteDefaultBranch = %q, want refs/remotes/origin/trunk (via bare-HEAD hint)", got)
|
|
}
|
|
}
|
|
|
|
// TestCreateWorktreeInstallsCoAuthoredByHook verifies that CreateWorktree
|
|
// installs a prepare-commit-msg hook that appends a Co-authored-by trailer
|
|
// for the Multica Agent to every commit made in the worktree.
|
|
func TestCreateWorktreeInstallsCoAuthoredByHook(t *testing.T) {
|
|
t.Parallel()
|
|
sourceRepo := createTestRepo(t)
|
|
cacheRoot := t.TempDir()
|
|
|
|
cache := New(cacheRoot, testLogger())
|
|
if err := cache.Sync("ws-1", []RepoInfo{{URL: sourceRepo}}); err != nil {
|
|
t.Fatalf("sync failed: %v", err)
|
|
}
|
|
|
|
workDir := t.TempDir()
|
|
result, err := cache.CreateWorktree(WorktreeParams{
|
|
WorkspaceID: "ws-1",
|
|
RepoURL: sourceRepo,
|
|
WorkDir: workDir,
|
|
AgentName: "Test Agent",
|
|
TaskID: "a1b2c3d4-0000-0000-0000-000000000000",
|
|
CoAuthoredByEnabled: true,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("CreateWorktree failed: %v", err)
|
|
}
|
|
|
|
// Make a commit in the worktree and verify the hook appends the trailer.
|
|
if err := os.WriteFile(filepath.Join(result.Path, "test.txt"), []byte("hello\n"), 0o644); err != nil {
|
|
t.Fatalf("write test file: %v", err)
|
|
}
|
|
runGitAuthored(t, result.Path, "add", ".")
|
|
runGitAuthored(t, result.Path, "commit", "-m", "test commit")
|
|
|
|
// Read the commit message.
|
|
out, err := exec.Command("git", "-C", result.Path, "log", "-1", "--format=%B").Output()
|
|
if err != nil {
|
|
t.Fatalf("git log failed: %v", err)
|
|
}
|
|
commitMsg := string(out)
|
|
expectedTrailer := "Co-authored-by: multica-agent <[email protected]>"
|
|
if !strings.Contains(commitMsg, expectedTrailer) {
|
|
t.Errorf("commit message missing Co-authored-by trailer.\ngot:\n%s", commitMsg)
|
|
}
|
|
}
|
|
|
|
// TestCoAuthoredByHookIdempotent verifies that the hook does not add a
|
|
// duplicate Co-authored-by trailer if one is already present in the message.
|
|
func TestCoAuthoredByHookIdempotent(t *testing.T) {
|
|
t.Parallel()
|
|
sourceRepo := createTestRepo(t)
|
|
cacheRoot := t.TempDir()
|
|
|
|
cache := New(cacheRoot, testLogger())
|
|
if err := cache.Sync("ws-1", []RepoInfo{{URL: sourceRepo}}); err != nil {
|
|
t.Fatalf("sync failed: %v", err)
|
|
}
|
|
|
|
workDir := t.TempDir()
|
|
result, err := cache.CreateWorktree(WorktreeParams{
|
|
WorkspaceID: "ws-1",
|
|
RepoURL: sourceRepo,
|
|
WorkDir: workDir,
|
|
AgentName: "Test Agent",
|
|
TaskID: "b2c3d4e5-0000-0000-0000-000000000000",
|
|
CoAuthoredByEnabled: true,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("CreateWorktree failed: %v", err)
|
|
}
|
|
|
|
// Commit with the trailer already in the message.
|
|
trailer := "Co-authored-by: multica-agent <[email protected]>"
|
|
if err := os.WriteFile(filepath.Join(result.Path, "test.txt"), []byte("hello\n"), 0o644); err != nil {
|
|
t.Fatalf("write test file: %v", err)
|
|
}
|
|
runGitAuthored(t, result.Path, "add", ".")
|
|
runGitAuthored(t, result.Path, "commit", "-m", "test commit\n\n"+trailer)
|
|
|
|
out, err := exec.Command("git", "-C", result.Path, "log", "-1", "--format=%B").Output()
|
|
if err != nil {
|
|
t.Fatalf("git log failed: %v", err)
|
|
}
|
|
commitMsg := string(out)
|
|
|
|
// Count occurrences — should appear exactly once.
|
|
count := strings.Count(commitMsg, trailer)
|
|
if count != 1 {
|
|
t.Errorf("expected exactly 1 Co-authored-by trailer, found %d.\ngot:\n%s", count, commitMsg)
|
|
}
|
|
}
|
|
|
|
// TestCreateWorktreeRemovesCoAuthoredByHookWhenDisabled verifies the toggle-off
|
|
// path: a bare cache that already carries the Multica prepare-commit-msg hook
|
|
// (e.g. from a prior worktree created with the setting on) must drop the hook
|
|
// when the next CreateWorktree call passes CoAuthoredByEnabled=false.
|
|
// Otherwise commits keep getting the trailer even after the user disables the
|
|
// workspace setting.
|
|
func TestCreateWorktreeRemovesCoAuthoredByHookWhenDisabled(t *testing.T) {
|
|
t.Parallel()
|
|
sourceRepo := createTestRepo(t)
|
|
cacheRoot := t.TempDir()
|
|
|
|
cache := New(cacheRoot, testLogger())
|
|
if err := cache.Sync("ws-1", []RepoInfo{{URL: sourceRepo}}); err != nil {
|
|
t.Fatalf("sync failed: %v", err)
|
|
}
|
|
|
|
// First worktree: setting enabled → hook installed in the bare cache's
|
|
// shared hooks dir.
|
|
workDir1 := t.TempDir()
|
|
if _, err := cache.CreateWorktree(WorktreeParams{
|
|
WorkspaceID: "ws-1",
|
|
RepoURL: sourceRepo,
|
|
WorkDir: workDir1,
|
|
AgentName: "Test Agent",
|
|
TaskID: "11111111-0000-0000-0000-000000000000",
|
|
CoAuthoredByEnabled: true,
|
|
}); err != nil {
|
|
t.Fatalf("CreateWorktree (enabled) failed: %v", err)
|
|
}
|
|
|
|
barePath := cache.Lookup("ws-1", sourceRepo)
|
|
hookPath := filepath.Join(barePath, "hooks", "prepare-commit-msg")
|
|
if _, err := os.Stat(hookPath); err != nil {
|
|
t.Fatalf("precondition: expected hook to be installed at %s: %v", hookPath, err)
|
|
}
|
|
|
|
// Second worktree on the same bare cache: setting disabled → hook must
|
|
// be removed and a commit in the new worktree must NOT carry the
|
|
// trailer.
|
|
workDir2 := t.TempDir()
|
|
result, err := cache.CreateWorktree(WorktreeParams{
|
|
WorkspaceID: "ws-1",
|
|
RepoURL: sourceRepo,
|
|
WorkDir: workDir2,
|
|
AgentName: "Test Agent",
|
|
TaskID: "22222222-0000-0000-0000-000000000000",
|
|
CoAuthoredByEnabled: false,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("CreateWorktree (disabled) failed: %v", err)
|
|
}
|
|
|
|
if _, err := os.Stat(hookPath); !os.IsNotExist(err) {
|
|
t.Errorf("expected hook to be removed at %s, stat err=%v", hookPath, err)
|
|
}
|
|
|
|
if err := os.WriteFile(filepath.Join(result.Path, "test.txt"), []byte("hello\n"), 0o644); err != nil {
|
|
t.Fatalf("write test file: %v", err)
|
|
}
|
|
runGitAuthored(t, result.Path, "add", ".")
|
|
runGitAuthored(t, result.Path, "commit", "-m", "test commit")
|
|
|
|
out, err := exec.Command("git", "-C", result.Path, "log", "-1", "--format=%B").Output()
|
|
if err != nil {
|
|
t.Fatalf("git log failed: %v", err)
|
|
}
|
|
commitMsg := string(out)
|
|
if strings.Contains(commitMsg, "Co-authored-by: multica-agent") {
|
|
t.Errorf("commit unexpectedly carries the Co-authored-by trailer with setting disabled.\ngot:\n%s", commitMsg)
|
|
}
|
|
}
|
|
|
|
// TestCreateWorktreeRemovesLegacyCoAuthoredByHook verifies the migration
|
|
// path: bare clones already on disk from previous daemon versions carry a
|
|
// prepare-commit-msg hook that does NOT include the multicaHookMarker
|
|
// sentinel — only the older `# Installed by the Multica daemon.` comment.
|
|
// Toggling the workspace setting off must still remove those legacy hooks,
|
|
// otherwise users who flip the toggle in production keep seeing the trailer
|
|
// indefinitely (the exact bug reported in MUL-1704).
|
|
func TestCreateWorktreeRemovesLegacyCoAuthoredByHook(t *testing.T) {
|
|
t.Parallel()
|
|
sourceRepo := createTestRepo(t)
|
|
cacheRoot := t.TempDir()
|
|
|
|
cache := New(cacheRoot, testLogger())
|
|
if err := cache.Sync("ws-1", []RepoInfo{{URL: sourceRepo}}); err != nil {
|
|
t.Fatalf("sync failed: %v", err)
|
|
}
|
|
|
|
// Seed the bare cache with the exact hook content shipped by the
|
|
// previous daemon release (no multicaHookMarker line). Keeping a
|
|
// verbatim copy here means the test fails if recognition logic ever
|
|
// drifts away from what production hosts actually have on disk.
|
|
const legacyHook = `#!/bin/sh
|
|
# Multica: add Co-authored-by trailer for the Multica Agent.
|
|
# Installed by the Multica daemon. Do not edit — it will be overwritten.
|
|
|
|
COMMIT_MSG_FILE="$1"
|
|
COMMIT_SOURCE="$2"
|
|
|
|
# Skip merge and squash commits.
|
|
case "$COMMIT_SOURCE" in
|
|
merge|squash) exit 0 ;;
|
|
esac
|
|
|
|
TRAILER="Co-authored-by: multica-agent <[email protected]>"
|
|
|
|
# Don't add if already present.
|
|
if grep -qF "$TRAILER" "$COMMIT_MSG_FILE"; then
|
|
exit 0
|
|
fi
|
|
|
|
# Use git interpret-trailers for proper formatting.
|
|
git interpret-trailers --in-place --trailer "$TRAILER" "$COMMIT_MSG_FILE"
|
|
`
|
|
|
|
barePath := cache.Lookup("ws-1", sourceRepo)
|
|
hooksDir := filepath.Join(barePath, "hooks")
|
|
if err := os.MkdirAll(hooksDir, 0o755); err != nil {
|
|
t.Fatalf("create hooks dir: %v", err)
|
|
}
|
|
hookPath := filepath.Join(hooksDir, "prepare-commit-msg")
|
|
if err := os.WriteFile(hookPath, []byte(legacyHook), 0o755); err != nil {
|
|
t.Fatalf("seed legacy hook: %v", err)
|
|
}
|
|
|
|
workDir := t.TempDir()
|
|
result, err := cache.CreateWorktree(WorktreeParams{
|
|
WorkspaceID: "ws-1",
|
|
RepoURL: sourceRepo,
|
|
WorkDir: workDir,
|
|
AgentName: "Test Agent",
|
|
TaskID: "44444444-0000-0000-0000-000000000000",
|
|
CoAuthoredByEnabled: false,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("CreateWorktree (disabled) failed: %v", err)
|
|
}
|
|
|
|
if _, err := os.Stat(hookPath); !os.IsNotExist(err) {
|
|
t.Errorf("expected legacy hook to be removed at %s, stat err=%v", hookPath, err)
|
|
}
|
|
|
|
if err := os.WriteFile(filepath.Join(result.Path, "test.txt"), []byte("hello\n"), 0o644); err != nil {
|
|
t.Fatalf("write test file: %v", err)
|
|
}
|
|
runGitAuthored(t, result.Path, "add", ".")
|
|
runGitAuthored(t, result.Path, "commit", "-m", "test commit")
|
|
|
|
out, err := exec.Command("git", "-C", result.Path, "log", "-1", "--format=%B").Output()
|
|
if err != nil {
|
|
t.Fatalf("git log failed: %v", err)
|
|
}
|
|
if commitMsg := string(out); strings.Contains(commitMsg, "Co-authored-by: multica-agent") {
|
|
t.Errorf("commit unexpectedly carries the Co-authored-by trailer after legacy hook removal.\ngot:\n%s", commitMsg)
|
|
}
|
|
}
|
|
|
|
// TestRemoveCoAuthoredByHookPreservesUserHook verifies that the disable path
|
|
// only deletes hooks installed by the daemon. A prepare-commit-msg hook
|
|
// without the Multica marker (e.g. one a user added manually) must be left
|
|
// untouched even when CoAuthoredByEnabled=false.
|
|
func TestRemoveCoAuthoredByHookPreservesUserHook(t *testing.T) {
|
|
t.Parallel()
|
|
sourceRepo := createTestRepo(t)
|
|
cacheRoot := t.TempDir()
|
|
|
|
cache := New(cacheRoot, testLogger())
|
|
if err := cache.Sync("ws-1", []RepoInfo{{URL: sourceRepo}}); err != nil {
|
|
t.Fatalf("sync failed: %v", err)
|
|
}
|
|
|
|
barePath := cache.Lookup("ws-1", sourceRepo)
|
|
hooksDir := filepath.Join(barePath, "hooks")
|
|
if err := os.MkdirAll(hooksDir, 0o755); err != nil {
|
|
t.Fatalf("create hooks dir: %v", err)
|
|
}
|
|
hookPath := filepath.Join(hooksDir, "prepare-commit-msg")
|
|
userHook := "#!/bin/sh\n# user hook, not Multica\nexit 0\n"
|
|
if err := os.WriteFile(hookPath, []byte(userHook), 0o755); err != nil {
|
|
t.Fatalf("seed user hook: %v", err)
|
|
}
|
|
|
|
workDir := t.TempDir()
|
|
if _, err := cache.CreateWorktree(WorktreeParams{
|
|
WorkspaceID: "ws-1",
|
|
RepoURL: sourceRepo,
|
|
WorkDir: workDir,
|
|
AgentName: "Test Agent",
|
|
TaskID: "33333333-0000-0000-0000-000000000000",
|
|
CoAuthoredByEnabled: false,
|
|
}); err != nil {
|
|
t.Fatalf("CreateWorktree (disabled) failed: %v", err)
|
|
}
|
|
|
|
got, err := os.ReadFile(hookPath)
|
|
if err != nil {
|
|
t.Fatalf("user hook unexpectedly removed: %v", err)
|
|
}
|
|
if string(got) != userHook {
|
|
t.Errorf("user hook contents changed.\nwant:\n%s\ngot:\n%s", userHook, string(got))
|
|
}
|
|
}
|
|
|
|
// TestGetRemoteDefaultBranchAmbiguousOriginReturnsEmpty verifies step 4's
|
|
// safe-scan gating: when the cache has multiple refs/remotes/origin/*
|
|
// entries, none match the common defaults, and none match the bare HEAD
|
|
// either, the resolver must refuse to guess and return "". The caller
|
|
// surfaces this as a hard error instead of silently basing new agent work
|
|
// on an arbitrary refname-order-first candidate.
|
|
func TestGetRemoteDefaultBranchAmbiguousOriginReturnsEmpty(t *testing.T) {
|
|
t.Parallel()
|
|
sourceRepo := createTestRepo(t)
|
|
cacheRoot := t.TempDir()
|
|
cache := New(cacheRoot, testLogger())
|
|
if err := cache.Sync("ws-1", []RepoInfo{{URL: sourceRepo}}); err != nil {
|
|
t.Fatalf("sync failed: %v", err)
|
|
}
|
|
barePath := cache.Lookup("ws-1", sourceRepo)
|
|
|
|
existing := getRemoteDefaultBranch(barePath)
|
|
if existing == "" {
|
|
t.Fatalf("precondition: cache should have a default branch right after sync")
|
|
}
|
|
commit := gitRefCommit(t, barePath, existing)
|
|
|
|
// Populate two unrelated origin branches (none of which match any of
|
|
// the step 1-3 fallbacks).
|
|
runGitAuthored(t, barePath, "update-ref", "refs/remotes/origin/feature-a", commit)
|
|
runGitAuthored(t, barePath, "update-ref", "refs/remotes/origin/feature-b", commit)
|
|
|
|
// Wipe every ref a step 1-3 fallback could pick up:
|
|
// step 1: origin/HEAD
|
|
// step 2: origin/main, origin/master
|
|
// step 3: the origin/<bareHEAD-name> bridge
|
|
_ = exec.Command("git", "-C", barePath, "symbolic-ref", "-d", "refs/remotes/origin/HEAD").Run()
|
|
_ = exec.Command("git", "-C", barePath, "update-ref", "-d", "refs/remotes/origin/main").Run()
|
|
_ = exec.Command("git", "-C", barePath, "update-ref", "-d", "refs/remotes/origin/master").Run()
|
|
if bareRef := bareHeadBranch(barePath); bareRef != "" {
|
|
sameName := strings.TrimPrefix(bareRef, "refs/heads/")
|
|
_ = exec.Command("git", "-C", barePath, "update-ref", "-d", "refs/remotes/origin/"+sameName).Run()
|
|
}
|
|
|
|
got := getRemoteDefaultBranch(barePath)
|
|
if got != "" {
|
|
t.Fatalf("getRemoteDefaultBranch = %q, want \"\" (ambiguous origin/* must not guess)", got)
|
|
}
|
|
}
|
|
|
|
// TestLastUsedMissingIsUnknownNotAncient pins the distinction the GC's
|
|
// upgrade safety depends on: a cache created before the stamp existed must
|
|
// report "unknown", never a zero time that reads as infinitely idle.
|
|
func TestLastUsedMissingIsUnknownNotAncient(t *testing.T) {
|
|
t.Parallel()
|
|
if _, ok := LastUsed(t.TempDir()); ok {
|
|
t.Fatal("LastUsed on a repo with no stamp must report ok=false")
|
|
}
|
|
}
|
|
|
|
// TestMarkUsedRoundTrip covers the stamp write/read pair on its own.
|
|
func TestMarkUsedRoundTrip(t *testing.T) {
|
|
t.Parallel()
|
|
barePath := t.TempDir()
|
|
|
|
before := time.Now().Add(-time.Second)
|
|
MarkUsed(barePath, testLogger())
|
|
|
|
stamp, ok := LastUsed(barePath)
|
|
if !ok {
|
|
t.Fatal("expected a stamp after MarkUsed")
|
|
}
|
|
if stamp.Before(before) {
|
|
t.Errorf("stamp %s is older than the call that wrote it (%s)", stamp, before)
|
|
}
|
|
if time.Since(stamp) > time.Hour {
|
|
t.Errorf("stamp %s is implausibly old", stamp)
|
|
}
|
|
}
|
|
|
|
// TestMarkUsedIgnoresUnwritableRepo keeps the stamp best-effort: a failure to
|
|
// record use must not break the checkout that triggered it.
|
|
func TestMarkUsedIgnoresUnwritableRepo(t *testing.T) {
|
|
t.Parallel()
|
|
MarkUsed(filepath.Join(t.TempDir(), "does-not-exist"), testLogger())
|
|
MarkUsed("", testLogger())
|
|
}
|
|
|
|
// TestCreateWorktreeStampsLastUsed is the one that matters for eviction: the
|
|
// stamp has to be written where "a task really used this repo" happens, since
|
|
// directory mtime cannot distinguish that from a routine fetch.
|
|
func TestCreateWorktreeStampsLastUsed(t *testing.T) {
|
|
t.Parallel()
|
|
sourceRepo := createTestRepo(t)
|
|
cacheRoot := t.TempDir()
|
|
|
|
cache := New(cacheRoot, testLogger())
|
|
if err := cache.Sync("ws-1", []RepoInfo{{URL: sourceRepo}}); err != nil {
|
|
t.Fatalf("sync failed: %v", err)
|
|
}
|
|
|
|
barePath := cache.Lookup("ws-1", sourceRepo)
|
|
if barePath == "" {
|
|
t.Fatal("expected cached repo")
|
|
}
|
|
// Sync alone must not look like use — only a checkout counts.
|
|
if _, ok := LastUsed(barePath); ok {
|
|
t.Fatal("Sync must not stamp last-used; only CreateWorktree does")
|
|
}
|
|
|
|
if _, err := cache.CreateWorktree(WorktreeParams{
|
|
WorkspaceID: "ws-1",
|
|
RepoURL: sourceRepo,
|
|
WorkDir: t.TempDir(),
|
|
AgentName: "Code Reviewer",
|
|
TaskID: "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
|
|
}); err != nil {
|
|
t.Fatalf("CreateWorktree failed: %v", err)
|
|
}
|
|
|
|
if _, ok := LastUsed(barePath); !ok {
|
|
t.Error("CreateWorktree must stamp last-used on the bare repo")
|
|
}
|
|
}
|
|
|
|
// TestBarePathIsIndependentOfExistence separates the two questions Lookup used
|
|
// to answer at once: Lookup means "is it cached?", BarePath means "where would
|
|
// it live?" — the GC needs the second to map live repo URLs onto directories.
|
|
func TestBarePathIsIndependentOfExistence(t *testing.T) {
|
|
t.Parallel()
|
|
cache := New(t.TempDir(), testLogger())
|
|
|
|
path := cache.BarePath("ws-1", "https://example.com/acme/widgets.git")
|
|
if path == "" {
|
|
t.Fatal("BarePath must return a path even when nothing is cached")
|
|
}
|
|
if cache.Lookup("ws-1", "https://example.com/acme/widgets.git") != "" {
|
|
t.Error("Lookup must still report an uncached repo as absent")
|
|
}
|
|
}
|
|
|
|
// TestTaskKeyMatchesExecenvContract pins this package's copy of the task-segment
|
|
// rule. execenv, repocache and the agent handler each carry one — execenv owns
|
|
// the canonical taskKey, and the handler has a contract test against
|
|
// PredictRootDir, but this copy had none and could drift silently.
|
|
//
|
|
// Both properties matter. The segment must come from the id's random TAIL:
|
|
// UUIDv7 leads with a millisecond timestamp, so a leading slice is identical
|
|
// for every task created in the same ~65.5s window, which gave two concurrent
|
|
// tasks of one agent the same branch name (#7326). And it must stay SHORT: a
|
|
// branch name becomes a path under .git/refs/heads/ inside the task checkout,
|
|
// where Windows enforces MAX_PATH.
|
|
func TestTaskKeyMatchesExecenvContract(t *testing.T) {
|
|
t.Parallel()
|
|
const id = "01a01ec0-e69d-7000-8000-0123456789ab"
|
|
if got, want := taskKey(id), "0123456789ab"; got != want {
|
|
t.Fatalf("taskKey(%q) = %q, want %q — the segment must be the random tail, not the timestamp head", id, got, want)
|
|
}
|
|
if got := taskKey(id); len(got) != taskKeyLen {
|
|
t.Fatalf("taskKey len = %d, want %d — long segments overflow MAX_PATH on Windows", len(got), taskKeyLen)
|
|
}
|
|
if got := taskKey("abc"); got != "abc" {
|
|
t.Fatalf("taskKey on a sub-length input = %q, want it returned as-is", got)
|
|
}
|
|
}
|
|
|
|
// TestBranchNameDistinctForSharedUUIDv7Prefix is the branch-name half of the
|
|
// #7326 regression: two tasks created inside one timestamp window must not ask
|
|
// git for the same ref.
|
|
func TestBranchNameDistinctForSharedUUIDv7Prefix(t *testing.T) {
|
|
t.Parallel()
|
|
a := fmt.Sprintf("agent/%s/%s", sanitizeName("Windows Codex"), taskKey("01a01ec0-e69d-7000-8000-000000000001"))
|
|
b := fmt.Sprintf("agent/%s/%s", sanitizeName("Windows Codex"), taskKey("01a01ec0-f014-7000-8000-000000000002"))
|
|
if a == b {
|
|
t.Fatalf("both tasks resolved to branch %q", a)
|
|
}
|
|
}
|
|
|
|
// TestCoAuthoredByStateStopsTrailerInExistingCheckout is the MUL-6921
|
|
// regression: disabling the workspace toggle used to apply only to checkouts
|
|
// created afterwards, because the decision was frozen into the hook file at
|
|
// checkout time. A checkout that already exists must honor the new value on
|
|
// its very next commit — including `git commit --no-verify`, which bypasses
|
|
// pre-commit and commit-msg but not prepare-commit-msg.
|
|
func TestCoAuthoredByStateStopsTrailerInExistingCheckout(t *testing.T) {
|
|
t.Parallel()
|
|
sourceRepo := createTestRepo(t)
|
|
cacheRoot := t.TempDir()
|
|
|
|
cache := New(cacheRoot, testLogger())
|
|
if err := cache.Sync("ws-1", []RepoInfo{{URL: sourceRepo}}); err != nil {
|
|
t.Fatalf("sync failed: %v", err)
|
|
}
|
|
|
|
workDir := t.TempDir()
|
|
result, err := cache.CreateWorktree(WorktreeParams{
|
|
WorkspaceID: "ws-1",
|
|
RepoURL: sourceRepo,
|
|
WorkDir: workDir,
|
|
AgentName: "Test Agent",
|
|
TaskID: "33333333-0000-0000-0000-000000000000",
|
|
CoAuthoredByEnabled: true,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("CreateWorktree failed: %v", err)
|
|
}
|
|
|
|
commit := func(name, message string, args ...string) string {
|
|
t.Helper()
|
|
if err := os.WriteFile(filepath.Join(result.Path, name), []byte("hello\n"), 0o644); err != nil {
|
|
t.Fatalf("write test file: %v", err)
|
|
}
|
|
runGitAuthored(t, result.Path, "add", ".")
|
|
runGitAuthored(t, result.Path, append(append([]string{"commit"}, args...), "-m", message)...)
|
|
out, err := exec.Command("git", "-C", result.Path, "log", "-1", "--format=%B").Output()
|
|
if err != nil {
|
|
t.Fatalf("git log failed: %v", err)
|
|
}
|
|
return string(out)
|
|
}
|
|
|
|
if msg := commit("a.txt", "enabled commit"); !strings.Contains(msg, "Co-authored-by: multica-agent") {
|
|
t.Fatalf("precondition: commit made with the setting on lacks the trailer.\ngot:\n%s", msg)
|
|
}
|
|
|
|
// The user flips the toggle off. No new checkout happens — the daemon
|
|
// only republishes the workspace's setting.
|
|
if err := cache.WriteCoAuthoredByState("ws-1", false); err != nil {
|
|
t.Fatalf("WriteCoAuthoredByState(false) failed: %v", err)
|
|
}
|
|
|
|
if msg := commit("b.txt", "disabled commit"); strings.Contains(msg, "Co-authored-by: multica-agent") {
|
|
t.Errorf("commit in the existing checkout still carries the trailer after the toggle was turned off.\ngot:\n%s", msg)
|
|
}
|
|
if msg := commit("c.txt", "disabled commit, no-verify", "--no-verify"); strings.Contains(msg, "Co-authored-by: multica-agent") {
|
|
t.Errorf("--no-verify commit still carries the trailer after the toggle was turned off.\ngot:\n%s", msg)
|
|
}
|
|
|
|
// Turning it back on restores the trailer without a new checkout either.
|
|
if err := cache.WriteCoAuthoredByState("ws-1", true); err != nil {
|
|
t.Fatalf("WriteCoAuthoredByState(true) failed: %v", err)
|
|
}
|
|
if msg := commit("d.txt", "re-enabled commit"); !strings.Contains(msg, "Co-authored-by: multica-agent") {
|
|
t.Errorf("commit missing the trailer after the toggle was turned back on.\ngot:\n%s", msg)
|
|
}
|
|
}
|
|
|
|
// TestCreateWorktreeInstallsGatedHook pins the fact the hook gate depends on:
|
|
// the hook a checkout installs reads the workspace state file the daemon
|
|
// publishes, and stays recognizable as daemon-owned so the disable path can
|
|
// still clean it up.
|
|
func TestCreateWorktreeInstallsGatedHook(t *testing.T) {
|
|
t.Parallel()
|
|
sourceRepo := createTestRepo(t)
|
|
cacheRoot := t.TempDir()
|
|
|
|
cache := New(cacheRoot, testLogger())
|
|
if err := cache.Sync("ws-1", []RepoInfo{{URL: sourceRepo}}); err != nil {
|
|
t.Fatalf("sync failed: %v", err)
|
|
}
|
|
|
|
workDir := t.TempDir()
|
|
if _, err := cache.CreateWorktree(WorktreeParams{
|
|
WorkspaceID: "ws-1",
|
|
RepoURL: sourceRepo,
|
|
WorkDir: workDir,
|
|
AgentName: "Test Agent",
|
|
TaskID: "44444444-0000-0000-0000-000000000000",
|
|
CoAuthoredByEnabled: true,
|
|
}); err != nil {
|
|
t.Fatalf("CreateWorktree failed: %v", err)
|
|
}
|
|
|
|
statePath := cache.CoAuthoredByStatePath("ws-1")
|
|
hook, err := os.ReadFile(filepath.Join(cache.Lookup("ws-1", sourceRepo), "hooks", "prepare-commit-msg"))
|
|
if err != nil {
|
|
t.Fatalf("read hook: %v", err)
|
|
}
|
|
if !strings.Contains(string(hook), filepath.ToSlash(statePath)) {
|
|
t.Errorf("installed hook does not read the state file at %s.\ngot:\n%s", statePath, hook)
|
|
}
|
|
if !isDaemonInstalledHook(hook) {
|
|
t.Error("gated hook is no longer recognized as daemon-installed; disabling the toggle would stop cleaning it up")
|
|
}
|
|
}
|
|
|
|
// TestWriteCoAuthoredByStateIsAtomic guards the hook's read: the state file is
|
|
// published by rename, so a commit racing a settings refresh reads either the
|
|
// old value or the new one, never an empty or half-written file.
|
|
func TestWriteCoAuthoredByStateIsAtomic(t *testing.T) {
|
|
t.Parallel()
|
|
cacheRoot := t.TempDir()
|
|
cache := New(cacheRoot, testLogger())
|
|
|
|
for i := 0; i < 20; i++ {
|
|
enabled := i%2 == 0
|
|
if err := cache.WriteCoAuthoredByState("ws-1", enabled); err != nil {
|
|
t.Fatalf("WriteCoAuthoredByState failed: %v", err)
|
|
}
|
|
state, err := os.ReadFile(cache.CoAuthoredByStatePath("ws-1"))
|
|
if err != nil {
|
|
t.Fatalf("read state: %v", err)
|
|
}
|
|
want := "0"
|
|
if enabled {
|
|
want = "1"
|
|
}
|
|
if got := strings.TrimSpace(string(state)); got != want {
|
|
t.Fatalf("state = %q, want %q", got, want)
|
|
}
|
|
}
|
|
|
|
entries, err := os.ReadDir(filepath.Dir(cache.CoAuthoredByStatePath("ws-1")))
|
|
if err != nil {
|
|
t.Fatalf("read workspace cache dir: %v", err)
|
|
}
|
|
for _, entry := range entries {
|
|
if entry.Name() != coAuthoredByStateFile {
|
|
t.Errorf("temp file %q left behind in the workspace cache dir", entry.Name())
|
|
}
|
|
}
|
|
}
|
|
|
|
// releasedUngatedHook is the exact prepare-commit-msg script shipped before
|
|
// MUL-6921: it carries the marker, so the daemon still recognizes it as its
|
|
// own, but it reads no state and appends the trailer unconditionally. Hosts
|
|
// that upgrade have this file sitting in every bare cache they checked out
|
|
// under the old release, so the migration path has to be tested against the
|
|
// real bytes rather than against a hook this PR's code produced.
|
|
const releasedUngatedHook = `#!/bin/sh
|
|
# multica:prepare-commit-msg:co-authored-by
|
|
# Multica: add Co-authored-by trailer for the Multica Agent.
|
|
# Installed by the Multica daemon. Do not edit — it will be overwritten.
|
|
|
|
COMMIT_MSG_FILE="$1"
|
|
COMMIT_SOURCE="$2"
|
|
|
|
# Skip merge and squash commits.
|
|
case "$COMMIT_SOURCE" in
|
|
merge|squash) exit 0 ;;
|
|
esac
|
|
|
|
TRAILER="Co-authored-by: multica-agent <[email protected]>"
|
|
|
|
# Don't add if already present.
|
|
if grep -qF "$TRAILER" "$COMMIT_MSG_FILE"; then
|
|
exit 0
|
|
fi
|
|
|
|
# Use git interpret-trailers for proper formatting.
|
|
git interpret-trailers --in-place --trailer "$TRAILER" "$COMMIT_MSG_FILE"
|
|
`
|
|
|
|
// seedUpgradedHost returns a cache, a worktree created under the previous
|
|
// release, and the path of the hook that release installed: the state of a
|
|
// machine at the moment it upgrades to a daemon carrying this change.
|
|
func seedUpgradedHost(t *testing.T, taskID string) (cache *Cache, worktreePath, hookPath string) {
|
|
t.Helper()
|
|
sourceRepo := createTestRepo(t)
|
|
cache = New(t.TempDir(), testLogger())
|
|
if err := cache.Sync("ws-1", []RepoInfo{{URL: sourceRepo}}); err != nil {
|
|
t.Fatalf("sync failed: %v", err)
|
|
}
|
|
result, err := cache.CreateWorktree(WorktreeParams{
|
|
WorkspaceID: "ws-1",
|
|
RepoURL: sourceRepo,
|
|
WorkDir: t.TempDir(),
|
|
AgentName: "Test Agent",
|
|
TaskID: taskID,
|
|
CoAuthoredByEnabled: true,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("CreateWorktree failed: %v", err)
|
|
}
|
|
|
|
hookPath = filepath.Join(cache.Lookup("ws-1", sourceRepo), "hooks", "prepare-commit-msg")
|
|
if err := os.WriteFile(hookPath, []byte(releasedUngatedHook), 0o755); err != nil {
|
|
t.Fatalf("seed previous-release hook: %v", err)
|
|
}
|
|
return cache, result.Path, hookPath
|
|
}
|
|
|
|
// commitInWorktree makes a real commit and returns the resulting message.
|
|
func commitInWorktree(t *testing.T, worktreePath, name, message string, args ...string) string {
|
|
t.Helper()
|
|
if err := os.WriteFile(filepath.Join(worktreePath, name), []byte("hello\n"), 0o644); err != nil {
|
|
t.Fatalf("write test file: %v", err)
|
|
}
|
|
runGitAuthored(t, worktreePath, "add", ".")
|
|
runGitAuthored(t, worktreePath, append(append([]string{"commit"}, args...), "-m", message)...)
|
|
out, err := exec.Command("git", "-C", worktreePath, "log", "-1", "--format=%B").Output()
|
|
if err != nil {
|
|
t.Fatalf("git log failed: %v", err)
|
|
}
|
|
return string(out)
|
|
}
|
|
|
|
// A host that upgrades carries hooks from the previous release, and those read
|
|
// no state file — publishing a new value cannot reach them. Turning the setting
|
|
// off must still take effect on the existing checkout, with no CreateWorktree
|
|
// call in between.
|
|
func TestReconcileCoAuthoredByHooksMigratesReleasedHook(t *testing.T) {
|
|
t.Parallel()
|
|
cache, worktreePath, hookPath := seedUpgradedHost(t, "55555555-0000-0000-0000-000000000000")
|
|
|
|
if msg := commitInWorktree(t, worktreePath, "a.txt", "before the toggle"); !strings.Contains(msg, "Co-authored-by: multica-agent") {
|
|
t.Fatalf("precondition: the previous release's hook should still add the trailer.\ngot:\n%s", msg)
|
|
}
|
|
|
|
// The daemon learns the setting is off: it publishes the new value and
|
|
// reconciles the hooks it owns. No checkout happens.
|
|
if err := cache.WriteCoAuthoredByState("ws-1", false); err != nil {
|
|
t.Fatalf("WriteCoAuthoredByState failed: %v", err)
|
|
}
|
|
if err := cache.ReconcileCoAuthoredByHooks("ws-1", false); err != nil {
|
|
t.Fatalf("ReconcileCoAuthoredByHooks failed: %v", err)
|
|
}
|
|
|
|
if _, err := os.Stat(hookPath); !os.IsNotExist(err) {
|
|
t.Errorf("expected the previous release's hook to be removed at %s, stat err=%v", hookPath, err)
|
|
}
|
|
if msg := commitInWorktree(t, worktreePath, "b.txt", "after the toggle"); strings.Contains(msg, "Co-authored-by: multica-agent") {
|
|
t.Errorf("upgraded host still adds the trailer after the toggle was turned off.\ngot:\n%s", msg)
|
|
}
|
|
}
|
|
|
|
// With the setting on there is nothing to remove, but the previous release's
|
|
// hook still has to be replaced by the gated one — otherwise the NEXT toggle-off
|
|
// is stuck waiting for a checkout all over again.
|
|
func TestReconcileCoAuthoredByHooksUpgradesReleasedHookInPlace(t *testing.T) {
|
|
t.Parallel()
|
|
cache, worktreePath, hookPath := seedUpgradedHost(t, "66666666-0000-0000-0000-000000000000")
|
|
|
|
if err := cache.WriteCoAuthoredByState("ws-1", true); err != nil {
|
|
t.Fatalf("WriteCoAuthoredByState failed: %v", err)
|
|
}
|
|
if err := cache.ReconcileCoAuthoredByHooks("ws-1", true); err != nil {
|
|
t.Fatalf("ReconcileCoAuthoredByHooks failed: %v", err)
|
|
}
|
|
|
|
hook, err := os.ReadFile(hookPath)
|
|
if err != nil {
|
|
t.Fatalf("read hook: %v", err)
|
|
}
|
|
if !strings.Contains(string(hook), filepath.ToSlash(cache.CoAuthoredByStatePath("ws-1"))) {
|
|
t.Fatalf("hook was not upgraded to read the state file.\ngot:\n%s", hook)
|
|
}
|
|
if msg := commitInWorktree(t, worktreePath, "a.txt", "still enabled"); !strings.Contains(msg, "Co-authored-by: multica-agent") {
|
|
t.Errorf("upgraded hook dropped the trailer while the setting is on.\ngot:\n%s", msg)
|
|
}
|
|
|
|
// The point of the upgrade: from here a publish alone is enough.
|
|
if err := cache.WriteCoAuthoredByState("ws-1", false); err != nil {
|
|
t.Fatalf("WriteCoAuthoredByState failed: %v", err)
|
|
}
|
|
if msg := commitInWorktree(t, worktreePath, "b.txt", "toggled off"); strings.Contains(msg, "Co-authored-by: multica-agent") {
|
|
t.Errorf("upgraded hook ignored the published state.\ngot:\n%s", msg)
|
|
}
|
|
}
|
|
|
|
// The sweep runs across every bare cache in a workspace, so it must be as
|
|
// conservative as the checkout path about hooks it does not own.
|
|
func TestReconcileCoAuthoredByHooksPreservesUserHook(t *testing.T) {
|
|
t.Parallel()
|
|
sourceRepo := createTestRepo(t)
|
|
cache := New(t.TempDir(), testLogger())
|
|
if err := cache.Sync("ws-1", []RepoInfo{{URL: sourceRepo}}); err != nil {
|
|
t.Fatalf("sync failed: %v", err)
|
|
}
|
|
|
|
const userHook = "#!/bin/sh\n# my own hook\nexit 0\n"
|
|
hooksDir := filepath.Join(cache.Lookup("ws-1", sourceRepo), "hooks")
|
|
if err := os.MkdirAll(hooksDir, 0o755); err != nil {
|
|
t.Fatalf("create hooks dir: %v", err)
|
|
}
|
|
hookPath := filepath.Join(hooksDir, "prepare-commit-msg")
|
|
if err := os.WriteFile(hookPath, []byte(userHook), 0o755); err != nil {
|
|
t.Fatalf("seed user hook: %v", err)
|
|
}
|
|
|
|
for _, enabled := range []bool{false, true} {
|
|
if err := cache.ReconcileCoAuthoredByHooks("ws-1", enabled); err != nil {
|
|
t.Fatalf("ReconcileCoAuthoredByHooks(%v) failed: %v", enabled, err)
|
|
}
|
|
got, err := os.ReadFile(hookPath)
|
|
if err != nil {
|
|
t.Fatalf("user hook disappeared after reconcile(%v): %v", enabled, err)
|
|
}
|
|
if string(got) != userHook {
|
|
t.Fatalf("reconcile(%v) rewrote a hook the daemon does not own.\ngot:\n%s", enabled, got)
|
|
}
|
|
}
|
|
}
|
|
|
|
// A checkout captures the setting before its fetch and lock waits, so it can
|
|
// finish long after that value stopped being true. It must not write that stale
|
|
// snapshot back over the value the daemon published in the meantime — doing so
|
|
// resurrects a trailer the user already turned off, on every checkout in the
|
|
// workspace.
|
|
func TestCreateWorktreeDoesNotResurrectDisabledState(t *testing.T) {
|
|
t.Parallel()
|
|
sourceRepo := createTestRepo(t)
|
|
cache := New(t.TempDir(), testLogger())
|
|
if err := cache.Sync("ws-1", []RepoInfo{{URL: sourceRepo}}); err != nil {
|
|
t.Fatalf("sync failed: %v", err)
|
|
}
|
|
|
|
// The daemon published "off" while a checkout that snapshotted "on" was
|
|
// still waiting on its fetch.
|
|
if err := cache.WriteCoAuthoredByState("ws-1", false); err != nil {
|
|
t.Fatalf("WriteCoAuthoredByState failed: %v", err)
|
|
}
|
|
|
|
result, err := cache.CreateWorktree(WorktreeParams{
|
|
WorkspaceID: "ws-1",
|
|
RepoURL: sourceRepo,
|
|
WorkDir: t.TempDir(),
|
|
AgentName: "Test Agent",
|
|
TaskID: "77777777-0000-0000-0000-000000000000",
|
|
CoAuthoredByEnabled: true, // stale snapshot
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("CreateWorktree failed: %v", err)
|
|
}
|
|
|
|
state, err := os.ReadFile(cache.CoAuthoredByStatePath("ws-1"))
|
|
if err != nil {
|
|
t.Fatalf("read co-authored-by state: %v", err)
|
|
}
|
|
if strings.TrimSpace(string(state)) != "0" {
|
|
t.Errorf("state = %q, want it left at 0: a checkout must not republish its snapshot", state)
|
|
}
|
|
if msg := commitInWorktree(t, result.Path, "a.txt", "checked out with a stale snapshot"); strings.Contains(msg, "Co-authored-by: multica-agent") {
|
|
t.Errorf("commit carries the trailer although the workspace setting is off.\ngot:\n%s", msg)
|
|
}
|
|
}
|
|
|
|
// Codex on Linux and the Windows sandbox check out with isolated git metadata,
|
|
// so the hook lives in the task workdir instead of the shared bare cache and no
|
|
// sweep of the cache root can see it. The daemon finds those checkouts and
|
|
// reconciles them one at a time; this is that path, end to end on a checkout
|
|
// carrying the previous release's hook.
|
|
func TestReconcileCoAuthoredByHookInCheckoutMigratesReleasedHook(t *testing.T) {
|
|
t.Parallel()
|
|
sourceRepo := createTestRepo(t)
|
|
cache := New(t.TempDir(), testLogger())
|
|
if err := cache.Sync("ws-1", []RepoInfo{{URL: sourceRepo}}); err != nil {
|
|
t.Fatalf("sync failed: %v", err)
|
|
}
|
|
|
|
result, err := cache.CreateWorktree(WorktreeParams{
|
|
WorkspaceID: "ws-1",
|
|
RepoURL: sourceRepo,
|
|
WorkDir: t.TempDir(),
|
|
AgentName: "Test Agent",
|
|
TaskID: "88888888-0000-0000-0000-000000000000",
|
|
CoAuthoredByEnabled: true,
|
|
IsolatedGitMetadata: true,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("CreateWorktree failed: %v", err)
|
|
}
|
|
|
|
hookPath := filepath.Join(result.Path, ".git", "hooks", "prepare-commit-msg")
|
|
if _, err := os.Stat(hookPath); err != nil {
|
|
t.Fatalf("precondition: isolated checkout should carry its own hook: %v", err)
|
|
}
|
|
// Roll it back to what the previous release installed.
|
|
if err := os.WriteFile(hookPath, []byte(releasedUngatedHook), 0o755); err != nil {
|
|
t.Fatalf("seed previous-release hook: %v", err)
|
|
}
|
|
if msg := commitInWorktree(t, result.Path, "a.txt", "before the toggle"); !strings.Contains(msg, "Co-authored-by: multica-agent") {
|
|
t.Fatalf("precondition: the previous release's hook should still add the trailer.\ngot:\n%s", msg)
|
|
}
|
|
|
|
if err := cache.WriteCoAuthoredByState("ws-1", false); err != nil {
|
|
t.Fatalf("WriteCoAuthoredByState failed: %v", err)
|
|
}
|
|
if err := cache.ReconcileCoAuthoredByHookInCheckout(result.Path, "ws-1", false); err != nil {
|
|
t.Fatalf("ReconcileCoAuthoredByHookInCheckout failed: %v", err)
|
|
}
|
|
|
|
if _, err := os.Stat(hookPath); !os.IsNotExist(err) {
|
|
t.Errorf("expected the hook to be removed at %s, stat err=%v", hookPath, err)
|
|
}
|
|
if msg := commitInWorktree(t, result.Path, "b.txt", "after the toggle"); strings.Contains(msg, "Co-authored-by: multica-agent") {
|
|
t.Errorf("isolated checkout still adds the trailer after the toggle was turned off.\ngot:\n%s", msg)
|
|
}
|
|
}
|
|
|
|
// A linked worktree keeps its hook in the bare cache, where the cache-root
|
|
// sweep already reconciles it. Reaching into the worktree would be reaching
|
|
// into someone else's git dir, so the per-checkout path must ignore it.
|
|
func TestReconcileCoAuthoredByHookInCheckoutIgnoresLinkedWorktree(t *testing.T) {
|
|
t.Parallel()
|
|
sourceRepo := createTestRepo(t)
|
|
cache := New(t.TempDir(), testLogger())
|
|
if err := cache.Sync("ws-1", []RepoInfo{{URL: sourceRepo}}); err != nil {
|
|
t.Fatalf("sync failed: %v", err)
|
|
}
|
|
|
|
result, err := cache.CreateWorktree(WorktreeParams{
|
|
WorkspaceID: "ws-1",
|
|
RepoURL: sourceRepo,
|
|
WorkDir: t.TempDir(),
|
|
AgentName: "Test Agent",
|
|
TaskID: "99999999-0000-0000-0000-000000000000",
|
|
CoAuthoredByEnabled: true,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("CreateWorktree failed: %v", err)
|
|
}
|
|
|
|
if err := cache.ReconcileCoAuthoredByHookInCheckout(result.Path, "ws-1", false); err != nil {
|
|
t.Fatalf("ReconcileCoAuthoredByHookInCheckout failed: %v", err)
|
|
}
|
|
|
|
// The bare cache's hook — the one that actually governs this worktree — is
|
|
// untouched by the per-checkout path.
|
|
if _, err := os.Stat(filepath.Join(cache.Lookup("ws-1", sourceRepo), "hooks", "prepare-commit-msg")); err != nil {
|
|
t.Errorf("per-checkout reconcile disturbed the bare cache's hook: %v", err)
|
|
}
|
|
}
|