Files
kandev/apps/backend/internal/worktree/manager_pr_refspec_test.go
T
2026-09-07 11:19:09 +01:00

285 lines
11 KiB
Go

package worktree
import (
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
)
// TestFetchBranchToLocal_PRNumberUsesPullRefspec verifies that when a PR
// number is supplied, the manager fetches refs/pull/<N>/head into an isolated
// Kandev-owned ref. This is the fork-PR path: the head branch does not
// exist on origin by name (only as a pull/<N>/head ref), so the previous
// branch-name fetch would fail with "couldn't find remote ref" or overwrite a
// local branch with the same name.
func TestFetchBranchToLocal_PRNumberUsesPullRefspec(t *testing.T) {
repoPath, prHeadSHA := initGitRepoWithPullRef(t, 974, "feature/enrich-linear-issue-hap")
cfg := newTestConfig(t)
log := newTestLogger()
store := newMockStore()
mgr, err := NewManager(cfg, store, log)
if err != nil {
t.Fatalf("NewManager failed: %v", err)
}
result, err := mgr.fetchBranchToLocal(
context.Background(), repoPath, "feature/enrich-linear-issue-hap", 974,
)
if err != nil {
t.Fatalf("fetchBranchToLocal(pr=974) unexpected error: %v", err)
}
if result.Warning != "" {
t.Fatalf("expected no warning, got %q", result.Warning)
}
if result.StartPoint != pullRequestSnapshotRef(974) {
t.Fatalf("start point = %q, want %q", result.StartPoint, pullRequestSnapshotRef(974))
}
gotSHA := strings.TrimSpace(runGit(t, repoPath, "rev-parse", pullRequestSnapshotRef(974)))
if gotSHA != prHeadSHA {
t.Fatalf("isolated PR ref SHA = %q, want %q (PR head)", gotSHA, prHeadSHA)
}
}
func TestFetchBranchToLocal_PRNumberSnapshotSurvivesPruneAndOriginPRBranch(t *testing.T) {
const prNumber = 976
const headBranch = "feature/pr-snapshot"
repoPath, prHeadSHA := initGitRepoWithPullRef(t, prNumber, headBranch)
mgr, err := NewManager(newTestConfig(t), newMockStore(), newTestLogger())
if err != nil {
t.Fatalf("NewManager failed: %v", err)
}
result, err := mgr.fetchBranchToLocal(context.Background(), repoPath, headBranch, prNumber)
if err != nil {
t.Fatalf("fetchBranchToLocal(pr=%d) unexpected error: %v", prNumber, err)
}
const expectedRef = "refs/kandev/pull/976/head"
if result.StartPoint != expectedRef {
t.Fatalf("start point = %q, want %q", result.StartPoint, expectedRef)
}
originURL := strings.TrimSpace(runGit(t, repoPath, "remote", "get-url", "origin"))
remoteClone := filepath.Join(t.TempDir(), "origin-pr-branch")
runGit(t, filepath.Dir(remoteClone), "clone", originURL, remoteClone)
runGit(t, remoteClone, "config", "user.email", "remote@example.com")
runGit(t, remoteClone, "config", "user.name", "Remote User")
runGit(t, remoteClone, "config", "commit.gpgsign", "false")
runGit(t, remoteClone, "checkout", "-b", "pr/976", "main")
writeTestFile(t, filepath.Join(remoteClone, "origin-pr-branch.txt"), "origin branch\n")
runGit(t, remoteClone, "add", "origin-pr-branch.txt")
runGit(t, remoteClone, "commit", "-m", "origin pr branch")
originPRBranchSHA := strings.TrimSpace(runGit(t, remoteClone, "rev-parse", "HEAD"))
runGit(t, remoteClone, "push", "origin", "HEAD:refs/heads/pr/976")
// A routine refresh must not prune or overwrite the Kandev-owned snapshot.
runGit(t, repoPath, "fetch", "--all", "--prune")
if got := strings.TrimSpace(runGit(t, repoPath, "rev-parse", expectedRef)); got != prHeadSHA {
t.Fatalf("Kandev PR snapshot SHA = %q, want %q", got, prHeadSHA)
}
if got := strings.TrimSpace(runGit(t, repoPath, "rev-parse", "refs/remotes/origin/pr/976")); got != originPRBranchSHA {
t.Fatalf("origin/pr/976 SHA = %q, want real branch SHA %q", got, originPRBranchSHA)
}
wt, err := mgr.Create(context.Background(), CreateRequest{
TaskID: "task-pr-snapshot", SessionID: "session-pr-snapshot", TaskTitle: "PR snapshot",
RepositoryID: "repo-1", RepositoryPath: repoPath, BaseBranch: "main",
CheckoutBranch: headBranch, PRNumber: prNumber, RemoteSyncHandled: true,
TaskDirName: "task-pr-snapshot", RepoName: "repo-1",
})
if err != nil {
t.Fatalf("Create() should use the Kandev snapshot after normal fetch: %v", err)
}
if got := strings.TrimSpace(runGit(t, wt.Path, "rev-parse", "HEAD")); got != prHeadSHA {
t.Fatalf("worktree HEAD = %q, want PR snapshot %q", got, prHeadSHA)
}
}
// TestFetchBranchToLocal_PRNumberZeroUsesBranchFetch confirms the legacy path
// is preserved when PRNumber == 0: the manager fetches origin/<branch>, which
// is the correct behavior for non-PR tasks and same-repo branches that the
// caller knows live on origin under the same name.
func TestFetchBranchToLocal_PRNumberZeroUsesBranchFetch(t *testing.T) {
repoPath := initGitRepoWithRemote(t)
cfg := newTestConfig(t)
log := newTestLogger()
store := newMockStore()
mgr, err := NewManager(cfg, store, log)
if err != nil {
t.Fatalf("NewManager failed: %v", err)
}
result, err := mgr.fetchBranchToLocal(
context.Background(), repoPath, "feature/pr-branch", 0,
)
if err != nil {
t.Fatalf("fetchBranchToLocal(pr=0) unexpected error: %v", err)
}
if result.Warning != "" {
t.Fatalf("expected no warning, got %q", result.Warning)
}
}
// TestCreateWorktree_PRNumberCreatesWorktreeFromForkRef is the end-to-end test
// for fork PRs: only the pull/<N>/head ref exists on origin (no branch named
// after the head ref). Worktree creation must still succeed by using the PR
// refspec internally, and the worktree must point at the PR head commit.
func TestCreateWorktree_PRNumberCreatesWorktreeFromForkRef(t *testing.T) {
repoPath, prHeadSHA := initGitRepoWithPullRef(t, 974, "feature/enrich-linear-issue-hap")
cfg := newTestConfig(t)
log := newTestLogger()
store := newMockStore()
mgr, err := NewManager(cfg, store, log)
if err != nil {
t.Fatalf("NewManager failed: %v", err)
}
wt, err := mgr.Create(context.Background(), CreateRequest{
TaskID: "task-fork-pr",
SessionID: "session-fork-pr",
TaskTitle: "Fork PR review",
RepositoryID: "repo-1",
RepositoryPath: repoPath,
BaseBranch: "main",
CheckoutBranch: "feature/enrich-linear-issue-hap",
PRNumber: 974,
TaskDirName: "task-fork-pr",
RepoName: "repo-1",
})
if err != nil {
t.Fatalf("Create() with PRNumber should succeed for fork PR, got: %v", err)
}
if wt.Branch != "feature/enrich-linear-issue-hap" {
t.Fatalf("expected branch %q, got %q", "feature/enrich-linear-issue-hap", wt.Branch)
}
worktreeSHA := strings.TrimSpace(runGit(t, wt.Path, "rev-parse", "HEAD"))
if worktreeSHA != prHeadSHA {
t.Fatalf("worktree HEAD SHA = %q, want %q (PR head)", worktreeSHA, prHeadSHA)
}
}
// TestCreateWorktree_PRNumberSecondWorktreeForSameForkPR exercises the
// PR-head isolation when two worktrees use the same source branch name. Each
// worktree must point at the immutable PR ref while its local branch remains
// unique.
func TestCreateWorktree_PRNumberSecondWorktreeForSameForkPR(t *testing.T) {
repoPath, prHeadSHA := initGitRepoWithPullRef(t, 974, "feature/fork-pr")
cfg := newTestConfig(t)
log := newTestLogger()
store := newMockStore()
mgr, err := NewManager(cfg, store, log)
if err != nil {
t.Fatalf("NewManager failed: %v", err)
}
wt1, err := mgr.Create(context.Background(), CreateRequest{
TaskID: "task-1", SessionID: "session-1", TaskTitle: "Fork PR review 1",
RepositoryID: "repo-1", RepositoryPath: repoPath,
BaseBranch: "main", CheckoutBranch: "feature/fork-pr", PRNumber: 974,
TaskDirName: "task-1", RepoName: "repo-1",
})
if err != nil {
t.Fatalf("Create() first fork-PR worktree failed: %v", err)
}
if wt1.Branch != "feature/fork-pr" {
t.Fatalf("first worktree: expected direct branch, got %q", wt1.Branch)
}
wt2, err := mgr.Create(context.Background(), CreateRequest{
TaskID: "task-2", SessionID: "session-2", TaskTitle: "Fork PR review 2",
RepositoryID: "repo-1", RepositoryPath: repoPath,
BaseBranch: "main", CheckoutBranch: "feature/fork-pr", PRNumber: 974,
TaskDirName: "task-2", RepoName: "repo-1",
})
if err != nil {
t.Fatalf("Create() second fork-PR worktree failed (regression in retry path): %v", err)
}
if wt2.Branch == "feature/fork-pr" {
t.Fatal("second worktree must NOT reuse the branch already checked out")
}
if !strings.HasPrefix(wt2.Branch, "feature/fork-pr-") {
t.Fatalf("expected suffixed branch like %q, got %q", "feature/fork-pr-xxx", wt2.Branch)
}
sha1 := strings.TrimSpace(runGit(t, wt1.Path, "rev-parse", "HEAD"))
sha2 := strings.TrimSpace(runGit(t, wt2.Path, "rev-parse", "HEAD"))
if sha1 != prHeadSHA || sha2 != prHeadSHA {
t.Fatalf("worktree SHAs must equal PR head: sha1=%q, sha2=%q, want=%q",
sha1, sha2, prHeadSHA)
}
}
// initGitRepoWithPullRef simulates a fork PR scenario: a bare origin repo
// containing main + an arbitrary commit registered under refs/pull/<N>/head
// (but NOT under any branch). Returns the clone path and the PR head SHA.
func initGitRepoWithPullRef(t *testing.T, prNumber int, headBranchName string) (string, string) {
t.Helper()
bareDir := filepath.Join(t.TempDir(), "origin.git")
runGit(t, t.TempDir(), "init", "--bare", "-b", "main", bareDir)
cloneDir := filepath.Join(t.TempDir(), "clone")
cmd := exec.Command("git", "clone", bareDir, cloneDir)
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("git clone failed: %v\n%s", err, out)
}
runGit(t, cloneDir, "config", "user.email", "test@example.com")
runGit(t, cloneDir, "config", "user.name", "Test User")
runGit(t, cloneDir, "config", "commit.gpgsign", "false")
filePath := filepath.Join(cloneDir, "README.md")
if err := os.WriteFile(filePath, []byte("initial\n"), 0644); err != nil {
t.Fatalf("failed to write README.md: %v", err)
}
runGit(t, cloneDir, "add", "README.md")
runGit(t, cloneDir, "commit", "-m", "initial commit")
runGit(t, cloneDir, "push", "origin", "main")
// Build the PR head commit on a temporary branch, then publish it ONLY
// under refs/pull/<N>/head on the bare origin. The branch name the
// contributor used does not exist on origin — matching the fork PR
// scenario where the head branch lives only on the contributor's fork.
runGit(t, cloneDir, "checkout", "-b", "tmp-pr-head")
if err := os.WriteFile(filePath, []byte("pr change\n"), 0644); err != nil {
t.Fatalf("failed to update README.md: %v", err)
}
runGit(t, cloneDir, "add", "README.md")
runGit(t, cloneDir, "commit", "-m", "pr head commit")
prHeadSHA := strings.TrimSpace(runGit(t, cloneDir, "rev-parse", "HEAD"))
// Push the commit object straight into origin under refs/pull/<N>/head.
// This mirrors how GitHub mirrors fork-PR head commits onto the base
// repository: the contributor's branch name is never registered, but
// the head ref is. Using `update-ref` on the bare repo directly would
// fail because the object only lives in the clone's loose objects.
runGit(t, cloneDir, "push", "origin", "HEAD:"+pullHeadRef(prNumber))
// Clean up the helper branch and go back to main; the PR head must NOT
// be reachable by a branch name on origin.
runGit(t, cloneDir, "checkout", "main")
runGit(t, cloneDir, "branch", "-D", "tmp-pr-head")
// Sanity check: no remote branch matching the head ref exists. If this
// assertion ever fails, the test would silently pass via the legacy
// branch-name fetch and stop exercising the PR refspec path.
out, _ := exec.Command("git", "-C", cloneDir, "ls-remote", "--heads", "origin", headBranchName).CombinedOutput()
if strings.TrimSpace(string(out)) != "" {
t.Fatalf("test setup leaked branch %q on origin: %s", headBranchName, out)
}
return cloneDir, prHeadSHA
}
func pullHeadRef(prNumber int) string {
return fmt.Sprintf("refs/pull/%d/head", prNumber)
}