Merge PR #8976: fix(commit): fail loudly when staged binary truncates split-commit diff (@roboomp)

This commit is contained in:
can1357
2026-08-19 11:52:53 +02:00
4 changed files with 148 additions and 8 deletions
+1
View File
@@ -16,6 +16,7 @@
- Fixed the `/btw` panel re-committing its frame to native scrollback on every update while the primary turn is still streaming: a live region that pins itself (an anchored HUD/panel such as `/btw`) no longer leaks its scrolled-off rows just because an unpinned transcript seam sits above it in the same frame ([#8793](https://github.com/can1357/oh-my-pi/issues/8793)).
- Fixed Claude Code marketplace plugins ignoring the `enabledPlugins` switch in `~/.claude/settings.json` and `.claude/settings(.local).json`: a plugin turned off for a project no longer loads there, and a local-scope install enabled for a project loads even when its recorded `projectPath` is a different directory
- Fixed revived subagents (warm lifecycle reviver and cold persisted reviver) rebuilding the session without initializing the extension runtime, leaving every runtime action throwing `ExtensionRuntimeNotInitializedError`. An extension with a `tool_call` handler that touched a runtime action (e.g. `appendEntry`) then tripped the fail-closed gate in `emitToolCall` and blocked every tool — including the hidden `yield` — so the revived agent could neither finish nor exit and looped until killed. Both revivers now call the shared `initializeExtensions` helper, restoring runtime actions, `onError`, and the `session_start` event ([#8824](https://github.com/can1357/oh-my-pi/issues/8824)).
- Fixed `omp commit` split-commit crashing with a misleading `No diff found for <path>` when a staged binary (or any payload) pushed `git diff --cached --binary` past the 8 MiB subprocess output cap. The capture is truncated silently, so files sorting after the binary vanished from the parsed diff; the split flow now requests a complete diff and fails fast naming the real cause instead ([#8897](https://github.com/can1357/oh-my-pi/issues/8897)).
- Fixed task and eval subagents discovering newly added agent definitions while resolving their role aliases from stale startup settings. Subagent preflight now atomically reloads persisted settings before agent discovery while preserving live runtime overrides.
- Fixed images returned by tools mounted under `xd://` rendering only as file links instead of inline terminal graphics.
- Resume Cursor idle-stall turns after completed MCP/todo tool results. The watchdog already closes the Connect stream, so unmarked blocks no longer need the `exec-resolved` marker to continue.
@@ -11,7 +11,7 @@ import { ModelRegistry } from "../../config/model-registry";
import { Settings } from "../../config/settings";
import { discoverAuthStorage, discoverContextFiles, loadCliExtensionProviders } from "../../sdk";
import * as git from "../../utils/git";
import { abortOnGitFailure, pushOrAbort } from "../execute";
import { abortOnGitFailure, CommitAbortedError, pushOrAbort } from "../execute";
import { type ExistingChangelogEntries, runCommitAgentSession } from "./agent";
import { generateFallbackProposal } from "./fallback";
import { assignLockFilesToPlan } from "./lock-files";
@@ -303,7 +303,20 @@ async function runSplitCommit(
}
process.stdout.write("● Creating split commits...\n");
const stagedDiff = await git.diff(ctx.cwd, { cached: true, binary: true });
let stagedDiff: string;
try {
stagedDiff = await git.diff(ctx.cwd, { cached: true, binary: true, requireComplete: true });
} catch (error) {
if (error instanceof git.GitOutputTruncatedError) {
process.stderr.write(
`✗ Cannot create split commits: ${error.message}\n` +
" A large or binary file makes the staged diff too big to slice safely.\n" +
" Commit the large file(s) on their own, then re-run for the rest.\n",
);
throw new CommitAbortedError();
}
throw error;
}
await git.stage.reset(ctx.cwd);
for (const [position, commitIndex] of order.entries()) {
const commit = plan.commits[commitIndex];
+44 -6
View File
@@ -21,6 +21,8 @@ export interface GitCommandResult {
exitCode: number;
stdout: string;
stderr: string;
/** True when stdout or stderr hit {@link GIT_COMMAND_OUTPUT_LIMIT_BYTES} and the captured text is incomplete. */
truncated: boolean;
}
export interface GitRepository {
@@ -66,6 +68,7 @@ export interface DiffOptions {
readonly numstat?: boolean;
readonly signal?: AbortSignal;
readonly stat?: boolean;
readonly requireComplete?: boolean;
}
export interface StatusOptions {
@@ -173,6 +176,29 @@ export class GitCommandError extends Error {
}
}
/**
* A git subprocess produced more output than {@link GIT_COMMAND_OUTPUT_LIMIT_BYTES}
* and its captured stdout was truncated. Thrown only for callers that opt into
* completeness via `diff({ requireComplete: true })`, where operating on a partial
* diff would silently corrupt downstream parsing — e.g. the split-commit builder,
* which would otherwise throw a misleading "No diff found" for files sorting after
* a large binary blob whose base85 payload pushed the diff past the cap.
*/
export class GitOutputTruncatedError extends Error {
readonly args: readonly string[];
readonly result: GitCommandResult;
constructor(args: readonly string[], result: GitCommandResult) {
const limitMiB = Math.round(GIT_COMMAND_OUTPUT_LIMIT_BYTES / (1024 * 1024));
super(
`git ${args.join(" ")} produced more than ${limitMiB} MiB of output; the captured result is truncated and incomplete.`,
);
this.name = "GitOutputTruncatedError";
this.args = [...args];
this.result = result;
}
}
// ════════════════════════════════════════════════════════════════════════════
// Internal: Core execution
// ════════════════════════════════════════════════════════════════════════════
@@ -312,7 +338,10 @@ async function waitForExitWithTimeout(
}
}
async function readCappedText(stream: ReadableStream<Uint8Array>, maxBytes: number): Promise<string> {
async function readCappedText(
stream: ReadableStream<Uint8Array>,
maxBytes: number,
): Promise<{ text: string; truncated: boolean }> {
const reader = stream.getReader();
const decoder = new TextDecoder();
const chunks: string[] = [];
@@ -335,7 +364,7 @@ async function readCappedText(stream: ReadableStream<Uint8Array>, maxBytes: numb
}
chunks.push(decoder.decode());
if (truncated) chunks.push(GIT_OUTPUT_TRUNCATED_MARKER);
return chunks.join("");
return { text: chunks.join(""), truncated };
} finally {
reader.releaseLock();
}
@@ -372,10 +401,15 @@ async function collectSubprocessResult(
void stdoutPromise.catch(() => undefined);
void stderrPromise.catch(() => undefined);
await Promise.all([cancelOutput(stdoutStream), cancelOutput(stderrStream)]);
return { exitCode: GIT_COMMAND_TIMEOUT_EXIT_CODE, stdout: "", stderr: exit.stderr };
return { exitCode: GIT_COMMAND_TIMEOUT_EXIT_CODE, stdout: "", stderr: exit.stderr, truncated: false };
}
const [stdout, stderr] = await Promise.all([stdoutPromise, stderrPromise]);
return { exitCode: exit.exitCode ?? 0, stdout, stderr };
return {
exitCode: exit.exitCode ?? 0,
stdout: stdout.text,
stderr: stderr.text,
truncated: stdout.truncated || stderr.truncated,
};
}
interface CommandOptions {
@@ -496,7 +530,7 @@ async function git(cwd: string, args: readonly string[], options: CommandOptions
// A deleted/nonexistent cwd also surfaces as a spawn ENOENT; only blame
// the binary when the working directory actually exists.
const stderr = fs.existsSync(cwd) ? "git is not installed." : `working directory does not exist: ${cwd}`;
return { exitCode: GIT_SPAWN_ENOENT_EXIT_CODE, stdout: "", stderr };
return { exitCode: GIT_SPAWN_ENOENT_EXIT_CODE, stdout: "", stderr, truncated: false };
}
throw err;
}
@@ -1240,7 +1274,11 @@ export const diff = Object.assign(
if (options.allowFailure) {
return (await git(cwd, args, { env: options.env, readOnly: true, signal: options.signal })).stdout;
}
return runText(cwd, args, { env: options.env, readOnly: true, signal: options.signal });
const result = await runChecked(cwd, args, { env: options.env, readOnly: true, signal: options.signal });
if (options.requireComplete && result.truncated) {
throw new GitOutputTruncatedError(args, result);
}
return result.stdout;
},
{
/** List changed file paths. */
@@ -0,0 +1,88 @@
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import * as fs from "node:fs/promises";
import * as os from "node:os";
import * as path from "node:path";
import * as git from "@oh-my-pi/pi-coding-agent/utils/git";
import { removeWithRetries } from "@oh-my-pi/pi-utils";
// Regression coverage for #8897: `omp commit` split-commit captured the staged
// diff with `git diff --cached --binary`, whose stdout is hard-capped at
// GIT_COMMAND_OUTPUT_LIMIT_BYTES (8 MiB). A single large binary (base85-encoded
// inline) pushed the diff past the cap; readCappedText silently truncated it and
// `stage.hunks` then threw a misleading "No diff found" for any file sorting
// after the binary. The fix surfaces truncation as `GitCommandResult.truncated`
// and lets `diff({ requireComplete: true })` fail loudly with the real cause.
const GIT_ENV = {
GIT_AUTHOR_NAME: "t",
GIT_AUTHOR_EMAIL: "t@example.com",
GIT_COMMITTER_NAME: "t",
GIT_COMMITTER_EMAIL: "t@example.com",
GIT_CONFIG_GLOBAL: "/dev/null",
GIT_CONFIG_SYSTEM: "/dev/null",
} as const;
function gitRun(cwd: string, args: string[]): void {
const env: Record<string, string | undefined> = { ...process.env, ...GIT_ENV };
const result = Bun.spawnSync({ cmd: ["git", ...args], cwd, env, stdout: "pipe", stderr: "pipe" });
if (result.exitCode !== 0) {
throw new Error(`git ${args.join(" ")} failed: ${result.stderr.toString()}`);
}
}
describe("git.diff with a staged binary past the 8 MiB capture cap", () => {
let repo: string;
beforeAll(async () => {
repo = await fs.mkdtemp(path.join(os.tmpdir(), "omp-git-trunc-test-"));
gitRun(repo, ["init", "-q", "-b", "main"]);
// ~9 MiB of incompressible bytes so `--binary` (zlib + base85) alone
// exceeds the 8 MiB cap. Name sorts before the text file so the text
// file's diff entry is the one that gets cut off.
const bin = new Uint8Array(9 * 1024 * 1024);
for (let offset = 0; offset < bin.length; offset += 65536) {
crypto.getRandomValues(bin.subarray(offset, Math.min(offset + 65536, bin.length)));
}
await Bun.write(path.join(repo, "a.bin"), bin);
await Bun.write(path.join(repo, "z.txt"), "hello world\nchanged line\n");
await git.stage.files(repo);
});
afterAll(async () => {
await removeWithRetries(repo);
});
test("unguarded capture silently truncates and drops the later-sorting file", async () => {
const staged = await git.diff(repo, { cached: true, binary: true });
expect(staged.length).toBeLessThanOrEqual(git.GIT_COMMAND_OUTPUT_LIMIT_BYTES + 64);
// Root cause: the truncated diff no longer contains `z.txt`, which is what
// made `stage.hunks` throw "No diff found for z.txt".
const files = git.diff.parseFiles(staged).map(f => f.filename);
expect(files).not.toContain("z.txt");
});
test("requireComplete converts silent truncation into a typed, descriptive error", async () => {
let thrown: unknown;
try {
await git.diff(repo, { cached: true, binary: true, requireComplete: true });
} catch (error) {
thrown = error;
}
expect(thrown).toBeInstanceOf(git.GitOutputTruncatedError);
expect((thrown as Error).message).toContain("truncated");
expect((thrown as git.GitOutputTruncatedError).result.truncated).toBe(true);
});
test("requireComplete leaves a complete diff untouched", async () => {
const small = await fs.mkdtemp(path.join(os.tmpdir(), "omp-git-small-test-"));
try {
gitRun(small, ["init", "-q", "-b", "main"]);
await Bun.write(path.join(small, "s.txt"), "one\ntwo\n");
await git.stage.files(small);
const diff = await git.diff(small, { cached: true, binary: true, requireComplete: true });
expect(diff).toContain("s.txt");
} finally {
await removeWithRetries(small);
}
});
});