285 lines
11 KiB
Go
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)
|
|
}
|