Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 9 additions & 9 deletions packages/host-core/src/agent-commands.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ import { testHostHandlers, type TestHandlerPorts } from "./testing/host-handlers
import { writeModelAccessDefault } from "./session-runtime/model-access-preferences";
import { archiveTicketCommand, updateTicketFieldsCommand } from "./ticket-commands";
import { createSessionTokenRegistry } from "./session-tokens";
import { scriptedGit } from "./worktree/scripted-git";
import { scriptedGit, scriptedReadOnlyGit } from "./worktree/scripted-git";
import * as worktree from "./worktree";
import { getWorktreeSnapshots, resetWorktreeSnapshotsForTest } from "./worktree/snapshot";
import { createInMemoryTranscriptArtifactStore } from "@volli/session-engine";
Expand Down Expand Up @@ -4910,7 +4910,7 @@ describe("agent command service", () => {
ticketPrefix: "VC",
}),
);
const { gitAsync } = scriptedGit((args) => {
const { gitAsync } = scriptedReadOnlyGit((args) => {
if (args[0] === "status") return " M src/a.ts\n";
if (args[0] === "rev-parse" && args[1] === "--verify") throw new Error("no origin ref");
if (args[0] === "rev-list" && args[1] === "--left-right") return "0\t3\n";
Expand Down Expand Up @@ -5106,7 +5106,7 @@ describe("agent command service", () => {
ticketPrefix: "VC",
}),
);
const { gitAsync } = scriptedGit((args) => {
const { gitAsync } = scriptedReadOnlyGit((args) => {
if (args[0] === "rev-parse" && args[1] === "--verify") throw new Error("no origin ref");
if (args[0] === "diff" && args.includes("main...HEAD")) return "3\t1\tsrc/a.ts\n";
if (args[0] === "diff" && args.includes("HEAD")) return "9\t0\tsrc/wip.ts\n";
Expand Down Expand Up @@ -5177,7 +5177,7 @@ describe("agent command service", () => {
);
const numstat =
Array.from({ length: 25 }, (_, i) => `1\t0\tsrc/file-${i}.ts`).join("\n") + "\n";
const { gitAsync } = scriptedGit((args) => {
const { gitAsync } = scriptedReadOnlyGit((args) => {
if (args[0] === "rev-parse" && args[1] === "--verify") throw new Error("no origin ref");
if (args[0] === "diff") return numstat;
return "";
Expand Down Expand Up @@ -5768,7 +5768,7 @@ describe("agent command service", () => {
);
void paths;
}
const { gitAsync } = scriptedGit((args, cwd) => {
const { gitAsync } = scriptedReadOnlyGit((args, cwd) => {
if (args[0] === "rev-parse" && args[1] === "--verify") return "base-tip\n";
if (args[0] === "merge-base") return "base-sha\n";
if (args[0] === "diff" && args.includes("--raw")) {
Expand Down Expand Up @@ -5847,7 +5847,7 @@ describe("agent command service", () => {
}),
);
}
const { gitAsync } = scriptedGit((args, cwd) => {
const { gitAsync } = scriptedReadOnlyGit((args, cwd) => {
if (args[0] === "rev-parse" && args[1] === "--verify") return "base-tip\n";
if (args[0] === "merge-base") return "base-sha\n";
if (args[0] === "diff" && args.includes("--raw")) {
Expand Down Expand Up @@ -5934,7 +5934,7 @@ describe("agent command service", () => {
baseBranch: "main",
}),
);
const { gitAsync } = scriptedGit((args) => {
const { gitAsync } = scriptedReadOnlyGit((args) => {
if (args[0] === "rev-parse" && args[1] === "--verify") return "base-tip\n";
if (args[0] === "merge-base") return "base-sha\n";
if (args[0] === "diff" && args.includes("--raw")) return rawModified(["package.json"]);
Expand Down Expand Up @@ -6062,7 +6062,7 @@ describe("agent command service", () => {
}),
);
}
return scriptedGit((args) => {
return scriptedReadOnlyGit((args) => {
if (args[0] === "rev-parse" && args[1] === "--verify") return "base-tip\n";
if (args[0] === "merge-base") return "base-sha\n";
// Every worktree, Done or not, has the same uncommitted edit (the diff
Expand Down Expand Up @@ -6144,7 +6144,7 @@ describe("agent command service", () => {
}
// VC-1 and VC-2 answer at once; every other worktree's git never returns,
// the way a read wedged on a contended index lock does.
const scripted = scriptedGit((args) => {
const scripted = scriptedReadOnlyGit((args) => {
if (args[0] === "rev-parse" && args[1] === "--verify") return "base-tip\n";
if (args[0] === "merge-base") return "base-sha\n";
return "";
Expand Down
12 changes: 6 additions & 6 deletions packages/host-core/src/worktree/change-set.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import { CHANGE_SET_FILE_CAP } from "@volli/shared";

import { changeSetSnapshot, readChangeSetBaseFile } from "./change-set";
import { GitError, runGitCapturingAsync } from "./git";
import { scriptedGit } from "./scripted-git";
import { scriptedGit, scriptedReadOnlyGit } from "./scripted-git";

/** Join NUL-terminated git -z fields without octal-escape hazards (`\03` ≠ NUL+"3"). */
function z(...fields: string[]): string {
Expand Down Expand Up @@ -65,7 +65,7 @@ function scriptedChangeSetGit(opts: {
numstat?: string;
status?: string;
}): ReturnType<typeof scriptedGit> {
return scriptedGit((args) => {
return scriptedReadOnlyGit((args) => {
if (args[0] === "rev-parse" && args[1] === "--verify") throw new Error("no such ref");
if (args[0] === "merge-base") return "basesha\n";
if (args[0] === "rev-parse" && args[1] === "main") return "tipsha\n";
Expand Down Expand Up @@ -97,7 +97,7 @@ describe("changeSetSnapshot — merge-base stamping", () => {
});

it("reuses the remote-tracking probe's SHA rather than re-resolving the tip", async () => {
const { gitAsync, calls } = scriptedGit((args) => {
const { gitAsync, calls } = scriptedReadOnlyGit((args) => {
// `rev-parse --verify` PRINTS the SHA it verified.
if (args[0] === "rev-parse" && args[1] === "--verify") return "remotetip\n";
if (args[0] === "merge-base") throw new GitError("failed", "fatal: no merge base", args);
Expand All @@ -117,7 +117,7 @@ describe("changeSetSnapshot — merge-base stamping", () => {
});

it("falls back to the comparison ref tip when merge-base cannot be computed", async () => {
const { gitAsync } = scriptedGit((args) => {
const { gitAsync } = scriptedReadOnlyGit((args) => {
if (args[0] === "rev-parse" && args[1] === "--verify") throw new Error("no such ref");
if (args[0] === "merge-base") throw new GitError("failed", "fatal: no merge base", args);
if (args[0] === "rev-parse" && args[1] === "main") return "tipsha\n";
Expand Down Expand Up @@ -664,7 +664,7 @@ describe("changeSetSnapshot — file cap", () => {

describe("changeSetSnapshot — failures", () => {
it("errs when no base branch is known", async () => {
const { gitAsync, calls } = scriptedGit(() => "");
const { gitAsync, calls } = scriptedReadOnlyGit(() => "");
const result = await changeSetSnapshot(gitAsync, { worktreePath: "/wt", baseBranch: null });
expect(result.ok).toBe(false);
if (result.ok) return;
Expand All @@ -673,7 +673,7 @@ describe("changeSetSnapshot — failures", () => {
});

it("surfaces real git stderr when a read fails", async () => {
const { gitAsync } = scriptedGit((args) => {
const { gitAsync } = scriptedReadOnlyGit((args) => {
if (args[0] === "rev-parse" && args[1] === "--verify") throw new Error("no such ref");
if (args[0] === "merge-base" || (args[0] === "rev-parse" && args[1] === "main")) {
throw new GitError("failed", "fatal: bad object main", args);
Expand Down
13 changes: 11 additions & 2 deletions packages/host-core/src/worktree/change-set.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import {

import { resolveChangeSetBaseRevision } from "./comparison-ref";
import { stderrOf } from "./git";
import { readOnlyGitAsync } from "./scan";
import { err, ok, type RunGitAsync, type WorktreeResult } from "./types";

export interface ChangeSetInput {
Expand Down Expand Up @@ -103,12 +104,18 @@ const UNTRACKED_STAT_CONCURRENCY = 16;
/**
* Builds a {@link ChangeSetSnapshot} for the worktree. Failures surface real
* git stderr (never a silent empty snapshot). Missing base fails fast.
*
* Read-only git ({@link readOnlyGitAsync}): this is refetched within a second
* of any write in the worktree, and a plain `git status -uall` holds the
* worktree's `index.lock` for its whole untracked scan — exactly when the
* agent that wrote is likely to run its own `git add`/`commit`.
*/
export async function changeSetSnapshot(
git: RunGitAsync,
runGit: RunGitAsync,
input: ChangeSetInput,
readUntrackedFileStat: ReadUntrackedFileStat = readUntrackedStat,
): Promise<WorktreeResult<ChangeSetSnapshot>> {
const git = readOnlyGitAsync(runGit);
if (!input.baseBranch) {
return err("No base branch is known for this worktree, so its Change Set cannot be computed.");
}
Expand Down Expand Up @@ -193,9 +200,11 @@ export async function changeSetSnapshot(
* the resolved base and appends untracked and unmerged paths from porcelain.
*/
export async function changeSetPaths(
git: RunGitAsync,
runGit: RunGitAsync,
input: ChangeSetInput,
): Promise<WorktreeResult<string[]>> {
// Read-only for the reason the snapshot is ({@link changeSetSnapshot}).
const git = readOnlyGitAsync(runGit);
if (!input.baseBranch) {
return err("No base branch is known for this worktree, so its Change Set cannot be computed.");
}
Expand Down
22 changes: 11 additions & 11 deletions packages/host-core/src/worktree/diff.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,11 @@ import { describe, expect, it } from "vite-plus/test";

import { diffStat } from "./diff";
import { GitError } from "./git";
import { scriptedGit } from "./scripted-git";
import { scriptedReadOnlyGit } from "./scripted-git";

describe("diffStat — working-tree mode", () => {
it("sums tracked numstat and appends untracked files with null counts", async () => {
const { gitAsync: git, calls } = scriptedGit((args) => {
const { gitAsync: git, calls } = scriptedReadOnlyGit((args) => {
if (args[0] === "diff") return "3\t1\tsrc/a.ts\n10\t0\tsrc/b.ts\n";
if (args[0] === "status") return "?? src/new.ts\n M src/a.ts\n";
return "";
Expand All @@ -27,7 +27,7 @@ describe("diffStat — working-tree mode", () => {
});

it("represents binary files (-\\t-) with null insertions/deletions, excluded from totals", async () => {
const { gitAsync: git } = scriptedGit((args) => {
const { gitAsync: git } = scriptedReadOnlyGit((args) => {
if (args[0] === "diff") return "-\t-\tassets/logo.png\n5\t2\tsrc/a.ts\n";
if (args[0] === "status") return "";
return "";
Expand All @@ -46,7 +46,7 @@ describe("diffStat — working-tree mode", () => {
});

it("ignores non-untracked porcelain lines (only ?? entries are appended)", async () => {
const { gitAsync: git } = scriptedGit((args) => {
const { gitAsync: git } = scriptedReadOnlyGit((args) => {
if (args[0] === "diff") return "";
if (args[0] === "status") return " M src/a.ts\nA src/staged.ts\n?? src/really-new.ts\n";
return "";
Expand All @@ -60,7 +60,7 @@ describe("diffStat — working-tree mode", () => {
});

it("returns err carrying stderr when the diff read fails", async () => {
const { gitAsync: git } = scriptedGit((args) => {
const { gitAsync: git } = scriptedReadOnlyGit((args) => {
if (args[0] === "diff") throw new GitError("failed", "fatal: bad revision HEAD", args);
return "";
});
Expand All @@ -73,7 +73,7 @@ describe("diffStat — working-tree mode", () => {

describe("diffStat — merge-base mode", () => {
it("diffs <base>...HEAD (three-dot) and never reads untracked", async () => {
const { gitAsync: git, calls } = scriptedGit((args) => {
const { gitAsync: git, calls } = scriptedReadOnlyGit((args) => {
if (args[0] === "diff") return "4\t0\tsrc/a.ts\n";
// No remote-tracking ref — comparisons stay on the local base.
if (args[0] === "rev-parse" && args[1] === "--verify") throw new Error("no such ref");
Expand All @@ -96,7 +96,7 @@ describe("diffStat — merge-base mode", () => {
});

it("diffs against origin/<base> when the remote-tracking ref exists", async () => {
const { gitAsync: git, calls } = scriptedGit((args) => {
const { gitAsync: git, calls } = scriptedReadOnlyGit((args) => {
if (args[0] === "rev-parse" && args[1] === "--verify") return "abc123\n";
if (args[0] === "diff") return "4\t0\tsrc/a.ts\n";
return "";
Expand All @@ -111,7 +111,7 @@ describe("diffStat — merge-base mode", () => {
});

it("errs when the base branch is unknown", async () => {
const { gitAsync: git, calls } = scriptedGit(() => "");
const { gitAsync: git, calls } = scriptedReadOnlyGit(() => "");
const result = await diffStat(git, { worktreePath: "/wt", baseBranch: null }, "merge-base");
expect(result.ok).toBe(false);
// No git spawned — it fails fast on the missing base.
Expand All @@ -121,7 +121,7 @@ describe("diffStat — merge-base mode", () => {

describe("diffStat — rename path handling", () => {
it("keeps the new path for a braced rename (common prefix/suffix)", async () => {
const { gitAsync: git } = scriptedGit((args) => {
const { gitAsync: git } = scriptedReadOnlyGit((args) => {
if (args[0] === "diff") return "2\t2\tsrc/{old.ts => new.ts}\n";
return "";
});
Expand All @@ -132,7 +132,7 @@ describe("diffStat — rename path handling", () => {
});

it("keeps the new path for a whole-path rename (old => new, no braces)", async () => {
const { gitAsync: git } = scriptedGit((args) => {
const { gitAsync: git } = scriptedReadOnlyGit((args) => {
if (args[0] === "diff") return "1\t1\told/a.ts => new/b.ts\n";
return "";
});
Expand All @@ -143,7 +143,7 @@ describe("diffStat — rename path handling", () => {
});

it("resolves a braced rename embedded mid-path (prefix/{old => new}/suffix)", async () => {
const { gitAsync: git } = scriptedGit((args) => {
const { gitAsync: git } = scriptedReadOnlyGit((args) => {
if (args[0] === "diff") return "0\t0\tsrc/{a => b}/index.ts\n";
return "";
});
Expand Down
9 changes: 8 additions & 1 deletion packages/host-core/src/worktree/diff.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import type { WorktreeDiffMode } from "@volli/shared";

import { resolveComparisonRefAsync } from "./comparison-ref";
import { stderrOf } from "./git";
import { readOnlyGitAsync } from "./scan";
import { err, ok, type RunGitAsync, type WorktreeResult } from "./types";

export interface DiffStatInput {
Expand Down Expand Up @@ -104,12 +105,18 @@ export function total(files: readonly DiffFileStat[], key: "insertions" | "delet
* IPC verb and the `volli worktree diff` CLI door, both of which run on the
* Electron main process, where `execFileSync` froze every window for the length
* of the spawn.
*
* Read-only git ({@link readOnlyGitAsync}): the working-tree `status` then never
* holds the worktree's `index.lock` out from under an agent's own git write.
* Porcelain `git diff` does not consult the switch — it may still refresh the
* index at its end when it finds stat-dirty entries (`diff.autoRefreshIndex`).
*/
export async function diffStat(
git: RunGitAsync,
runGit: RunGitAsync,
input: DiffStatInput,
mode: WorktreeDiffMode,
): Promise<WorktreeResult<DiffStat>> {
const git = readOnlyGitAsync(runGit);
if (mode === "merge-base" && !input.baseBranch) {
return err("No base branch is known for this worktree, so its PR diff cannot be computed.");
}
Expand Down
Loading
Loading