fix: report apply-patch partial failures
This commit is contained in:
+115
-31
@@ -23,10 +23,27 @@ export interface PatchUpdateResult {
|
||||
newContent: string;
|
||||
}
|
||||
|
||||
export interface ApplyPatchV2Outcome {
|
||||
hunk: number;
|
||||
action: PatchHunk["kind"] | "move";
|
||||
status: "applied" | "failed" | "pending";
|
||||
path: string;
|
||||
targetPath?: string;
|
||||
change?: string;
|
||||
reason?: string;
|
||||
partialChanges?: string[];
|
||||
error?: {
|
||||
name?: string;
|
||||
message: string;
|
||||
details?: unknown;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ApplyPatchV2Options {
|
||||
executor: ApplyPatchV2Executor;
|
||||
stdin: Readable;
|
||||
stdout: Writable;
|
||||
stderr?: Writable;
|
||||
argv?: string[];
|
||||
}
|
||||
|
||||
@@ -66,6 +83,7 @@ type Replacement = [start: number, oldLength: number, newLines: string[]];
|
||||
|
||||
interface ApplyPatchV2Plan {
|
||||
changed: string[];
|
||||
outcomes: ApplyPatchV2Outcome[];
|
||||
}
|
||||
|
||||
const beginMarker = "*** Begin Patch";
|
||||
@@ -185,35 +203,27 @@ export async function runApplyPatchV2(options: ApplyPatchV2Options): Promise<num
|
||||
options.stdout.write(`${JSON.stringify(applyPatchV2HelpPayload(), null, 2)}\n`);
|
||||
return 0;
|
||||
}
|
||||
const stderr = options.stderr ?? process.stderr;
|
||||
if ((options.argv?.length ?? 0) > 0) {
|
||||
options.stdout.write(`${JSON.stringify({
|
||||
ok: false,
|
||||
error: {
|
||||
code: "apply_patch_unsupported_args",
|
||||
message: "ssh apply-patch uses the v2 engine and accepts no helper flags. Use apply-patch-v1 for legacy helper options."
|
||||
},
|
||||
unsupportedArgs: options.argv,
|
||||
help: applyPatchV2HelpPayload()
|
||||
}, null, 2)}\n`);
|
||||
stderr.write("ssh apply-patch uses the v2 engine and accepts no helper flags. Use apply-patch-v1 for legacy helper options.\n");
|
||||
return 2;
|
||||
}
|
||||
const patchText = await readStreamText(options.stdin);
|
||||
if (!patchText.trim()) {
|
||||
options.stdout.write(`${JSON.stringify({
|
||||
ok: false,
|
||||
error: {
|
||||
code: "apply_patch_stdin_required",
|
||||
message: "ssh apply-patch requires patch text on stdin."
|
||||
},
|
||||
help: applyPatchV2HelpPayload()
|
||||
}, null, 2)}\n`);
|
||||
stderr.write("ssh apply-patch requires patch text on stdin.\n");
|
||||
return 2;
|
||||
}
|
||||
const parsed = parseApplyPatchV2(patchText);
|
||||
const plan = await applyPatchV2Hunks(options.executor, parsed.hunks);
|
||||
options.stdout.write("Success. Updated the following files:\n");
|
||||
for (const item of plan.changed) options.stdout.write(`${item}\n`);
|
||||
return 0;
|
||||
try {
|
||||
const parsed = parseApplyPatchV2(patchText);
|
||||
const plan = await applyPatchV2Hunks(options.executor, parsed.hunks);
|
||||
options.stdout.write("Success. Updated the following files:\n");
|
||||
for (const item of plan.changed) options.stdout.write(`${item}\n`);
|
||||
return 0;
|
||||
} catch (error) {
|
||||
if (options.stderr === undefined) throw error;
|
||||
options.stderr.write(formatApplyPatchFailure(error));
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
async function applyPatchV2Hunks(executor: ApplyPatchV2Executor, hunks: PatchHunk[]): Promise<ApplyPatchV2Plan> {
|
||||
@@ -221,6 +231,7 @@ async function applyPatchV2Hunks(executor: ApplyPatchV2Executor, hunks: PatchHun
|
||||
|
||||
const states = new Map<string, PlannedFileState>();
|
||||
const changed: string[] = [];
|
||||
const outcomes: ApplyPatchV2Outcome[] = [];
|
||||
|
||||
async function readPlannedText(filePath: string): Promise<string> {
|
||||
const state = states.get(filePath);
|
||||
@@ -249,16 +260,20 @@ async function applyPatchV2Hunks(executor: ApplyPatchV2Executor, hunks: PatchHun
|
||||
states.set(filePath, { exists: false, content: "" });
|
||||
}
|
||||
|
||||
try {
|
||||
for (const hunk of hunks) {
|
||||
for (let index = 0; index < hunks.length; index += 1) {
|
||||
const hunk = hunks[index] as PatchHunk;
|
||||
const changedBefore = changed.length;
|
||||
try {
|
||||
if (hunk.kind === "add") {
|
||||
await applyWrite(hunk.path, hunk.content);
|
||||
changed.push(`A ${hunk.path}`);
|
||||
outcomes.push({ ...outcomeBase(hunk, index), status: "applied", change: `A ${hunk.path}` });
|
||||
continue;
|
||||
}
|
||||
if (hunk.kind === "delete") {
|
||||
await applyDelete(hunk.path);
|
||||
changed.push(`D ${hunk.path}`);
|
||||
outcomes.push({ ...outcomeBase(hunk, index), status: "applied", change: `D ${hunk.path}` });
|
||||
continue;
|
||||
}
|
||||
const originalContent = await readPlannedText(hunk.path);
|
||||
@@ -267,20 +282,89 @@ async function applyPatchV2Hunks(executor: ApplyPatchV2Executor, hunks: PatchHun
|
||||
await applyWrite(hunk.movePath, update.newContent);
|
||||
changed.push(`M ${hunk.movePath}`);
|
||||
await applyDelete(hunk.path);
|
||||
outcomes.push({ ...outcomeBase(hunk, index), action: "move", status: "applied", change: `M ${hunk.movePath}` });
|
||||
continue;
|
||||
}
|
||||
await applyWrite(hunk.path, update.newContent);
|
||||
changed.push(`M ${hunk.path}`);
|
||||
outcomes.push({ ...outcomeBase(hunk, index), status: "applied", change: `M ${hunk.path}` });
|
||||
} catch (error) {
|
||||
const partialChanges = changed.slice(changedBefore);
|
||||
outcomes.push({
|
||||
...outcomeBase(hunk, index),
|
||||
status: "failed",
|
||||
...(partialChanges.length > 0 ? { partialChanges } : {}),
|
||||
error: errorSummary(error),
|
||||
});
|
||||
for (let pendingIndex = index + 1; pendingIndex < hunks.length; pendingIndex += 1) {
|
||||
outcomes.push({
|
||||
...outcomeBase(hunks[pendingIndex] as PatchHunk, pendingIndex),
|
||||
status: "pending",
|
||||
reason: "skipped_after_previous_failure",
|
||||
});
|
||||
}
|
||||
throw new ApplyPatchV2Error(error instanceof Error ? error.message : String(error), {
|
||||
partialChanges: changed,
|
||||
outcomes,
|
||||
failed: outcomes.find((item) => item.status === "failed") ?? null,
|
||||
pending: outcomes.filter((item) => item.status === "pending"),
|
||||
cause: error instanceof ApplyPatchV2Error ? error.details : undefined,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
if (changed.length === 0) throw error;
|
||||
throw new ApplyPatchV2Error(error instanceof Error ? error.message : String(error), {
|
||||
partialChanges: changed,
|
||||
cause: error instanceof ApplyPatchV2Error ? error.details : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
return { changed };
|
||||
return { changed, outcomes };
|
||||
}
|
||||
|
||||
function outcomeBase(hunk: PatchHunk, index: number): Omit<ApplyPatchV2Outcome, "status"> {
|
||||
if (hunk.kind === "update" && hunk.movePath !== null && hunk.movePath !== hunk.path) {
|
||||
return { hunk: index + 1, action: "move", path: hunk.path, targetPath: hunk.movePath };
|
||||
}
|
||||
return { hunk: index + 1, action: hunk.kind, path: hunk.path };
|
||||
}
|
||||
|
||||
function errorSummary(error: unknown): ApplyPatchV2Outcome["error"] {
|
||||
if (error instanceof ApplyPatchV2Error) {
|
||||
return { name: error.name, message: error.message, details: error.details };
|
||||
}
|
||||
if (error instanceof Error) {
|
||||
return { name: error.name, message: error.message };
|
||||
}
|
||||
return { message: String(error) };
|
||||
}
|
||||
|
||||
function formatApplyPatchFailure(error: unknown): string {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
const details = error instanceof ApplyPatchV2Error ? error.details : {};
|
||||
const outcomes = Array.isArray(details.outcomes) ? details.outcomes as ApplyPatchV2Outcome[] : [];
|
||||
const lines = [`${message.trimEnd()}`];
|
||||
if (outcomes.length > 1 || outcomes.some((item) => item.status === "applied")) {
|
||||
lines.push("Patch status:");
|
||||
appendOutcomeSection(lines, "Applied before failure:", outcomes.filter((item) => item.status === "applied"));
|
||||
appendOutcomeSection(lines, "Failed:", outcomes.filter((item) => item.status === "failed"));
|
||||
appendOutcomeSection(lines, "Pending after failure:", outcomes.filter((item) => item.status === "pending"));
|
||||
}
|
||||
return `${lines.join("\n")}\n`;
|
||||
}
|
||||
|
||||
function appendOutcomeSection(lines: string[], title: string, outcomes: ApplyPatchV2Outcome[]): void {
|
||||
if (outcomes.length === 0) return;
|
||||
lines.push(title);
|
||||
for (const outcome of outcomes) {
|
||||
lines.push(` ${formatOutcome(outcome)}`);
|
||||
}
|
||||
}
|
||||
|
||||
function formatOutcome(outcome: ApplyPatchV2Outcome): string {
|
||||
const target = outcome.targetPath === undefined ? outcome.path : `${outcome.path} -> ${outcome.targetPath}`;
|
||||
const base = `hunk ${outcome.hunk} ${outcome.action} ${target}`;
|
||||
if (outcome.status === "applied") return outcome.change === undefined ? base : `${outcome.change} (${base})`;
|
||||
if (outcome.status === "pending") return `${base} (pending)`;
|
||||
const error = outcome.error?.message ?? "failed";
|
||||
const partial = outcome.partialChanges === undefined || outcome.partialChanges.length === 0
|
||||
? ""
|
||||
: `; partial changes: ${outcome.partialChanges.join(", ")}`;
|
||||
return `${base}: ${error}${partial}`;
|
||||
}
|
||||
|
||||
async function ensureRemoteFileExists(executor: ApplyPatchV2Executor, target: string): Promise<void> {
|
||||
|
||||
Reference in New Issue
Block a user