mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-10 02:15:02 +00:00
* feat(protocol): define streamed diff revisions and resolve tag bases * feat(server): stream shared diff revisions with paced file bodies * feat(client): observe streamed diffs with atomic revision updates * feat(app): show the Changes tree while diff bodies stream * fix(server): retain the legacy structured diff payload budget * fix(diff): preserve revision ordering and readable files * fix(diff): select renamed patches and read dangling symlinks * fix(diff): preserve nested checkouts and ambiguous patch paths Only regular files and symlinks are readable untracked entries. Prefer patch metadata to ambiguous unquoted headers so paths containing a prefix delimiter keep their hunks. * fix(diff): preserve BOM text and complete file replacements Decode each string without consuming its leading BOM. File-type replacements emit multiple patch sections for one listed path, so retain all matching hunks. Assert the optional rename metadata in the existing commit-file journey. * fix(diff): highlight symlinks from their stored target text Working tree source context must match the symlink blob emitted by Git rather than the contents reached by following the link. Otherwise highlight runs do not cover the patch line and encoding fails the revision. * fix(diff): tolerate disappearing highlight sources Source context is optional after Git has produced the patch. Missing worktree link metadata must preserve the existing hunk-based highlighting fallback instead of failing the revision. * fix(diff): batch cold reads and refresh logical comparison inputs Acquire bounded pages of tracked patches lazily after Begin while retaining per-file highlighting and awaited frame delivery. Keep legacy preferred-base selection live across revisions and include submodule Git state in worktree cache identity. * fix(diff): distinguish gitlink directories from replacements A raw old gitlink mode also occurs when a submodule becomes a regular file. Query nested Git state only for a current directory, preserving normal file identity for replacements. * fix(diff): invalidate timestamp-preserving file replacements Size and modification time do not identify live file contents after a preserving copy. Include filesystem change time and file identity for tracked and untracked reuse, and verify live revisions agree with fresh reads. * fix(diff): align highlight tokens with normalized patch lines Git can normalize CRLF worktrees while highlighting reads their original line endings. Match tokens to the emitted patch text and trim only a normalization CR before encoding runs; mismatched source tokens are not attached. * fix(diff): refresh equivalent worktree paths across platforms Explicit refreshes must match filesystem-equivalent roots, including Windows path aliases. Use the existing realpath-aware matcher, budget real Git completion waits consistently, and assert optional status metadata in the filesystem commit-file journey. * fix(diff): validate Git inputs and resolve refresh roots Reuse now accounts for effective attributes, configuration, and canonical worktree blobs as well as filesystem state. Explicit refresh resolves repository identity like subscription creation, and callers await that resolution before acknowledging the command. * fix(diff): keep non-Git workspace refresh successful Diff invalidation has no target repository for an ordinary folder. Treat that expected absence as a no-op while preserving other root-resolution errors. * fix(diff): observe scoped Git interpretation settings Classify each metadata root with its worktree and common scopes, retaining both when the directories coincide. Shared attributes affect all checkouts, while config.worktree belongs to the owning checkout. Real subscriptions verify automatic binary and hunk-context refreshes in main and linked worktrees. * fix(diff): observe interpretation inputs for pinned comparisons Immutable commit objects still depend on mutable Git configuration and attributes. Declare those metadata inputs independently of ref, index, and worktree mutability, while keeping unrelated endpoint events out of pinned comparisons. * fix(diff): observe interpretation independently of comparison endpoints Commit comparisons still read worktree attributes. Watch those inputs independently, checking effective Git interpretation before invalidating fixed contents. Retain paths hidden by whitespace filtering so attribute changes can expose them. Forward binary frames through the branch-loading test interceptor.
443 lines
16 KiB
TypeScript
443 lines
16 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 {
|
|
getCommitFileDiff,
|
|
listCheckoutCommits,
|
|
getCheckoutStatus,
|
|
getCheckoutDiff,
|
|
} 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", "--author=Test User <test@test.com>", "-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("keeps fixture authors independent of command-scoped Git configuration", async () => {
|
|
const overrides = {
|
|
GIT_CONFIG_COUNT: "1",
|
|
GIT_CONFIG_KEY_0: "user.name",
|
|
GIT_CONFIG_VALUE_0: "Other User",
|
|
};
|
|
const previous = Object.fromEntries(
|
|
Object.keys(overrides).map((key) => [key, process.env[key]]),
|
|
);
|
|
let repoDir: string;
|
|
try {
|
|
Object.assign(process.env, overrides);
|
|
({ repoDir } = initRepoOnMain());
|
|
} finally {
|
|
for (const [key, value] of Object.entries(previous)) {
|
|
if (value === undefined) delete process.env[key];
|
|
else process.env[key] = value;
|
|
}
|
|
}
|
|
|
|
const { commits } = await listCheckoutCommits({ cwd: repoDir });
|
|
expect(commits[0]?.authorName).toBe("Test User");
|
|
});
|
|
|
|
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");
|
|
await 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.each([
|
|
{
|
|
label: "without edits",
|
|
content: "one\ntwo\nthree\nfour\nfive\n",
|
|
additions: 0,
|
|
deletions: 0,
|
|
},
|
|
{ label: "with edits", content: "one\nTWO\nthree\nfour\nfive\n", additions: 1, deletions: 1 },
|
|
])(
|
|
"opens a file moved up out of a directory $label",
|
|
async ({ content, additions, deletions }) => {
|
|
const { repoDir } = initRepoOnMain();
|
|
git(["checkout", "-b", "feature"], repoDir);
|
|
mkdirSync(join(repoDir, "src", "nested"), { recursive: true });
|
|
commitFile(repoDir, "src/nested/a.txt", "one\ntwo\nthree\nfour\nfive\n", "Add nested file");
|
|
git(["mv", "src/nested/a.txt", "src/a.txt"], repoDir);
|
|
writeFileSync(join(repoDir, "src/a.txt"), content);
|
|
git(["add", "src/a.txt"], repoDir);
|
|
commit(repoDir, "Move file up");
|
|
|
|
const { commits } = await listCheckoutCommits({ cwd: repoDir });
|
|
|
|
const movedCommit = commits[0]!;
|
|
const movedFile = movedCommit.files[0]!;
|
|
expect(movedCommit.files).toEqual([
|
|
{
|
|
path: "src/a.txt",
|
|
additions,
|
|
deletions,
|
|
status: "renamed",
|
|
},
|
|
]);
|
|
expect(
|
|
await getCommitFileDiff({ cwd: repoDir, sha: movedCommit.sha, path: movedFile.path }),
|
|
).toMatchObject({
|
|
path: "src/a.txt",
|
|
isNew: false,
|
|
isDeleted: false,
|
|
additions,
|
|
deletions,
|
|
});
|
|
},
|
|
);
|
|
|
|
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" },
|
|
]);
|
|
});
|
|
});
|
|
|
|
it("compares tag-based worktrees in status, history and legacy diff modes", async () => {
|
|
const { repoDir: mainRepoDir, tempDir } = initRepoOnMain();
|
|
const worktreesRoot = join(tempDir, "worktrees");
|
|
const repoDir = join(worktreesRoot, "repo-hash", "tag-feature");
|
|
mkdirSync(join(worktreesRoot, "repo-hash"), { recursive: true });
|
|
const context = { worktreesRoot };
|
|
git(["tag", "v1"], mainRepoDir);
|
|
git(["worktree", "add", "-b", "tag-feature", repoDir, "v1"], mainRepoDir);
|
|
await writePaseoWorktreeMetadata(repoDir, { baseRefName: "refs/tags/v1" });
|
|
commitFile(repoDir, "tag.txt", "committed\n", "tag feature");
|
|
writeFileSync(join(repoDir, "tag.txt"), "edited\n");
|
|
const status = await getCheckoutStatus(repoDir, context);
|
|
expect(status).toMatchObject({
|
|
isGit: true,
|
|
comparisonBaseRef: "refs/tags/v1",
|
|
aheadBehind: { ahead: 1, behind: 0 },
|
|
});
|
|
const history = await listCheckoutCommits({ cwd: repoDir, context });
|
|
expect(history.baseRef).toBe("refs/tags/v1");
|
|
expect(history.commits[0].subject).toBe("tag feature");
|
|
const committed = await getCheckoutDiff(repoDir, { mode: "base", baseRef: "refs/tags/v1" });
|
|
expect(committed.diff).toContain("+committed");
|
|
const all = await getCheckoutDiff(repoDir, { mode: "all", baseRef: "refs/tags/v1" });
|
|
expect(all.diff).toContain("+edited");
|
|
});
|
|
|
|
it("announces a usable comparison base while checked out on the base branch", async () => {
|
|
const cwd = makeTempDir();
|
|
git(["init", "-b", "main"], cwd);
|
|
git(["config", "user.name", "Test"], cwd);
|
|
git(["config", "user.email", "test@example.com"], cwd);
|
|
writeFileSync(join(cwd, "a.txt"), "before\n");
|
|
git(["add", "."], cwd);
|
|
commit(cwd, "base");
|
|
writeFileSync(join(cwd, "a.txt"), "after\n");
|
|
const status = await getCheckoutStatus(cwd);
|
|
expect(status).toMatchObject({ isGit: true, comparisonBaseRef: "refs/heads/main" });
|
|
if (!status.isGit || !status.comparisonBaseRef) throw new Error("Missing comparison base");
|
|
const all = await getCheckoutDiff(cwd, { mode: "all", baseRef: status.comparisonBaseRef });
|
|
expect(all.diff).toContain("+after");
|
|
});
|