mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-29 14:37:44 +02:00
* fix(daemon): remove Co-authored-by hook when workspace setting is off The prepare-commit-msg hook is installed in the bare repo's shared hooks dir, so once installed it persists across worktrees. CreateWorktree only installed the hook when the setting was enabled, but never removed it — so disabling the workspace toggle had no effect on subsequent commits. Add removeCoAuthoredByHook and call it in both CreateWorktree branches when the setting is disabled. Use a marker comment in the hook script so removal only deletes hooks the daemon owns; user-installed hooks at the same path are left alone. Co-authored-by: multica-agent <github@multica.ai> * fix(daemon): recognize legacy Multica prepare-commit-msg hook on removal The first cut of removeCoAuthoredByHook only recognized hooks installed by the new code (containing the multicaHookMarker sentinel). Bare clones already on disk from previous daemon releases carry the older script without that line, so toggling the workspace setting off would have treated them as user hooks and left the trailer in place — exactly the state reported in MUL-1704. Match against a list of known daemon signatures (current marker + the legacy "Installed by the Multica daemon." comment), and add a test that seeds the verbatim legacy hook before CreateWorktree(... disabled) to keep recognition aligned with what production hosts actually have on disk. Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: multica-agent <github@multica.ai>
1409 lines
51 KiB
Go
1409 lines
51 KiB
Go
package repocache
|
|
|
|
import (
|
|
"log/slog"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
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 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"},
|
|
{"git@github.com:org/my-repo.git", "github.com+org+my-repo.git"},
|
|
{"git@github.com:org/my-repo", "github.com+org+my-repo.git"},
|
|
{"https://github.com/org/repo/", "github.com+org+repo.git"},
|
|
{"ssh://git@gitlab.example.com: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://git@gitlab.example.com:22/relisty/app.git", "gitlab.example.com%3A22+relisty+app.git"},
|
|
{"ssh://git@gitlab.example.com: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{
|
|
{"git@github.com:foo/bar-baz.git", "git@github.com: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{
|
|
{"git@github.com:org/repo.git", "git@gitlab.example.com:org/repo.git"},
|
|
{"https://github.com/org/repo.git", "https://gitlab.example.com/org/repo.git"},
|
|
{"ssh://git@github.com/org/repo.git", "ssh://git@gitlab.example.com/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://git@gitlab.example.com:22/org/repo.git", "git@gitlab.example.com-22: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://git@host.example.com:443/a/b.git", "git@host.example.com-443: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", "GIT_AUTHOR_EMAIL=test@test.com",
|
|
"GIT_COMMITTER_NAME=test", "GIT_COMMITTER_EMAIL=test@test.com",
|
|
)
|
|
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 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", "GIT_AUTHOR_EMAIL=test@test.com",
|
|
"GIT_COMMITTER_NAME=test", "GIT_COMMITTER_EMAIL=test@test.com",
|
|
)
|
|
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", "GIT_AUTHOR_EMAIL=test@test.com",
|
|
"GIT_COMMITTER_NAME=test", "GIT_COMMITTER_EMAIL=test@test.com",
|
|
)
|
|
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 <github@multica.ai>"
|
|
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 <github@multica.ai>"
|
|
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 <github@multica.ai>"
|
|
|
|
# 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)
|
|
}
|
|
}
|