Server: drop the synchronous branch-name generator dep, use the worktree slug as the initial branch, and move first-agent branch auto-naming to an async post-creation step backed by a structured LLM call. Guards against renaming when the placeholder branch has already been changed. App: make workspace and worktree archive optimistic, rolling back the local hide if the daemon call fails, and suppress incoming workspace upserts while an archive is pending.
1900 lines
74 KiB
TypeScript
1900 lines
74 KiB
TypeScript
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
import { execSync } from "child_process";
|
|
import { existsSync, mkdtempSync, rmSync, writeFileSync, readFileSync, realpathSync } from "fs";
|
|
import { join } from "path";
|
|
import { tmpdir } from "os";
|
|
import {
|
|
__resetCheckoutShortstatCacheForTests,
|
|
__resetPullRequestStatusCacheForTests,
|
|
__setPullRequestStatusCacheTtlForTests,
|
|
commitAll,
|
|
getCachedCheckoutShortstat,
|
|
getCurrentBranch,
|
|
getCheckoutDiff,
|
|
getCheckoutShortstat,
|
|
getPullRequestStatus,
|
|
getCheckoutStatus,
|
|
checkoutResolvedBranch,
|
|
listBranchSuggestions,
|
|
mergeToBase,
|
|
mergeFromBase,
|
|
MergeConflictError,
|
|
MergeFromBaseConflictError,
|
|
NotGitRepoError,
|
|
pullCurrentBranch,
|
|
pushCurrentBranch,
|
|
resolveBranchCheckout,
|
|
resolveRepositoryDefaultBranch,
|
|
parseWorktreeList,
|
|
isPaseoWorktreePath,
|
|
isDescendantPath,
|
|
warmCheckoutShortstatInBackground,
|
|
} from "./checkout-git.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<WorktreeConfig> {
|
|
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(mkdtempSync(join(tmpdir(), "checkout-git-test-")));
|
|
const repoDir = join(tempDir, "repo");
|
|
execSync(`mkdir -p ${repoDir}`);
|
|
execSync("git init -b main", { cwd: repoDir });
|
|
execSync("git config user.email 'test@test.com'", { cwd: repoDir });
|
|
execSync("git config user.name 'Test'", { cwd: repoDir });
|
|
writeFileSync(join(repoDir, "file.txt"), "hello\n");
|
|
execSync("git add .", { cwd: repoDir });
|
|
execSync("git -c commit.gpgsign=false commit -m 'initial'", { cwd: repoDir });
|
|
return { tempDir, repoDir };
|
|
}
|
|
|
|
function sleep(ms: number): Promise<void> {
|
|
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,
|
|
}),
|
|
isAuthenticated: async () => true,
|
|
invalidate: () => {},
|
|
};
|
|
}
|
|
|
|
function createPullRequestStatus(overrides?: Partial<GitHubCurrentPullRequestStatus>) {
|
|
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,
|
|
};
|
|
}
|
|
|
|
function setupRemoteTrackingMain(
|
|
repoDir: string,
|
|
tempDir: string,
|
|
): { remoteDir: string; cloneDir: string } {
|
|
const remoteDir = join(tempDir, "remote.git");
|
|
const cloneDir = join(tempDir, "upstream-clone");
|
|
execSync(`git init --bare -b main ${remoteDir}`);
|
|
execSync(`git remote add origin ${remoteDir}`, { cwd: repoDir });
|
|
execSync("git push -u origin main", { cwd: repoDir });
|
|
execSync(`git clone ${remoteDir} ${cloneDir}`);
|
|
execSync("git config user.email 'test@test.com'", { cwd: cloneDir });
|
|
execSync("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);
|
|
execSync(`git add ${path}`, { cwd });
|
|
execSync(`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");
|
|
execSync(`mkdir -p ${nonGitDir}`);
|
|
|
|
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");
|
|
execSync(`mkdir -p ${emptyRepo}`);
|
|
execSync("git init -b main", { cwd: emptyRepo });
|
|
|
|
const branch = await getCurrentBranch(emptyRepo);
|
|
expect(branch).toBeNull();
|
|
});
|
|
|
|
it("returns the branch being rebased when HEAD is detached during a rebase", async () => {
|
|
execSync("git checkout -b feature/rebase-test", { cwd: repoDir });
|
|
writeFileSync(join(repoDir, "file.txt"), "feature\n");
|
|
execSync("git add file.txt", { cwd: repoDir });
|
|
execSync("git -c commit.gpgsign=false commit -m 'feature change'", { cwd: repoDir });
|
|
|
|
execSync("git checkout main", { cwd: repoDir });
|
|
writeFileSync(join(repoDir, "file.txt"), "main\n");
|
|
execSync("git add file.txt", { cwd: repoDir });
|
|
execSync("git -c commit.gpgsign=false commit -m 'main change'", { cwd: repoDir });
|
|
|
|
execSync("git checkout feature/rebase-test", { cwd: repoDir });
|
|
expect(() => execSync("git rebase main", { cwd: repoDir, stdio: "pipe" })).toThrow();
|
|
|
|
const branch = await getCurrentBranch(repoDir);
|
|
expect(branch).toBe("feature/rebase-test");
|
|
});
|
|
|
|
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 = execSync("git log -1 --pretty=%B", { cwd: repoDir }).toString().trim();
|
|
expect(message).toBe("update file");
|
|
});
|
|
|
|
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);
|
|
execSync("git add example.ts", { cwd: repoDir });
|
|
execSync("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("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(status.repoRoot).toBe(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");
|
|
execSync(`git init --bare -b main ${remoteDir}`);
|
|
execSync(`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");
|
|
execSync(`git init --bare -b main ${remoteDir}`);
|
|
execSync(`git remote add origin ${remoteDir}`, { cwd: repoDir });
|
|
execSync("git push -u origin main", { cwd: repoDir });
|
|
|
|
execSync(`git clone ${remoteDir} ${cloneDir}`);
|
|
execSync("git config user.email 'test@test.com'", { cwd: cloneDir });
|
|
execSync("git config user.name 'Test'", { cwd: cloneDir });
|
|
writeFileSync(join(cloneDir, "file.txt"), "remote\n");
|
|
execSync("git add file.txt", { cwd: cloneDir });
|
|
execSync("git -c commit.gpgsign=false commit -m 'remote update'", { cwd: cloneDir });
|
|
execSync("git push", { cwd: cloneDir });
|
|
|
|
execSync("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");
|
|
execSync("git add local.txt", { cwd: repoDir });
|
|
execSync("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("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");
|
|
execSync("git push", { cwd: cloneDir });
|
|
execSync("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");
|
|
execSync("git push", { cwd: cloneDir });
|
|
execSync("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");
|
|
execSync("git push", { cwd: cloneDir });
|
|
execSync("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");
|
|
execSync("git push", { cwd: cloneDir });
|
|
execSync("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");
|
|
execSync("git push", { cwd: cloneDir });
|
|
execSync("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);
|
|
execSync("git checkout -b feature", { cwd: repoDir });
|
|
commitFile(repoDir, "feature.txt", "feature\n", "feature update");
|
|
execSync("git checkout main", { cwd: cloneDir });
|
|
commitFile(cloneDir, "base.txt", "base\n", "base update");
|
|
execSync("git push", { cwd: cloneDir });
|
|
execSync("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);
|
|
execSync("git checkout -b feature", { cwd: repoDir });
|
|
execSync("git checkout main", { cwd: cloneDir });
|
|
commitFile(cloneDir, "incoming.txt", "incoming\n", "remote incoming");
|
|
execSync("git push", { cwd: cloneDir });
|
|
execSync("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);
|
|
execSync("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("uses the merge-base for shortstat when a feature branch diverged from its tracked remote", async () => {
|
|
setupRemoteTrackingMain(repoDir, tempDir);
|
|
execSync("git checkout -b feature", { cwd: repoDir });
|
|
execSync("git push -u origin feature", { cwd: repoDir });
|
|
const featureCloneDir = join(tempDir, "feature-clone");
|
|
execSync(`git clone ${join(tempDir, "remote.git")} ${featureCloneDir}`);
|
|
execSync("git config user.email 'test@test.com'", { cwd: featureCloneDir });
|
|
execSync("git config user.name 'Test'", { cwd: featureCloneDir });
|
|
execSync("git checkout feature", { cwd: featureCloneDir });
|
|
commitFile(featureCloneDir, "remote-feature.txt", "remote feature\n", "remote feature update");
|
|
execSync("git push", { cwd: featureCloneDir });
|
|
commitFile(repoDir, "local-feature.txt", "local feature\n", "local feature update");
|
|
execSync("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);
|
|
execSync("git remote set-head origin main", { cwd: repoDir });
|
|
execSync("git checkout -b feature", { cwd: repoDir });
|
|
commitFile(repoDir, "feature.txt", "feature\n", "feature update");
|
|
execSync("git branch -D main", { cwd: repoDir });
|
|
commitFile(cloneDir, "base.txt", "base\n", "base update");
|
|
execSync("git push", { cwd: cloneDir });
|
|
execSync("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);
|
|
execSync("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");
|
|
execSync(`git init --bare -b main ${remoteDir}`);
|
|
execSync(`git remote add origin ${remoteDir}`, { cwd: repoDir });
|
|
execSync("git push -u origin main", { cwd: repoDir });
|
|
|
|
execSync(`git clone ${remoteDir} ${cloneDir}`);
|
|
execSync("git config user.email 'test@test.com'", { cwd: cloneDir });
|
|
execSync("git config user.name 'Test'", { cwd: cloneDir });
|
|
writeFileSync(join(cloneDir, "upstream.txt"), "upstream 1\nupstream 2\n");
|
|
execSync("git add upstream.txt", { cwd: cloneDir });
|
|
execSync("git -c commit.gpgsign=false commit -m 'remote update'", { cwd: cloneDir });
|
|
execSync("git push", { cwd: cloneDir });
|
|
|
|
execSync("git fetch origin", { cwd: repoDir });
|
|
execSync("git checkout -b feature origin/main", { cwd: repoDir });
|
|
writeFileSync(join(repoDir, "feature.txt"), "feature\n");
|
|
execSync("git add feature.txt", { cwd: repoDir });
|
|
execSync("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");
|
|
execSync(`git init --bare -b main ${remoteDir}`);
|
|
execSync(`git remote add origin ${remoteDir}`, { cwd: repoDir });
|
|
execSync("git push -u origin main", { cwd: repoDir });
|
|
|
|
execSync(`git clone ${remoteDir} ${otherClone}`);
|
|
execSync("git config user.email 'test@test.com'", { cwd: otherClone });
|
|
execSync("git config user.name 'Test'", { cwd: otherClone });
|
|
writeFileSync(join(otherClone, "already-on-origin.txt"), "origin\n");
|
|
execSync("git add already-on-origin.txt", { cwd: otherClone });
|
|
execSync("git -c commit.gpgsign=false commit -m 'origin base commit'", { cwd: otherClone });
|
|
execSync("git push", { cwd: otherClone });
|
|
|
|
writeFileSync(join(repoDir, "local-only-base.txt"), "local\n");
|
|
execSync("git add local-only-base.txt", { cwd: repoDir });
|
|
execSync("git -c commit.gpgsign=false commit -m 'local base drift'", { cwd: repoDir });
|
|
execSync("git fetch origin", { cwd: repoDir });
|
|
execSync("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 () => {
|
|
execSync("git checkout -b feature", { cwd: repoDir });
|
|
writeFileSync(join(repoDir, "local-feature.txt"), "feature\n");
|
|
execSync("git add local-feature.txt", { cwd: repoDir });
|
|
execSync("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");
|
|
execSync(`git init --bare -b main ${remoteDir}`);
|
|
execSync(`git remote add origin ${remoteDir}`, { cwd: repoDir });
|
|
execSync("git push -u origin main", { cwd: repoDir });
|
|
|
|
execSync(`git clone ${remoteDir} ${otherClone}`);
|
|
execSync("git config user.email 'test@test.com'", { cwd: otherClone });
|
|
execSync("git config user.name 'Test'", { cwd: otherClone });
|
|
writeFileSync(join(otherClone, "origin-base.txt"), "origin\n");
|
|
execSync("git add origin-base.txt", { cwd: otherClone });
|
|
execSync("git -c commit.gpgsign=false commit -m 'origin base'", { cwd: otherClone });
|
|
execSync("git push", { cwd: otherClone });
|
|
|
|
writeFileSync(join(repoDir, "local-drift.txt"), "local\n");
|
|
execSync("git add local-drift.txt", { cwd: repoDir });
|
|
execSync("git -c commit.gpgsign=false commit -m 'local drift'", { cwd: repoDir });
|
|
execSync("git fetch origin", { cwd: repoDir });
|
|
execSync("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");
|
|
execSync(`git init --bare -b main ${remoteDir}`);
|
|
execSync(`git remote add origin ${remoteDir}`, { cwd: repoDir });
|
|
execSync("git push -u origin main", { cwd: repoDir });
|
|
execSync("git fetch origin", { cwd: repoDir });
|
|
|
|
execSync("git checkout -b feature", { cwd: repoDir });
|
|
writeFileSync(join(repoDir, "feature.txt"), "feature\n");
|
|
execSync("git add feature.txt", { cwd: repoDir });
|
|
execSync("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 () => {
|
|
execSync("git checkout -b feature", { cwd: repoDir });
|
|
writeFileSync(join(repoDir, "feature.txt"), "feature\n");
|
|
execSync("git add feature.txt", { cwd: repoDir });
|
|
execSync("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 = execSync("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 () => {
|
|
execSync("git checkout -b feature", { cwd: repoDir });
|
|
|
|
// Advance base branch after feature splits off.
|
|
execSync("git checkout main", { cwd: repoDir });
|
|
writeFileSync(join(repoDir, "base-only.txt"), "base\n");
|
|
execSync("git add base-only.txt", { cwd: repoDir });
|
|
execSync("git -c commit.gpgsign=false commit -m 'base only'", { cwd: repoDir });
|
|
|
|
// Make a feature change.
|
|
execSync("git checkout feature", { cwd: repoDir });
|
|
writeFileSync(join(repoDir, "feature.txt"), "feature\n");
|
|
execSync("git add feature.txt", { cwd: repoDir });
|
|
execSync("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("short-circuits tracked binary files", async () => {
|
|
const trackedBinaryPath = join(repoDir, "tracked-blob.bin");
|
|
writeFileSync(trackedBinaryPath, Buffer.from([0x00, 0xff, 0x10, 0x80, 0x00]));
|
|
execSync("git add tracked-blob.bin", { cwd: repoDir });
|
|
execSync("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(status.repoRoot).toBe(result.worktreePath);
|
|
expect(status.isDirty).toBe(true);
|
|
expect(status.isPaseoOwnedWorktree).toBe(true);
|
|
expect(status.mainRepoRoot).toBe(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 = execSync("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(status.repoRoot).toBe(result.worktreePath);
|
|
expect(status.isPaseoOwnedWorktree).toBe(true);
|
|
expect(status.mainRepoRoot).toBe(repoDir);
|
|
});
|
|
|
|
it("returns mainRepoRoot pointing to first non-bare worktree for bare repos", async () => {
|
|
const bareRepoDir = join(tempDir, "bare-repo");
|
|
execSync(`git clone --bare ${repoDir} ${bareRepoDir}`);
|
|
|
|
const mainCheckoutDir = join(tempDir, "main-checkout");
|
|
execSync(`git -C ${bareRepoDir} worktree add ${mainCheckoutDir} main`);
|
|
execSync("git config user.email 'test@test.com'", { cwd: mainCheckoutDir });
|
|
execSync("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(status.mainRepoRoot).toBe(mainCheckoutDir);
|
|
});
|
|
|
|
it("detects plain git worktrees from git alone", async () => {
|
|
const worktreeDir = join(tempDir, "plain-git-worktree");
|
|
execSync(`git worktree add -b feature/plain ${worktreeDir} main`, { cwd: repoDir });
|
|
|
|
const status = await getCheckoutStatus(worktreeDir, { paseoHome });
|
|
expect(status.isGit).toBe(true);
|
|
expect(status.repoRoot).toBe(worktreeDir);
|
|
expect(status.isPaseoOwnedWorktree).toBe(false);
|
|
expect(status.mainRepoRoot).toBe(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");
|
|
execSync("git checkout -b feature", { cwd: worktree.worktreePath });
|
|
execSync("git add merge.txt", { cwd: worktree.worktreePath });
|
|
execSync("git -c commit.gpgsign=false commit -m 'feature commit'", {
|
|
cwd: worktree.worktreePath,
|
|
});
|
|
const featureCommit = execSync("git rev-parse HEAD", { cwd: worktree.worktreePath })
|
|
.toString()
|
|
.trim();
|
|
|
|
await mergeToBase(worktree.worktreePath, { baseRef: "main" }, { paseoHome });
|
|
|
|
const baseContainsFeature = execSync(`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 = execSync("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 () => {
|
|
execSync("git checkout -b develop", { cwd: repoDir });
|
|
writeFileSync(join(repoDir, "develop.txt"), "develop\n");
|
|
execSync("git add develop.txt", { cwd: repoDir });
|
|
execSync("git -c commit.gpgsign=false commit -m 'develop commit'", { cwd: repoDir });
|
|
execSync("git checkout main", { cwd: repoDir });
|
|
|
|
const baseWorktreePath = join(tempDir, "base-worktree");
|
|
execSync(`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");
|
|
execSync("git add feature.txt", { cwd: featureWorktree.worktreePath });
|
|
execSync("git -c commit.gpgsign=false commit -m 'feature commit'", {
|
|
cwd: featureWorktree.worktreePath,
|
|
});
|
|
|
|
const mutatedCwd = await mergeToBase(featureWorktree.worktreePath, {}, { paseoHome });
|
|
|
|
expect(mutatedCwd).toBe(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");
|
|
execSync(`git init --bare -b main ${remoteDir}`);
|
|
execSync(`git remote add origin ${remoteDir}`, { cwd: repoDir });
|
|
execSync("git push -u origin main", { cwd: repoDir });
|
|
|
|
// Advance origin/main without advancing local main.
|
|
const otherClone = join(tempDir, "other-clone");
|
|
execSync(`git clone ${remoteDir} ${otherClone}`);
|
|
execSync("git config user.email 'test@test.com'", { cwd: otherClone });
|
|
execSync("git config user.name 'Test'", { cwd: otherClone });
|
|
writeFileSync(join(otherClone, "remote-only.txt"), "remote\n");
|
|
execSync("git add remote-only.txt", { cwd: otherClone });
|
|
execSync("git -c commit.gpgsign=false commit -m 'remote only'", { cwd: otherClone });
|
|
const remoteOnlyCommit = execSync("git rev-parse HEAD", { cwd: otherClone }).toString().trim();
|
|
execSync("git push", { cwd: otherClone });
|
|
execSync("git fetch origin", { cwd: repoDir });
|
|
|
|
execSync("git checkout -b feature", { cwd: repoDir });
|
|
writeFileSync(join(repoDir, "feature.txt"), "feature\n");
|
|
execSync("git add feature.txt", { cwd: repoDir });
|
|
execSync("git -c commit.gpgsign=false commit -m 'feature commit'", { cwd: repoDir });
|
|
|
|
await mergeFromBase(repoDir, { baseRef: "main", requireCleanTarget: true });
|
|
|
|
execSync(`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");
|
|
execSync(`git init --bare -b main ${remoteDir}`);
|
|
execSync(`git remote add origin ${remoteDir}`, { cwd: repoDir });
|
|
execSync("git push -u origin main", { cwd: repoDir });
|
|
|
|
// Advance local main without pushing.
|
|
writeFileSync(join(repoDir, "local-only.txt"), "local\n");
|
|
execSync("git add local-only.txt", { cwd: repoDir });
|
|
execSync("git -c commit.gpgsign=false commit -m 'local only'", { cwd: repoDir });
|
|
const localOnlyCommit = execSync("git rev-parse HEAD", { cwd: repoDir }).toString().trim();
|
|
|
|
execSync(`git checkout -b feature ${localOnlyCommit}~1`, { cwd: repoDir });
|
|
writeFileSync(join(repoDir, "feature.txt"), "feature\n");
|
|
execSync("git add feature.txt", { cwd: repoDir });
|
|
execSync("git -c commit.gpgsign=false commit -m 'feature commit'", { cwd: repoDir });
|
|
|
|
await mergeFromBase(repoDir, { baseRef: "main", requireCleanTarget: true });
|
|
|
|
execSync(`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");
|
|
execSync("git add conflict.txt", { cwd: repoDir });
|
|
execSync("git -c commit.gpgsign=false commit -m 'add conflict file'", { cwd: repoDir });
|
|
|
|
execSync("git checkout -b feature", { cwd: repoDir });
|
|
writeFileSync(join(repoDir, "conflict.txt"), "feature\n");
|
|
execSync("git add conflict.txt", { cwd: repoDir });
|
|
execSync("git -c commit.gpgsign=false commit -m 'feature change'", { cwd: repoDir });
|
|
|
|
execSync("git checkout main", { cwd: repoDir });
|
|
writeFileSync(join(repoDir, "conflict.txt"), "main change\n");
|
|
execSync("git add conflict.txt", { cwd: repoDir });
|
|
execSync("git -c commit.gpgsign=false commit -m 'main change'", { cwd: repoDir });
|
|
|
|
execSync("git checkout feature", { cwd: repoDir });
|
|
|
|
await expect(
|
|
mergeFromBase(repoDir, { baseRef: "main", requireCleanTarget: true }),
|
|
).rejects.toBeInstanceOf(MergeFromBaseConflictError);
|
|
|
|
const porcelain = execSync("git status --porcelain", { cwd: repoDir }).toString().trim();
|
|
expect(porcelain).toBe("");
|
|
expect(() => execSync("git rev-parse -q --verify MERGE_HEAD", { cwd: repoDir })).toThrow();
|
|
});
|
|
|
|
it("pulls the current branch from origin", async () => {
|
|
const remoteDir = join(tempDir, "remote.git");
|
|
execSync(`git init --bare -b main ${remoteDir}`);
|
|
execSync(`git remote add origin ${remoteDir}`, { cwd: repoDir });
|
|
execSync("git push -u origin main", { cwd: repoDir });
|
|
|
|
const otherClone = join(tempDir, "other-clone");
|
|
execSync(`git clone ${remoteDir} ${otherClone}`);
|
|
execSync("git config user.email 'test@test.com'", { cwd: otherClone });
|
|
execSync("git config user.name 'Test'", { cwd: otherClone });
|
|
writeFileSync(join(otherClone, "pulled.txt"), "remote\n");
|
|
execSync("git add pulled.txt", { cwd: otherClone });
|
|
execSync("git -c commit.gpgsign=false commit -m 'remote pull commit'", { cwd: otherClone });
|
|
const remoteCommit = execSync("git rev-parse HEAD", { cwd: otherClone }).toString().trim();
|
|
execSync("git push", { cwd: otherClone });
|
|
|
|
await pullCurrentBranch(repoDir);
|
|
|
|
execSync(`git merge-base --is-ancestor ${remoteCommit} HEAD`, { cwd: repoDir });
|
|
expect(readFileSync(join(repoDir, "pulled.txt"), "utf8")).toBe("remote\n");
|
|
});
|
|
|
|
it("invalidates GitHub cache after successful local git mutation paths", async () => {
|
|
const remoteDir = join(tempDir, "remote.git");
|
|
execSync(`git init --bare -b main ${remoteDir}`);
|
|
execSync(`git remote add origin ${remoteDir}`, { cwd: repoDir });
|
|
execSync("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");
|
|
execSync(`git init --bare -b main ${remoteDir}`);
|
|
execSync(`git remote add origin ${remoteDir}`, { cwd: repoDir });
|
|
execSync("git push -u origin main", { cwd: repoDir });
|
|
|
|
writeFileSync(join(repoDir, "conflict.txt"), "local\n");
|
|
execSync("git add conflict.txt", { cwd: repoDir });
|
|
execSync("git -c commit.gpgsign=false commit -m 'local conflict commit'", { cwd: repoDir });
|
|
|
|
const otherClone = join(tempDir, "other-clone");
|
|
execSync(`git clone ${remoteDir} ${otherClone}`);
|
|
execSync("git config user.email 'test@test.com'", { cwd: otherClone });
|
|
execSync("git config user.name 'Test'", { cwd: otherClone });
|
|
writeFileSync(join(otherClone, "conflict.txt"), "remote\n");
|
|
execSync("git add conflict.txt", { cwd: otherClone });
|
|
execSync("git -c commit.gpgsign=false commit -m 'remote conflict commit'", { cwd: otherClone });
|
|
execSync("git push", { cwd: otherClone });
|
|
|
|
await expect(pullCurrentBranch(repoDir)).rejects.toBeInstanceOf(Error);
|
|
|
|
const porcelain = execSync("git status --porcelain", { cwd: repoDir }).toString().trim();
|
|
expect(porcelain).toBe("");
|
|
expect(() => execSync("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");
|
|
execSync(`git init --bare -b main ${remoteDir}`);
|
|
execSync(`git remote add origin ${remoteDir}`, { cwd: repoDir });
|
|
execSync("git push -u origin main", { cwd: repoDir });
|
|
execSync("git config pull.rebase true", { cwd: repoDir });
|
|
|
|
writeFileSync(join(repoDir, "conflict.txt"), "local\n");
|
|
execSync("git add conflict.txt", { cwd: repoDir });
|
|
execSync("git -c commit.gpgsign=false commit -m 'local rebase conflict commit'", {
|
|
cwd: repoDir,
|
|
});
|
|
|
|
const otherClone = join(tempDir, "other-clone");
|
|
execSync(`git clone ${remoteDir} ${otherClone}`);
|
|
execSync("git config user.email 'test@test.com'", { cwd: otherClone });
|
|
execSync("git config user.name 'Test'", { cwd: otherClone });
|
|
writeFileSync(join(otherClone, "conflict.txt"), "remote\n");
|
|
execSync("git add conflict.txt", { cwd: otherClone });
|
|
execSync("git -c commit.gpgsign=false commit -m 'remote rebase conflict commit'", {
|
|
cwd: otherClone,
|
|
});
|
|
execSync("git push", { cwd: otherClone });
|
|
|
|
await expect(pullCurrentBranch(repoDir)).rejects.toBeInstanceOf(Error);
|
|
|
|
const gitDir = execSync("git rev-parse --absolute-git-dir", { cwd: repoDir }).toString().trim();
|
|
const porcelain = execSync("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");
|
|
execSync(`git init --bare -b main ${remoteDir}`);
|
|
execSync(`git remote add origin ${remoteDir}`, { cwd: repoDir });
|
|
execSync("git push -u origin main", { cwd: repoDir });
|
|
|
|
execSync("git checkout -b feature", { cwd: repoDir });
|
|
writeFileSync(join(repoDir, "push.txt"), "push\n");
|
|
execSync("git add push.txt", { cwd: repoDir });
|
|
execSync("git -c commit.gpgsign=false commit -m 'push commit'", { cwd: repoDir });
|
|
|
|
await pushCurrentBranch(repoDir);
|
|
|
|
execSync(`git --git-dir ${remoteDir} show-ref --verify refs/heads/feature`);
|
|
});
|
|
|
|
it("lists merged local and remote branch suggestions with provenance", async () => {
|
|
const remoteDir = join(tempDir, "remote.git");
|
|
execSync(`git init --bare -b main ${remoteDir}`);
|
|
execSync(`git remote add origin ${remoteDir}`, { cwd: repoDir });
|
|
execSync("git push -u origin main", { cwd: repoDir });
|
|
|
|
execSync("git checkout -b feature/local-only", { cwd: repoDir });
|
|
execSync("git checkout main", { cwd: repoDir });
|
|
execSync("git checkout -b feature/shared", { cwd: repoDir });
|
|
writeFileSync(join(repoDir, "shared.txt"), "shared\n");
|
|
execSync("git add shared.txt", { cwd: repoDir });
|
|
execSync("git -c commit.gpgsign=false commit -m 'shared branch'", { cwd: repoDir });
|
|
execSync("git push -u origin feature/shared", { cwd: repoDir });
|
|
execSync("git checkout main", { cwd: repoDir });
|
|
|
|
const otherClone = join(tempDir, "other-clone");
|
|
execSync(`git clone ${remoteDir} ${otherClone}`);
|
|
execSync("git config user.email 'test@test.com'", { cwd: otherClone });
|
|
execSync("git config user.name 'Test'", { cwd: otherClone });
|
|
execSync("git checkout -b feature/remote-only", { cwd: otherClone });
|
|
writeFileSync(join(otherClone, "remote-only.txt"), "remote-only\n");
|
|
execSync("git add remote-only.txt", { cwd: otherClone });
|
|
execSync("git -c commit.gpgsign=false commit -m 'remote only branch'", { cwd: otherClone });
|
|
execSync("git push -u origin feature/remote-only", { cwd: otherClone });
|
|
execSync("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");
|
|
execSync(`git init --bare -b main ${remoteDir}`);
|
|
execSync(`git remote add origin ${remoteDir}`, { cwd: repoDir });
|
|
execSync("git push -u origin main", { cwd: repoDir });
|
|
|
|
execSync("git checkout -b feature/local", { cwd: repoDir });
|
|
execSync("git checkout main", { cwd: repoDir });
|
|
execSync("git checkout -b feature/shared", { cwd: repoDir });
|
|
writeFileSync(join(repoDir, "shared.txt"), "shared\n");
|
|
execSync("git add shared.txt", { cwd: repoDir });
|
|
execSync("git -c commit.gpgsign=false commit -m 'shared branch'", { cwd: repoDir });
|
|
execSync("git push -u origin feature/shared", { cwd: repoDir });
|
|
execSync("git checkout main", { cwd: repoDir });
|
|
|
|
const otherClone = join(tempDir, "other-clone");
|
|
execSync(`git clone ${remoteDir} ${otherClone}`);
|
|
execSync("git config user.email 'test@test.com'", { cwd: otherClone });
|
|
execSync("git config user.name 'Test'", { cwd: otherClone });
|
|
execSync("git checkout -b feature/remote-only", { cwd: otherClone });
|
|
writeFileSync(join(otherClone, "remote-only.txt"), "remote-only\n");
|
|
execSync("git add remote-only.txt", { cwd: otherClone });
|
|
execSync("git -c commit.gpgsign=false commit -m 'remote only branch'", { cwd: otherClone });
|
|
execSync("git push -u origin feature/remote-only", { cwd: otherClone });
|
|
execSync("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 () => {
|
|
execSync("git checkout -b feature/a", { cwd: repoDir });
|
|
execSync("git checkout main", { cwd: repoDir });
|
|
execSync("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");
|
|
execSync(`git init --bare -b main ${remoteDir}`);
|
|
execSync(`git remote add origin ${remoteDir}`, { cwd: repoDir });
|
|
execSync("git push -u origin main", { cwd: repoDir });
|
|
|
|
const otherClone = join(tempDir, "other-clone");
|
|
execSync(`git clone ${remoteDir} ${otherClone}`);
|
|
execSync("git config user.email 'test@test.com'", { cwd: otherClone });
|
|
execSync("git config user.name 'Test'", { cwd: otherClone });
|
|
execSync("git checkout -b feature/remote-only", { cwd: otherClone });
|
|
writeFileSync(join(otherClone, "remote-only.txt"), "remote-only\n");
|
|
execSync("git add remote-only.txt", { cwd: otherClone });
|
|
execSync("git -c commit.gpgsign=false commit -m 'remote only branch'", { cwd: otherClone });
|
|
execSync("git push -u origin feature/remote-only", { cwd: otherClone });
|
|
execSync("git fetch origin", { cwd: repoDir });
|
|
|
|
const resolution = await resolveBranchCheckout(repoDir, "feature/remote-only");
|
|
await expect(checkoutResolvedBranch({ cwd: repoDir, resolution })).resolves.toEqual({
|
|
source: "remote",
|
|
});
|
|
|
|
expect(execSync("git symbolic-ref --short HEAD", { cwd: repoDir }).toString().trim()).toBe(
|
|
"feature/remote-only",
|
|
);
|
|
execSync("git symbolic-ref -q HEAD", { cwd: repoDir });
|
|
expect(
|
|
execSync("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");
|
|
execSync(`git init --bare -b main ${remoteDir}`);
|
|
execSync(`git remote add origin ${remoteDir}`, { cwd: repoDir });
|
|
execSync("git push -u origin main", { cwd: repoDir });
|
|
|
|
const otherClone = join(tempDir, "other-clone");
|
|
execSync(`git clone ${remoteDir} ${otherClone}`);
|
|
execSync("git config user.email 'test@test.com'", { cwd: otherClone });
|
|
execSync("git config user.name 'Test'", { cwd: otherClone });
|
|
execSync("git checkout -b feature/remote-only", { cwd: otherClone });
|
|
writeFileSync(join(otherClone, "remote-only.txt"), "remote-only\n");
|
|
execSync("git add remote-only.txt", { cwd: otherClone });
|
|
execSync("git -c commit.gpgsign=false commit -m 'remote only branch'", { cwd: otherClone });
|
|
execSync("git push -u origin feature/remote-only", { cwd: otherClone });
|
|
execSync("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(execSync("git symbolic-ref --short HEAD", { cwd: repoDir }).toString().trim()).toBe(
|
|
"feature/remote-only",
|
|
);
|
|
execSync("git symbolic-ref -q HEAD", { cwd: repoDir });
|
|
expect(
|
|
execSync("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");
|
|
execSync(`git init --bare -b main ${remoteDir}`);
|
|
execSync(`git remote add origin ${remoteDir}`, { cwd: repoDir });
|
|
execSync("git push -u origin main", { cwd: repoDir });
|
|
execSync("git checkout -b feature/shared", { cwd: repoDir });
|
|
writeFileSync(join(repoDir, "shared.txt"), "shared\n");
|
|
execSync("git add shared.txt", { cwd: repoDir });
|
|
execSync("git -c commit.gpgsign=false commit -m 'shared branch'", { cwd: repoDir });
|
|
execSync("git push -u origin feature/shared", { cwd: repoDir });
|
|
execSync("git checkout main", { cwd: repoDir });
|
|
|
|
const resolution = await resolveBranchCheckout(repoDir, "feature/shared");
|
|
await expect(checkoutResolvedBranch({ cwd: repoDir, resolution })).resolves.toEqual({
|
|
source: "local",
|
|
});
|
|
|
|
expect(execSync("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 () => {
|
|
execSync("git checkout -b feature/alpha", { cwd: repoDir });
|
|
execSync("git checkout main", { cwd: repoDir });
|
|
execSync("git checkout -b feature/beta", { cwd: repoDir });
|
|
execSync("git checkout main", { cwd: repoDir });
|
|
execSync("git checkout -b chore/docs", { cwd: repoDir });
|
|
execSync("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 () => {
|
|
execSync("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 () => {
|
|
execSync("git checkout -b feature", { cwd: repoDir });
|
|
execSync("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 () => {
|
|
execSync("git checkout -b feature", { cwd: repoDir });
|
|
execSync("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 the tracked fork branch for PR worktree status lookup", async () => {
|
|
execSync("git checkout -b chethanuk/main", { cwd: repoDir });
|
|
execSync("git remote add origin https://github.com/getpaseo/paseo.git", { cwd: repoDir });
|
|
execSync("git remote add paseo-pr-345 git@github.com:chethanuk/paseo.git", { cwd: repoDir });
|
|
execSync("git config branch.chethanuk/main.remote paseo-pr-345", { cwd: repoDir });
|
|
execSync("git config branch.chethanuk/main.merge refs/heads/main", { cwd: repoDir });
|
|
|
|
const requestedTargets: Array<{ headRef: string; headRepositoryOwner?: string }> = [];
|
|
const github = createGitHubServiceForStatus(
|
|
createPullRequestStatus({
|
|
number: 345,
|
|
url: "https://github.com/getpaseo/paseo/pull/345",
|
|
headRefName: "main",
|
|
}),
|
|
{
|
|
onStatus: () => {},
|
|
},
|
|
);
|
|
github.getCurrentPullRequestStatus = async (options) => {
|
|
requestedTargets.push({
|
|
headRef: options.headRef,
|
|
...(options.headRepositoryOwner
|
|
? { headRepositoryOwner: options.headRepositoryOwner }
|
|
: {}),
|
|
});
|
|
return createPullRequestStatus({
|
|
number: 345,
|
|
url: "https://github.com/getpaseo/paseo/pull/345",
|
|
headRefName: options.headRef,
|
|
});
|
|
};
|
|
|
|
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 () => {
|
|
execSync("git checkout -b feature", { cwd: repoDir });
|
|
execSync("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 () => {
|
|
execSync("git checkout -b feature", { cwd: repoDir });
|
|
execSync("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("expires cached PR status after the TTL", async () => {
|
|
execSync("git checkout -b feature", { cwd: repoDir });
|
|
execSync("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 () => {
|
|
execSync("git checkout -b feature", { cwd: repoDir });
|
|
execSync("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("clears stale PR status after a successful no-PR refresh", async () => {
|
|
execSync("git checkout -b feature", { cwd: repoDir });
|
|
execSync("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 () => {
|
|
execSync("git checkout -b feature", { cwd: repoDir });
|
|
execSync("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");
|
|
execSync("git add conflict.txt", { cwd: repoDir });
|
|
execSync("git -c commit.gpgsign=false commit -m 'add conflict file'", {
|
|
cwd: repoDir,
|
|
});
|
|
|
|
execSync("git checkout -b feature", { cwd: repoDir });
|
|
writeFileSync(conflictFile, "feature change\n");
|
|
execSync("git add conflict.txt", { cwd: repoDir });
|
|
execSync("git -c commit.gpgsign=false commit -m 'feature change'", {
|
|
cwd: repoDir,
|
|
});
|
|
|
|
execSync("git checkout main", { cwd: repoDir });
|
|
writeFileSync(conflictFile, "main change\n");
|
|
execSync("git add conflict.txt", { cwd: repoDir });
|
|
execSync("git -c commit.gpgsign=false commit -m 'main change'", {
|
|
cwd: repoDir,
|
|
});
|
|
|
|
execSync("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.
|
|
execSync("git checkout -b develop", { cwd: repoDir });
|
|
writeFileSync(join(repoDir, "file.txt"), "develop\n");
|
|
execSync("git add file.txt", { cwd: repoDir });
|
|
execSync("git -c commit.gpgsign=false commit -m 'develop change'", { cwd: repoDir });
|
|
execSync("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");
|
|
execSync("git add feature.txt", { cwd: worktree.worktreePath });
|
|
execSync("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");
|
|
execSync("git add feature.txt", { cwd: worktree.worktreePath });
|
|
execSync("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 () => {
|
|
execSync("git checkout -b develop", { cwd: repoDir });
|
|
execSync("git checkout main", { cwd: repoDir });
|
|
execSync("git remote add origin https://github.com/acme/repo.git", { cwd: repoDir });
|
|
execSync("git update-ref refs/remotes/origin/main refs/heads/main", { cwd: repoDir });
|
|
execSync("git update-ref refs/remotes/origin/develop refs/heads/develop", { cwd: repoDir });
|
|
execSync("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.
|
|
execSync("git checkout -b develop", { cwd: repoDir });
|
|
writeFileSync(join(repoDir, "file.txt"), "develop\n");
|
|
execSync("git add file.txt", { cwd: repoDir });
|
|
execSync("git -c commit.gpgsign=false commit -m 'develop change'", { cwd: repoDir });
|
|
execSync("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");
|
|
execSync("git add feature.txt", { cwd: worktree.worktreePath });
|
|
execSync("git -c commit.gpgsign=false commit -m 'feature commit'", {
|
|
cwd: worktree.worktreePath,
|
|
});
|
|
const featureCommit = execSync("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 });
|
|
|
|
execSync(`git merge-base --is-ancestor ${featureCommit} develop`, {
|
|
cwd: repoDir,
|
|
stdio: "pipe",
|
|
});
|
|
expect(() =>
|
|
execSync(`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");
|
|
execSync("git add feature.txt", { cwd: worktree.worktreePath });
|
|
execSync("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(status.repoRoot).toBe(worktree.worktreePath);
|
|
expect(status.isPaseoOwnedWorktree).toBe(true);
|
|
expect(status.mainRepoRoot).toBe(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("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);
|
|
});
|
|
});
|
|
});
|