The bulk-history cases spawned about 80 Git processes, which could exhaust the per-test timeout on loaded Windows runners. Import each linear history in one Git process while preserving the real repository behavior under test.
325 lines
12 KiB
TypeScript
325 lines
12 KiB
TypeScript
import { execFileSync } from "child_process";
|
|
import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "fs";
|
|
import { tmpdir } from "os";
|
|
import { join } from "path";
|
|
import { afterEach, describe, expect, it } from "vitest";
|
|
import { listCheckoutCommits } from "./checkout-git.js";
|
|
import { writePaseoWorktreeMetadata } from "./worktree-metadata.js";
|
|
|
|
const tempDirs: string[] = [];
|
|
|
|
afterEach(() => {
|
|
for (const dir of tempDirs.splice(0)) {
|
|
rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
function makeTempDir(): string {
|
|
const dir = realpathSync.native(mkdtempSync(join(tmpdir(), "checkout-commits-test-")));
|
|
tempDirs.push(dir);
|
|
return dir;
|
|
}
|
|
|
|
function git(args: string[], cwd?: string): void {
|
|
execFileSync("git", args, cwd ? { cwd } : {});
|
|
}
|
|
|
|
function commit(repoDir: string, message: string): void {
|
|
git(["-c", "commit.gpgsign=false", "commit", "-m", message], repoDir);
|
|
}
|
|
|
|
function commitFile(repoDir: string, name: string, content: string, message: string): void {
|
|
writeFileSync(join(repoDir, name), content);
|
|
git(["add", "."], repoDir);
|
|
commit(repoDir, message);
|
|
}
|
|
|
|
function importLinearHistory({
|
|
repoDir,
|
|
branch,
|
|
file,
|
|
subject,
|
|
count,
|
|
}: {
|
|
repoDir: string;
|
|
branch: string;
|
|
file: string;
|
|
subject: string;
|
|
count: number;
|
|
}): void {
|
|
const startingSha = execFileSync("git", ["rev-parse", "HEAD"], {
|
|
cwd: repoDir,
|
|
encoding: "utf8",
|
|
}).trim();
|
|
const commands: string[] = [];
|
|
let parent = startingSha;
|
|
|
|
for (let index = 1; index <= count; index += 1) {
|
|
const blobMark = index * 2 - 1;
|
|
const commitMark = index * 2;
|
|
const content = `${index}\n`;
|
|
const message = `${subject} ${index}`;
|
|
commands.push(
|
|
`blob\nmark :${blobMark}\ndata ${Buffer.byteLength(content)}\n${content}`,
|
|
`commit refs/heads/${branch}\nmark :${commitMark}\ncommitter Test User <test@test.com> ${1_700_000_000 + index} +0000\ndata ${Buffer.byteLength(message)}\n${message}\nfrom ${parent}\nM 100644 :${blobMark} ${file}\n\n`,
|
|
);
|
|
parent = `:${commitMark}`;
|
|
}
|
|
|
|
execFileSync("git", ["fast-import", "--quiet"], {
|
|
cwd: repoDir,
|
|
input: commands.join(""),
|
|
});
|
|
git(["reset", "--hard", branch], repoDir);
|
|
}
|
|
|
|
function initRepoOnMain(): { repoDir: string; tempDir: string } {
|
|
const tempDir = makeTempDir();
|
|
const repoDir = join(tempDir, "repo");
|
|
mkdirSync(repoDir, { recursive: true });
|
|
git(["init", "-b", "main"], repoDir);
|
|
git(["config", "user.email", "test@test.com"], repoDir);
|
|
git(["config", "user.name", "Test User"], repoDir);
|
|
commitFile(repoDir, "README.md", "base\n", "initial");
|
|
return { repoDir, tempDir };
|
|
}
|
|
|
|
function addBareRemote(repoDir: string, tempDir: string): string {
|
|
const remoteDir = join(tempDir, "remote.git");
|
|
git(["init", "--bare", "-b", "main", remoteDir]);
|
|
git(["remote", "add", "origin", remoteDir], repoDir);
|
|
return remoteDir;
|
|
}
|
|
|
|
describe("listCheckoutCommits", () => {
|
|
it("lists recent commits newest-first with on-remote flags and file stats", async () => {
|
|
const { repoDir, tempDir } = initRepoOnMain();
|
|
git(["checkout", "-b", "feature"], repoDir);
|
|
commitFile(repoDir, "foo.txt", "a\nb\nc\n", "Add foo");
|
|
|
|
// Push feature (containing only commit A) to the remote, then add B locally.
|
|
addBareRemote(repoDir, tempDir);
|
|
git(["push", "-u", "origin", "feature"], repoDir);
|
|
commitFile(repoDir, "bar.txt", "x\n", "Add bar");
|
|
|
|
const { baseRef, commits } = await listCheckoutCommits({ cwd: repoDir });
|
|
|
|
expect(baseRef).toBe("main");
|
|
expect(commits).toHaveLength(3);
|
|
expect(commits[0]?.subject).toBe("Add bar");
|
|
expect(commits[1]?.subject).toBe("Add foo");
|
|
expect(commits[2]?.subject).toBe("initial");
|
|
|
|
expect(commits[0]?.isOnRemote).toBe(false);
|
|
expect(commits[1]?.isOnRemote).toBe(true);
|
|
expect(commits[2]?.isOnRemote).toBe(true);
|
|
expect(commits[0]?.isOnBase).toBe(false);
|
|
expect(commits[1]?.isOnBase).toBe(false);
|
|
expect(commits[2]?.isOnBase).toBe(true);
|
|
|
|
expect(commits[0]?.files).toEqual([
|
|
{ path: "bar.txt", additions: 1, deletions: 0, status: "added" },
|
|
]);
|
|
expect(commits[1]?.files).toEqual([
|
|
{ path: "foo.txt", additions: 3, deletions: 0, status: "added" },
|
|
]);
|
|
|
|
expect(commits[0]?.authorName).toBe("Test User");
|
|
expect(commits[0]?.sha).toHaveLength(40);
|
|
expect((commits[0]?.shortSha.length ?? 0) > 0).toBe(true);
|
|
expect(Number.isNaN(new Date(commits[0]?.authorDate ?? "").getTime())).toBe(false);
|
|
});
|
|
|
|
it("shows recent history on the base branch", async () => {
|
|
const { repoDir } = initRepoOnMain();
|
|
const { baseRef, commits } = await listCheckoutCommits({ cwd: repoDir });
|
|
expect(baseRef).toBeNull();
|
|
expect(commits.map((entry) => entry.subject)).toEqual(["initial"]);
|
|
expect(commits[0]?.isOnBase).toBe(true);
|
|
});
|
|
|
|
it("keeps recent history when the saved base branch no longer exists", async () => {
|
|
const { repoDir, tempDir } = initRepoOnMain();
|
|
const worktreesRoot = join(tempDir, "worktrees");
|
|
const worktreeDir = join(worktreesRoot, "repo-hash", "feature");
|
|
mkdirSync(join(worktreesRoot, "repo-hash"), { recursive: true });
|
|
git(["worktree", "add", "-b", "feature", worktreeDir], repoDir);
|
|
commitFile(worktreeDir, "feature.txt", "feature\n", "Feature work");
|
|
writePaseoWorktreeMetadata(worktreeDir, { baseRefName: "deleted-base" });
|
|
|
|
const { baseRef, commits } = await listCheckoutCommits({
|
|
cwd: worktreeDir,
|
|
context: { worktreesRoot },
|
|
});
|
|
|
|
expect(baseRef).toBe("main");
|
|
expect(commits.map((entry) => entry.subject)).toEqual(["Feature work", "initial"]);
|
|
expect(commits.map((entry) => entry.isOnBase)).toEqual([false, true]);
|
|
});
|
|
|
|
it("marks all commits local-only when there is no remote", async () => {
|
|
const { repoDir } = initRepoOnMain();
|
|
git(["checkout", "-b", "feature"], repoDir);
|
|
commitFile(repoDir, "foo.txt", "a\n", "Add foo");
|
|
commitFile(repoDir, "bar.txt", "b\n", "Add bar");
|
|
|
|
const { baseRef, commits } = await listCheckoutCommits({ cwd: repoDir });
|
|
|
|
expect(baseRef).toBe("main");
|
|
expect(commits).toHaveLength(3);
|
|
expect(commits.every((c) => c.isOnRemote === false)).toBe(true);
|
|
});
|
|
|
|
it("recognizes base history on a remote before the feature branch is pushed", async () => {
|
|
const { repoDir, tempDir } = initRepoOnMain();
|
|
addBareRemote(repoDir, tempDir);
|
|
git(["push", "-u", "origin", "main"], repoDir);
|
|
git(["checkout", "-b", "feature"], repoDir);
|
|
commitFile(repoDir, "feature.txt", "local\n", "Local feature");
|
|
|
|
const { commits } = await listCheckoutCommits({ cwd: repoDir });
|
|
|
|
expect(commits.map(({ subject, isOnRemote }) => ({ subject, isOnRemote }))).toEqual([
|
|
{ subject: "Local feature", isOnRemote: false },
|
|
{ subject: "initial", isOnRemote: true },
|
|
]);
|
|
});
|
|
|
|
it("keeps local base commits out of workspace history when the local base is ahead", async () => {
|
|
const { repoDir, tempDir } = initRepoOnMain();
|
|
addBareRemote(repoDir, tempDir);
|
|
git(["push", "-u", "origin", "main"], repoDir);
|
|
commitFile(repoDir, "local-base.txt", "base\n", "Local base work");
|
|
git(["checkout", "-b", "feature"], repoDir);
|
|
commitFile(repoDir, "feature.txt", "feature\n", "Feature work");
|
|
|
|
const { commits } = await listCheckoutCommits({ cwd: repoDir });
|
|
|
|
expect(commits.map(({ subject, isOnBase }) => ({ subject, isOnBase }))).toEqual([
|
|
{ subject: "Feature work", isOnBase: false },
|
|
{ subject: "Local base work", isOnBase: true },
|
|
{ subject: "initial", isOnBase: true },
|
|
]);
|
|
});
|
|
|
|
it("shows every workspace commit followed by at most 10 base commits", async () => {
|
|
const { repoDir } = initRepoOnMain();
|
|
importLinearHistory({
|
|
repoDir,
|
|
branch: "main",
|
|
file: "base-history.txt",
|
|
subject: "Base",
|
|
count: 14,
|
|
});
|
|
git(["checkout", "-b", "feature"], repoDir);
|
|
importLinearHistory({
|
|
repoDir,
|
|
branch: "feature",
|
|
file: "workspace-history.txt",
|
|
subject: "Workspace",
|
|
count: 24,
|
|
});
|
|
|
|
const { commits } = await listCheckoutCommits({ cwd: repoDir });
|
|
|
|
expect(commits).toHaveLength(34);
|
|
expect(commits.every((entry) => entry.isOnRemote === false)).toBe(true);
|
|
expect(commits.slice(0, 24).map((entry) => entry.subject)).toEqual(
|
|
Array.from({ length: 24 }, (_, index) => `Workspace ${24 - index}`),
|
|
);
|
|
expect(commits.slice(0, 24).every((entry) => entry.isOnBase === false)).toBe(true);
|
|
expect(commits.slice(24).map((entry) => entry.subject)).toEqual(
|
|
Array.from({ length: 10 }, (_, index) => `Base ${14 - index}`),
|
|
);
|
|
expect(commits.slice(24).every((entry) => entry.isOnBase === true)).toBe(true);
|
|
});
|
|
|
|
it("starts base context at the fork point when the base branch has advanced", async () => {
|
|
const { repoDir } = initRepoOnMain();
|
|
commitFile(repoDir, "shared.txt", "shared\n", "Shared base");
|
|
git(["checkout", "-b", "feature"], repoDir);
|
|
commitFile(repoDir, "feature.txt", "feature\n", "Feature work");
|
|
git(["checkout", "main"], repoDir);
|
|
commitFile(repoDir, "newer-base.txt", "newer\n", "Newer base");
|
|
git(["checkout", "feature"], repoDir);
|
|
|
|
const { commits } = await listCheckoutCommits({ cwd: repoDir });
|
|
|
|
expect(commits.map(({ subject, isOnBase }) => ({ subject, isOnBase }))).toEqual([
|
|
{ subject: "Feature work", isOnBase: false },
|
|
{ subject: "Shared base", isOnBase: true },
|
|
{ subject: "initial", isOnBase: true },
|
|
]);
|
|
});
|
|
|
|
it("limits base-branch history to 10 commits", async () => {
|
|
const { repoDir } = initRepoOnMain();
|
|
importLinearHistory({
|
|
repoDir,
|
|
branch: "main",
|
|
file: "history.txt",
|
|
subject: "Commit",
|
|
count: 14,
|
|
});
|
|
|
|
const { commits } = await listCheckoutCommits({ cwd: repoDir });
|
|
|
|
expect(commits.map((entry) => entry.subject)).toEqual(
|
|
Array.from({ length: 10 }, (_, index) => `Commit ${14 - index}`),
|
|
);
|
|
expect(commits.every((entry) => entry.isOnBase === true)).toBe(true);
|
|
});
|
|
|
|
it("shows merged branch commits and compares the merge against its first parent", async () => {
|
|
const { repoDir } = initRepoOnMain();
|
|
git(["checkout", "-b", "feature"], repoDir);
|
|
commitFile(repoDir, "feature.txt", "feature\n", "Add feature");
|
|
git(["checkout", "main"], repoDir);
|
|
commitFile(repoDir, "main.txt", "main\n", "Advance main");
|
|
git(["merge", "--no-ff", "feature", "-m", "Merge feature"], repoDir);
|
|
|
|
const { commits } = await listCheckoutCommits({ cwd: repoDir });
|
|
|
|
expect(commits.map((entry) => entry.subject)).toEqual([
|
|
"Merge feature",
|
|
"Advance main",
|
|
"Add feature",
|
|
"initial",
|
|
]);
|
|
expect(commits[0]?.files).toEqual([
|
|
{ path: "feature.txt", additions: 1, deletions: 0, status: "added" },
|
|
]);
|
|
});
|
|
|
|
it("classifies renamed files with status renamed and correct destination path", async () => {
|
|
const { repoDir } = initRepoOnMain();
|
|
git(["checkout", "-b", "feature"], repoDir);
|
|
commitFile(repoDir, "original.txt", "content\n", "Add original");
|
|
git(["mv", "original.txt", "renamed.txt"], repoDir);
|
|
commit(repoDir, "Rename file");
|
|
|
|
const { commits } = await listCheckoutCommits({ cwd: repoDir });
|
|
|
|
expect(commits[0]?.files).toEqual([
|
|
{ path: "renamed.txt", additions: 0, deletions: 0, status: "renamed" },
|
|
]);
|
|
});
|
|
|
|
it("derives status for modified and deleted files", async () => {
|
|
const { repoDir } = initRepoOnMain();
|
|
git(["checkout", "-b", "feature"], repoDir);
|
|
commitFile(repoDir, "README.md", "base\nmore\n", "Edit readme");
|
|
git(["rm", "README.md"], repoDir);
|
|
commit(repoDir, "Delete readme");
|
|
|
|
const { commits } = await listCheckoutCommits({ cwd: repoDir });
|
|
|
|
expect(commits[0]?.files).toEqual([
|
|
{ path: "README.md", additions: 0, deletions: 2, status: "deleted" },
|
|
]);
|
|
expect(commits[1]?.files).toEqual([
|
|
{ path: "README.md", additions: 1, deletions: 0, status: "modified" },
|
|
]);
|
|
});
|
|
});
|