mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-06 14:00:09 +02:00
* fix(execenv): write OpenCode skills to .opencode/skills/ for native discovery * fix(repocache): exclude OpenCode skill directory
1458 lines
52 KiB
Go
1458 lines
52 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 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, ".config/opencode") {
|
|
t.Fatalf("expected .git/info/exclude to not contain stale .config/opencode, got:\n%s", exclude)
|
|
}
|
|
}
|
|
|
|
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", "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)
|
|
}
|
|
}
|