mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-03 07:03:18 +00:00
* fix(server): keep Git observation and worktree IO off the event loop Resolve subscription path identities before callbacks and serialize async worktree metadata mutations by canonical ownership. Git command tracing must not probe the working directory before spawning. * test(server): verify Git trace records without filesystem probes * fix(server): establish Git identities at observation setup Read-only checkout refreshes should not resolve watcher namespaces. Observation tests await setup and compare cross-workspace fan-out without depending on completion order. * fix(server): retain literal symlink targets in Git event identity Canonical realpaths can erase a symlink target spelling when its parent is also a symlink, such as /var on macOS. Resolve these aliases once during setup so deleted and ignored event paths still match without per-event IO. * fix(server): preserve Windows worktree path spelling * fix(server): retain native aliases in worktree ownership * test(server): write Git IO trace markers asynchronously
4199 lines
164 KiB
TypeScript
4199 lines
164 KiB
TypeScript
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
import { execFileSync, execSync, spawnSync } from "child_process";
|
|
import {
|
|
existsSync,
|
|
mkdtempSync,
|
|
rmSync,
|
|
writeFileSync,
|
|
readFileSync,
|
|
realpathSync,
|
|
mkdirSync,
|
|
statSync,
|
|
} from "fs";
|
|
import { join } from "path";
|
|
import { win32 } from "node:path";
|
|
import { tmpdir } from "os";
|
|
import pino from "pino";
|
|
import { base64EncryptedWireByteLength, maxSecuredPlaintextByteLength } from "@getpaseo/relay";
|
|
import {
|
|
__resetCheckoutShortstatCacheForTests,
|
|
__resetPullRequestStatusCacheForTests,
|
|
__setPullRequestStatusCacheTtlForTests,
|
|
commitAll,
|
|
discardChanges,
|
|
CHECKOUT_DIFF_MAX_STRUCTURED_BYTES,
|
|
createPullRequest,
|
|
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 { createForgeResolver } from "../services/forge-resolver.js";
|
|
import { GitHubCommandError, GitHubCliMissingError } from "../services/github-service.js";
|
|
import type { CurrentPullRequestStatus, ForgeService } from "../services/forge-service.js";
|
|
import {
|
|
TeaAuthenticationError,
|
|
TeaCliMissingError,
|
|
TeaCommandError,
|
|
} from "../services/gitea-service.js";
|
|
import {
|
|
createWorktree as createWorktreePrimitive,
|
|
type CreateWorktreeOptions,
|
|
type WorktreeConfig,
|
|
} from "./worktree.js";
|
|
|
|
interface LegacyCreateWorktreeTestOptions {
|
|
branchName: string;
|
|
cwd: string;
|
|
baseBranch: string;
|
|
worktreeSlug: string;
|
|
|
|
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,
|
|
},
|
|
|
|
paseoHome: options.paseoHome,
|
|
});
|
|
}
|
|
import {
|
|
getPaseoWorktreeMetadataPath,
|
|
readPaseoWorktreeMetadata,
|
|
writePaseoWorktreeMetadata,
|
|
} 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 readTextFile(path: string): string {
|
|
return readFileSync(path, "utf8").replaceAll("\r\n", "\n");
|
|
}
|
|
|
|
function sleep(ms: number): Promise<void> {
|
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
}
|
|
|
|
function createGitHubServiceForStatus(
|
|
status: CurrentPullRequestStatus | null,
|
|
options?: { onStatus?: () => void },
|
|
): ForgeService {
|
|
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",
|
|
getPullRequestCheckoutTarget: async ({ number }) => ({
|
|
number,
|
|
baseRefName: "main",
|
|
headRefName: "feature",
|
|
headOwnerLogin: null,
|
|
headRepositorySshUrl: null,
|
|
headRepositoryUrl: null,
|
|
isCrossRepository: false,
|
|
}),
|
|
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<CurrentPullRequestStatus>) {
|
|
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;
|
|
headSha?: string;
|
|
headRepositoryOwner?: string;
|
|
}
|
|
|
|
interface RecordingPullRequestTargetsOptions {
|
|
requestedTargets: RequestedPullRequestTarget[];
|
|
statusOverrides?: Partial<CurrentPullRequestStatus>;
|
|
}
|
|
|
|
function createGitHubServiceRecordingPullRequestTargets(
|
|
options: RecordingPullRequestTargetsOptions,
|
|
): ForgeService {
|
|
const github = createGitHubServiceForStatus(null);
|
|
github.getCurrentPullRequestStatus = async (request) => {
|
|
options.requestedTargets.push({
|
|
headRef: request.headRef,
|
|
...(request.headSha ? { headSha: request.headSha } : {}),
|
|
...(request.headRepositoryOwner ? { headRepositoryOwner: request.headRepositoryOwner } : {}),
|
|
});
|
|
return createPullRequestStatus({
|
|
...options.statusOverrides,
|
|
headRefName: request.headRef,
|
|
});
|
|
};
|
|
return github;
|
|
}
|
|
|
|
async function readPullRequestLookupTargetFromFacts(
|
|
repoDir: string,
|
|
paseoHome: string,
|
|
): Promise<RequestedPullRequestTarget | null> {
|
|
const facts = await getCheckoutSnapshotFacts(repoDir, { paseoHome });
|
|
if (!facts.isGit) {
|
|
throw new Error("Expected git checkout facts");
|
|
}
|
|
return facts.pullRequestLookupTarget;
|
|
}
|
|
|
|
function getBranchUpstream(cwd: string): string | null {
|
|
const result = spawnSync("git", ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"], {
|
|
cwd,
|
|
encoding: "utf8",
|
|
});
|
|
return result.status === 0 ? result.stdout.trim() : null;
|
|
}
|
|
|
|
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("creates a pull request via the given adapter without resolving a repo slug in the shell", async () => {
|
|
setupRemoteTrackingMain(repoDir, tempDir);
|
|
let adapterReached = false;
|
|
const adapter = createGitHubServiceForStatus(null);
|
|
adapter.createPullRequest = async () => {
|
|
adapterReached = true;
|
|
return { url: "https://gitlab.com/group/proj/-/merge_requests/7", number: 7 };
|
|
};
|
|
|
|
const result = await createPullRequest(
|
|
repoDir,
|
|
{ title: "Add thing", body: "desc", base: "main" },
|
|
adapter,
|
|
);
|
|
|
|
expect(adapterReached).toBe(true);
|
|
expect(result).toEqual({
|
|
url: "https://gitlab.com/group/proj/-/merge_requests/7",
|
|
number: 7,
|
|
});
|
|
});
|
|
|
|
it("resolves a gitlab remote through the resolver and creates via the gitlab adapter", async () => {
|
|
// origin is a real local bare repo so the push succeeds; the resolver reads a
|
|
// gitlab remote URL (injected) so it selects the gitlab adapter from the host.
|
|
setupRemoteTrackingMain(repoDir, tempDir);
|
|
let adapterReached = false;
|
|
const adapter = createGitHubServiceForStatus(null);
|
|
adapter.createPullRequest = async () => {
|
|
adapterReached = true;
|
|
return { url: "https://gitlab.com/group/proj/-/merge_requests/9", number: 9 };
|
|
};
|
|
|
|
const resolver = createForgeResolver({
|
|
resolveRemoteUrl: async () => "git@gitlab.com:group/proj.git",
|
|
createService: (forge) => (forge === "gitlab" ? adapter : null),
|
|
});
|
|
const resolution = await resolver.resolve(repoDir);
|
|
expect(resolution).toMatchObject({ forge: "gitlab", host: "gitlab.com" });
|
|
expect(resolution?.service).toBe(adapter);
|
|
|
|
const result = await createPullRequest(
|
|
repoDir,
|
|
{ title: "Add thing", body: "desc", base: "main" },
|
|
resolution?.service,
|
|
);
|
|
|
|
expect(adapterReached).toBe(true);
|
|
expect(result).toEqual({
|
|
url: "https://gitlab.com/group/proj/-/merge_requests/9",
|
|
number: 9,
|
|
});
|
|
});
|
|
|
|
it("reports an ordinary directory as non-git without warning", async () => {
|
|
const nonGitDir = join(tempDir, "not-git-status");
|
|
mkdirSync(nonGitDir, { recursive: true });
|
|
const records: unknown[] = [];
|
|
const logger = pino(
|
|
{ level: "warn" },
|
|
{
|
|
write(line: string) {
|
|
records.push(JSON.parse(line));
|
|
},
|
|
},
|
|
);
|
|
|
|
await expect(getCheckoutStatus(nonGitDir, { logger })).resolves.toEqual({ isGit: false });
|
|
expect(records).toEqual([]);
|
|
});
|
|
|
|
it.runIf(
|
|
process.platform !== "win32" &&
|
|
existsSync("/dev/shm") &&
|
|
statSync("/dev").dev !== statSync("/dev/shm").dev,
|
|
)("does not warn for a non-git directory at a filesystem boundary", async () => {
|
|
const nonGitDir = realpathSync.native(mkdtempSync("/dev/shm/checkout-git-boundary-test-"));
|
|
const records: unknown[] = [];
|
|
const logger = pino(
|
|
{ level: "warn" },
|
|
{
|
|
write(line: string) {
|
|
records.push(JSON.parse(line));
|
|
},
|
|
},
|
|
);
|
|
try {
|
|
await expect(getCheckoutStatus(nonGitDir, { logger })).resolves.toEqual({ isGit: false });
|
|
expect(records).toEqual([]);
|
|
} finally {
|
|
rmSync(nonGitDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("warns when git discovery fails unexpectedly", async () => {
|
|
const missingDir = join(tempDir, "missing-git-cwd");
|
|
const records: unknown[] = [];
|
|
const logger = pino(
|
|
{ level: "warn" },
|
|
{
|
|
write(line: string) {
|
|
records.push(JSON.parse(line));
|
|
},
|
|
},
|
|
);
|
|
|
|
await expect(getCheckoutStatus(missingDir, { logger })).resolves.toEqual({ isGit: false });
|
|
expect(records).toEqual([
|
|
expect.objectContaining({
|
|
level: 40,
|
|
cwd: missingDir,
|
|
msg: "Git worktree discovery failed; treating directory as non-Git",
|
|
}),
|
|
]);
|
|
});
|
|
|
|
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("honors commit signing configuration", async () => {
|
|
execFileSync("git", ["config", "commit.gpgsign", "true"], { cwd: repoDir });
|
|
execFileSync("git", ["config", "gpg.format", "openpgp"], { cwd: repoDir });
|
|
execFileSync("git", ["config", "gpg.program", process.execPath], { cwd: repoDir });
|
|
writeFileSync(join(repoDir, "file.txt"), "signed\n");
|
|
|
|
await expect(commitAll(repoDir, "signed update")).rejects.toThrow("failed to sign the data");
|
|
});
|
|
|
|
it("includes both paths for a staged rename in structured diffs", async () => {
|
|
execFileSync("git", ["mv", "file.txt", "renamed.txt"], { cwd: repoDir });
|
|
|
|
const diff = await getCheckoutDiff(repoDir, {
|
|
mode: "uncommitted",
|
|
includeStructured: true,
|
|
});
|
|
|
|
expect(diff.structured).toContainEqual(
|
|
expect.objectContaining({
|
|
path: "renamed.txt",
|
|
oldPath: "file.txt",
|
|
additions: 0,
|
|
deletions: 0,
|
|
hunks: [],
|
|
}),
|
|
);
|
|
});
|
|
|
|
it.each([
|
|
{ mode: "uncommitted", oldPath: "to-rename.txt", path: "renamed.txt" },
|
|
{ mode: "base", oldPath: "to-rename.txt", path: "renamed.txt" },
|
|
{ mode: "uncommitted", oldPath: "old [file].txt", path: "new [file].txt" },
|
|
] as const)(
|
|
"shows only the edited line of a rename ($mode, $path)",
|
|
async ({ mode, oldPath, path }) => {
|
|
const original = Array.from({ length: 12 }, (_, index) => `line ${index + 1}`);
|
|
commitFile(repoDir, oldPath, original.join("\n") + "\n", "file to rename");
|
|
if (mode === "base") {
|
|
execFileSync("git", ["checkout", "-b", "feature/rename"], { cwd: repoDir });
|
|
}
|
|
execFileSync("git", ["mv", oldPath, path], { cwd: repoDir });
|
|
const edited = [...original];
|
|
edited[5] = "edited line 6";
|
|
writeFileSync(join(repoDir, path), edited.join("\n") + "\n");
|
|
execFileSync("git", ["add", path], { cwd: repoDir });
|
|
|
|
expect(
|
|
execFileSync("git", ["diff", "--name-status", "HEAD"], { cwd: repoDir }).toString(),
|
|
).toMatch(/R\d+\t/);
|
|
if (mode === "base") {
|
|
execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "rename and edit"], {
|
|
cwd: repoDir,
|
|
});
|
|
}
|
|
|
|
const diff = await getCheckoutDiff(repoDir, {
|
|
mode,
|
|
...(mode === "base" ? { baseRef: "main" } : {}),
|
|
includeStructured: true,
|
|
});
|
|
|
|
expect(diff.structured).toEqual([
|
|
expect.objectContaining({
|
|
path,
|
|
oldPath,
|
|
isNew: false,
|
|
isDeleted: false,
|
|
additions: 1,
|
|
deletions: 1,
|
|
status: "ok",
|
|
}),
|
|
]);
|
|
expect(diff.diff).toContain(`rename from ${oldPath}`);
|
|
expect(diff.diff).toContain(`rename to ${path}`);
|
|
expect(diff.diff).toContain("-line 6\n+edited line 6");
|
|
expect(diff.diff).not.toContain("new file mode");
|
|
},
|
|
);
|
|
|
|
it("includes a changed copy source only once in the diff", async () => {
|
|
const original =
|
|
Array.from({ length: 12 }, (_, index) => `line ${index + 1}`).join("\n") + "\n";
|
|
commitFile(repoDir, "source.txt", original, "copy source");
|
|
execFileSync("git", ["config", "diff.renames", "copies"], { cwd: repoDir });
|
|
writeFileSync(join(repoDir, "copy.txt"), original);
|
|
writeFileSync(join(repoDir, "source.txt"), original.replace("line 6", "edited line 6"));
|
|
execFileSync("git", ["add", "copy.txt", "source.txt"], { cwd: repoDir });
|
|
expect(
|
|
execFileSync("git", ["diff", "--name-status", "HEAD"], { cwd: repoDir }).toString(),
|
|
).toContain("C100\tsource.txt\tcopy.txt");
|
|
|
|
const diff = await getCheckoutDiff(repoDir, {
|
|
mode: "uncommitted",
|
|
includeStructured: true,
|
|
});
|
|
expect(diff.structured).toEqual([
|
|
expect.objectContaining({
|
|
path: "copy.txt",
|
|
oldPath: "source.txt",
|
|
additions: 0,
|
|
deletions: 0,
|
|
}),
|
|
expect.objectContaining({ path: "source.txt", additions: 1, deletions: 1 }),
|
|
]);
|
|
expect(diff.diff.match(/^diff --git a\/source.txt b\/source.txt$/gm)).toHaveLength(1);
|
|
expect(diff.diff).toContain("copy from source.txt");
|
|
expect(diff.diff).toContain("copy to copy.txt");
|
|
});
|
|
|
|
it("keeps a small copy patch when its edited source exceeds the patch limit", async () => {
|
|
const original =
|
|
Array.from({ length: 12 }, (_, index) => `line ${index + 1}`).join("\n") + "\n";
|
|
commitFile(repoDir, "source.txt", original, "copy source");
|
|
execFileSync("git", ["config", "diff.renames", "copies"], { cwd: repoDir });
|
|
writeFileSync(join(repoDir, "copy.txt"), original);
|
|
writeFileSync(join(repoDir, "source.txt"), original + "added source line\n".repeat(80_000));
|
|
execFileSync("git", ["add", "copy.txt", "source.txt"], { cwd: repoDir });
|
|
expect(
|
|
execFileSync("git", ["diff", "--name-status", "HEAD"], { cwd: repoDir }).toString(),
|
|
).toContain("C100\tsource.txt\tcopy.txt");
|
|
|
|
const diff = await getCheckoutDiff(repoDir, {
|
|
mode: "uncommitted",
|
|
includeStructured: true,
|
|
});
|
|
expect(diff.structured).toEqual([
|
|
expect.objectContaining({
|
|
path: "copy.txt",
|
|
oldPath: "source.txt",
|
|
additions: 0,
|
|
deletions: 0,
|
|
hunks: [],
|
|
status: "ok",
|
|
}),
|
|
expect.objectContaining({ path: "source.txt", status: "too_large", hunks: [] }),
|
|
]);
|
|
expect(diff.diff).toContain("copy from source.txt");
|
|
expect(diff.diff).toContain("copy to copy.txt");
|
|
expect(diff.diff).not.toContain("+added source line");
|
|
});
|
|
|
|
it("reads the origin URL once when collecting facts for an origin-tracking branch", async () => {
|
|
setupRemoteTrackingMain(repoDir, tempDir);
|
|
|
|
startGitCommandMetrics();
|
|
const facts = await getCheckoutSnapshotFacts(repoDir, { paseoHome });
|
|
const metrics = stopGitCommandMetrics();
|
|
const originUrlCommands = metrics.commands.filter(
|
|
(command) => command.args.join(" ") === "config --get remote.origin.url",
|
|
);
|
|
|
|
expect(facts.isGit).toBe(true);
|
|
expect(originUrlCommands).toHaveLength(1);
|
|
});
|
|
|
|
it("reads a non-origin branch remote without replacing it with the origin URL", async () => {
|
|
setupRemoteTrackingMain(repoDir, tempDir);
|
|
execFileSync("git", ["remote", "set-url", "origin", "git@github.com:upstream/repo.git"], {
|
|
cwd: repoDir,
|
|
});
|
|
execFileSync("git", ["remote", "add", "fork", "git@github.com:contributor/repo.git"], {
|
|
cwd: repoDir,
|
|
});
|
|
execFileSync("git", ["config", "branch.main.remote", "fork"], { cwd: repoDir });
|
|
execFileSync("git", ["config", "branch.main.merge", "refs/heads/main"], { cwd: repoDir });
|
|
|
|
startGitCommandMetrics();
|
|
const facts = await getCheckoutSnapshotFacts(repoDir, { paseoHome });
|
|
const metrics = stopGitCommandMetrics();
|
|
const commands = metrics.commands.map((command) => command.args.join(" "));
|
|
|
|
expect(facts.isGit).toBe(true);
|
|
expect(commands.filter((command) => command === "config --get remote.origin.url")).toHaveLength(
|
|
1,
|
|
);
|
|
expect(commands.filter((command) => command === "config --get remote.fork.url")).toHaveLength(
|
|
1,
|
|
);
|
|
});
|
|
|
|
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);
|
|
|
|
commitFile(repoDir, "local-1.txt", "local 1\n", "local update 1");
|
|
commitFile(repoDir, "local-2.txt", "local 2\n", "local update 2");
|
|
commitFile(repoDir, "local-3.txt", "local 3\n", "local update 3");
|
|
commitFile(cloneDir, "remote-2.txt", "remote 2\n", "remote update 2");
|
|
execFileSync("git", ["push"], { cwd: cloneDir });
|
|
execFileSync("git", ["fetch", "origin"], { cwd: repoDir });
|
|
|
|
startGitCommandMetrics();
|
|
const facts = await getCheckoutSnapshotFacts(repoDir);
|
|
const divergedStatus = await getCheckoutStatus(repoDir, { facts });
|
|
const metrics = stopGitCommandMetrics();
|
|
const upstreamCountCommands = metrics.commands.filter(
|
|
(command) =>
|
|
command.args[0] === "for-each-ref" && command.args.join(" ").includes("%(upstream)"),
|
|
);
|
|
|
|
expect(divergedStatus.isGit).toBe(true);
|
|
if (!divergedStatus.isGit) {
|
|
return;
|
|
}
|
|
expect(divergedStatus.upstreamRef).toBe("refs/remotes/origin/main");
|
|
expect(divergedStatus.aheadOfOrigin).toBe(3);
|
|
expect(divergedStatus.behindOfOrigin).toBe(2);
|
|
expect(upstreamCountCommands).toHaveLength(1);
|
|
expect(upstreamCountCommands[0]?.args).toEqual([
|
|
"for-each-ref",
|
|
"--format=%(upstream)%00%(upstream:track,nobracket)",
|
|
"refs/heads/main",
|
|
]);
|
|
});
|
|
|
|
it("reports the fork remote, not origin, when the branch tracks a second remote", async () => {
|
|
setupRemoteTrackingMain(repoDir, tempDir);
|
|
const forkDir = join(tempDir, "fork.git");
|
|
execFileSync("git", ["init", "--bare", "-b", "main", forkDir]);
|
|
execFileSync("git", ["remote", "add", "upstream", forkDir], { cwd: repoDir });
|
|
execFileSync("git", ["push", "-u", "upstream", "main"], { cwd: repoDir });
|
|
|
|
const status = await getCheckoutStatus(repoDir);
|
|
expect(status.isGit).toBe(true);
|
|
if (!status.isGit) {
|
|
return;
|
|
}
|
|
expect(status.upstreamRef).toBe("refs/remotes/upstream/main");
|
|
});
|
|
|
|
it("reports the upstream branch name when it differs from the local branch name", async () => {
|
|
setupRemoteTrackingMain(repoDir, tempDir);
|
|
execFileSync("git", ["checkout", "-b", "feature"], { cwd: repoDir });
|
|
commitFile(repoDir, "feature.txt", "feature\n", "feature commit");
|
|
execFileSync("git", ["push", "-u", "origin", "HEAD:other-name"], { cwd: repoDir });
|
|
|
|
const status = await getCheckoutStatus(repoDir);
|
|
expect(status.isGit).toBe(true);
|
|
if (!status.isGit) {
|
|
return;
|
|
}
|
|
expect(status.upstreamRef).toBe("refs/remotes/origin/other-name");
|
|
});
|
|
|
|
it("reports the upstream when the repo's only remote is not origin", async () => {
|
|
const forkDir = join(tempDir, "fork.git");
|
|
execFileSync("git", ["init", "--bare", "-b", "main", forkDir]);
|
|
execFileSync("git", ["remote", "add", "upstream", forkDir], { cwd: repoDir });
|
|
execFileSync("git", ["push", "-u", "upstream", "main"], { cwd: repoDir });
|
|
|
|
const status = await getCheckoutStatus(repoDir);
|
|
expect(status.isGit).toBe(true);
|
|
if (!status.isGit) {
|
|
return;
|
|
}
|
|
expect(status.upstreamRef).toBe("refs/remotes/upstream/main");
|
|
expect(status.aheadOfOrigin).toBe(0);
|
|
});
|
|
|
|
// A legal local branch named "origin/main" shadows the short form "origin/main" in git's
|
|
// ref resolution. The counts and the reported ref have to describe the same commit.
|
|
it("is not fooled by a local branch named like the remote-tracking ref", async () => {
|
|
setupRemoteTrackingMain(repoDir, tempDir);
|
|
commitFile(repoDir, "shadow.txt", "shadow\n", "shadow commit");
|
|
// Pointed at HEAD, so comparing against the shadow reports 0 ahead while the real
|
|
// remote-tracking ref reports 1.
|
|
execFileSync("git", ["branch", "origin/main", "main"], { cwd: repoDir });
|
|
|
|
const status = await getCheckoutStatus(repoDir);
|
|
expect(status.isGit).toBe(true);
|
|
if (!status.isGit) {
|
|
return;
|
|
}
|
|
expect(status.upstreamRef).toBe("refs/remotes/origin/main");
|
|
expect(status.aheadOfOrigin).toBe(1);
|
|
expect(status.behindOfOrigin).toBe(0);
|
|
});
|
|
|
|
it("reports no upstream ref for a branch that was never pushed", async () => {
|
|
setupRemoteTrackingMain(repoDir, tempDir);
|
|
execFileSync("git", ["checkout", "-b", "local-only"], { cwd: repoDir });
|
|
commitFile(repoDir, "local-only.txt", "local\n", "local commit");
|
|
|
|
const status = await getCheckoutStatus(repoDir);
|
|
expect(status.isGit).toBe(true);
|
|
if (!status.isGit) {
|
|
return;
|
|
}
|
|
expect(status.upstreamRef).toBeNull();
|
|
expect(status.aheadOfOrigin).toBeNull();
|
|
});
|
|
|
|
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();
|
|
expect(status.behindOfOrigin).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 () => {
|
|
// This case needs a remote base ref, not a second clone or extra base history.
|
|
execFileSync("git", ["remote", "add", "origin", repoDir], { cwd: repoDir });
|
|
execFileSync("git", ["fetch", "origin", "main:refs/remotes/origin/main"], { 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,
|
|
hasRemote: 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.each([
|
|
{ contents: "hello\n", expectedFiles: [], patch: "" },
|
|
{
|
|
contents: "restored\n",
|
|
expectedFiles: [
|
|
{ path: "file.txt", isNew: false, isDeleted: false, additions: 1, deletions: 1 },
|
|
],
|
|
patch: "-hello\n+restored\n",
|
|
},
|
|
])(
|
|
"compares restored untracked base files against disk: $contents",
|
|
async ({ contents, expectedFiles, patch }) => {
|
|
execFileSync("git", ["checkout", "-b", "feature"], { cwd: repoDir });
|
|
execFileSync("git", ["rm", "file.txt"], { cwd: repoDir });
|
|
execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "delete"], {
|
|
cwd: repoDir,
|
|
});
|
|
writeFileSync(join(repoDir, "file.txt"), contents);
|
|
const indexPath = execFileSync("git", ["rev-parse", "--git-path", "index"], {
|
|
cwd: repoDir,
|
|
encoding: "utf8",
|
|
}).trim();
|
|
const beforeIndex = readFileSync(join(repoDir, indexPath));
|
|
const beforeStatus = execFileSync("git", ["status", "--porcelain=v1", "-z"], {
|
|
cwd: repoDir,
|
|
});
|
|
const result = await getCheckoutDiff(repoDir, {
|
|
mode: "all",
|
|
baseRef: "main",
|
|
includeStructured: true,
|
|
});
|
|
expect(
|
|
result.structured?.map(({ path, isNew, isDeleted, additions, deletions }) => ({
|
|
path,
|
|
isNew,
|
|
isDeleted,
|
|
additions,
|
|
deletions,
|
|
})),
|
|
).toEqual(expectedFiles);
|
|
expect(result.diff).toContain(patch);
|
|
expect(result.diff.length === 0).toBe(expectedFiles.length === 0);
|
|
expect(readFileSync(join(repoDir, indexPath))).toEqual(beforeIndex);
|
|
expect(execFileSync("git", ["status", "--porcelain=v1", "-z"], { cwd: repoDir })).toEqual(
|
|
beforeStatus,
|
|
);
|
|
expect(readFileSync(join(repoDir, "file.txt"), "utf8")).toBe(contents);
|
|
expect((await getCheckoutDiff(repoDir, { mode: "base", baseRef: "main" })).diff).toContain(
|
|
"deleted file mode",
|
|
);
|
|
expect((await getCheckoutDiff(repoDir, { mode: "uncommitted" })).diff).toContain(
|
|
"new file mode",
|
|
);
|
|
},
|
|
);
|
|
|
|
it("compares all changes as one net patch including untracked files", async () => {
|
|
execFileSync("git", ["checkout", "-b", "feature"], { cwd: repoDir });
|
|
writeFileSync(join(repoDir, "file.txt"), "committed\n");
|
|
execFileSync("git", ["add", "."], { cwd: repoDir });
|
|
execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "feature"], {
|
|
cwd: repoDir,
|
|
});
|
|
writeFileSync(join(repoDir, "file.txt"), "working\n");
|
|
writeFileSync(join(repoDir, "new.txt"), "untracked\n");
|
|
const compare = { mode: "all", baseRef: "main", includeStructured: true } as const;
|
|
const before = await getCheckoutDiff(repoDir, compare);
|
|
expect(before.structured?.map((file) => file.path)).toEqual(["file.txt", "new.txt"]);
|
|
expect(before.diff).toContain("-hello\n+working\n");
|
|
expect(before.diff).not.toContain("committed");
|
|
expect(before.diff).toContain("+untracked");
|
|
const committed = await getCheckoutDiff(repoDir, { ...compare, mode: "base" });
|
|
expect(committed.diff).toContain("-hello\n+committed\n");
|
|
const uncommitted = await getCheckoutDiff(repoDir, { ...compare, mode: "uncommitted" });
|
|
expect(uncommitted.diff).toContain("-committed\n+working\n");
|
|
execFileSync("git", ["add", "."], { cwd: repoDir });
|
|
execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "working"], {
|
|
cwd: repoDir,
|
|
});
|
|
expect((await getCheckoutDiff(repoDir, compare)).diff).toBe(before.diff);
|
|
writeFileSync(join(repoDir, "file.txt"), "hello\n");
|
|
expect((await getCheckoutDiff(repoDir, compare)).structured?.map((file) => file.path)).toEqual([
|
|
"new.txt",
|
|
]);
|
|
});
|
|
|
|
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();
|
|
|
|
const warmed = new Promise<void>((resolve) => {
|
|
warmCheckoutShortstatInBackground(repoDir, undefined, resolve);
|
|
});
|
|
// The warm hands off to git and returns; listing callers see no value yet.
|
|
expect(getCachedCheckoutShortstat(repoDir)).toBeUndefined();
|
|
|
|
await warmed;
|
|
// A repo with no origin/main computes to null, but null should still be cached.
|
|
expect(getCachedCheckoutShortstat(repoDir)).toBeNull();
|
|
});
|
|
|
|
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("keeps the structured diff cap below the relay frame limit", () => {
|
|
const relayFrameBytes = 32 * 1024 * 1024;
|
|
const frameEnvelopeHeadroomBytes = 1024 * 1024;
|
|
const diffWireBytes = base64EncryptedWireByteLength(CHECKOUT_DIFF_MAX_STRUCTURED_BYTES);
|
|
const maximumFrameWireBytes = base64EncryptedWireByteLength(
|
|
CHECKOUT_DIFF_MAX_STRUCTURED_BYTES + frameEnvelopeHeadroomBytes,
|
|
);
|
|
|
|
// The cap is sized for the tighter of the legacy and protected relay framings.
|
|
expect(CHECKOUT_DIFF_MAX_STRUCTURED_BYTES).toBe(
|
|
maxSecuredPlaintextByteLength(relayFrameBytes) - frameEnvelopeHeadroomBytes,
|
|
);
|
|
expect(CHECKOUT_DIFF_MAX_STRUCTURED_BYTES).toBeLessThanOrEqual(24_117_208);
|
|
expect(CHECKOUT_DIFF_MAX_STRUCTURED_BYTES).toBeGreaterThan(24_000_000);
|
|
expect(diffWireBytes).toBeLessThan(relayFrameBytes);
|
|
expect(maximumFrameWireBytes).toBeLessThanOrEqual(relayFrameBytes);
|
|
});
|
|
|
|
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 --no-ext-diff --no-color --src-prefix=a/ --dst-prefix=b/ -z --numstat HEAD",
|
|
);
|
|
expect(commands).toContain(
|
|
"diff --no-ext-diff --no-color --src-prefix=a/ --dst-prefix=b/ HEAD -- :(literal)generated.js :(literal)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("resolves the Git common directory once when reading Paseo worktree facts", async () => {
|
|
const result = await createLegacyWorktreeForTest({
|
|
branchName: "main",
|
|
cwd: repoDir,
|
|
baseBranch: "main",
|
|
worktreeSlug: "common-dir",
|
|
paseoHome,
|
|
});
|
|
|
|
startGitCommandMetrics();
|
|
const facts = await getCheckoutSnapshotFacts(result.worktreePath, { paseoHome });
|
|
const metrics = stopGitCommandMetrics();
|
|
const commonDirCommands = metrics.commands.filter(
|
|
(command) => command.args.join(" ") === "rev-parse --git-common-dir",
|
|
);
|
|
|
|
expect(facts.isGit).toBe(true);
|
|
expect(commonDirCommands).toHaveLength(1);
|
|
});
|
|
|
|
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("pushes the current branch to its configured upstream", async () => {
|
|
const originDir = join(tempDir, "origin.git");
|
|
const prRemoteDir = join(tempDir, "pr-remote.git");
|
|
execFileSync("git", ["clone", "--bare", repoDir, originDir]);
|
|
execFileSync("git", ["clone", "--bare", repoDir, prRemoteDir]);
|
|
execFileSync("git", ["remote", "add", "origin", originDir], { cwd: repoDir });
|
|
execFileSync("git", ["remote", "add", "paseo-pr-526", prRemoteDir], { cwd: repoDir });
|
|
execFileSync("git", ["checkout", "-b", "therainisme/main"], { cwd: repoDir });
|
|
execFileSync("git", ["fetch", "paseo-pr-526", "main"], { cwd: repoDir });
|
|
execFileSync("git", ["config", "branch.therainisme/main.remote", "paseo-pr-526"], {
|
|
cwd: repoDir,
|
|
});
|
|
execFileSync("git", ["config", "branch.therainisme/main.merge", "refs/heads/main"], {
|
|
cwd: repoDir,
|
|
});
|
|
writeFileSync(join(repoDir, "fork-pr.txt"), "fork pr edit\n");
|
|
execFileSync("git", ["add", "fork-pr.txt"], { cwd: repoDir });
|
|
execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "fork pr edit"], {
|
|
cwd: repoDir,
|
|
});
|
|
const localHead = execFileSync("git", ["rev-parse", "HEAD"], { cwd: repoDir })
|
|
.toString()
|
|
.trim();
|
|
|
|
await pushCurrentBranch(repoDir);
|
|
|
|
const prRemoteMain = execFileSync("git", [
|
|
"--git-dir",
|
|
prRemoteDir,
|
|
"rev-parse",
|
|
"refs/heads/main",
|
|
])
|
|
.toString()
|
|
.trim();
|
|
const upstream = execFileSync(
|
|
"git",
|
|
["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"],
|
|
{ cwd: repoDir },
|
|
)
|
|
.toString()
|
|
.trim();
|
|
expect(prRemoteMain).toBe(localHead);
|
|
expect(upstream).toBe("paseo-pr-526/main");
|
|
});
|
|
|
|
it("pushes the current branch to its configured push remote", async () => {
|
|
const originDir = join(tempDir, "origin.git");
|
|
const prRemoteDir = join(tempDir, "pr-remote.git");
|
|
execFileSync("git", ["clone", "--bare", repoDir, originDir]);
|
|
execFileSync("git", ["clone", "--bare", repoDir, prRemoteDir]);
|
|
execFileSync("git", ["remote", "add", "origin", originDir], { cwd: repoDir });
|
|
execFileSync("git", ["remote", "add", "paseo-pr-526", prRemoteDir], { cwd: repoDir });
|
|
execFileSync("git", ["checkout", "-b", "therainisme/main"], { cwd: repoDir });
|
|
execFileSync("git", ["config", "branch.therainisme/main.pushRemote", "paseo-pr-526"], {
|
|
cwd: repoDir,
|
|
});
|
|
execFileSync("git", ["config", "remote.paseo-pr-526.push", "HEAD:refs/heads/main"], {
|
|
cwd: repoDir,
|
|
});
|
|
writeFileSync(join(repoDir, "fork-pr.txt"), "fork pr edit\n");
|
|
execFileSync("git", ["add", "fork-pr.txt"], { cwd: repoDir });
|
|
execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "fork pr edit"], {
|
|
cwd: repoDir,
|
|
});
|
|
const localHead = execFileSync("git", ["rev-parse", "HEAD"], { cwd: repoDir })
|
|
.toString()
|
|
.trim();
|
|
const upstreamBeforePush = getBranchUpstream(repoDir);
|
|
|
|
await pushCurrentBranch(repoDir);
|
|
|
|
const prRemoteMain = execFileSync("git", [
|
|
"--git-dir",
|
|
prRemoteDir,
|
|
"rev-parse",
|
|
"refs/heads/main",
|
|
])
|
|
.toString()
|
|
.trim();
|
|
const originBranch = spawnSync(
|
|
"git",
|
|
["--git-dir", originDir, "show-ref", "--verify", "--quiet", "refs/heads/therainisme/main"],
|
|
{ encoding: "utf8" },
|
|
);
|
|
const trackedPrRemoteHead = execFileSync(
|
|
"git",
|
|
["rev-parse", "refs/remotes/paseo-pr-526/main"],
|
|
{
|
|
cwd: repoDir,
|
|
},
|
|
)
|
|
.toString()
|
|
.trim();
|
|
const afterPushStatus = await getCheckoutStatus(repoDir);
|
|
expect(upstreamBeforePush).toBeNull();
|
|
expect(prRemoteMain).toBe(localHead);
|
|
expect(getBranchUpstream(repoDir)).toBe("paseo-pr-526/main");
|
|
expect(trackedPrRemoteHead).toBe(localHead);
|
|
expect(afterPushStatus).toMatchObject({ aheadOfOrigin: 0, behindOfOrigin: 0 });
|
|
expect(originBranch.status).toBe(1);
|
|
});
|
|
|
|
it("refreshes the tracked ref after pushing through a configured push remote", async () => {
|
|
const originDir = join(tempDir, "origin.git");
|
|
execFileSync("git", ["clone", "--bare", repoDir, originDir]);
|
|
execFileSync("git", ["remote", "add", "origin", originDir], { cwd: repoDir });
|
|
execFileSync("git", ["checkout", "-b", "feature"], { cwd: repoDir });
|
|
execFileSync("git", ["push", "-u", "origin", "feature"], { cwd: repoDir });
|
|
execFileSync("git", ["remote", "add", "paseo-pr-1790", originDir], { cwd: repoDir });
|
|
execFileSync("git", ["config", "branch.feature.pushRemote", "paseo-pr-1790"], {
|
|
cwd: repoDir,
|
|
});
|
|
execFileSync("git", ["config", "remote.paseo-pr-1790.push", "HEAD:refs/heads/feature"], {
|
|
cwd: repoDir,
|
|
});
|
|
writeFileSync(join(repoDir, "feature.txt"), "feature edit\n");
|
|
execFileSync("git", ["add", "feature.txt"], { cwd: repoDir });
|
|
execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "feature edit"], {
|
|
cwd: repoDir,
|
|
});
|
|
const localHead = execFileSync("git", ["rev-parse", "HEAD"], { cwd: repoDir })
|
|
.toString()
|
|
.trim();
|
|
|
|
const beforePushStatus = await getCheckoutStatus(repoDir);
|
|
await pushCurrentBranch(repoDir);
|
|
|
|
const trackedOriginHead = execFileSync("git", ["rev-parse", "refs/remotes/origin/feature"], {
|
|
cwd: repoDir,
|
|
})
|
|
.toString()
|
|
.trim();
|
|
const afterPushStatus = await getCheckoutStatus(repoDir);
|
|
expect(beforePushStatus).toMatchObject({ aheadOfOrigin: 1, behindOfOrigin: 0 });
|
|
expect(trackedOriginHead).toBe(localHead);
|
|
expect(afterPushStatus).toMatchObject({ aheadOfOrigin: 0, behindOfOrigin: 0 });
|
|
});
|
|
|
|
it("pushes ordinary branches to their configured upstream", async () => {
|
|
const originDir = join(tempDir, "origin.git");
|
|
const upstreamDir = join(tempDir, "upstream.git");
|
|
execFileSync("git", ["clone", "--bare", repoDir, originDir]);
|
|
execFileSync("git", ["clone", "--bare", repoDir, upstreamDir]);
|
|
execFileSync("git", ["remote", "add", "origin", originDir], { cwd: repoDir });
|
|
execFileSync("git", ["remote", "add", "upstream", upstreamDir], { cwd: repoDir });
|
|
execFileSync("git", ["fetch", "upstream", "main"], { cwd: repoDir });
|
|
execFileSync("git", ["checkout", "-b", "contrib"], { cwd: repoDir });
|
|
execFileSync("git", ["config", "branch.contrib.remote", "upstream"], { cwd: repoDir });
|
|
execFileSync("git", ["config", "branch.contrib.merge", "refs/heads/main"], { cwd: repoDir });
|
|
writeFileSync(join(repoDir, "contrib.txt"), "contrib edit\n");
|
|
execFileSync("git", ["add", "contrib.txt"], { cwd: repoDir });
|
|
execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "contrib edit"], {
|
|
cwd: repoDir,
|
|
});
|
|
const localHead = execFileSync("git", ["rev-parse", "HEAD"], { cwd: repoDir })
|
|
.toString()
|
|
.trim();
|
|
|
|
await pushCurrentBranch(repoDir);
|
|
|
|
const upstreamMainAfter = execFileSync("git", [
|
|
"--git-dir",
|
|
upstreamDir,
|
|
"rev-parse",
|
|
"refs/heads/main",
|
|
])
|
|
.toString()
|
|
.trim();
|
|
const originContrib = spawnSync(
|
|
"git",
|
|
["--git-dir", originDir, "show-ref", "--verify", "--quiet", "refs/heads/contrib"],
|
|
{ encoding: "utf8" },
|
|
);
|
|
expect(upstreamMainAfter).toBe(localHead);
|
|
expect(originContrib.status).toBe(1);
|
|
});
|
|
|
|
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,
|
|
localAhead: 0,
|
|
localBehind: 0,
|
|
});
|
|
await expect(listBranchSuggestions(repoDir, { query: "origin/main" })).resolves.toEqual([
|
|
expect.objectContaining({ name: "main", hasLocal: true, hasRemote: true }),
|
|
]);
|
|
});
|
|
|
|
it("reports local and origin divergence for branch suggestions", 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, "local.txt"), "local\n");
|
|
execFileSync("git", ["add", "local.txt"], { cwd: repoDir });
|
|
execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "local commit"], {
|
|
cwd: repoDir,
|
|
});
|
|
|
|
const otherClone = join(tempDir, "diverged-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.txt"), "remote\n");
|
|
execFileSync("git", ["add", "remote.txt"], { cwd: otherClone });
|
|
execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "remote commit"], {
|
|
cwd: otherClone,
|
|
});
|
|
execFileSync("git", ["push", "origin", "main"], { cwd: otherClone });
|
|
execFileSync("git", ["fetch", "origin"], { cwd: repoDir });
|
|
|
|
const branches = await listBranchSuggestions(repoDir, { limit: 50 });
|
|
|
|
expect(branches.find((branch) => branch.name === "main")).toMatchObject({
|
|
hasLocal: true,
|
|
hasRemote: true,
|
|
localAhead: 1,
|
|
localBehind: 1,
|
|
});
|
|
});
|
|
|
|
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,
|
|
authState: "authenticated",
|
|
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).toMatchObject({ headRef: "refactor/workspace-scripts" });
|
|
expect(lookupTarget?.headSha).toMatch(/^[0-9a-f]{40}$/);
|
|
});
|
|
|
|
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).toMatchObject({ headRef: "feature" });
|
|
expect(lookupTarget?.headSha).toMatch(/^[0-9a-f]{40}$/);
|
|
});
|
|
|
|
it.each([
|
|
{ state: "open", isMerged: false },
|
|
{ state: "closed", isMerged: false },
|
|
{ state: "merged", isMerged: true },
|
|
])(
|
|
"shows a $state PR for the branch currently checked out after workspace creation",
|
|
async ({ state, isMerged }) => {
|
|
execFileSync("git", ["remote", "add", "origin", "https://github.com/acme/repo.git"], {
|
|
cwd: repoDir,
|
|
});
|
|
execFileSync("git", ["branch", "contributor/old-change"], { cwd: repoDir });
|
|
execFileSync("git", ["branch", "new-change"], { cwd: repoDir });
|
|
execFileSync("git", ["config", "branch.new-change.remote", "origin"], { cwd: repoDir });
|
|
execFileSync("git", ["config", "branch.new-change.merge", "refs/heads/new-change"], {
|
|
cwd: repoDir,
|
|
});
|
|
const workspaceDir = join(paseoHome, "worktrees", "repo", "pr-worktree");
|
|
mkdirSync(join(paseoHome, "worktrees", "repo"), { recursive: true });
|
|
execFileSync("git", ["worktree", "add", workspaceDir, "contributor/old-change"], {
|
|
cwd: repoDir,
|
|
});
|
|
await writePaseoWorktreeMetadata(workspaceDir, {
|
|
baseRefName: "main",
|
|
changeRequestLookupTarget: {
|
|
headRef: "old-change",
|
|
headRepositoryOwner: "contributor",
|
|
changeRequestNumber: 41,
|
|
localBranchName: "contributor/old-change",
|
|
},
|
|
});
|
|
|
|
execFileSync("git", ["checkout", "new-change"], { cwd: workspaceDir });
|
|
const requestedTargets: RequestedPullRequestTarget[] = [];
|
|
const facts = await getCheckoutSnapshotFacts(workspaceDir, { paseoHome });
|
|
const result = await getPullRequestStatus(
|
|
workspaceDir,
|
|
createGitHubServiceRecordingPullRequestTargets({
|
|
requestedTargets,
|
|
statusOverrides: { state, isMerged },
|
|
}),
|
|
{ force: true, reason: "current-checkout-pr" },
|
|
{ paseoHome, facts },
|
|
);
|
|
|
|
expect(requestedTargets).toEqual([expect.objectContaining({ headRef: "new-change" })]);
|
|
expect(result.status).toMatchObject({
|
|
headRefName: "new-change",
|
|
state,
|
|
isMerged,
|
|
});
|
|
},
|
|
);
|
|
|
|
it("shows the PR after an agent renames the branch directly with git", async () => {
|
|
execFileSync("git", ["remote", "add", "origin", "https://github.com/acme/repo.git"], {
|
|
cwd: repoDir,
|
|
});
|
|
execFileSync("git", ["branch", "placeholder"], { cwd: repoDir });
|
|
const workspaceDir = join(paseoHome, "worktrees", "repo", "renamed-by-agent");
|
|
mkdirSync(join(paseoHome, "worktrees", "repo"), { recursive: true });
|
|
execFileSync("git", ["worktree", "add", workspaceDir, "placeholder"], { cwd: repoDir });
|
|
await writePaseoWorktreeMetadata(workspaceDir, {
|
|
baseRefName: "main",
|
|
changeRequestLookupTarget: {
|
|
headRef: "placeholder",
|
|
localBranchName: "placeholder",
|
|
},
|
|
});
|
|
|
|
execFileSync("git", ["branch", "-m", "agent-chosen-name"], { cwd: workspaceDir });
|
|
const requestedTargets: RequestedPullRequestTarget[] = [];
|
|
const facts = await getCheckoutSnapshotFacts(workspaceDir, { paseoHome });
|
|
const result = await getPullRequestStatus(
|
|
workspaceDir,
|
|
createGitHubServiceRecordingPullRequestTargets({ requestedTargets }),
|
|
{ force: true, reason: "agent-renamed-branch-pr" },
|
|
{ paseoHome, facts },
|
|
);
|
|
|
|
expect(requestedTargets).toEqual([expect.objectContaining({ headRef: "agent-chosen-name" })]);
|
|
expect(result.status).toMatchObject({
|
|
headRefName: "agent-chosen-name",
|
|
state: "open",
|
|
isMerged: false,
|
|
});
|
|
});
|
|
|
|
it("does not replace a managed branch pin from shared push config", async () => {
|
|
execFileSync("git", ["branch", "feature/pinned"], { cwd: repoDir });
|
|
execFileSync("git", ["remote", "add", "origin", "https://github.com/acme/repo.git"], {
|
|
cwd: repoDir,
|
|
});
|
|
execFileSync("git", ["remote", "add", "fork", "https://github.com/other/repo.git"], {
|
|
cwd: repoDir,
|
|
});
|
|
const workspaceDir = join(paseoHome, "worktrees", "repo", "pinned-worktree");
|
|
mkdirSync(join(paseoHome, "worktrees", "repo"), { recursive: true });
|
|
execFileSync("git", ["worktree", "add", workspaceDir, "feature/pinned"], { cwd: repoDir });
|
|
await writePaseoWorktreeMetadata(workspaceDir, {
|
|
baseRefName: "main",
|
|
changeRequestLookupTarget: {
|
|
headRef: "feature/pinned",
|
|
localBranchName: "feature/pinned",
|
|
},
|
|
});
|
|
execFileSync("git", ["config", "branch.feature/pinned.pushRemote", "fork"], {
|
|
cwd: repoDir,
|
|
});
|
|
execFileSync("git", ["config", "remote.fork.push", "HEAD:refs/heads/unrelated"], {
|
|
cwd: repoDir,
|
|
});
|
|
|
|
expect(await readPullRequestLookupTargetFromFacts(workspaceDir, paseoHome)).toMatchObject({
|
|
headRef: "feature/pinned",
|
|
});
|
|
});
|
|
|
|
it("uses the checked-out branch when a managed worktree has no metadata", async () => {
|
|
execFileSync("git", ["branch", "feature/unpinned"], { cwd: repoDir });
|
|
const workspaceDir = join(paseoHome, "worktrees", "repo", "unpinned-worktree");
|
|
mkdirSync(join(paseoHome, "worktrees", "repo"), { recursive: true });
|
|
execFileSync("git", ["worktree", "add", workspaceDir, "feature/unpinned"], {
|
|
cwd: repoDir,
|
|
});
|
|
|
|
expect(await readPullRequestLookupTargetFromFacts(workspaceDir, paseoHome)).toMatchObject({
|
|
headRef: "feature/unpinned",
|
|
});
|
|
});
|
|
|
|
it("uses the checked-out branch instead of ambiguous legacy PR metadata", async () => {
|
|
execFileSync("git", ["branch", "contributor/old-change-1"], { cwd: repoDir });
|
|
const workspaceDir = join(paseoHome, "worktrees", "repo", "legacy-pr-worktree");
|
|
mkdirSync(join(paseoHome, "worktrees", "repo"), { recursive: true });
|
|
execFileSync("git", ["worktree", "add", workspaceDir, "contributor/old-change-1"], {
|
|
cwd: repoDir,
|
|
});
|
|
await writePaseoWorktreeMetadata(workspaceDir, {
|
|
baseRefName: "main",
|
|
changeRequestLookupTarget: {
|
|
headRef: "old-change",
|
|
headRepositoryOwner: "contributor",
|
|
changeRequestNumber: 41,
|
|
},
|
|
});
|
|
|
|
const lookupTarget = await readPullRequestLookupTargetFromFacts(workspaceDir, paseoHome);
|
|
|
|
expect(lookupTarget).toMatchObject({ headRef: "contributor/old-change-1" });
|
|
});
|
|
|
|
it("does not apply a legacy fork hint to an ownerless branch with the same head", async () => {
|
|
execFileSync("git", ["branch", "contributor/old-change"], { cwd: repoDir });
|
|
execFileSync("git", ["branch", "old-change"], { cwd: repoDir });
|
|
const workspaceDir = join(paseoHome, "worktrees", "repo", "legacy-fork-worktree");
|
|
mkdirSync(join(paseoHome, "worktrees", "repo"), { recursive: true });
|
|
execFileSync("git", ["worktree", "add", workspaceDir, "contributor/old-change"], {
|
|
cwd: repoDir,
|
|
});
|
|
await writePaseoWorktreeMetadata(workspaceDir, {
|
|
baseRefName: "main",
|
|
changeRequestLookupTarget: {
|
|
headRef: "old-change",
|
|
headRepositoryOwner: "contributor",
|
|
changeRequestNumber: 41,
|
|
},
|
|
});
|
|
const requestedTargets: RequestedPullRequestTarget[] = [];
|
|
const forge = createGitHubServiceRecordingPullRequestTargets({ requestedTargets });
|
|
|
|
const forkFacts = await getCheckoutSnapshotFacts(workspaceDir, { paseoHome });
|
|
await getPullRequestStatus(
|
|
workspaceDir,
|
|
forge,
|
|
{ force: true, reason: "legacy-fork-branch" },
|
|
{ paseoHome, facts: forkFacts },
|
|
);
|
|
expect(requestedTargets.at(-1)).toMatchObject({
|
|
headRef: "old-change",
|
|
headRepositoryOwner: "contributor",
|
|
});
|
|
|
|
execFileSync("git", ["checkout", "old-change"], { cwd: workspaceDir });
|
|
const ownerlessFacts = await getCheckoutSnapshotFacts(workspaceDir, { paseoHome });
|
|
await getPullRequestStatus(
|
|
workspaceDir,
|
|
forge,
|
|
{ force: true, reason: "ownerless-same-head" },
|
|
{ paseoHome, facts: ownerlessFacts },
|
|
);
|
|
|
|
expect(requestedTargets).toEqual([
|
|
expect.objectContaining({
|
|
headRef: "old-change",
|
|
headRepositoryOwner: "contributor",
|
|
}),
|
|
expect.objectContaining({ headRef: "old-change" }),
|
|
]);
|
|
expect(requestedTargets[1]).not.toHaveProperty("headRepositoryOwner");
|
|
});
|
|
|
|
it("recognizes a normalized GitHub owner branch from legacy Enterprise metadata", async () => {
|
|
execFileSync("git", ["remote", "add", "origin", "git@github.acme.internal:base/repo.git"], {
|
|
cwd: repoDir,
|
|
});
|
|
execFileSync(
|
|
"git",
|
|
["remote", "add", "enterprise-fork", "git@github.acme.internal:MixedOwner/repo.git"],
|
|
{
|
|
cwd: repoDir,
|
|
},
|
|
);
|
|
execFileSync("git", ["branch", "mixedowner/old-change"], { cwd: repoDir });
|
|
execFileSync("git", ["config", "branch.mixedowner/old-change.remote", "enterprise-fork"], {
|
|
cwd: repoDir,
|
|
});
|
|
execFileSync("git", ["config", "branch.mixedowner/old-change.merge", "refs/heads/old-change"], {
|
|
cwd: repoDir,
|
|
});
|
|
const workspaceDir = join(paseoHome, "worktrees", "repo", "legacy-enterprise-worktree");
|
|
mkdirSync(join(paseoHome, "worktrees", "repo"), { recursive: true });
|
|
execFileSync("git", ["worktree", "add", workspaceDir, "mixedowner/old-change"], {
|
|
cwd: repoDir,
|
|
});
|
|
await writePaseoWorktreeMetadata(workspaceDir, {
|
|
baseRefName: "main",
|
|
changeRequestLookupTarget: {
|
|
headRef: "old-change",
|
|
headRepositoryOwner: "MixedOwner",
|
|
changeRequestNumber: 41,
|
|
},
|
|
});
|
|
const requestedTargets: RequestedPullRequestTarget[] = [];
|
|
const forge = createGitHubServiceRecordingPullRequestTargets({ requestedTargets });
|
|
|
|
const facts = await getCheckoutSnapshotFacts(workspaceDir, { paseoHome });
|
|
await getPullRequestStatus(
|
|
workspaceDir,
|
|
forge,
|
|
{ force: true, reason: "legacy-enterprise-owner" },
|
|
{ paseoHome, facts },
|
|
);
|
|
|
|
expect(requestedTargets).toEqual([
|
|
expect.objectContaining({ headRef: "old-change", headRepositoryOwner: "MixedOwner" }),
|
|
]);
|
|
|
|
execFileSync(
|
|
"git",
|
|
["remote", "add", "replacement-fork", "git@github.acme.internal:OtherOwner/repo.git"],
|
|
{ cwd: repoDir },
|
|
);
|
|
execFileSync("git", ["config", "branch.mixedowner/old-change.remote", "replacement-fork"], {
|
|
cwd: repoDir,
|
|
});
|
|
const repointedFacts = await getCheckoutSnapshotFacts(workspaceDir, { paseoHome });
|
|
await getPullRequestStatus(
|
|
workspaceDir,
|
|
forge,
|
|
{ force: true, reason: "repointed-enterprise-owner" },
|
|
{ paseoHome, facts: repointedFacts },
|
|
);
|
|
|
|
expect(requestedTargets.at(-1)).toMatchObject({
|
|
headRef: "old-change",
|
|
headRepositoryOwner: "MixedOwner",
|
|
});
|
|
});
|
|
|
|
it("keeps a ref-only change request across rename and follows a later branch switch", async () => {
|
|
execFileSync("git", ["branch", "feature/gitlab-mr"], { cwd: repoDir });
|
|
const workspaceDir = join(paseoHome, "worktrees", "repo", "gitlab-mr-worktree");
|
|
mkdirSync(join(paseoHome, "worktrees", "repo"), { recursive: true });
|
|
execFileSync("git", ["worktree", "add", workspaceDir, "feature/gitlab-mr"], {
|
|
cwd: repoDir,
|
|
});
|
|
await writePaseoWorktreeMetadata(workspaceDir, {
|
|
baseRefName: "main",
|
|
changeRequestLookupTarget: {
|
|
headRef: "feature/gitlab-mr",
|
|
changeRequestNumber: 14,
|
|
localBranchName: "feature/gitlab-mr",
|
|
},
|
|
});
|
|
const workspaceCwd = join(workspaceDir, "packages", "service");
|
|
mkdirSync(workspaceCwd, { recursive: true });
|
|
const requestedTargets: RequestedPullRequestTarget[] = [];
|
|
const forge = createGitHubServiceRecordingPullRequestTargets({ requestedTargets });
|
|
|
|
await renameCurrentBranch(workspaceCwd, "feature/renamed");
|
|
expect(
|
|
(await readPaseoWorktreeMetadata(workspaceDir))?.changeRequestLookupTarget,
|
|
).toMatchObject({
|
|
localBranchName: "feature/renamed",
|
|
});
|
|
const renamedFacts = await getCheckoutSnapshotFacts(workspaceDir, { paseoHome });
|
|
const renamedStatus = await getPullRequestStatus(
|
|
workspaceDir,
|
|
forge,
|
|
{ force: true, reason: "renamed-change-request" },
|
|
{ paseoHome, facts: renamedFacts },
|
|
);
|
|
|
|
expect(requestedTargets).toEqual([expect.objectContaining({ headRef: "feature/gitlab-mr" })]);
|
|
expect(renamedStatus.status?.headRefName).toBe("feature/gitlab-mr");
|
|
|
|
execFileSync("git", ["checkout", "-b", "other-branch"], { cwd: workspaceDir });
|
|
const switchedFacts = await getCheckoutSnapshotFacts(workspaceDir, { paseoHome });
|
|
await getPullRequestStatus(
|
|
workspaceDir,
|
|
forge,
|
|
{ force: true, reason: "switched-after-rename" },
|
|
{ paseoHome, facts: switchedFacts },
|
|
);
|
|
|
|
expect(requestedTargets).toEqual([
|
|
expect.objectContaining({ headRef: "feature/gitlab-mr" }),
|
|
expect.objectContaining({ headRef: "other-branch" }),
|
|
]);
|
|
});
|
|
|
|
it("moves a managed branch identity pin when its branch is renamed", async () => {
|
|
execFileSync("git", ["branch", "feature/placeholder"], { cwd: repoDir });
|
|
const workspaceDir = join(paseoHome, "worktrees", "repo", "renamed-worktree");
|
|
mkdirSync(join(paseoHome, "worktrees", "repo"), { recursive: true });
|
|
execFileSync("git", ["worktree", "add", workspaceDir, "feature/placeholder"], {
|
|
cwd: repoDir,
|
|
});
|
|
await writePaseoWorktreeMetadata(workspaceDir, {
|
|
baseRefName: "main",
|
|
changeRequestLookupTarget: {
|
|
headRef: "feature/placeholder",
|
|
localBranchName: "feature/placeholder",
|
|
},
|
|
});
|
|
|
|
await renameCurrentBranch(workspaceDir, "feature/generated");
|
|
|
|
expect((await readPaseoWorktreeMetadata(workspaceDir))?.changeRequestLookupTarget).toEqual({
|
|
headRef: "feature/generated",
|
|
localBranchName: "feature/generated",
|
|
});
|
|
const facts = await getCheckoutSnapshotFacts(workspaceDir, { paseoHome });
|
|
expect(facts.isGit && facts.pullRequestLookupTarget).toMatchObject({
|
|
headRef: "feature/generated",
|
|
});
|
|
});
|
|
|
|
it("keeps fork identity when the local and tracked branch names match", async () => {
|
|
execFileSync("git", ["remote", "add", "origin", "https://github.com/getpaseo/paseo.git"], {
|
|
cwd: repoDir,
|
|
});
|
|
execFileSync("git", ["remote", "add", "contributor", "git@github.com:contributor/paseo.git"], {
|
|
cwd: repoDir,
|
|
});
|
|
execFileSync("git", ["checkout", "-b", "topic"], { cwd: repoDir });
|
|
execFileSync("git", ["config", "branch.topic.remote", "contributor"], { cwd: repoDir });
|
|
execFileSync("git", ["config", "branch.topic.merge", "refs/heads/topic"], { cwd: repoDir });
|
|
|
|
const lookupTarget = await readPullRequestLookupTargetFromFacts(repoDir, paseoHome);
|
|
|
|
expect(lookupTarget).toMatchObject({
|
|
headRef: "topic",
|
|
headRepositoryOwner: "contributor",
|
|
});
|
|
});
|
|
|
|
it.each([
|
|
"git@github.com:contributor/paseo.git",
|
|
"https://github.com/contributor/paseo.git",
|
|
"ssh://git@github.com/contributor/paseo.git",
|
|
])("preserves fork PR identity with branch.remote=%s", async (branchRemote) => {
|
|
execFileSync("git", ["remote", "add", "origin", "git@github.com:getpaseo/paseo.git"], {
|
|
cwd: repoDir,
|
|
});
|
|
execFileSync("git", ["checkout", "-b", "topic"], { cwd: repoDir });
|
|
execFileSync("git", ["config", "branch.topic.remote", branchRemote], {
|
|
cwd: repoDir,
|
|
});
|
|
execFileSync("git", ["config", "branch.topic.pushRemote", branchRemote], {
|
|
cwd: repoDir,
|
|
});
|
|
execFileSync("git", ["config", "branch.topic.merge", "refs/heads/topic"], {
|
|
cwd: repoDir,
|
|
});
|
|
const headSha = execFileSync("git", ["rev-parse", "HEAD"], {
|
|
cwd: repoDir,
|
|
encoding: "utf8",
|
|
}).trim();
|
|
|
|
expect(await readPullRequestLookupTargetFromFacts(repoDir, paseoHome)).toEqual({
|
|
headRef: "topic",
|
|
headRepositoryOwner: "contributor",
|
|
headSha,
|
|
});
|
|
});
|
|
|
|
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).toMatchObject({ headRef: "refactor/workspace-scripts" });
|
|
expect(lookupTarget?.headSha).toMatch(/^[0-9a-f]{40}$/);
|
|
});
|
|
|
|
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).toMatchObject({ headRef: "main", headRepositoryOwner: "chethanuk" });
|
|
expect(lookupTarget?.headSha).toMatch(/^[0-9a-f]{40}$/);
|
|
});
|
|
|
|
it("uses the configured push remote for fork PR lookup when upstream is absent", 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.pushRemote", "paseo-pr-345"], {
|
|
cwd: repoDir,
|
|
});
|
|
execFileSync("git", ["config", "remote.paseo-pr-345.push", "HEAD:refs/heads/main"], {
|
|
cwd: repoDir,
|
|
});
|
|
const requestedTargets: RequestedPullRequestTarget[] = [];
|
|
const github = createGitHubServiceRecordingPullRequestTargets({ requestedTargets });
|
|
|
|
const factsTarget = await readPullRequestLookupTargetFromFacts(repoDir, paseoHome);
|
|
await getPullRequestStatus(
|
|
repoDir,
|
|
github,
|
|
{ force: true, reason: "push-remote-pr-lookup" },
|
|
{ paseoHome },
|
|
);
|
|
|
|
expect(getBranchUpstream(repoDir)).toBeNull();
|
|
expect(factsTarget).toMatchObject({ headRef: "main", headRepositoryOwner: "chethanuk" });
|
|
expect(factsTarget?.headSha).toMatch(/^[0-9a-f]{40}$/);
|
|
expect(requestedTargets).toEqual([
|
|
expect.objectContaining({ 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).toMatchObject({ headRef: "tender-parrot" });
|
|
expect(lookupTarget?.headSha).toMatch(/^[0-9a-f]{40}$/);
|
|
});
|
|
|
|
it("keeps the local branch lookup when a fork tracks the upstream base branch", async () => {
|
|
execFileSync("git", ["checkout", "-b", "local-feature"], { cwd: repoDir });
|
|
execFileSync("git", ["remote", "add", "origin", "git@github.com:contributor/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/main"], {
|
|
cwd: repoDir,
|
|
});
|
|
|
|
const lookupTarget = await readPullRequestLookupTargetFromFacts(repoDir, paseoHome);
|
|
|
|
expect(lookupTarget).toMatchObject({ headRef: "local-feature" });
|
|
expect(lookupTarget).not.toHaveProperty("headRepositoryOwner");
|
|
});
|
|
|
|
it("treats differently cased Enterprise remotes as the same repository", async () => {
|
|
execFileSync("git", ["checkout", "-b", "local-feature"], { cwd: repoDir });
|
|
execFileSync("git", ["remote", "add", "origin", "git@github.acme.internal:Acme/Repo.git"], {
|
|
cwd: repoDir,
|
|
});
|
|
execFileSync(
|
|
"git",
|
|
["remote", "add", "enterprise-alias", "git@github.acme.internal:acme/repo.git"],
|
|
{ cwd: repoDir },
|
|
);
|
|
execFileSync("git", ["config", "branch.local-feature.remote", "enterprise-alias"], {
|
|
cwd: repoDir,
|
|
});
|
|
execFileSync("git", ["config", "branch.local-feature.merge", "refs/heads/main"], {
|
|
cwd: repoDir,
|
|
});
|
|
|
|
const lookupTarget = await readPullRequestLookupTargetFromFacts(repoDir, paseoHome);
|
|
|
|
expect(lookupTarget).toMatchObject({ headRef: "local-feature" });
|
|
expect(lookupTarget).not.toHaveProperty("headRepositoryOwner");
|
|
});
|
|
|
|
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([
|
|
expect.objectContaining({ 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("does not reuse a PR status cache entry after HEAD changes on the same branch", async () => {
|
|
execFileSync("git", ["checkout", "-b", "feature"], { cwd: repoDir });
|
|
execFileSync("git", ["remote", "add", "origin", "https://github.com/getpaseo/paseo.git"], {
|
|
cwd: repoDir,
|
|
});
|
|
|
|
const requestedShas: string[] = [];
|
|
const github = createGitHubServiceForStatus(null);
|
|
github.getCurrentPullRequestStatus = async (options) => {
|
|
if (options.headSha) requestedShas.push(options.headSha);
|
|
return createPullRequestStatus({
|
|
url: `https://github.com/getpaseo/paseo/pull/${requestedShas.length}`,
|
|
});
|
|
};
|
|
|
|
const first = await getPullRequestStatus(repoDir, github);
|
|
writeFileSync(join(repoDir, "next.txt"), "next\n");
|
|
execFileSync("git", ["add", "next.txt"], { cwd: repoDir });
|
|
execFileSync("git", ["commit", "-m", "next commit"], { cwd: repoDir });
|
|
const second = await getPullRequestStatus(repoDir, github);
|
|
|
|
expect(first.status?.url).toContain("/pull/1");
|
|
expect(second.status?.url).toContain("/pull/2");
|
|
expect(requestedShas).toHaveLength(2);
|
|
expect(requestedShas[0]).not.toBe(requestedShas[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("keeps stale PR status when a Gitea-family refresh hits a transient command error", async () => {
|
|
execFileSync("git", ["checkout", "-b", "feature"], { cwd: repoDir });
|
|
execFileSync("git", ["remote", "add", "origin", "https://gitea.example.com/acme/repo.git"], {
|
|
cwd: repoDir,
|
|
});
|
|
|
|
__setPullRequestStatusCacheTtlForTests(50);
|
|
try {
|
|
let callCount = 0;
|
|
const service = createGitHubServiceForStatus(null);
|
|
service.getCurrentPullRequestStatus = async () => {
|
|
callCount += 1;
|
|
if (callCount === 1) {
|
|
return createPullRequestStatus({ url: "https://gitea.example.com/acme/repo/pulls/7" });
|
|
}
|
|
throw new TeaCommandError({
|
|
args: ["pr", "list"],
|
|
cwd: repoDir,
|
|
exitCode: 1,
|
|
stderr: "request timed out",
|
|
});
|
|
};
|
|
|
|
const fresh = await getPullRequestStatus(repoDir, service);
|
|
await sleep(80);
|
|
const stale = await getPullRequestStatus(repoDir, service);
|
|
|
|
expect(stale).toEqual(fresh);
|
|
expect(stale.status?.url).toContain("/pulls/7");
|
|
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,
|
|
authState: "authenticated",
|
|
status: null,
|
|
});
|
|
expect(callCount).toBe(2);
|
|
} finally {
|
|
__resetPullRequestStatusCacheForTests();
|
|
}
|
|
});
|
|
|
|
it("maps missing Gitea CLI PR status lookups to cli_missing auth state", async () => {
|
|
execFileSync("git", ["checkout", "-b", "feature"], { cwd: repoDir });
|
|
execFileSync("git", ["remote", "add", "origin", "https://gitea.example.com/acme/repo.git"], {
|
|
cwd: repoDir,
|
|
});
|
|
|
|
const service = createGitHubServiceForStatus(null);
|
|
service.getCurrentPullRequestStatus = async () => {
|
|
throw new TeaCliMissingError();
|
|
};
|
|
|
|
await expect(getPullRequestStatus(repoDir, service)).resolves.toEqual({
|
|
status: null,
|
|
authState: "cli_missing",
|
|
githubFeaturesEnabled: false,
|
|
});
|
|
});
|
|
|
|
it("maps Gitea authentication PR status failures to unauthenticated auth state", async () => {
|
|
execFileSync("git", ["checkout", "-b", "feature"], { cwd: repoDir });
|
|
execFileSync("git", ["remote", "add", "origin", "https://gitea.example.com/acme/repo.git"], {
|
|
cwd: repoDir,
|
|
});
|
|
|
|
const service = createGitHubServiceForStatus(null);
|
|
service.getCurrentPullRequestStatus = async () => {
|
|
throw new TeaAuthenticationError({ stderr: "401 unauthorized" });
|
|
};
|
|
|
|
await expect(getPullRequestStatus(repoDir, service)).resolves.toEqual({
|
|
status: null,
|
|
authState: "unauthenticated",
|
|
githubFeaturesEnabled: false,
|
|
});
|
|
});
|
|
|
|
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("honors commit signing configuration for squash merges", 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,
|
|
});
|
|
execFileSync("git", ["config", "commit.gpgsign", "true"], { cwd: repoDir });
|
|
execFileSync("git", ["config", "gpg.format", "openpgp"], { cwd: repoDir });
|
|
execFileSync("git", ["config", "gpg.program", process.execPath], { cwd: repoDir });
|
|
|
|
await expect(mergeToBase(repoDir, { baseRef: "main", mode: "squash" })).rejects.toThrow(
|
|
"failed to sign the data",
|
|
);
|
|
});
|
|
|
|
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("names both refs when a requested base ref does not match the stored one", async () => {
|
|
const worktree = await createLegacyWorktreeForTest({
|
|
branchName: "mismatch-feature",
|
|
cwd: repoDir,
|
|
baseBranch: "main",
|
|
worktreeSlug: "mismatch-feature",
|
|
paseoHome,
|
|
});
|
|
|
|
await expect(
|
|
getCheckoutDiff(worktree.worktreePath, { mode: "base", baseRef: "other" }, { paseoHome }),
|
|
).rejects.toThrow("Base ref mismatch: stored refs/heads/main, requested other");
|
|
});
|
|
|
|
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 = await 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 = await 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);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("discardChanges", () => {
|
|
it("discards staged and unstaged modifications, deletions, and untracked files", async () => {
|
|
const { tempDir, repoDir } = initRepo();
|
|
try {
|
|
writeFileSync(join(repoDir, "file.txt"), "changed\n");
|
|
writeFileSync(join(repoDir, "staged.txt"), "staged\n");
|
|
execFileSync("git", ["add", "staged.txt"], { cwd: repoDir });
|
|
mkdirSync(join(repoDir, "junk"), { recursive: true });
|
|
writeFileSync(join(repoDir, "junk", "scratch.txt"), "scratch\n");
|
|
|
|
await discardChanges(repoDir, ["file.txt", "staged.txt", "junk"]);
|
|
|
|
expect(readTextFile(join(repoDir, "file.txt"))).toBe("hello\n");
|
|
expect(existsSync(join(repoDir, "staged.txt"))).toBe(false);
|
|
expect(existsSync(join(repoDir, "junk"))).toBe(false);
|
|
const status = execFileSync("git", ["status", "--porcelain"], { cwd: repoDir })
|
|
.toString()
|
|
.trim();
|
|
expect(status).toBe("");
|
|
} finally {
|
|
rmSync(tempDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("restores both sides of a staged rename", async () => {
|
|
const { tempDir, repoDir } = initRepo();
|
|
try {
|
|
execFileSync("git", ["mv", "file.txt", "renamed.txt"], { cwd: repoDir });
|
|
|
|
await discardChanges(repoDir, ["file.txt", "renamed.txt"]);
|
|
|
|
expect(readTextFile(join(repoDir, "file.txt"))).toBe("hello\n");
|
|
expect(existsSync(join(repoDir, "renamed.txt"))).toBe(false);
|
|
expect(execFileSync("git", ["status", "--porcelain"], { cwd: repoDir }).toString()).toBe("");
|
|
} finally {
|
|
rmSync(tempDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("unstages and removes files in a repository with an unborn HEAD", async () => {
|
|
const tempDir = realpathSync.native(mkdtempSync(join(tmpdir(), "checkout-git-unborn-")));
|
|
const repoDir = join(tempDir, "repo");
|
|
mkdirSync(repoDir, { recursive: true });
|
|
try {
|
|
execFileSync("git", ["init", "-b", "main"], { cwd: repoDir });
|
|
writeFileSync(join(repoDir, "new.txt"), "new\n");
|
|
execFileSync("git", ["add", "new.txt"], { cwd: repoDir });
|
|
|
|
await discardChanges(repoDir, ["new.txt"]);
|
|
|
|
expect(existsSync(join(repoDir, "new.txt"))).toBe(false);
|
|
expect(execFileSync("git", ["status", "--porcelain"], { cwd: repoDir }).toString()).toBe("");
|
|
} finally {
|
|
rmSync(tempDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("discards a nested folder pathspec without touching sibling changes", async () => {
|
|
const { tempDir, repoDir } = initRepo();
|
|
try {
|
|
mkdirSync(join(repoDir, "nested"));
|
|
writeFileSync(join(repoDir, "nested", "tracked.txt"), "original\n");
|
|
execFileSync("git", ["add", "nested/tracked.txt"], { cwd: repoDir });
|
|
execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "add nested"], {
|
|
cwd: repoDir,
|
|
});
|
|
writeFileSync(join(repoDir, "nested", "tracked.txt"), "changed\n");
|
|
writeFileSync(join(repoDir, "nested", "untracked.txt"), "remove\n");
|
|
writeFileSync(join(repoDir, "outside.txt"), "keep\n");
|
|
|
|
await discardChanges(repoDir, ["nested"]);
|
|
|
|
expect(readTextFile(join(repoDir, "nested", "tracked.txt"))).toBe("original\n");
|
|
expect(existsSync(join(repoDir, "nested", "untracked.txt"))).toBe(false);
|
|
expect(readFileSync(join(repoDir, "outside.txt"), "utf8")).toBe("keep\n");
|
|
expect(execFileSync("git", ["status", "--porcelain"], { cwd: repoDir }).toString()).toBe(
|
|
"?? outside.txt\n",
|
|
);
|
|
} finally {
|
|
rmSync(tempDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("restores a staged deletion", async () => {
|
|
const { tempDir, repoDir } = initRepo();
|
|
try {
|
|
execFileSync("git", ["rm", "file.txt"], { cwd: repoDir });
|
|
await discardChanges(repoDir, ["file.txt"]);
|
|
expect(readTextFile(join(repoDir, "file.txt"))).toBe("hello\n");
|
|
} finally {
|
|
rmSync(tempDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("leaves files outside the given pathspecs untouched", async () => {
|
|
const { tempDir, repoDir } = initRepo();
|
|
try {
|
|
writeFileSync(join(repoDir, "file.txt"), "changed\n");
|
|
writeFileSync(join(repoDir, "other.txt"), "keep\n");
|
|
await discardChanges(repoDir, ["file.txt"]);
|
|
expect(readTextFile(join(repoDir, "file.txt"))).toBe("hello\n");
|
|
expect(readFileSync(join(repoDir, "other.txt"), "utf8")).toBe("keep\n");
|
|
} finally {
|
|
rmSync(tempDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("treats requested filenames as literal pathspecs", async () => {
|
|
const { tempDir, repoDir } = initRepo();
|
|
try {
|
|
writeFileSync(join(repoDir, "foo[ab].txt"), "literal original\n");
|
|
writeFileSync(join(repoDir, "fooa.txt"), "sibling original\n");
|
|
execFileSync("git", ["add", "foo[ab].txt", "fooa.txt"], { cwd: repoDir });
|
|
execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "add pathspec files"], {
|
|
cwd: repoDir,
|
|
});
|
|
writeFileSync(join(repoDir, "foo[ab].txt"), "literal changed\n");
|
|
writeFileSync(join(repoDir, "fooa.txt"), "sibling changed\n");
|
|
|
|
await discardChanges(repoDir, ["foo[ab].txt"]);
|
|
|
|
expect(readTextFile(join(repoDir, "foo[ab].txt"))).toBe("literal original\n");
|
|
expect(readTextFile(join(repoDir, "fooa.txt"))).toBe("sibling changed\n");
|
|
} finally {
|
|
rmSync(tempDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
});
|