import { describe, it, expect, beforeEach, afterEach } from "vitest"; import { execFileSync, execSync } from "child_process"; import { existsSync, mkdtempSync, rmSync, writeFileSync, readFileSync, realpathSync, mkdirSync, } from "fs"; import { join } from "path"; import { win32 } from "node:path"; import { tmpdir } from "os"; import { __resetCheckoutShortstatCacheForTests, __resetPullRequestStatusCacheForTests, __setPullRequestStatusCacheTtlForTests, commitAll, getCachedCheckoutShortstat, getCheckoutSnapshotFacts, getCurrentBranch, getCheckoutDiff, getCheckoutShortstat, getPullRequestStatus, getCheckoutStatus, checkoutResolvedBranch, listBranchSuggestions, mergeToBase, mergeFromBase, MergeConflictError, MergeFromBaseConflictError, NotGitRepoError, pullCurrentBranch, pushCurrentBranch, resolveBranchCheckout, resolveRepositoryDefaultBranch, parseWorktreeList, renameCurrentBranch, isPaseoWorktreePath, isDescendantPath, warmCheckoutShortstatInBackground, } from "./checkout-git.js"; import { startGitCommandMetrics, stopGitCommandMetrics } from "./run-git-command.js"; import { GitHubCommandError, GitHubCliMissingError, type GitHubCurrentPullRequestStatus, type GitHubService, } from "../services/github-service.js"; import { createWorktree as createWorktreePrimitive, type CreateWorktreeOptions, type WorktreeConfig, } from "./worktree.js"; interface LegacyCreateWorktreeTestOptions { branchName: string; cwd: string; baseBranch: string; worktreeSlug: string; runSetup?: boolean; paseoHome?: string; } function createLegacyWorktreeForTest( options: CreateWorktreeOptions | LegacyCreateWorktreeTestOptions, ): Promise { if ("source" in options) { return createWorktreePrimitive(options); } return createWorktreePrimitive({ cwd: options.cwd, worktreeSlug: options.worktreeSlug, source: { kind: "branch-off", baseBranch: options.baseBranch, branchName: options.branchName, }, runSetup: options.runSetup ?? true, paseoHome: options.paseoHome, }); } import { getPaseoWorktreeMetadataPath } from "./worktree-metadata.js"; function initRepo(): { tempDir: string; repoDir: string } { const tempDir = realpathSync.native(mkdtempSync(join(tmpdir(), "checkout-git-test-"))); const repoDir = join(tempDir, "repo"); mkdirSync(repoDir, { recursive: true }); execFileSync("git", ["init", "-b", "main"], { cwd: repoDir }); execFileSync("git", ["config", "user.email", "test@test.com"], { cwd: repoDir }); execFileSync("git", ["config", "user.name", "Test"], { cwd: repoDir }); writeFileSync(join(repoDir, "file.txt"), "hello\n"); execFileSync("git", ["add", "."], { cwd: repoDir }); execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "initial"], { cwd: repoDir }); return { tempDir, repoDir }; } function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } function createGitHubServiceForStatus( status: GitHubCurrentPullRequestStatus | null, options?: { onStatus?: () => void }, ): GitHubService { return { listPullRequests: async () => [], listIssues: async () => [], searchIssuesAndPrs: async () => ({ items: [], githubFeaturesEnabled: true }), getPullRequest: async () => ({ number: 1, title: "PR", url: "https://github.com/getpaseo/paseo/pull/1", state: "OPEN", body: null, baseRefName: "main", headRefName: "feature", labels: [], }), getPullRequestHeadRef: async () => "feature", getCurrentPullRequestStatus: async () => { options?.onStatus?.(); return status; }, createPullRequest: async () => ({ url: "https://github.com/getpaseo/paseo/pull/1", number: 1, }), mergePullRequest: async () => ({ success: true }), isAuthenticated: async () => true, invalidate: () => {}, }; } function createPullRequestStatus(overrides?: Partial) { return { url: "https://github.com/getpaseo/paseo/pull/123", title: "Ship feature", state: "open", baseRefName: "main", headRefName: "feature", isMerged: false, checks: [], checksStatus: "none" as const, reviewDecision: null, ...overrides, }; } interface RequestedPullRequestTarget { headRef: string; headRepositoryOwner?: string; } interface RecordingPullRequestTargetsOptions { requestedTargets: RequestedPullRequestTarget[]; statusOverrides?: Partial; } function createGitHubServiceRecordingPullRequestTargets( options: RecordingPullRequestTargetsOptions, ): GitHubService { const github = createGitHubServiceForStatus(null); github.getCurrentPullRequestStatus = async (request) => { options.requestedTargets.push({ headRef: request.headRef, ...(request.headRepositoryOwner ? { headRepositoryOwner: request.headRepositoryOwner } : {}), }); return createPullRequestStatus({ ...options.statusOverrides, headRefName: request.headRef, }); }; return github; } async function readPullRequestLookupTargetFromFacts( repoDir: string, paseoHome: string, ): Promise { const facts = await getCheckoutSnapshotFacts(repoDir, { paseoHome }); if (!facts.isGit) { throw new Error("Expected git checkout facts"); } return facts.pullRequestLookupTarget; } function setupRemoteTrackingMain( repoDir: string, tempDir: string, ): { remoteDir: string; cloneDir: string } { const remoteDir = join(tempDir, "remote.git"); const cloneDir = join(tempDir, "upstream-clone"); execFileSync("git", ["init", "--bare", "-b", "main", remoteDir]); execFileSync("git", ["remote", "add", "origin", remoteDir], { cwd: repoDir }); execFileSync("git", ["push", "-u", "origin", "main"], { cwd: repoDir }); execFileSync("git", ["clone", remoteDir, cloneDir]); execFileSync("git", ["config", "user.email", "test@test.com"], { cwd: cloneDir }); execFileSync("git", ["config", "user.name", "Test"], { cwd: cloneDir }); return { remoteDir, cloneDir }; } function commitFile(cwd: string, path: string, content: string, message: string): void { writeFileSync(join(cwd, path), content); execFileSync("git", ["add", path], { cwd }); execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", message], { cwd }); } describe("checkout git utilities", () => { let tempDir: string; let repoDir: string; let paseoHome: string; beforeEach(() => { const setup = initRepo(); tempDir = setup.tempDir; repoDir = setup.repoDir; paseoHome = join(tempDir, "paseo-home"); __resetCheckoutShortstatCacheForTests(); __resetPullRequestStatusCacheForTests(); }); afterEach(() => { __resetCheckoutShortstatCacheForTests(); __resetPullRequestStatusCacheForTests(); rmSync(tempDir, { recursive: true, force: true }); }); it("throws NotGitRepoError for non-git directories", async () => { const nonGitDir = join(tempDir, "not-git"); mkdirSync(nonGitDir, { recursive: true }); await expect(getCheckoutDiff(nonGitDir, { mode: "uncommitted" })).rejects.toBeInstanceOf( NotGitRepoError, ); }); it("returns null for getCurrentBranch in a repo with no commits", async () => { const emptyRepo = join(tempDir, "empty-repo"); mkdirSync(emptyRepo, { recursive: true }); execFileSync("git", ["init", "-b", "main"], { cwd: emptyRepo }); const branch = await getCurrentBranch(emptyRepo); expect(branch).toBeNull(); }); it("returns untracked files in an uncommitted diff before the first commit", async () => { const unbornRepo = join(tempDir, "unborn-repo"); mkdirSync(unbornRepo, { recursive: true }); execFileSync("git", ["init", "-b", "main"], { cwd: unbornRepo }); writeFileSync(join(unbornRepo, "greeting.txt"), "hello\n"); const diff = await getCheckoutDiff(unbornRepo, { mode: "uncommitted", includeStructured: true, }); expect(diff.structured).toEqual([ { path: "greeting.txt", isNew: true, isDeleted: false, additions: 1, deletions: 0, hunks: [ { oldStart: 0, oldCount: 0, newStart: 1, newCount: 1, lines: [ { type: "header", content: "@@ -0,0 +1 @@" }, { type: "add", content: "hello" }, ], }, ], status: "ok", }, ]); }); it("returns the branch being rebased when HEAD is detached during a rebase", async () => { execFileSync("git", ["checkout", "-b", "feature/rebase-test"], { cwd: repoDir }); writeFileSync(join(repoDir, "file.txt"), "feature\n"); execFileSync("git", ["add", "file.txt"], { cwd: repoDir }); execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "feature change"], { cwd: repoDir, }); execFileSync("git", ["checkout", "main"], { cwd: repoDir }); writeFileSync(join(repoDir, "file.txt"), "main\n"); execFileSync("git", ["add", "file.txt"], { cwd: repoDir }); execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "main change"], { cwd: repoDir, }); execFileSync("git", ["checkout", "feature/rebase-test"], { cwd: repoDir }); expect(() => execFileSync("git", ["rebase", "main"], { cwd: repoDir, stdio: "pipe" }), ).toThrow(); const branch = await getCurrentBranch(repoDir); expect(branch).toBe("feature/rebase-test"); }); it("renames the checked out branch and returns concrete branch names", async () => { execSync("git checkout -b feature/old-name", { cwd: repoDir }); const result = await renameCurrentBranch(repoDir, "feature/new-name"); const currentBranch = execSync("git branch --show-current", { cwd: repoDir }).toString().trim(); expect(currentBranch).toBe("feature/new-name"); expect(result).toEqual({ previousBranch: "feature/old-name", currentBranch: "feature/new-name", }); expect(() => execSync("git show-ref --verify refs/heads/feature/old-name", { cwd: repoDir }), ).toThrow(); expect( execSync("git show-ref --verify refs/heads/feature/new-name", { cwd: repoDir }) .toString() .trim(), ).toContain("refs/heads/feature/new-name"); }); it("fails when renaming the checked out branch to an existing branch", async () => { execSync("git branch feature/new-name", { cwd: repoDir }); execSync("git checkout -b feature/old-name", { cwd: repoDir }); await expect(renameCurrentBranch(repoDir, "feature/new-name")).rejects.toThrow(); expect(execSync("git branch --show-current", { cwd: repoDir }).toString().trim()).toBe( "feature/old-name", ); expect( execSync("git show-ref --verify refs/heads/feature/old-name", { cwd: repoDir }) .toString() .trim(), ).toContain("refs/heads/feature/old-name"); expect( execSync("git show-ref --verify refs/heads/feature/new-name", { cwd: repoDir }) .toString() .trim(), ).toContain("refs/heads/feature/new-name"); }); it("handles status/diff/commit in a normal repo", async () => { writeFileSync(join(repoDir, "file.txt"), "updated\n"); const status = await getCheckoutStatus(repoDir); expect(status.isGit).toBe(true); expect(status.currentBranch).toBe("main"); expect(status.isDirty).toBe(true); expect(status.hasRemote).toBe(false); const diff = await getCheckoutDiff(repoDir, { mode: "uncommitted" }); expect(diff.diff).toContain("-hello"); expect(diff.diff).toContain("+updated"); await commitAll(repoDir, "update file"); const cleanStatus = await getCheckoutStatus(repoDir); expect(cleanStatus.isDirty).toBe(false); const message = execFileSync("git", ["log", "-1", "--pretty=%B"], { cwd: repoDir }) .toString() .trim(); expect(message).toBe("update file"); }); it("reuses checkout snapshot facts across status, shortstat, and PR status reads", async () => { setupRemoteTrackingMain(repoDir, tempDir); execFileSync("git", ["checkout", "-b", "feature/facts"], { cwd: repoDir }); commitFile(repoDir, "feature.txt", "feature\n", "feature"); writeFileSync(join(repoDir, "feature.txt"), "feature\nchanged\n"); const github = createGitHubServiceForStatus(createPullRequestStatus()); const facts = await getCheckoutSnapshotFacts(repoDir, { paseoHome }); const status = await getCheckoutStatus(repoDir, { paseoHome, facts }); const shortstat = await getCheckoutShortstat(repoDir, { paseoHome, facts }, { force: true }); const prStatus = await getPullRequestStatus( repoDir, github, { force: true, reason: "snapshot-equivalence" }, { paseoHome, facts }, ); __resetCheckoutShortstatCacheForTests(); __resetPullRequestStatusCacheForTests(); startGitCommandMetrics(); const statusWithFacts = await getCheckoutStatus(repoDir, { paseoHome, facts }); const shortstatWithFacts = await getCheckoutShortstat( repoDir, { paseoHome, facts }, { force: true }, ); const prStatusWithFacts = await getPullRequestStatus( repoDir, github, { force: true, reason: "snapshot-equivalence-with-facts" }, { paseoHome, facts }, ); const metrics = stopGitCommandMetrics(); const commands = metrics.commands.map((command) => command.args.join(" ")); expect(statusWithFacts).toEqual(status); expect(shortstatWithFacts).toEqual(shortstat); expect(prStatusWithFacts).toEqual(prStatus); expect(commands).not.toContain("rev-parse --show-toplevel"); expect(commands).not.toContain("rev-parse --abbrev-ref HEAD"); }); it("hides whitespace-only changes when requested", async () => { writeFileSync(join(repoDir, "file.txt"), "hello \n"); const visibleDiff = await getCheckoutDiff(repoDir, { mode: "uncommitted" }); expect(visibleDiff.diff).toContain("file.txt"); const hiddenDiff = await getCheckoutDiff(repoDir, { mode: "uncommitted", ignoreWhitespace: true, includeStructured: true, }); expect(hiddenDiff.diff).toBe(""); expect(hiddenDiff.structured).toEqual([]); }); it("preserves removed-line syntax highlighting with structured diffs", async () => { const originalContent = `/* comment line 1 comment line 2 comment line 3 comment line 4 comment line 5 comment line 6 old comment line comment line 8 */ const x = 1; `; const updatedContent = `/* comment line 1 comment line 2 comment line 3 comment line 4 comment line 5 comment line 6 new comment line comment line 8 */ const x = 1; `; writeFileSync(join(repoDir, "example.ts"), originalContent); execFileSync("git", ["add", "example.ts"], { cwd: repoDir }); execFileSync( "git", ["-c", "commit.gpgsign=false", "commit", "-m", "add multiline comment fixture"], { cwd: repoDir, }, ); writeFileSync(join(repoDir, "example.ts"), updatedContent); const diff = await getCheckoutDiff(repoDir, { mode: "uncommitted", includeStructured: true }); const file = diff.structured?.find((entry) => entry.path === "example.ts"); const removedLine = file?.hunks[0]?.lines.find((line) => line.type === "remove"); const addedLine = file?.hunks[0]?.lines.find((line) => line.type === "add"); expect(addedLine?.tokens).toEqual([{ text: "new comment line", style: "comment" }]); expect(removedLine?.tokens).toEqual([{ text: "old comment line", style: "comment" }]); }); it("preserves no-prefix structured paths that start with a or b", async () => { mkdirSync(join(repoDir, "a")); mkdirSync(join(repoDir, "b")); commitFile(repoDir, "a/example.ts", "const value = 1;\n", "add a-prefixed path"); commitFile(repoDir, "b/other.ts", "const value = 1;\n", "add b-prefixed path"); commitFile(repoDir, "file with space.ts", "const value = 1;\n", "add path with space"); execFileSync("git", ["config", "diff.noprefix", "true"], { cwd: repoDir }); writeFileSync(join(repoDir, "a/example.ts"), "const value = 2;\n"); writeFileSync(join(repoDir, "b/other.ts"), "const value = 2;\n"); writeFileSync(join(repoDir, "file with space.ts"), "const value = 2;\n"); const diff = await getCheckoutDiff(repoDir, { mode: "uncommitted", includeStructured: true }); expect(diff.structured?.map((file) => [file.path, file.hunks.length])).toEqual([ ["a/example.ts", 1], ["b/other.ts", 1], ["file with space.ts", 1], ]); }); it("returns checkout root metadata for normal repos", async () => { const status = await getCheckoutStatus(repoDir); expect(status.isGit).toBe(true); if (!status.isGit) { return; } expect(status.currentBranch).toBe("main"); expect(realpathSync.native(status.repoRoot)).toBe(realpathSync.native(repoDir)); expect(status.isPaseoOwnedWorktree).toBe(false); expect(status.mainRepoRoot ?? null).toBeNull(); }); it("exposes hasRemote when origin is configured", async () => { const remoteDir = join(tempDir, "remote.git"); execFileSync("git", ["init", "--bare", "-b", "main", remoteDir]); execFileSync("git", ["remote", "add", "origin", remoteDir], { cwd: repoDir }); const status = await getCheckoutStatus(repoDir); expect(status.isGit).toBe(true); if (status.isGit) { expect(status.hasRemote).toBe(true); } }); it("reports ahead/behind relative to origin on the base branch", async () => { const remoteDir = join(tempDir, "remote.git"); const cloneDir = join(tempDir, "clone"); execFileSync("git", ["init", "--bare", "-b", "main", remoteDir]); execFileSync("git", ["remote", "add", "origin", remoteDir], { cwd: repoDir }); execFileSync("git", ["push", "-u", "origin", "main"], { cwd: repoDir }); execFileSync("git", ["clone", remoteDir, cloneDir]); execFileSync("git", ["config", "user.email", "test@test.com"], { cwd: cloneDir }); execFileSync("git", ["config", "user.name", "Test"], { cwd: cloneDir }); writeFileSync(join(cloneDir, "file.txt"), "remote\n"); execFileSync("git", ["add", "file.txt"], { cwd: cloneDir }); execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "remote update"], { cwd: cloneDir, }); execFileSync("git", ["push"], { cwd: cloneDir }); execFileSync("git", ["fetch", "origin"], { cwd: repoDir }); const behindStatus = await getCheckoutStatus(repoDir); expect(behindStatus.isGit).toBe(true); if (!behindStatus.isGit) { return; } expect(behindStatus.aheadOfOrigin).toBe(0); expect(behindStatus.behindOfOrigin).toBe(1); writeFileSync(join(repoDir, "local.txt"), "local\n"); execFileSync("git", ["add", "local.txt"], { cwd: repoDir }); execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "local update"], { cwd: repoDir, }); const divergedStatus = await getCheckoutStatus(repoDir); expect(divergedStatus.isGit).toBe(true); if (!divergedStatus.isGit) { return; } expect(divergedStatus.aheadOfOrigin).toBe(1); expect(divergedStatus.behindOfOrigin).toBe(1); }); it("reports a PR worktree as not ahead when its branch is pushed to the configured PR remote", async () => { setupRemoteTrackingMain(repoDir, tempDir); const prRemoteDir = join(tempDir, "pr-remote.git"); execFileSync("git", ["init", "--bare", "-b", "main", prRemoteDir]); execFileSync("git", ["checkout", "-b", "aaronzhongg/open-button-targets-active-file"], { cwd: repoDir, }); commitFile(repoDir, "feature.txt", "feature\n", "feature commit"); execFileSync("git", ["remote", "add", "paseo-pr-1285", prRemoteDir], { cwd: repoDir }); execFileSync( "git", ["push", "paseo-pr-1285", "HEAD:refs/heads/open-button-targets-active-file"], { cwd: repoDir }, ); execFileSync( "git", ["config", "branch.aaronzhongg/open-button-targets-active-file.remote", "paseo-pr-1285"], { cwd: repoDir, }, ); execFileSync( "git", [ "config", "branch.aaronzhongg/open-button-targets-active-file.merge", "refs/heads/open-button-targets-active-file", ], { cwd: repoDir }, ); const status = await getCheckoutStatus(repoDir); expect(status).toMatchObject({ isGit: true, currentBranch: "aaronzhongg/open-button-targets-active-file", aheadOfOrigin: 0, }); }); it("reports a PR worktree as behind when its configured PR remote has newer commits", async () => { setupRemoteTrackingMain(repoDir, tempDir); const prRemoteDir = join(tempDir, "pr-remote.git"); const prCloneDir = join(tempDir, "pr-clone"); execFileSync("git", ["init", "--bare", "-b", "main", prRemoteDir]); execFileSync("git", ["checkout", "-b", "aaronzhongg/open-button-targets-active-file"], { cwd: repoDir, }); commitFile(repoDir, "feature.txt", "feature\n", "feature commit"); execFileSync("git", ["remote", "add", "paseo-pr-1285", prRemoteDir], { cwd: repoDir }); execFileSync( "git", ["push", "paseo-pr-1285", "HEAD:refs/heads/open-button-targets-active-file"], { cwd: repoDir }, ); execFileSync( "git", ["config", "branch.aaronzhongg/open-button-targets-active-file.remote", "paseo-pr-1285"], { cwd: repoDir }, ); execFileSync( "git", [ "config", "branch.aaronzhongg/open-button-targets-active-file.merge", "refs/heads/open-button-targets-active-file", ], { cwd: repoDir }, ); execFileSync("git", ["clone", prRemoteDir, prCloneDir]); execFileSync("git", ["checkout", "open-button-targets-active-file"], { cwd: prCloneDir }); execFileSync("git", ["config", "user.email", "test@test.com"], { cwd: prCloneDir }); execFileSync("git", ["config", "user.name", "Test"], { cwd: prCloneDir }); commitFile(prCloneDir, "remote.txt", "remote\n", "remote update"); execFileSync("git", ["push"], { cwd: prCloneDir }); execFileSync("git", ["fetch", "paseo-pr-1285"], { cwd: repoDir }); const status = await getCheckoutStatus(repoDir); expect(status).toMatchObject({ isGit: true, currentBranch: "aaronzhongg/open-button-targets-active-file", behindOfOrigin: 1, }); }); it("does not report the full branch history as ahead when the current branch remote is gone", async () => { setupRemoteTrackingMain(repoDir, tempDir); execFileSync("git", ["checkout", "-b", "feature"], { cwd: repoDir }); commitFile(repoDir, "feature.txt", "feature\n", "feature commit"); execFileSync("git", ["push", "-u", "origin", "feature"], { cwd: repoDir }); execFileSync("git", ["push", "origin", "--delete", "feature"], { cwd: repoDir }); execFileSync("git", ["fetch", "--prune", "origin"], { cwd: repoDir }); const status = await getCheckoutStatus(repoDir); expect(status.isGit).toBe(true); if (!status.isGit) { return; } expect(status.aheadOfOrigin).toBeNull(); }); it("does not report full history as unpushed for fresh no-track Paseo worktrees", async () => { setupRemoteTrackingMain(repoDir, tempDir); commitFile(repoDir, "second.txt", "second\n", "second commit"); execFileSync("git", ["push"], { cwd: repoDir }); const worktree = await createLegacyWorktreeForTest({ branchName: "fresh-feature", cwd: repoDir, baseBranch: "main", worktreeSlug: "fresh-feature", paseoHome, }); const status = await getCheckoutStatus(worktree.worktreePath, { paseoHome }); expect(status).toMatchObject({ isGit: true, isPaseoOwnedWorktree: true, baseRef: "main", aheadBehind: { ahead: 0, behind: 0 }, aheadOfOrigin: null, }); }); it("does not report local-only no-track worktree commits as ahead of origin", async () => { setupRemoteTrackingMain(repoDir, tempDir); commitFile(repoDir, "second.txt", "second\n", "second commit"); execFileSync("git", ["push"], { cwd: repoDir }); const worktree = await createLegacyWorktreeForTest({ branchName: "fresh-feature", cwd: repoDir, baseBranch: "main", worktreeSlug: "fresh-feature", paseoHome, }); commitFile(worktree.worktreePath, "feature.txt", "feature\n", "feature commit"); const status = await getCheckoutStatus(worktree.worktreePath, { paseoHome }); expect(status).toMatchObject({ isGit: true, isPaseoOwnedWorktree: true, baseRef: "main", aheadBehind: { ahead: 1, behind: 0 }, aheadOfOrigin: null, }); }); it("does not report incoming additions when the base branch is behind its remote", async () => { const { cloneDir } = setupRemoteTrackingMain(repoDir, tempDir); commitFile(cloneDir, "file.txt", "remote one\nremote two\n", "remote update"); execFileSync("git", ["push"], { cwd: cloneDir }); execFileSync("git", ["fetch", "origin"], { cwd: repoDir }); const shortstat = await getCheckoutShortstat(repoDir); expect(shortstat).toBeNull(); }); it("does not report incoming deletions when the base branch is behind its remote", async () => { const { cloneDir } = setupRemoteTrackingMain(repoDir, tempDir); commitFile(cloneDir, "file.txt", "", "remote deletion"); execFileSync("git", ["push"], { cwd: cloneDir }); execFileSync("git", ["fetch", "origin"], { cwd: repoDir }); const shortstat = await getCheckoutShortstat(repoDir); expect(shortstat).toBeNull(); }); it("reports outgoing changes when the base branch is ahead of its remote", async () => { setupRemoteTrackingMain(repoDir, tempDir); commitFile(repoDir, "file.txt", "local one\nlocal two\n", "local update"); const shortstat = await getCheckoutShortstat(repoDir); expect(shortstat).toEqual({ additions: 2, deletions: 1 }); }); it("uses the merge-base for shortstat when the base branch diverged from its remote", async () => { const { cloneDir } = setupRemoteTrackingMain(repoDir, tempDir); commitFile(cloneDir, "file.txt", "remote one\nremote two\n", "remote update"); execFileSync("git", ["push"], { cwd: cloneDir }); execFileSync("git", ["fetch", "origin"], { cwd: repoDir }); commitFile(repoDir, "local.txt", "local\n", "local update"); const shortstat = await getCheckoutShortstat(repoDir); expect(shortstat).toEqual({ additions: 1, deletions: 0 }); }); it("keeps base branch divergence pointed at local work when the remote has more commits", async () => { const { cloneDir } = setupRemoteTrackingMain(repoDir, tempDir); commitFile(cloneDir, "remote-one.txt", "remote one\n", "remote update one"); commitFile(cloneDir, "remote-two.txt", "remote two\n", "remote update two"); execFileSync("git", ["push"], { cwd: cloneDir }); execFileSync("git", ["fetch", "origin"], { cwd: repoDir }); commitFile(repoDir, "local.txt", "local\n", "local update"); const shortstat = await getCheckoutShortstat(repoDir); expect(shortstat).toEqual({ additions: 1, deletions: 0 }); }); it("reports only working tree changes when the base branch is behind", async () => { commitFile(repoDir, "tracked.txt", "tracked base\n", "add tracked file"); const { cloneDir } = setupRemoteTrackingMain(repoDir, tempDir); commitFile(cloneDir, "incoming.txt", "incoming\n", "remote incoming"); execFileSync("git", ["push"], { cwd: cloneDir }); execFileSync("git", ["fetch", "origin"], { cwd: repoDir }); writeFileSync(join(repoDir, "tracked.txt"), "local one\nlocal two\n"); const shortstat = await getCheckoutShortstat(repoDir); expect(shortstat).toEqual({ additions: 2, deletions: 1 }); }); it("keeps feature shortstat scoped to feature changes when the base remote is ahead", async () => { const { cloneDir } = setupRemoteTrackingMain(repoDir, tempDir); execFileSync("git", ["checkout", "-b", "feature"], { cwd: repoDir }); commitFile(repoDir, "feature.txt", "feature\n", "feature update"); execFileSync("git", ["checkout", "main"], { cwd: cloneDir }); commitFile(cloneDir, "base.txt", "base\n", "base update"); execFileSync("git", ["push"], { cwd: cloneDir }); execFileSync("git", ["fetch", "origin"], { cwd: repoDir }); const shortstat = await getCheckoutShortstat(repoDir); expect(shortstat).toEqual({ additions: 1, deletions: 0 }); }); it("does not report incoming base changes when a feature branch has no local work beyond merge-base", async () => { const { cloneDir } = setupRemoteTrackingMain(repoDir, tempDir); execFileSync("git", ["checkout", "-b", "feature"], { cwd: repoDir }); execFileSync("git", ["checkout", "main"], { cwd: cloneDir }); commitFile(cloneDir, "incoming.txt", "incoming\n", "remote incoming"); execFileSync("git", ["push"], { cwd: cloneDir }); execFileSync("git", ["fetch", "origin"], { cwd: repoDir }); const shortstat = await getCheckoutShortstat(repoDir); expect(shortstat).toBeNull(); }); it("reports feature shortstat ahead of the comparison merge-base", async () => { setupRemoteTrackingMain(repoDir, tempDir); execFileSync("git", ["checkout", "-b", "feature"], { cwd: repoDir }); commitFile(repoDir, "feature.txt", "feature\n", "feature update"); const shortstat = await getCheckoutShortstat(repoDir); expect(shortstat).toEqual({ additions: 1, deletions: 0 }); }); it("includes untracked file lines in shortstat additions", async () => { setupRemoteTrackingMain(repoDir, tempDir); execFileSync("git", ["checkout", "-b", "feature"], { cwd: repoDir }); commitFile(repoDir, "committed.txt", "one\n", "add committed"); writeFileSync(join(repoDir, "untracked.txt"), "line1\nline2\nline3\n"); const shortstat = await getCheckoutShortstat(repoDir); expect(shortstat).toEqual({ additions: 4, deletions: 0 }); }); it("reports untracked-only additions when no tracked changes exist", async () => { setupRemoteTrackingMain(repoDir, tempDir); writeFileSync(join(repoDir, "newfile.txt"), "a\nb\n"); const shortstat = await getCheckoutShortstat(repoDir); expect(shortstat).toEqual({ additions: 2, deletions: 0 }); }); it("counts empty untracked files as 0 additions", async () => { setupRemoteTrackingMain(repoDir, tempDir); execFileSync("git", ["checkout", "-b", "feature"], { cwd: repoDir }); commitFile(repoDir, "committed.txt", "one\n", "add committed"); writeFileSync(join(repoDir, "empty.txt"), ""); const shortstat = await getCheckoutShortstat(repoDir); expect(shortstat).toEqual({ additions: 1, deletions: 0 }); }); it("uses the merge-base for shortstat when a feature branch diverged from its tracked remote", async () => { setupRemoteTrackingMain(repoDir, tempDir); execFileSync("git", ["checkout", "-b", "feature"], { cwd: repoDir }); execFileSync("git", ["push", "-u", "origin", "feature"], { cwd: repoDir }); const featureCloneDir = join(tempDir, "feature-clone"); execFileSync("git", ["clone", join(tempDir, "remote.git"), featureCloneDir]); execFileSync("git", ["config", "user.email", "test@test.com"], { cwd: featureCloneDir }); execFileSync("git", ["config", "user.name", "Test"], { cwd: featureCloneDir }); execFileSync("git", ["checkout", "feature"], { cwd: featureCloneDir }); commitFile(featureCloneDir, "remote-feature.txt", "remote feature\n", "remote feature update"); execFileSync("git", ["push"], { cwd: featureCloneDir }); commitFile(repoDir, "local-feature.txt", "local feature\n", "local feature update"); execFileSync("git", ["fetch", "origin"], { cwd: repoDir }); const shortstat = await getCheckoutShortstat(repoDir); expect(shortstat).toEqual({ additions: 1, deletions: 0 }); }); it("uses the remote-only base branch as the feature shortstat comparison", async () => { const { cloneDir } = setupRemoteTrackingMain(repoDir, tempDir); execFileSync("git", ["remote", "set-head", "origin", "main"], { cwd: repoDir }); execFileSync("git", ["checkout", "-b", "feature"], { cwd: repoDir }); commitFile(repoDir, "feature.txt", "feature\n", "feature update"); execFileSync("git", ["branch", "-D", "main"], { cwd: repoDir }); commitFile(cloneDir, "base.txt", "base\n", "base update"); execFileSync("git", ["push"], { cwd: cloneDir }); execFileSync("git", ["fetch", "origin"], { cwd: repoDir }); const shortstat = await getCheckoutShortstat(repoDir); expect(shortstat).toEqual({ additions: 1, deletions: 0 }); }); it("returns no shortstat for a clean base branch that is up to date with its remote", async () => { setupRemoteTrackingMain(repoDir, tempDir); execFileSync("git", ["fetch", "origin"], { cwd: repoDir }); const shortstat = await getCheckoutShortstat(repoDir); expect(shortstat).toBeNull(); }); it("reports working tree changes when the base branch has no ahead commits", async () => { setupRemoteTrackingMain(repoDir, tempDir); writeFileSync(join(repoDir, "file.txt"), "local one\nlocal two\n"); const shortstat = await getCheckoutShortstat(repoDir); expect(shortstat).toEqual({ additions: 2, deletions: 1 }); }); it("uses the freshest comparison base for status and shortstat when local main is stale", async () => { const remoteDir = join(tempDir, "remote.git"); const cloneDir = join(tempDir, "clone"); execFileSync("git", ["init", "--bare", "-b", "main", remoteDir]); execFileSync("git", ["remote", "add", "origin", remoteDir], { cwd: repoDir }); execFileSync("git", ["push", "-u", "origin", "main"], { cwd: repoDir }); execFileSync("git", ["clone", remoteDir, cloneDir]); execFileSync("git", ["config", "user.email", "test@test.com"], { cwd: cloneDir }); execFileSync("git", ["config", "user.name", "Test"], { cwd: cloneDir }); writeFileSync(join(cloneDir, "upstream.txt"), "upstream 1\nupstream 2\n"); execFileSync("git", ["add", "upstream.txt"], { cwd: cloneDir }); execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "remote update"], { cwd: cloneDir, }); execFileSync("git", ["push"], { cwd: cloneDir }); execFileSync("git", ["fetch", "origin"], { cwd: repoDir }); execFileSync("git", ["checkout", "-b", "feature", "origin/main"], { cwd: repoDir }); writeFileSync(join(repoDir, "feature.txt"), "feature\n"); execFileSync("git", ["add", "feature.txt"], { cwd: repoDir }); execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "feature update"], { cwd: repoDir, }); const status = await getCheckoutStatus(repoDir); expect(status.isGit).toBe(true); if (!status.isGit) { return; } expect(status.baseRef).toBe("main"); expect(status.aheadBehind).toEqual({ ahead: 1, behind: 0 }); const shortstat = await getCheckoutShortstat(repoDir); expect(shortstat).toEqual({ additions: 1, deletions: 0 }); }); it("does not count origin base commits as feature changes when local main is stale", async () => { const remoteDir = join(tempDir, "remote.git"); const otherClone = join(tempDir, "other-clone"); execFileSync("git", ["init", "--bare", "-b", "main", remoteDir]); execFileSync("git", ["remote", "add", "origin", remoteDir], { cwd: repoDir }); execFileSync("git", ["push", "-u", "origin", "main"], { cwd: repoDir }); execFileSync("git", ["clone", remoteDir, otherClone]); execFileSync("git", ["config", "user.email", "test@test.com"], { cwd: otherClone }); execFileSync("git", ["config", "user.name", "Test"], { cwd: otherClone }); writeFileSync(join(otherClone, "already-on-origin.txt"), "origin\n"); execFileSync("git", ["add", "already-on-origin.txt"], { cwd: otherClone }); execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "origin base commit"], { cwd: otherClone, }); execFileSync("git", ["push"], { cwd: otherClone }); writeFileSync(join(repoDir, "local-only-base.txt"), "local\n"); execFileSync("git", ["add", "local-only-base.txt"], { cwd: repoDir }); execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "local base drift"], { cwd: repoDir, }); execFileSync("git", ["fetch", "origin"], { cwd: repoDir }); execFileSync("git", ["checkout", "-b", "feature", "origin/main"], { cwd: repoDir }); const shortstat = await getCheckoutShortstat(repoDir); expect(shortstat).toBeNull(); const diff = await getCheckoutDiff(repoDir, { mode: "base", baseRef: "main", includeStructured: true, }); expect(diff.diff).toBe(""); expect(diff.structured).toEqual([]); }); it("falls back to the local base branch when origin is absent", async () => { execFileSync("git", ["checkout", "-b", "feature"], { cwd: repoDir }); writeFileSync(join(repoDir, "local-feature.txt"), "feature\n"); execFileSync("git", ["add", "local-feature.txt"], { cwd: repoDir }); execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "local feature"], { cwd: repoDir, }); const shortstat = await getCheckoutShortstat(repoDir); expect(shortstat).toEqual({ additions: 1, deletions: 0 }); const diff = await getCheckoutDiff(repoDir, { mode: "base", baseRef: "main" }); expect(diff.diff).toContain("local-feature.txt"); }); it("keeps an explicit origin base ref instead of stripping it to a stale local branch", async () => { const remoteDir = join(tempDir, "remote.git"); const otherClone = join(tempDir, "other-clone"); execFileSync("git", ["init", "--bare", "-b", "main", remoteDir]); execFileSync("git", ["remote", "add", "origin", remoteDir], { cwd: repoDir }); execFileSync("git", ["push", "-u", "origin", "main"], { cwd: repoDir }); execFileSync("git", ["clone", remoteDir, otherClone]); execFileSync("git", ["config", "user.email", "test@test.com"], { cwd: otherClone }); execFileSync("git", ["config", "user.name", "Test"], { cwd: otherClone }); writeFileSync(join(otherClone, "origin-base.txt"), "origin\n"); execFileSync("git", ["add", "origin-base.txt"], { cwd: otherClone }); execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "origin base"], { cwd: otherClone, }); execFileSync("git", ["push"], { cwd: otherClone }); writeFileSync(join(repoDir, "local-drift.txt"), "local\n"); execFileSync("git", ["add", "local-drift.txt"], { cwd: repoDir }); execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "local drift"], { cwd: repoDir, }); execFileSync("git", ["fetch", "origin"], { cwd: repoDir }); execFileSync("git", ["checkout", "-b", "feature", "origin/main"], { cwd: repoDir }); const diff = await getCheckoutDiff(repoDir, { mode: "base", baseRef: "origin/main" }); expect(diff.diff).toBe(""); }); it("shows feature commits when the local and origin base branches are up to date", async () => { const remoteDir = join(tempDir, "remote.git"); execFileSync("git", ["init", "--bare", "-b", "main", remoteDir]); execFileSync("git", ["remote", "add", "origin", remoteDir], { cwd: repoDir }); execFileSync("git", ["push", "-u", "origin", "main"], { cwd: repoDir }); execFileSync("git", ["fetch", "origin"], { cwd: repoDir }); execFileSync("git", ["checkout", "-b", "feature"], { cwd: repoDir }); writeFileSync(join(repoDir, "feature.txt"), "feature\n"); execFileSync("git", ["add", "feature.txt"], { cwd: repoDir }); execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "feature commit"], { cwd: repoDir, }); const shortstat = await getCheckoutShortstat(repoDir); expect(shortstat).toEqual({ additions: 1, deletions: 0 }); const diff = await getCheckoutDiff(repoDir, { mode: "base", baseRef: "main" }); expect(diff.diff).toContain("feature.txt"); }); it("does not include dirty working tree changes in base mode", async () => { writeFileSync(join(repoDir, "file.txt"), "dirty\n"); writeFileSync(join(repoDir, "untracked.txt"), "untracked\n"); const diff = await getCheckoutDiff(repoDir, { mode: "base", baseRef: "main", includeStructured: true, }); expect(diff.diff).toBe(""); expect(diff.structured).toEqual([]); }); it("shows committed branch changes without dirty working tree changes in base mode", async () => { execFileSync("git", ["checkout", "-b", "feature"], { cwd: repoDir }); writeFileSync(join(repoDir, "feature.txt"), "feature\n"); execFileSync("git", ["add", "feature.txt"], { cwd: repoDir }); execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "feature commit"], { cwd: repoDir, }); writeFileSync(join(repoDir, "file.txt"), "dirty\n"); writeFileSync(join(repoDir, "untracked.txt"), "untracked\n"); const diff = await getCheckoutDiff(repoDir, { mode: "base", baseRef: "main", includeStructured: true, }); expect(diff.diff).toContain("feature.txt"); expect(diff.diff).not.toContain("file.txt"); expect(diff.diff).not.toContain("untracked.txt"); expect(diff.structured?.map((file) => file.path)).toEqual(["feature.txt"]); }); it("warms shortstat cache in the background without blocking listing callers", async () => { expect(getCachedCheckoutShortstat(repoDir)).toBeUndefined(); warmCheckoutShortstatInBackground(repoDir); // A repo with no origin/main computes to null, but null should still be cached. for (let attempts = 0; attempts < 20; attempts += 1) { const cached = getCachedCheckoutShortstat(repoDir); if (cached !== undefined) { expect(cached).toBeNull(); return; } await sleep(25); } throw new Error("shortstat background warm did not populate cache in time"); }); it("commits messages with quotes safely", async () => { const message = `He said "hello" and it's fine`; writeFileSync(join(repoDir, "file.txt"), "quoted\n"); await commitAll(repoDir, message); const logMessage = execFileSync("git", ["log", "-1", "--pretty=%B"], { cwd: repoDir }) .toString() .trim(); expect(logMessage).toBe(message); }); it("diffs base mode against merge-base (no base-only deletions)", async () => { execFileSync("git", ["checkout", "-b", "feature"], { cwd: repoDir }); // Advance base branch after feature splits off. execFileSync("git", ["checkout", "main"], { cwd: repoDir }); writeFileSync(join(repoDir, "base-only.txt"), "base\n"); execFileSync("git", ["add", "base-only.txt"], { cwd: repoDir }); execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "base only"], { cwd: repoDir, }); // Make a feature change. execFileSync("git", ["checkout", "feature"], { cwd: repoDir }); writeFileSync(join(repoDir, "feature.txt"), "feature\n"); execFileSync("git", ["add", "feature.txt"], { cwd: repoDir }); execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "feature commit"], { cwd: repoDir, }); const diff = await getCheckoutDiff(repoDir, { mode: "base", baseRef: "main" }); expect(diff.diff).toContain("feature.txt"); expect(diff.diff).not.toContain("base-only.txt"); }); it("does not throw on large diffs (marks file as too_large)", async () => { const large = Array.from({ length: 200_000 }, (_, i) => `line ${i}`).join("\n") + "\n"; writeFileSync(join(repoDir, "file.txt"), large); const diff = await getCheckoutDiff(repoDir, { mode: "uncommitted", includeStructured: true }); expect(diff.structured?.some((f) => f.path === "file.txt" && f.status === "too_large")).toBe( true, ); }); it("marks tracked generated one-line diffs as too_large by content size", async () => { writeFileSync(join(repoDir, "generated.js"), `const data = "old";\n`); execFileSync("git", ["add", "generated.js"], { cwd: repoDir }); execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "add generated"], { cwd: repoDir, }); writeFileSync(join(repoDir, "generated.js"), `const data = "${"x".repeat(1_100_000)}";\n`); const diff = await getCheckoutDiff(repoDir, { mode: "uncommitted", includeStructured: true }); const entry = diff.structured?.find((file) => file.path === "generated.js"); expect(entry).toBeTruthy(); expect(entry?.status).toBe("too_large"); expect(entry?.additions).toBe(1); expect(entry?.deletions).toBe(1); expect(entry?.hunks).toEqual([]); expect(diff.diff).toContain("# generated.js: diff too large omitted"); expect(diff.diff).not.toContain("x".repeat(10_000)); }); it("keeps small tracked files displayable when another tracked file has a massive diff", async () => { writeFileSync(join(repoDir, "generated.js"), `const data = "old";\n`); writeFileSync(join(repoDir, "small.ts"), `export const value = "old";\n`); execFileSync("git", ["add", "generated.js", "small.ts"], { cwd: repoDir }); execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "add tracked files"], { cwd: repoDir, }); writeFileSync(join(repoDir, "generated.js"), `const data = "${"x".repeat(2_100_000)}";\n`); writeFileSync(join(repoDir, "small.ts"), `export const value = "new";\n`); startGitCommandMetrics(); const diff = await getCheckoutDiff(repoDir, { mode: "uncommitted", includeStructured: true }); const metrics = stopGitCommandMetrics(); const commands = metrics.commands.map((command) => command.args.join(" ")); expect( diff.structured?.map((file) => ({ path: file.path, status: file.status, hunks: file.hunks.length, })), ).toEqual([ { path: "generated.js", status: "too_large", hunks: 0 }, { path: "small.ts", status: "ok", hunks: 1 }, ]); expect(diff.diff).toContain("# generated.js: diff too large omitted"); expect(diff.diff).toContain(`-export const value = "old";`); expect(diff.diff).toContain(`+export const value = "new";`); expect(commands).toContain("diff --numstat HEAD"); expect(commands).toContain("diff HEAD -- generated.js"); expect(commands).toContain("diff HEAD -- small.ts"); expect(metrics.maxConcurrent).toBeLessThanOrEqual(8); }); it("marks tracked files omitted by the total diff budget as too_large", async () => { for (let i = 1; i <= 4; i += 1) { writeFileSync(join(repoDir, `budget-${i}.txt`), "old\n"); } execFileSync("git", ["add", "."], { cwd: repoDir }); execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "add budget files"], { cwd: repoDir, }); const largeLine = "x".repeat(700_000); for (let i = 1; i <= 4; i += 1) { writeFileSync(join(repoDir, `budget-${i}.txt`), `${largeLine}-${i}\n`); } const diff = await getCheckoutDiff(repoDir, { mode: "uncommitted", includeStructured: true }); expect( diff.structured?.map((file) => ({ path: file.path, status: file.status, hunks: file.hunks.length, })), ).toEqual([ { path: "budget-1.txt", status: "ok", hunks: 1 }, { path: "budget-2.txt", status: "ok", hunks: 1 }, { path: "budget-3.txt", status: "too_large", hunks: 0 }, { path: "budget-4.txt", status: "too_large", hunks: 0 }, ]); expect(diff.diff).toContain("budget-1.txt"); expect(diff.diff).toContain("budget-2.txt"); expect(diff.diff).toContain("# budget-3.txt: diff too large omitted"); expect(diff.diff).toContain("# budget-4.txt: diff too large omitted"); }); it("short-circuits tracked binary files", async () => { const trackedBinaryPath = join(repoDir, "tracked-blob.bin"); writeFileSync(trackedBinaryPath, Buffer.from([0x00, 0xff, 0x10, 0x80, 0x00])); execFileSync("git", ["add", "tracked-blob.bin"], { cwd: repoDir }); execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "add tracked binary"], { cwd: repoDir, }); writeFileSync(trackedBinaryPath, Buffer.from([0x00, 0xff, 0x11, 0x81, 0x00])); const diff = await getCheckoutDiff(repoDir, { mode: "uncommitted", includeStructured: true, }); const entry = diff.structured?.find((file) => file.path === "tracked-blob.bin"); expect(entry).toBeTruthy(); expect(entry?.status).toBe("binary"); expect(diff.diff).toContain("# tracked-blob.bin: binary diff omitted"); }); it("short-circuits untracked binary files", async () => { const binaryPath = join(repoDir, "blob.bin"); writeFileSync(binaryPath, Buffer.from([0x00, 0xff, 0x10, 0x80, 0x00, 0x7f, 0x00])); const diff = await getCheckoutDiff(repoDir, { mode: "uncommitted", includeStructured: true, }); const entry = diff.structured?.find((file) => file.path === "blob.bin"); expect(entry).toBeTruthy(); expect(entry?.status).toBe("binary"); expect(diff.diff).toContain("# blob.bin: binary diff omitted"); }); it("marks untracked oversized files as too_large", async () => { const large = Array.from({ length: 240_000 }, (_, i) => `line ${i}`).join("\n") + "\n"; writeFileSync(join(repoDir, "untracked-large.txt"), large); const diff = await getCheckoutDiff(repoDir, { mode: "uncommitted", includeStructured: true, }); const entry = diff.structured?.find((file) => file.path === "untracked-large.txt"); expect(entry).toBeTruthy(); expect(entry?.status).toBe("too_large"); expect(diff.diff).toContain("# untracked-large.txt: diff too large omitted"); }); it("handles status/diff/commit in a .paseo worktree", async () => { const result = await createLegacyWorktreeForTest({ branchName: "main", cwd: repoDir, baseBranch: "main", worktreeSlug: "alpha", paseoHome, }); writeFileSync(join(result.worktreePath, "file.txt"), "worktree change\n"); const status = await getCheckoutStatus(result.worktreePath, { paseoHome }); expect(status.isGit).toBe(true); expect(realpathSync.native(status.repoRoot)).toBe(realpathSync.native(result.worktreePath)); expect(status.isDirty).toBe(true); expect(status.isPaseoOwnedWorktree).toBe(true); expect(realpathSync.native(status.mainRepoRoot ?? "")).toBe(realpathSync.native(repoDir)); const diff = await getCheckoutDiff(result.worktreePath, { mode: "uncommitted" }, { paseoHome }); expect(diff.diff).toContain("-hello"); expect(diff.diff).toContain("+worktree change"); await commitAll(result.worktreePath, "worktree update"); const cleanStatus = await getCheckoutStatus(result.worktreePath, { paseoHome }); expect(cleanStatus.isDirty).toBe(false); const message = execFileSync("git", ["log", "-1", "--pretty=%B"], { cwd: result.worktreePath, }) .toString() .trim(); expect(message).toBe("worktree update"); }); it("returns checkout root metadata for .paseo worktrees", async () => { const result = await createLegacyWorktreeForTest({ branchName: "main", cwd: repoDir, baseBranch: "main", worktreeSlug: "lite-alpha", paseoHome, }); const status = await getCheckoutStatus(result.worktreePath, { paseoHome }); expect(status.isGit).toBe(true); if (!status.isGit) { return; } expect(realpathSync.native(status.repoRoot)).toBe(realpathSync.native(result.worktreePath)); expect(status.isPaseoOwnedWorktree).toBe(true); expect(realpathSync.native(status.mainRepoRoot ?? "")).toBe(realpathSync.native(repoDir)); }); it("returns mainRepoRoot pointing to first non-bare worktree for bare repos", async () => { const bareRepoDir = join(tempDir, "bare-repo"); execFileSync("git", ["clone", "--bare", repoDir, bareRepoDir]); const mainCheckoutDir = join(tempDir, "main-checkout"); execFileSync("git", ["-C", bareRepoDir, "worktree", "add", mainCheckoutDir, "main"]); execFileSync("git", ["config", "user.email", "test@test.com"], { cwd: mainCheckoutDir }); execFileSync("git", ["config", "user.name", "Test"], { cwd: mainCheckoutDir }); const worktree = await createLegacyWorktreeForTest({ branchName: "feature", cwd: mainCheckoutDir, baseBranch: "main", worktreeSlug: "feature-worktree", paseoHome, }); const status = await getCheckoutStatus(worktree.worktreePath, { paseoHome }); expect(status.isGit).toBe(true); expect(status.isPaseoOwnedWorktree).toBe(true); expect(realpathSync.native(status.mainRepoRoot ?? "")).toBe( realpathSync.native(mainCheckoutDir), ); }); it("detects plain git worktrees from git alone", async () => { const worktreeDir = join(tempDir, "plain-git-worktree"); execFileSync("git", ["worktree", "add", "-b", "feature/plain", worktreeDir, "main"], { cwd: repoDir, }); const status = await getCheckoutStatus(worktreeDir, { paseoHome }); expect(status.isGit).toBe(true); expect(realpathSync.native(status.repoRoot)).toBe(realpathSync.native(worktreeDir)); expect(status.isPaseoOwnedWorktree).toBe(false); expect(realpathSync.native(status.mainRepoRoot ?? "")).toBe(realpathSync.native(repoDir)); expect(status.currentBranch).toBe("feature/plain"); }); it("merges the current branch into base from a worktree checkout", async () => { const worktree = await createLegacyWorktreeForTest({ branchName: "main", cwd: repoDir, baseBranch: "main", worktreeSlug: "merge", paseoHome, }); writeFileSync(join(worktree.worktreePath, "merge.txt"), "feature\n"); execFileSync("git", ["checkout", "-b", "feature"], { cwd: worktree.worktreePath }); execFileSync("git", ["add", "merge.txt"], { cwd: worktree.worktreePath }); execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "feature commit"], { cwd: worktree.worktreePath, }); const featureCommit = execFileSync("git", ["rev-parse", "HEAD"], { cwd: worktree.worktreePath }) .toString() .trim(); await mergeToBase(worktree.worktreePath, { baseRef: "main" }, { paseoHome }); const baseContainsFeature = execFileSync( "git", ["merge-base", "--is-ancestor", featureCommit, "main"], { cwd: repoDir, stdio: "pipe", }, ); expect(baseContainsFeature).toBeDefined(); const statusAfterMerge = await getCheckoutStatus(worktree.worktreePath, { paseoHome }); expect(statusAfterMerge.isGit).toBe(true); if (statusAfterMerge.isGit) { expect(statusAfterMerge.aheadBehind?.ahead ?? 0).toBe(0); } const currentBranch = execFileSync("git", ["rev-parse", "--abbrev-ref", "HEAD"], { cwd: worktree.worktreePath, }) .toString() .trim(); expect(currentBranch).toBe("feature"); }); it("reports the base worktree cwd when merge-to-base mutates a separate checkout", async () => { execFileSync("git", ["checkout", "-b", "develop"], { cwd: repoDir }); writeFileSync(join(repoDir, "develop.txt"), "develop\n"); execFileSync("git", ["add", "develop.txt"], { cwd: repoDir }); execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "develop commit"], { cwd: repoDir, }); execFileSync("git", ["checkout", "main"], { cwd: repoDir }); const baseWorktreePath = join(tempDir, "base-worktree"); execFileSync("git", ["worktree", "add", baseWorktreePath, "develop"], { cwd: repoDir }); const featureWorktree = await createLegacyWorktreeForTest({ branchName: "feature", cwd: repoDir, baseBranch: "develop", worktreeSlug: "feature-worktree", paseoHome, }); writeFileSync(join(featureWorktree.worktreePath, "feature.txt"), "feature\n"); execFileSync("git", ["add", "feature.txt"], { cwd: featureWorktree.worktreePath }); execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "feature commit"], { cwd: featureWorktree.worktreePath, }); const mutatedCwd = await mergeToBase(featureWorktree.worktreePath, {}, { paseoHome }); expect(realpathSync.native(mutatedCwd)).toBe(realpathSync.native(baseWorktreePath)); expect(mutatedCwd).not.toBe(featureWorktree.worktreePath); }); it("merges from the most-ahead base ref (origin/main when it is ahead)", async () => { const remoteDir = join(tempDir, "remote.git"); execFileSync("git", ["init", "--bare", "-b", "main", remoteDir]); execFileSync("git", ["remote", "add", "origin", remoteDir], { cwd: repoDir }); execFileSync("git", ["push", "-u", "origin", "main"], { cwd: repoDir }); // Advance origin/main without advancing local main. const otherClone = join(tempDir, "other-clone"); execFileSync("git", ["clone", remoteDir, otherClone]); execFileSync("git", ["config", "user.email", "test@test.com"], { cwd: otherClone }); execFileSync("git", ["config", "user.name", "Test"], { cwd: otherClone }); writeFileSync(join(otherClone, "remote-only.txt"), "remote\n"); execFileSync("git", ["add", "remote-only.txt"], { cwd: otherClone }); execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "remote only"], { cwd: otherClone, }); const remoteOnlyCommit = execFileSync("git", ["rev-parse", "HEAD"], { cwd: otherClone }) .toString() .trim(); execFileSync("git", ["push"], { cwd: otherClone }); execFileSync("git", ["fetch", "origin"], { cwd: repoDir }); execFileSync("git", ["checkout", "-b", "feature"], { cwd: repoDir }); writeFileSync(join(repoDir, "feature.txt"), "feature\n"); execFileSync("git", ["add", "feature.txt"], { cwd: repoDir }); execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "feature commit"], { cwd: repoDir, }); await mergeFromBase(repoDir, { baseRef: "main", requireCleanTarget: true }); execFileSync("git", ["merge-base", "--is-ancestor", remoteOnlyCommit, "feature"], { cwd: repoDir, }); }); it("merges from the most-ahead base ref (local main when it is ahead)", async () => { const remoteDir = join(tempDir, "remote.git"); execFileSync("git", ["init", "--bare", "-b", "main", remoteDir]); execFileSync("git", ["remote", "add", "origin", remoteDir], { cwd: repoDir }); execFileSync("git", ["push", "-u", "origin", "main"], { cwd: repoDir }); // Advance local main without pushing. writeFileSync(join(repoDir, "local-only.txt"), "local\n"); execFileSync("git", ["add", "local-only.txt"], { cwd: repoDir }); execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "local only"], { cwd: repoDir, }); const localOnlyCommit = execFileSync("git", ["rev-parse", "HEAD"], { cwd: repoDir }) .toString() .trim(); execFileSync("git", ["checkout", "-b", "feature", `${localOnlyCommit}~1`], { cwd: repoDir }); writeFileSync(join(repoDir, "feature.txt"), "feature\n"); execFileSync("git", ["add", "feature.txt"], { cwd: repoDir }); execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "feature commit"], { cwd: repoDir, }); await mergeFromBase(repoDir, { baseRef: "main", requireCleanTarget: true }); execFileSync("git", ["merge-base", "--is-ancestor", localOnlyCommit, "feature"], { cwd: repoDir, }); }); it("aborts merge-from-base on conflicts and leaves no merge in progress", async () => { writeFileSync(join(repoDir, "conflict.txt"), "base\n"); execFileSync("git", ["add", "conflict.txt"], { cwd: repoDir }); execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "add conflict file"], { cwd: repoDir, }); execFileSync("git", ["checkout", "-b", "feature"], { cwd: repoDir }); writeFileSync(join(repoDir, "conflict.txt"), "feature\n"); execFileSync("git", ["add", "conflict.txt"], { cwd: repoDir }); execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "feature change"], { cwd: repoDir, }); execFileSync("git", ["checkout", "main"], { cwd: repoDir }); writeFileSync(join(repoDir, "conflict.txt"), "main change\n"); execFileSync("git", ["add", "conflict.txt"], { cwd: repoDir }); execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "main change"], { cwd: repoDir, }); execFileSync("git", ["checkout", "feature"], { cwd: repoDir }); await expect( mergeFromBase(repoDir, { baseRef: "main", requireCleanTarget: true }), ).rejects.toBeInstanceOf(MergeFromBaseConflictError); const porcelain = execFileSync("git", ["status", "--porcelain"], { cwd: repoDir }) .toString() .trim(); expect(porcelain).toBe(""); expect(() => execFileSync("git", ["rev-parse", "-q", "--verify", "MERGE_HEAD"], { cwd: repoDir }), ).toThrow(); }); it("pulls the current branch from origin", async () => { const remoteDir = join(tempDir, "remote.git"); execFileSync("git", ["init", "--bare", "-b", "main", remoteDir]); execFileSync("git", ["remote", "add", "origin", remoteDir], { cwd: repoDir }); execFileSync("git", ["push", "-u", "origin", "main"], { cwd: repoDir }); const otherClone = join(tempDir, "other-clone"); execFileSync("git", ["clone", remoteDir, otherClone]); execFileSync("git", ["config", "user.email", "test@test.com"], { cwd: otherClone }); execFileSync("git", ["config", "user.name", "Test"], { cwd: otherClone }); writeFileSync(join(otherClone, "pulled.txt"), "remote\n"); execFileSync("git", ["add", "pulled.txt"], { cwd: otherClone }); execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "remote pull commit"], { cwd: otherClone, }); const remoteCommit = execFileSync("git", ["rev-parse", "HEAD"], { cwd: otherClone }) .toString() .trim(); execFileSync("git", ["push"], { cwd: otherClone }); await pullCurrentBranch(repoDir); execFileSync("git", ["merge-base", "--is-ancestor", remoteCommit, "HEAD"], { cwd: repoDir }); expect(readFileSync(join(repoDir, "pulled.txt"), "utf8").replace(/\r\n/g, "\n")).toBe( "remote\n", ); }); it("invalidates GitHub cache after successful local git mutation paths", async () => { const remoteDir = join(tempDir, "remote.git"); execFileSync("git", ["init", "--bare", "-b", "main", remoteDir]); execFileSync("git", ["remote", "add", "origin", remoteDir], { cwd: repoDir }); execFileSync("git", ["push", "-u", "origin", "main"], { cwd: repoDir }); const invalidatedCwds: string[] = []; const github = createGitHubServiceForStatus(null); github.invalidate = ({ cwd }) => { invalidatedCwds.push(cwd); }; await pullCurrentBranch(repoDir, github); expect(invalidatedCwds).toEqual([repoDir]); }); it("aborts pull on merge conflicts and leaves no merge in progress", async () => { const remoteDir = join(tempDir, "remote.git"); execFileSync("git", ["init", "--bare", "-b", "main", remoteDir]); execFileSync("git", ["remote", "add", "origin", remoteDir], { cwd: repoDir }); execFileSync("git", ["push", "-u", "origin", "main"], { cwd: repoDir }); writeFileSync(join(repoDir, "conflict.txt"), "local\n"); execFileSync("git", ["add", "conflict.txt"], { cwd: repoDir }); execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "local conflict commit"], { cwd: repoDir, }); const otherClone = join(tempDir, "other-clone"); execFileSync("git", ["clone", remoteDir, otherClone]); execFileSync("git", ["config", "user.email", "test@test.com"], { cwd: otherClone }); execFileSync("git", ["config", "user.name", "Test"], { cwd: otherClone }); writeFileSync(join(otherClone, "conflict.txt"), "remote\n"); execFileSync("git", ["add", "conflict.txt"], { cwd: otherClone }); execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "remote conflict commit"], { cwd: otherClone, }); execFileSync("git", ["push"], { cwd: otherClone }); await expect(pullCurrentBranch(repoDir)).rejects.toBeInstanceOf(Error); const porcelain = execFileSync("git", ["status", "--porcelain"], { cwd: repoDir }) .toString() .trim(); expect(porcelain).toBe(""); expect(() => execFileSync("git", ["rev-parse", "-q", "--verify", "MERGE_HEAD"], { cwd: repoDir }), ).toThrow(); }); it("aborts pull on rebase conflicts and leaves no rebase in progress", async () => { const remoteDir = join(tempDir, "remote.git"); execFileSync("git", ["init", "--bare", "-b", "main", remoteDir]); execFileSync("git", ["remote", "add", "origin", remoteDir], { cwd: repoDir }); execFileSync("git", ["push", "-u", "origin", "main"], { cwd: repoDir }); execFileSync("git", ["config", "pull.rebase", "true"], { cwd: repoDir }); writeFileSync(join(repoDir, "conflict.txt"), "local\n"); execFileSync("git", ["add", "conflict.txt"], { cwd: repoDir }); execFileSync( "git", ["-c", "commit.gpgsign=false", "commit", "-m", "local rebase conflict commit"], { cwd: repoDir, }, ); const otherClone = join(tempDir, "other-clone"); execFileSync("git", ["clone", remoteDir, otherClone]); execFileSync("git", ["config", "user.email", "test@test.com"], { cwd: otherClone }); execFileSync("git", ["config", "user.name", "Test"], { cwd: otherClone }); writeFileSync(join(otherClone, "conflict.txt"), "remote\n"); execFileSync("git", ["add", "conflict.txt"], { cwd: otherClone }); execFileSync( "git", ["-c", "commit.gpgsign=false", "commit", "-m", "remote rebase conflict commit"], { cwd: otherClone, }, ); execFileSync("git", ["push"], { cwd: otherClone }); await expect(pullCurrentBranch(repoDir)).rejects.toBeInstanceOf(Error); const gitDir = execFileSync("git", ["rev-parse", "--absolute-git-dir"], { cwd: repoDir }) .toString() .trim(); const porcelain = execFileSync("git", ["status", "--porcelain"], { cwd: repoDir }) .toString() .trim(); expect(porcelain).toBe(""); expect(existsSync(join(gitDir, "rebase-merge"))).toBe(false); expect(existsSync(join(gitDir, "rebase-apply"))).toBe(false); }); it("pushes the current branch to origin", async () => { const remoteDir = join(tempDir, "remote.git"); execFileSync("git", ["init", "--bare", "-b", "main", remoteDir]); execFileSync("git", ["remote", "add", "origin", remoteDir], { cwd: repoDir }); execFileSync("git", ["push", "-u", "origin", "main"], { cwd: repoDir }); execFileSync("git", ["checkout", "-b", "feature"], { cwd: repoDir }); writeFileSync(join(repoDir, "push.txt"), "push\n"); execFileSync("git", ["add", "push.txt"], { cwd: repoDir }); execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "push commit"], { cwd: repoDir, }); await pushCurrentBranch(repoDir); execFileSync("git", ["--git-dir", remoteDir, "show-ref", "--verify", "refs/heads/feature"]); const upstream = execFileSync( "git", ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"], { cwd: repoDir }, ) .toString() .trim(); expect(upstream).toBe("origin/feature"); }); it("lists merged local and remote branch suggestions with provenance", async () => { const remoteDir = join(tempDir, "remote.git"); execFileSync("git", ["init", "--bare", "-b", "main", remoteDir]); execFileSync("git", ["remote", "add", "origin", remoteDir], { cwd: repoDir }); execFileSync("git", ["push", "-u", "origin", "main"], { cwd: repoDir }); execFileSync("git", ["checkout", "-b", "feature/local-only"], { cwd: repoDir }); execFileSync("git", ["checkout", "main"], { cwd: repoDir }); execFileSync("git", ["checkout", "-b", "feature/shared"], { cwd: repoDir }); writeFileSync(join(repoDir, "shared.txt"), "shared\n"); execFileSync("git", ["add", "shared.txt"], { cwd: repoDir }); execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "shared branch"], { cwd: repoDir, }); execFileSync("git", ["push", "-u", "origin", "feature/shared"], { cwd: repoDir }); execFileSync("git", ["checkout", "main"], { cwd: repoDir }); const otherClone = join(tempDir, "other-clone"); execFileSync("git", ["clone", remoteDir, otherClone]); execFileSync("git", ["config", "user.email", "test@test.com"], { cwd: otherClone }); execFileSync("git", ["config", "user.name", "Test"], { cwd: otherClone }); execFileSync("git", ["checkout", "-b", "feature/remote-only"], { cwd: otherClone }); writeFileSync(join(otherClone, "remote-only.txt"), "remote-only\n"); execFileSync("git", ["add", "remote-only.txt"], { cwd: otherClone }); execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "remote only branch"], { cwd: otherClone, }); execFileSync("git", ["push", "-u", "origin", "feature/remote-only"], { cwd: otherClone }); execFileSync("git", ["fetch", "origin"], { cwd: repoDir }); const branches = await listBranchSuggestions(repoDir, { limit: 50 }); const branchNames = branches.map((branch) => branch.name); expect(branchNames).toContain("main"); expect(branchNames).toContain("feature/local-only"); expect(branchNames).toContain("feature/remote-only"); expect(branchNames).toContain("feature/shared"); expect(branchNames.filter((name) => name === "main")).toHaveLength(1); expect(branchNames).not.toContain("HEAD"); expect(branchNames.some((name) => name.startsWith("origin/"))).toBe(false); expect(branches).toEqual( expect.arrayContaining([ expect.objectContaining({ name: "main", committerDate: expect.any(Number), }), ]), ); expect(branches.find((branch) => branch.name === "feature/local-only")).toMatchObject({ hasLocal: true, hasRemote: false, }); expect(branches.find((branch) => branch.name === "feature/remote-only")).toMatchObject({ hasLocal: false, hasRemote: true, }); expect(branches.find((branch) => branch.name === "feature/shared")).toMatchObject({ hasLocal: true, hasRemote: true, }); }); it("resolves branch checkout targets with local precedence and origin normalization", async () => { const remoteDir = join(tempDir, "remote.git"); execFileSync("git", ["init", "--bare", "-b", "main", remoteDir]); execFileSync("git", ["remote", "add", "origin", remoteDir], { cwd: repoDir }); execFileSync("git", ["push", "-u", "origin", "main"], { cwd: repoDir }); execFileSync("git", ["checkout", "-b", "feature/local"], { cwd: repoDir }); execFileSync("git", ["checkout", "main"], { cwd: repoDir }); execFileSync("git", ["checkout", "-b", "feature/shared"], { cwd: repoDir }); writeFileSync(join(repoDir, "shared.txt"), "shared\n"); execFileSync("git", ["add", "shared.txt"], { cwd: repoDir }); execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "shared branch"], { cwd: repoDir, }); execFileSync("git", ["push", "-u", "origin", "feature/shared"], { cwd: repoDir }); execFileSync("git", ["checkout", "main"], { cwd: repoDir }); const otherClone = join(tempDir, "other-clone"); execFileSync("git", ["clone", remoteDir, otherClone]); execFileSync("git", ["config", "user.email", "test@test.com"], { cwd: otherClone }); execFileSync("git", ["config", "user.name", "Test"], { cwd: otherClone }); execFileSync("git", ["checkout", "-b", "feature/remote-only"], { cwd: otherClone }); writeFileSync(join(otherClone, "remote-only.txt"), "remote-only\n"); execFileSync("git", ["add", "remote-only.txt"], { cwd: otherClone }); execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "remote only branch"], { cwd: otherClone, }); execFileSync("git", ["push", "-u", "origin", "feature/remote-only"], { cwd: otherClone }); execFileSync("git", ["fetch", "origin"], { cwd: repoDir }); await expect(resolveBranchCheckout(repoDir, "feature/local")).resolves.toEqual({ kind: "local", name: "feature/local", }); await expect(resolveBranchCheckout(repoDir, "feature/remote-only")).resolves.toEqual({ kind: "remote-only", name: "feature/remote-only", remoteRef: "origin/feature/remote-only", }); await expect(resolveBranchCheckout(repoDir, "origin/feature/remote-only")).resolves.toEqual({ kind: "remote-only", name: "feature/remote-only", remoteRef: "origin/feature/remote-only", }); await expect(resolveBranchCheckout(repoDir, "feature/shared")).resolves.toEqual({ kind: "local", name: "feature/shared", }); await expect(resolveBranchCheckout(repoDir, "feature/unknown")).resolves.toEqual({ kind: "not-found", }); }); it("does not resolve tags as branch checkout targets", async () => { execFileSync("git", ["checkout", "-b", "feature/a"], { cwd: repoDir }); execFileSync("git", ["checkout", "main"], { cwd: repoDir }); execFileSync("git", ["tag", "v1"], { cwd: repoDir }); await expect(resolveBranchCheckout(repoDir, "v1")).resolves.toEqual({ kind: "not-found", }); }); it("checks out a remote-only branch as a local tracking branch", async () => { const remoteDir = join(tempDir, "remote.git"); execFileSync("git", ["init", "--bare", "-b", "main", remoteDir]); execFileSync("git", ["remote", "add", "origin", remoteDir], { cwd: repoDir }); execFileSync("git", ["push", "-u", "origin", "main"], { cwd: repoDir }); const otherClone = join(tempDir, "other-clone"); execFileSync("git", ["clone", remoteDir, otherClone]); execFileSync("git", ["config", "user.email", "test@test.com"], { cwd: otherClone }); execFileSync("git", ["config", "user.name", "Test"], { cwd: otherClone }); execFileSync("git", ["checkout", "-b", "feature/remote-only"], { cwd: otherClone }); writeFileSync(join(otherClone, "remote-only.txt"), "remote-only\n"); execFileSync("git", ["add", "remote-only.txt"], { cwd: otherClone }); execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "remote only branch"], { cwd: otherClone, }); execFileSync("git", ["push", "-u", "origin", "feature/remote-only"], { cwd: otherClone }); execFileSync("git", ["fetch", "origin"], { cwd: repoDir }); const resolution = await resolveBranchCheckout(repoDir, "feature/remote-only"); await expect(checkoutResolvedBranch({ cwd: repoDir, resolution })).resolves.toEqual({ source: "remote", }); expect( execFileSync("git", ["symbolic-ref", "--short", "HEAD"], { cwd: repoDir }).toString().trim(), ).toBe("feature/remote-only"); execFileSync("git", ["symbolic-ref", "-q", "HEAD"], { cwd: repoDir }); expect( execFileSync("git", ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"], { cwd: repoDir, }) .toString() .trim(), ).toBe("origin/feature/remote-only"); }); it("normalizes explicit origin input when checking out a remote-only branch", async () => { const remoteDir = join(tempDir, "remote.git"); execFileSync("git", ["init", "--bare", "-b", "main", remoteDir]); execFileSync("git", ["remote", "add", "origin", remoteDir], { cwd: repoDir }); execFileSync("git", ["push", "-u", "origin", "main"], { cwd: repoDir }); const otherClone = join(tempDir, "other-clone"); execFileSync("git", ["clone", remoteDir, otherClone]); execFileSync("git", ["config", "user.email", "test@test.com"], { cwd: otherClone }); execFileSync("git", ["config", "user.name", "Test"], { cwd: otherClone }); execFileSync("git", ["checkout", "-b", "feature/remote-only"], { cwd: otherClone }); writeFileSync(join(otherClone, "remote-only.txt"), "remote-only\n"); execFileSync("git", ["add", "remote-only.txt"], { cwd: otherClone }); execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "remote only branch"], { cwd: otherClone, }); execFileSync("git", ["push", "-u", "origin", "feature/remote-only"], { cwd: otherClone }); execFileSync("git", ["fetch", "origin"], { cwd: repoDir }); const resolution = await resolveBranchCheckout(repoDir, "origin/feature/remote-only"); await expect(checkoutResolvedBranch({ cwd: repoDir, resolution })).resolves.toEqual({ source: "remote", }); expect( execFileSync("git", ["symbolic-ref", "--short", "HEAD"], { cwd: repoDir }).toString().trim(), ).toBe("feature/remote-only"); execFileSync("git", ["symbolic-ref", "-q", "HEAD"], { cwd: repoDir }); expect( execFileSync("git", ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"], { cwd: repoDir, }) .toString() .trim(), ).toBe("origin/feature/remote-only"); }); it("checks out the local branch when local and remote branches share a name", async () => { const remoteDir = join(tempDir, "remote.git"); execFileSync("git", ["init", "--bare", "-b", "main", remoteDir]); execFileSync("git", ["remote", "add", "origin", remoteDir], { cwd: repoDir }); execFileSync("git", ["push", "-u", "origin", "main"], { cwd: repoDir }); execFileSync("git", ["checkout", "-b", "feature/shared"], { cwd: repoDir }); writeFileSync(join(repoDir, "shared.txt"), "shared\n"); execFileSync("git", ["add", "shared.txt"], { cwd: repoDir }); execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "shared branch"], { cwd: repoDir, }); execFileSync("git", ["push", "-u", "origin", "feature/shared"], { cwd: repoDir }); execFileSync("git", ["checkout", "main"], { cwd: repoDir }); const resolution = await resolveBranchCheckout(repoDir, "feature/shared"); await expect(checkoutResolvedBranch({ cwd: repoDir, resolution })).resolves.toEqual({ source: "local", }); expect( execFileSync("git", ["symbolic-ref", "--short", "HEAD"], { cwd: repoDir }).toString().trim(), ).toBe("feature/shared"); }); it("throws the existing branch-not-found message for unknown checkout targets", async () => { await expect( checkoutResolvedBranch({ cwd: repoDir, resolution: { kind: "not-found" }, requestedBranch: "missing-branch", }), ).rejects.toThrow(/^Branch not found: missing-branch$/); }); it("filters branch suggestions by query and enforces result limit", async () => { execFileSync("git", ["checkout", "-b", "feature/alpha"], { cwd: repoDir }); execFileSync("git", ["checkout", "main"], { cwd: repoDir }); execFileSync("git", ["checkout", "-b", "feature/beta"], { cwd: repoDir }); execFileSync("git", ["checkout", "main"], { cwd: repoDir }); execFileSync("git", ["checkout", "-b", "chore/docs"], { cwd: repoDir }); execFileSync("git", ["checkout", "main"], { cwd: repoDir }); const branches = await listBranchSuggestions(repoDir, { query: "FEATURE/", limit: 1, }); expect(branches).toHaveLength(1); expect(branches[0]?.name.toLowerCase()).toContain("feature/"); expect(branches[0]?.committerDate).toEqual(expect.any(Number)); }); it("disables GitHub features when gh is unavailable", async () => { execFileSync("git", ["remote", "add", "origin", "https://github.com/getpaseo/paseo.git"], { cwd: repoDir, }); const github = createGitHubServiceForStatus(null); github.getCurrentPullRequestStatus = async () => { throw new GitHubCliMissingError(); }; const status = await getPullRequestStatus(repoDir, github); expect(status.githubFeaturesEnabled).toBe(false); expect(status.status).toBeNull(); }); it("returns merged PR status when no open PR exists for the current branch", async () => { execFileSync("git", ["checkout", "-b", "feature"], { cwd: repoDir }); execFileSync("git", ["remote", "add", "origin", "https://github.com/getpaseo/paseo.git"], { cwd: repoDir, }); const status = await getPullRequestStatus( repoDir, createGitHubServiceForStatus( createPullRequestStatus({ state: "merged", isMerged: true, }), ), ); expect(status.githubFeaturesEnabled).toBe(true); expect(status.status).not.toBeNull(); expect(status.status?.url).toContain("/pull/123"); expect(status.status?.baseRefName).toBe("main"); expect(status.status?.headRefName).toBe("feature"); expect(status.status?.isMerged).toBe(true); expect(status.status?.state).toBe("merged"); }); it("propagates S1 PR metadata and check display fields through checkout PR status", async () => { execFileSync("git", ["checkout", "-b", "feature"], { cwd: repoDir }); execFileSync("git", ["remote", "add", "origin", "https://github.com/getpaseo/paseo.git"], { cwd: repoDir, }); const status = await getPullRequestStatus( repoDir, createGitHubServiceForStatus( createPullRequestStatus({ number: 123, isDraft: true, checks: [ { name: "server-tests", status: "success", url: "https://github.com/getpaseo/paseo/actions/runs/123", workflow: "Server CI", duration: "2m 14s", }, ], }), ), ); expect(status).toEqual({ githubFeaturesEnabled: true, status: { number: 123, url: "https://github.com/getpaseo/paseo/pull/123", title: "Ship feature", state: "open", baseRefName: "main", headRefName: "feature", isMerged: false, isDraft: true, checks: [ { name: "server-tests", status: "success", url: "https://github.com/getpaseo/paseo/actions/runs/123", workflow: "Server CI", duration: "2m 14s", }, ], checksStatus: "none", reviewDecision: null, }, }); }); it("uses an origin tracked head when the local branch name differs", async () => { execFileSync("git", ["checkout", "-b", "tender-parrot"], { cwd: repoDir }); execFileSync("git", ["remote", "add", "origin", "https://github.com/getpaseo/paseo.git"], { cwd: repoDir, }); execFileSync("git", ["config", "branch.tender-parrot.remote", "origin"], { cwd: repoDir }); execFileSync( "git", ["config", "branch.tender-parrot.merge", "refs/heads/refactor/workspace-scripts"], { cwd: repoDir }, ); const lookupTarget = await readPullRequestLookupTargetFromFacts(repoDir, paseoHome); expect(lookupTarget).toEqual({ headRef: "refactor/workspace-scripts" }); }); it("keeps the local branch lookup when origin tracking uses the same head name", async () => { execFileSync("git", ["checkout", "-b", "feature"], { cwd: repoDir }); execFileSync("git", ["remote", "add", "origin", "https://github.com/getpaseo/paseo.git"], { cwd: repoDir, }); execFileSync("git", ["config", "branch.feature.remote", "origin"], { cwd: repoDir }); execFileSync("git", ["config", "branch.feature.merge", "refs/heads/feature"], { cwd: repoDir, }); const lookupTarget = await readPullRequestLookupTargetFromFacts(repoDir, paseoHome); expect(lookupTarget).toEqual({ headRef: "feature" }); }); it("does not attach an owner when the tracked remote is the same GitHub repository", async () => { execFileSync("git", ["checkout", "-b", "local-feature"], { cwd: repoDir }); execFileSync("git", ["remote", "add", "origin", "git@github.com:getpaseo/paseo.git"], { cwd: repoDir, }); execFileSync("git", ["remote", "add", "upstream", "https://github.com/getpaseo/paseo.git"], { cwd: repoDir, }); execFileSync("git", ["config", "branch.local-feature.remote", "upstream"], { cwd: repoDir, }); execFileSync( "git", ["config", "branch.local-feature.merge", "refs/heads/refactor/workspace-scripts"], { cwd: repoDir }, ); const lookupTarget = await readPullRequestLookupTargetFromFacts(repoDir, paseoHome); expect(lookupTarget).toEqual({ headRef: "refactor/workspace-scripts" }); }); it("keeps the fork owner when same-repo comparison is indeterminate", async () => { execFileSync("git", ["checkout", "-b", "chethanuk/main"], { cwd: repoDir }); execFileSync("git", ["remote", "add", "origin", "not-a-github-remote"], { cwd: repoDir }); execFileSync("git", ["remote", "add", "paseo-pr-345", "git@github.com:chethanuk/paseo.git"], { cwd: repoDir, }); execFileSync("git", ["config", "branch.chethanuk/main.remote", "paseo-pr-345"], { cwd: repoDir, }); execFileSync("git", ["config", "branch.chethanuk/main.merge", "refs/heads/main"], { cwd: repoDir, }); const lookupTarget = await readPullRequestLookupTargetFromFacts(repoDir, paseoHome); expect(lookupTarget).toEqual({ headRef: "main", headRepositoryOwner: "chethanuk" }); }); it("keeps the local branch lookup when same-repo tracking points at the base branch", async () => { execFileSync("git", ["checkout", "-b", "tender-parrot"], { cwd: repoDir }); execFileSync("git", ["remote", "add", "origin", "https://github.com/getpaseo/paseo.git"], { cwd: repoDir, }); execFileSync("git", ["config", "branch.tender-parrot.remote", "origin"], { cwd: repoDir }); execFileSync("git", ["config", "branch.tender-parrot.merge", "refs/heads/main"], { cwd: repoDir, }); const lookupTarget = await readPullRequestLookupTargetFromFacts(repoDir, paseoHome); expect(lookupTarget).toEqual({ headRef: "tender-parrot" }); }); it("derives the same origin tracked head for on-demand PR status reads", async () => { execFileSync("git", ["checkout", "-b", "tender-parrot"], { cwd: repoDir }); execFileSync("git", ["remote", "add", "origin", "https://github.com/getpaseo/paseo.git"], { cwd: repoDir, }); execFileSync("git", ["config", "branch.tender-parrot.remote", "origin"], { cwd: repoDir }); execFileSync( "git", ["config", "branch.tender-parrot.merge", "refs/heads/refactor/workspace-scripts"], { cwd: repoDir }, ); const factsTarget = await readPullRequestLookupTargetFromFacts(repoDir, paseoHome); const requestedTargets: RequestedPullRequestTarget[] = []; const github = createGitHubServiceRecordingPullRequestTargets({ requestedTargets }); await getPullRequestStatus( repoDir, github, { force: true, reason: "tracked-head-parity" }, { paseoHome }, ); expect(requestedTargets).toEqual([factsTarget]); }); it("uses the tracked fork branch for PR worktree status lookup", async () => { execFileSync("git", ["checkout", "-b", "chethanuk/main"], { cwd: repoDir }); execFileSync("git", ["remote", "add", "origin", "https://github.com/getpaseo/paseo.git"], { cwd: repoDir, }); execFileSync("git", ["remote", "add", "paseo-pr-345", "git@github.com:chethanuk/paseo.git"], { cwd: repoDir, }); execFileSync("git", ["config", "branch.chethanuk/main.remote", "paseo-pr-345"], { cwd: repoDir, }); execFileSync("git", ["config", "branch.chethanuk/main.merge", "refs/heads/main"], { cwd: repoDir, }); const requestedTargets: RequestedPullRequestTarget[] = []; const github = createGitHubServiceRecordingPullRequestTargets({ requestedTargets, statusOverrides: { number: 345, url: "https://github.com/getpaseo/paseo/pull/345", }, }); const status = await getPullRequestStatus(repoDir, github); expect(requestedTargets).toEqual([{ headRef: "main", headRepositoryOwner: "chethanuk" }]); expect(status.status?.number).toBe(345); expect(status.status?.headRefName).toBe("main"); }); it("returns closed-unmerged PR status without marking it as merged", async () => { execFileSync("git", ["checkout", "-b", "feature"], { cwd: repoDir }); execFileSync("git", ["remote", "add", "origin", "https://github.com/getpaseo/paseo.git"], { cwd: repoDir, }); const status = await getPullRequestStatus( repoDir, createGitHubServiceForStatus( createPullRequestStatus({ url: "https://github.com/getpaseo/paseo/pull/999", title: "Closed without merge", state: "closed", }), ), ); expect(status.githubFeaturesEnabled).toBe(true); expect(status.status).not.toBeNull(); expect(status.status?.url).toContain("/pull/999"); expect(status.status?.baseRefName).toBe("main"); expect(status.status?.headRefName).toBe("feature"); expect(status.status?.isMerged).toBe(false); expect(status.status?.state).toBe("closed"); }); it("caches PR status results for duplicate lookups", async () => { execFileSync("git", ["checkout", "-b", "feature"], { cwd: repoDir }); execFileSync("git", ["remote", "add", "origin", "https://github.com/getpaseo/paseo.git"], { cwd: repoDir, }); let callCount = 0; const github = createGitHubServiceForStatus(createPullRequestStatus(), { onStatus: () => { callCount += 1; }, }); const first = await getPullRequestStatus(repoDir, github); const second = await getPullRequestStatus(repoDir, github); expect(first).toEqual(second); expect(first.status?.url).toContain("/pull/123"); expect(callCount).toBe(1); }); it("passes forced PR status reads through to the GitHub service", async () => { execFileSync("git", ["checkout", "-b", "feature"], { cwd: repoDir }); execFileSync("git", ["remote", "add", "origin", "https://github.com/getpaseo/paseo.git"], { cwd: repoDir, }); const requested: Array<{ force?: boolean; reason?: string }> = []; const github = createGitHubServiceForStatus(null); github.getCurrentPullRequestStatus = async (options) => { requested.push({ ...(options.force ? { force: options.force } : {}), ...(options.reason ? { reason: options.reason } : {}), }); return createPullRequestStatus(); }; await getPullRequestStatus(repoDir, github, { force: true, reason: "merge-pr-validation", }); expect(requested).toEqual([{ force: true, reason: "merge-pr-validation" }]); }); it("expires cached PR status after the TTL", async () => { execFileSync("git", ["checkout", "-b", "feature"], { cwd: repoDir }); execFileSync("git", ["remote", "add", "origin", "https://github.com/getpaseo/paseo.git"], { cwd: repoDir, }); __setPullRequestStatusCacheTtlForTests(50); try { let callCount = 0; const github = createGitHubServiceForStatus(null, { onStatus: () => { callCount += 1; }, }); github.getCurrentPullRequestStatus = async () => { callCount += 1; return createPullRequestStatus({ url: `https://github.com/getpaseo/paseo/pull/${callCount}`, }); }; const first = await getPullRequestStatus(repoDir, github); await sleep(80); const second = await getPullRequestStatus(repoDir, github); expect(first.status?.url).toContain("/pull/1"); expect(second.status?.url).toContain("/pull/2"); expect(callCount).toBe(2); } finally { __resetPullRequestStatusCacheForTests(); } }); it("keeps stale PR status when a refresh hits a transient GitHub error", async () => { execFileSync("git", ["checkout", "-b", "feature"], { cwd: repoDir }); execFileSync("git", ["remote", "add", "origin", "https://github.com/getpaseo/paseo.git"], { cwd: repoDir, }); __setPullRequestStatusCacheTtlForTests(50); try { let callCount = 0; const github = createGitHubServiceForStatus(null); github.getCurrentPullRequestStatus = async () => { callCount += 1; if (callCount === 1) { return createPullRequestStatus({ url: "https://github.com/getpaseo/paseo/pull/123", }); } throw new GitHubCommandError({ args: ["pr", "view"], cwd: repoDir, exitCode: 1, stderr: "could not resolve host: github.com", }); }; const fresh = await getPullRequestStatus(repoDir, github); await sleep(80); const stale = await getPullRequestStatus(repoDir, github); expect(stale).toEqual(fresh); expect(stale.githubFeaturesEnabled).toBe(true); expect(stale.status?.url).toContain("/pull/123"); expect(callCount).toBe(2); } finally { __resetPullRequestStatusCacheForTests(); } }); it("does not use stale PR status fallback for forced GitHub errors", async () => { execFileSync("git", ["checkout", "-b", "feature"], { cwd: repoDir }); execFileSync("git", ["remote", "add", "origin", "https://github.com/getpaseo/paseo.git"], { cwd: repoDir, }); const github = createGitHubServiceForStatus(null); github.getCurrentPullRequestStatus = async () => createPullRequestStatus({ url: "https://github.com/getpaseo/paseo/pull/123", }); const fresh = await getPullRequestStatus(repoDir, github); expect(fresh.status?.url).toContain("/pull/123"); const error = new GitHubCommandError({ args: ["pr", "view"], cwd: repoDir, exitCode: 1, stderr: "could not resolve host: github.com", }); github.getCurrentPullRequestStatus = async () => { throw error; }; await expect( getPullRequestStatus(repoDir, github, { force: true, reason: "merge-pr-validation", }), ).rejects.toBe(error); }); it("clears stale PR status after a successful no-PR refresh", async () => { execFileSync("git", ["checkout", "-b", "feature"], { cwd: repoDir }); execFileSync("git", ["remote", "add", "origin", "https://github.com/getpaseo/paseo.git"], { cwd: repoDir, }); __setPullRequestStatusCacheTtlForTests(50); try { let callCount = 0; const github = createGitHubServiceForStatus(null); github.getCurrentPullRequestStatus = async () => { callCount += 1; if (callCount === 1) { return createPullRequestStatus({ url: "https://github.com/getpaseo/paseo/pull/123", }); } return null; }; const fresh = await getPullRequestStatus(repoDir, github); await sleep(80); const cleared = await getPullRequestStatus(repoDir, github); expect(fresh.status?.url).toContain("/pull/123"); expect(cleared).toEqual({ githubFeaturesEnabled: true, status: null, }); expect(callCount).toBe(2); } finally { __resetPullRequestStatusCacheForTests(); } }); it("dedupes concurrent PR status lookups for the same cwd", async () => { execFileSync("git", ["checkout", "-b", "feature"], { cwd: repoDir }); execFileSync("git", ["remote", "add", "origin", "https://github.com/getpaseo/paseo.git"], { cwd: repoDir, }); let callCount = 0; const github = createGitHubServiceForStatus(createPullRequestStatus(), { onStatus: () => { callCount += 1; }, }); const [first, second] = await Promise.all([ getPullRequestStatus(repoDir, github), getPullRequestStatus(repoDir, github), ]); expect(first).toEqual(second); expect(callCount).toBe(1); }); it("returns typed MergeConflictError on merge conflicts", async () => { const conflictFile = join(repoDir, "conflict.txt"); writeFileSync(conflictFile, "base\n"); execFileSync("git", ["add", "conflict.txt"], { cwd: repoDir }); execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "add conflict file"], { cwd: repoDir, }); execFileSync("git", ["checkout", "-b", "feature"], { cwd: repoDir }); writeFileSync(conflictFile, "feature change\n"); execFileSync("git", ["add", "conflict.txt"], { cwd: repoDir }); execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "feature change"], { cwd: repoDir, }); execFileSync("git", ["checkout", "main"], { cwd: repoDir }); writeFileSync(conflictFile, "main change\n"); execFileSync("git", ["add", "conflict.txt"], { cwd: repoDir }); execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "main change"], { cwd: repoDir, }); execFileSync("git", ["checkout", "feature"], { cwd: repoDir }); await expect(mergeToBase(repoDir, { baseRef: "main" })).rejects.toBeInstanceOf( MergeConflictError, ); }); it("uses stored baseRefName for Paseo worktrees (no heuristics)", async () => { // Create a non-default base branch with a unique commit. execFileSync("git", ["checkout", "-b", "develop"], { cwd: repoDir }); writeFileSync(join(repoDir, "file.txt"), "develop\n"); execFileSync("git", ["add", "file.txt"], { cwd: repoDir }); execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "develop change"], { cwd: repoDir, }); execFileSync("git", ["checkout", "main"], { cwd: repoDir }); // Create a worktree/branch based on develop, but keep main as the repo default. const worktree = await createLegacyWorktreeForTest({ branchName: "feature", cwd: repoDir, baseBranch: "develop", worktreeSlug: "feature", paseoHome, }); writeFileSync(join(worktree.worktreePath, "feature.txt"), "feature\n"); execFileSync("git", ["add", "feature.txt"], { cwd: worktree.worktreePath }); execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "feature commit"], { cwd: worktree.worktreePath, }); const status = await getCheckoutStatus(worktree.worktreePath, { paseoHome }); expect(status.isGit).toBe(true); expect(status.baseRef).toBe("develop"); expect(status.aheadBehind?.ahead).toBe(1); const baseDiff = await getCheckoutDiff(worktree.worktreePath, { mode: "base" }, { paseoHome }); expect(baseDiff.diff).toContain("feature.txt"); expect(baseDiff.diff).not.toContain("file.txt"); }); it("excludes dirty working tree changes from Paseo worktree base diffs", async () => { const worktree = await createLegacyWorktreeForTest({ branchName: "feature", cwd: repoDir, baseBranch: "main", worktreeSlug: "dirty-feature", paseoHome, }); writeFileSync(join(worktree.worktreePath, "feature.txt"), "feature\n"); execFileSync("git", ["add", "feature.txt"], { cwd: worktree.worktreePath }); execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "feature commit"], { cwd: worktree.worktreePath, }); writeFileSync(join(worktree.worktreePath, "file.txt"), "dirty\n"); writeFileSync(join(worktree.worktreePath, "untracked.txt"), "untracked\n"); const baseDiff = await getCheckoutDiff( worktree.worktreePath, { mode: "base", includeStructured: true }, { paseoHome }, ); expect(baseDiff.diff).toContain("feature.txt"); expect(baseDiff.diff).not.toContain("file.txt"); expect(baseDiff.diff).not.toContain("untracked.txt"); expect(baseDiff.structured?.map((file) => file.path)).toEqual(["feature.txt"]); }); it("resolves the repository default branch from origin HEAD", async () => { execFileSync("git", ["checkout", "-b", "develop"], { cwd: repoDir }); execFileSync("git", ["checkout", "main"], { cwd: repoDir }); execFileSync("git", ["remote", "add", "origin", "https://github.com/acme/repo.git"], { cwd: repoDir, }); execFileSync("git", ["update-ref", "refs/remotes/origin/main", "refs/heads/main"], { cwd: repoDir, }); execFileSync("git", ["update-ref", "refs/remotes/origin/develop", "refs/heads/develop"], { cwd: repoDir, }); execFileSync("git", ["symbolic-ref", "refs/remotes/origin/HEAD", "refs/remotes/origin/main"], { cwd: repoDir, }); await expect(resolveRepositoryDefaultBranch(repoDir)).resolves.toBe("main"); }); it("merges to stored baseRefName when baseRef is not provided", async () => { // Create a non-default base branch with a unique commit. execFileSync("git", ["checkout", "-b", "develop"], { cwd: repoDir }); writeFileSync(join(repoDir, "file.txt"), "develop\n"); execFileSync("git", ["add", "file.txt"], { cwd: repoDir }); execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "develop change"], { cwd: repoDir, }); execFileSync("git", ["checkout", "main"], { cwd: repoDir }); // Create a Paseo worktree configured to use develop as base. const worktree = await createLegacyWorktreeForTest({ branchName: "feature", cwd: repoDir, baseBranch: "develop", worktreeSlug: "merge-to-develop", paseoHome, }); writeFileSync(join(worktree.worktreePath, "feature.txt"), "feature\n"); execFileSync("git", ["add", "feature.txt"], { cwd: worktree.worktreePath }); execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "feature commit"], { cwd: worktree.worktreePath, }); const featureCommit = execFileSync("git", ["rev-parse", "HEAD"], { cwd: worktree.worktreePath }) .toString() .trim(); // No baseRef passed: should merge into the configured base (develop), not default/main. await mergeToBase(worktree.worktreePath, {}, { paseoHome }); execFileSync("git", ["merge-base", "--is-ancestor", featureCommit, "develop"], { cwd: repoDir, stdio: "pipe", }); expect(() => execFileSync("git", ["merge-base", "--is-ancestor", featureCommit, "main"], { cwd: repoDir, stdio: "pipe", }), ).toThrow(); }); it("falls back to the repository default branch for base-dependent operations when metadata is missing", async () => { const worktree = await createLegacyWorktreeForTest({ branchName: "feature-default-base", cwd: repoDir, baseBranch: "main", worktreeSlug: "missing-metadata", paseoHome, }); writeFileSync(join(worktree.worktreePath, "feature.txt"), "feature\n"); execFileSync("git", ["add", "feature.txt"], { cwd: worktree.worktreePath }); execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "feature commit"], { cwd: worktree.worktreePath, }); const metadataPath = getPaseoWorktreeMetadataPath(worktree.worktreePath); rmSync(metadataPath, { force: true }); const baseDiff = await getCheckoutDiff(worktree.worktreePath, { mode: "base" }, { paseoHome }); expect(baseDiff.diff).toContain("feature.txt"); const shortstat = await getCheckoutShortstat(worktree.worktreePath, { paseoHome }); expect(shortstat).toEqual({ additions: 1, deletions: 0 }); }); it("falls back to plain git checkout status when Paseo worktree metadata is missing", async () => { const worktree = await createLegacyWorktreeForTest({ branchName: "feature", cwd: repoDir, baseBranch: "main", worktreeSlug: "missing-metadata-status-fallback", paseoHome, }); const metadataPath = getPaseoWorktreeMetadataPath(worktree.worktreePath); rmSync(metadataPath, { force: true }); const status = await getCheckoutStatus(worktree.worktreePath, { paseoHome }); expect(status.isGit).toBe(true); expect(status.currentBranch).toBe("feature"); expect(realpathSync.native(status.repoRoot)).toBe(realpathSync.native(worktree.worktreePath)); expect(status.isPaseoOwnedWorktree).toBe(true); expect(realpathSync.native(status.mainRepoRoot ?? "")).toBe(realpathSync.native(repoDir)); expect(status.baseRef).toBe("main"); }); describe("parseWorktreeList", () => { it("parses porcelain worktree output", () => { const output = [ "worktree /home/user/repo", "branch refs/heads/main", "", "worktree /home/user/.paseo/worktrees/feature", "branch refs/heads/feature", "", ].join("\n"); const entries = parseWorktreeList(output); expect(entries).toHaveLength(2); expect(entries[0]).toEqual({ path: "/home/user/repo", branchRef: "refs/heads/main" }); expect(entries[1]).toEqual({ path: "/home/user/.paseo/worktrees/feature", branchRef: "refs/heads/feature", }); }); it("detects bare repos", () => { const output = ["worktree /home/user/repo.git", "bare", ""].join("\n"); const entries = parseWorktreeList(output); expect(entries).toHaveLength(1); expect(entries[0]?.isBare).toBe(true); }); }); describe("isPaseoWorktreePath", () => { it("matches Unix .paseo/worktrees/ paths", () => { expect(isPaseoWorktreePath("/home/user/.paseo/worktrees/feature")).toBe(true); }); it("matches Windows .paseo\\worktrees\\ paths", () => { expect(isPaseoWorktreePath("C:\\Users\\dev\\.paseo\\worktrees\\feature")).toBe(true); }); it("matches worktrees under a custom PASEO_HOME", () => { const customPaseoHome = process.platform === "win32" ? "C:\\paseo" : "/var/lib/paseo"; const worktreePath = process.platform === "win32" ? win32.join(customPaseoHome, "worktrees", "project", "feature") : `${customPaseoHome}/worktrees/project/feature`; expect( isPaseoWorktreePath(worktreePath, { paseoHome: customPaseoHome, }), ).toBe(true); }); it("rejects paths without .paseo/worktrees segment", () => { expect(isPaseoWorktreePath("/home/user/repo")).toBe(false); expect(isPaseoWorktreePath("C:\\Users\\dev\\repo")).toBe(false); }); }); describe("isDescendantPath", () => { it("detects children with Unix separators", () => { expect(isDescendantPath("/home/user/repo/child", "/home/user/repo")).toBe(true); }); it("detects children with Windows separators", () => { expect(isDescendantPath("C:\\repos\\child", "C:\\repos")).toBe(true); }); it("rejects the parent itself", () => { expect(isDescendantPath("/home/user/repo", "/home/user/repo")).toBe(false); }); it("rejects siblings that share a prefix", () => { expect(isDescendantPath("/home/user/repo-extra", "/home/user/repo")).toBe(false); }); it("handles mixed separators", () => { expect(isDescendantPath("C:/repo/child", "C:\\repo")).toBe(true); }); it("is case insensitive on Windows paths", () => { expect(isDescendantPath("c:\\repo\\child", "C:\\repo")).toBe(true); }); }); });