@aefree/pi-plastic 0.7.6 → 0.8.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +43 -0
- package/README.md +48 -174
- package/docs/2026-10-06-development.md +101 -0
- package/docs/2026-10-06-diffs-and-patches.md +136 -0
- package/docs/2026-10-06-discovery.md +135 -0
- package/docs/2026-10-06-integrations.md +49 -0
- package/docs/2026-10-06-merging.md +153 -0
- package/docs/2026-10-06-safety-and-receipts.md +95 -0
- package/docs/2026-10-06-setup.md +101 -0
- package/docs/2026-10-06-shelvesets-and-reviews.md +113 -0
- package/docs/2026-10-06-status.md +130 -0
- package/docs/2026-10-06-tools-and-loading.md +64 -0
- package/docs/2026-10-06-workspace-operations.md +142 -0
- package/docs/README.md +65 -0
- package/extensions/bash-cm-diff-guard.ts +1 -1
- package/index.ts +4 -687
- package/package.json +10 -4
- package/skills/using-plastic/SKILL.md +11 -5
- package/skills/using-plastic/references/branch-management.md +5 -1
- package/skills/using-plastic/references/changeset-operations.md +9 -8
- package/skills/using-plastic/references/code-review-creation.md +25 -18
- package/skills/using-plastic/references/integration.md +9 -5
- package/skills/using-plastic/references/quick-reference.md +4 -4
- package/skills/using-plastic/references/reviewing-changes.md +10 -10
- package/skills/using-plastic/references/shelving.md +26 -11
- package/skills/using-plastic/references/troubleshooting.md +4 -4
- package/src/diff/local-snapshot.ts +74 -0
- package/src/diff/text.ts +187 -0
- package/src/domain/add-contract.ts +24 -0
- package/src/domain/branch-create-contract.ts +38 -0
- package/src/domain/branch-delete-contract.ts +15 -0
- package/src/domain/branches.ts +263 -0
- package/src/domain/checkin-contract.ts +70 -0
- package/src/domain/closeout-contract.ts +30 -0
- package/src/domain/copied-merge.ts +42 -0
- package/src/domain/diff-base-xml.ts +65 -0
- package/src/domain/diff-pending-xml.ts +34 -0
- package/src/domain/merge-output.ts +327 -0
- package/src/domain/paths.ts +253 -0
- package/src/domain/pending.ts +499 -0
- package/src/domain/removal-contract.ts +25 -0
- package/src/domain/revisions.ts +301 -0
- package/src/domain/server-merge-contract.ts +45 -0
- package/src/domain/server-merge-resolutions.ts +61 -0
- package/src/domain/status-xml.ts +122 -0
- package/src/domain/switch-contract.ts +60 -0
- package/src/domain/switch-policy.ts +95 -0
- package/src/domain/undo-contract.ts +24 -0
- package/src/domain/update-contract.ts +16 -0
- package/src/domain/workspace-merge-contract.ts +41 -0
- package/src/execution/add-command.ts +118 -0
- package/src/execution/branch-create-command.ts +118 -0
- package/src/execution/branch-list-command.ts +75 -0
- package/src/execution/checkin-command.ts +116 -0
- package/src/execution/cli-version.ts +31 -0
- package/src/execution/cm.ts +59 -0
- package/src/execution/code-review-find-command.ts +75 -0
- package/src/execution/context.ts +20 -0
- package/src/execution/diff-revisions-command.ts +74 -0
- package/src/execution/process.ts +260 -0
- package/src/execution/removal-command.ts +118 -0
- package/src/execution/server-merge-command.ts +92 -0
- package/src/execution/shelveset-list-command.ts +75 -0
- package/src/execution/status-command.ts +29 -0
- package/src/execution/status-xml-command.ts +72 -0
- package/src/execution/status-xml-limits.ts +1 -0
- package/src/execution/switch-command.ts +118 -0
- package/src/execution/test-seams.ts +10 -0
- package/src/execution/undo-command.ts +118 -0
- package/src/execution/update-command.ts +118 -0
- package/src/execution/workspace-list-command.ts +75 -0
- package/src/execution/workspace-merge-command.ts +118 -0
- package/src/operations/add-receipt.ts +37 -0
- package/src/operations/arguments.ts +3 -0
- package/src/operations/branch-create.ts +74 -0
- package/src/operations/branch-delete-receipt.ts +25 -0
- package/src/operations/branch-list.ts +94 -0
- package/src/operations/branch-reads.ts +94 -0
- package/src/operations/branches.ts +111 -0
- package/src/operations/checkin-receipt.ts +140 -0
- package/src/operations/checkin.ts +102 -0
- package/src/operations/closeout-receipt.ts +74 -0
- package/src/operations/closeout.ts +19 -0
- package/src/operations/code-review-find.ts +97 -0
- package/src/operations/consolidated-diff.ts +144 -0
- package/src/operations/diff-revisions.ts +157 -0
- package/src/operations/diff.ts +53 -0
- package/src/operations/merge.ts +26 -0
- package/src/operations/object-delete-receipt.ts +21 -0
- package/src/operations/object-write-receipt.ts +105 -0
- package/src/operations/patch-receipt.ts +96 -0
- package/src/operations/patch-support.ts +375 -0
- package/src/operations/patch.ts +25 -0
- package/src/operations/removal-receipt.ts +42 -0
- package/src/operations/reviews.ts +90 -0
- package/src/operations/server-merge.ts +130 -0
- package/src/operations/shelveset-list.ts +90 -0
- package/src/operations/shelvesets.ts +90 -0
- package/src/operations/status.ts +129 -0
- package/src/operations/switch-receipt.ts +85 -0
- package/src/operations/switch.ts +25 -0
- package/src/operations/undo-receipt.ts +37 -0
- package/src/operations/update-receipt.ts +35 -0
- package/src/operations/workspace-list.ts +92 -0
- package/src/operations/workspace-merge-receipt.ts +50 -0
- package/src/operations/workspace.ts +120 -0
- package/src/pi/add-output.ts +52 -0
- package/src/pi/arguments.ts +298 -0
- package/src/pi/branch-create-output.ts +83 -0
- package/src/pi/branch-delete-output.ts +46 -0
- package/src/pi/branch-list-output.ts +59 -0
- package/src/pi/branch-output.ts +63 -0
- package/src/pi/capability-diagnostics.ts +23 -0
- package/src/pi/checkin-output.ts +68 -0
- package/src/pi/closeout-output.ts +87 -0
- package/src/pi/code-review-find-output.ts +50 -0
- package/src/pi/diff-output.ts +66 -0
- package/src/pi/diff-revisions-output.ts +55 -0
- package/src/pi/object-delete-output.ts +15 -0
- package/src/pi/object-write-output.ts +54 -0
- package/src/pi/patch-output.ts +69 -0
- package/src/pi/register.ts +188 -0
- package/src/pi/removal-output.ts +59 -0
- package/src/pi/schemas.ts +146 -0
- package/src/pi/server-merge-output.ts +83 -0
- package/src/pi/shelveset-list-output.ts +50 -0
- package/src/pi/status-output.ts +204 -0
- package/src/pi/switch-output.ts +80 -0
- package/src/pi/tool-registry.ts +72 -0
- package/src/pi/undo-output.ts +52 -0
- package/src/pi/update-output.ts +48 -0
- package/src/pi/workspace-list-output.ts +53 -0
- package/src/pi/workspace-merge-output.ts +46 -0
- package/src/plastic-core.ts +14 -5775
- package/src/plastic-renderers.ts +102 -22
- package/src/plastic-tool-loading.ts +7 -12
- package/src/presentation/add-results.ts +5 -0
- package/src/presentation/branch-create.ts +7 -0
- package/src/presentation/branch-delete-results.ts +7 -0
- package/src/presentation/checkin-results.ts +14 -0
- package/src/presentation/closeout-results.ts +2 -0
- package/src/presentation/diff-results.ts +237 -0
- package/src/presentation/removal-results.ts +7 -0
- package/src/presentation/results.ts +84 -0
- package/src/presentation/switch-results.ts +7 -0
- package/src/presentation/undo-results.ts +5 -0
- package/src/presentation/update-results.ts +5 -0
- package/src/presentation/workspace-merge-results.ts +6 -0
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import type { Readable } from "node:stream";
|
|
3
|
+
import { commandExecutionStorage, getActiveAbortSignal } from "./context";
|
|
4
|
+
import { getCmExecutable } from "./process";
|
|
5
|
+
export type UpdateAttempt = { state: "not-attempted" | "not-started" | "started" | "unknown"; terminal: "not-observed" | "observed"; exitCode: number | null; aborted: boolean; timedOut: boolean };
|
|
6
|
+
export const emptyUpdateAttempt = (): UpdateAttempt => ({ state: "not-attempted", terminal: "not-observed", exitCode: null, aborted: false, timedOut: false });
|
|
7
|
+
export type UpdateCapture = { stdoutBytes: number; stderrBytes: number; stdoutRetainedBytes: number; stderrRetainedBytes: number; truncated: boolean; complete: boolean; validUtf8: boolean };
|
|
8
|
+
export type UpdateCommandObservation = { attempt: UpdateAttempt; capture: UpdateCapture; stdout: string | null; stderr: string | null; failed: boolean };
|
|
9
|
+
// Forced-retirement guards have no per-command state or retained buffers.
|
|
10
|
+
const ignoreRetiredError = () => {};
|
|
11
|
+
/** Selected-tool byte capture; never retries, changes encoding, or discards effect uncertainty. */
|
|
12
|
+
export async function captureUpdateCommand(args: string[], workdir?: string, timeoutMs = 30000): Promise<UpdateCommandObservation> {
|
|
13
|
+
const attempt = emptyUpdateAttempt();
|
|
14
|
+
const capture: UpdateCapture = { stdoutBytes: 0, stderrBytes: 0, stdoutRetainedBytes: 0, stderrRetainedBytes: 0, truncated: false, complete: false, validUtf8: false };
|
|
15
|
+
const result: UpdateCommandObservation = { attempt, capture, stdout: null, stderr: null, failed: false };
|
|
16
|
+
const signal = getActiveAbortSignal(), deps = commandExecutionStorage.getStore() ?? {};
|
|
17
|
+
if (signal?.aborted) { attempt.aborted = true; return result; }
|
|
18
|
+
let child: ReturnType<typeof spawn>;
|
|
19
|
+
try { child = (deps.spawn ?? spawn)(getCmExecutable(), args, { cwd: workdir ?? process.cwd(), shell: false, stdio: ["ignore", "pipe", "pipe"] }); }
|
|
20
|
+
catch { attempt.state = "not-started"; result.failed = true; return result; }
|
|
21
|
+
attempt.state = "unknown";
|
|
22
|
+
const schedule = deps.setTimeout ?? setTimeout, cancel = deps.clearTimeout ?? clearTimeout;
|
|
23
|
+
let settled = false, failed = false, started = false, retired = false, finalized = false;
|
|
24
|
+
let terminationRequested = false, killTimer: NodeJS.Timeout | undefined, retireTimer: NodeJS.Timeout | undefined;
|
|
25
|
+
let settleExit!: () => void;
|
|
26
|
+
const finishers: Array<() => void> = [], disposers: Array<() => void> = [];
|
|
27
|
+
const retire = () => {
|
|
28
|
+
if (retired || finalized) return;
|
|
29
|
+
retired = true; failed = true;
|
|
30
|
+
// Retirement is not an observed terminal event or proof that the child stopped.
|
|
31
|
+
for (const emitter of [child, child.stdout, child.stderr]) emitter?.on("error", ignoreRetiredError);
|
|
32
|
+
settleExit();
|
|
33
|
+
for (const finish of finishers) finish();
|
|
34
|
+
child.stdout?.destroy(); child.stderr?.destroy();
|
|
35
|
+
};
|
|
36
|
+
const terminate = () => {
|
|
37
|
+
if (terminationRequested || retired || finalized) return;
|
|
38
|
+
terminationRequested = true;
|
|
39
|
+
const grace = deps.abortKillDelayMs ?? 5000;
|
|
40
|
+
if (!settled) { try { child.kill("SIGTERM"); } catch { /* not termination proof */ } }
|
|
41
|
+
killTimer = schedule(() => {
|
|
42
|
+
if (finalized || retired) return;
|
|
43
|
+
if (!settled) { try { child.kill("SIGKILL"); } catch { /* not termination proof */ } }
|
|
44
|
+
// Same bounded drain grace after KILL; terminal state never gates retirement.
|
|
45
|
+
retireTimer = schedule(retire, grace);
|
|
46
|
+
}, grace);
|
|
47
|
+
};
|
|
48
|
+
const collect = (stream: Readable | null, cap: number, name: "stdout" | "stderr") => {
|
|
49
|
+
let length = 0; const parts: Buffer[] = [];
|
|
50
|
+
if (!stream) { failed = true; terminate(); return Promise.resolve(Buffer.alloc(0)); }
|
|
51
|
+
if (stream.readableEncoding) { failed = true; terminate(); }
|
|
52
|
+
return new Promise<Buffer>(resolve => {
|
|
53
|
+
let done = false;
|
|
54
|
+
const finish = () => { if (!done) { done = true; const bytes = Buffer.concat(parts, length); parts.length = 0; resolve(bytes); } };
|
|
55
|
+
finishers.push(finish);
|
|
56
|
+
const data = (chunk: unknown) => {
|
|
57
|
+
if (done || retired || finalized) return;
|
|
58
|
+
if (!(chunk instanceof Uint8Array)) { failed = true; terminate(); return; }
|
|
59
|
+
const key = name === "stdout" ? "stdoutBytes" : "stderrBytes";
|
|
60
|
+
capture[key] = Math.min(Number.MAX_SAFE_INTEGER, capture[key] + chunk.byteLength);
|
|
61
|
+
const available = cap - length;
|
|
62
|
+
if (chunk.byteLength > available) { capture.truncated = true; terminate(); }
|
|
63
|
+
if (available > 0) { const part = Buffer.from(chunk.subarray(0, available)); parts.push(part); length += part.length; }
|
|
64
|
+
};
|
|
65
|
+
const error = () => { if (retired || finalized) return; failed = true; terminate(); finish(); };
|
|
66
|
+
const close = () => { if (retired || finalized) return; if (!done && !stream.readableEnded) failed = true; finish(); };
|
|
67
|
+
stream.on("data", data); stream.once("end", finish); stream.on("error", error); stream.once("close", close);
|
|
68
|
+
disposers.push(() => { stream.removeListener("data", data); stream.removeListener("end", finish); stream.removeListener("error", error); stream.removeListener("close", close); });
|
|
69
|
+
});
|
|
70
|
+
};
|
|
71
|
+
let onClose!: (code: number | null, terminalSignal?: string | null) => void, onError!: (error: NodeJS.ErrnoException) => void;
|
|
72
|
+
const onSpawn = () => { if (retired || finalized) return; started = true; attempt.state = "started"; };
|
|
73
|
+
const exit = new Promise<void>(resolve => {
|
|
74
|
+
settleExit = resolve;
|
|
75
|
+
onClose = (code, terminalSignal) => {
|
|
76
|
+
if (settled || retired || finalized) return; settled = true; attempt.terminal = "observed";
|
|
77
|
+
attempt.exitCode = Number.isInteger(code) && code !== null && code >= 0 && code <= 2147483647 ? code : null;
|
|
78
|
+
// A terminal event is not a substitute for the native start event.
|
|
79
|
+
if (!started) failed = true;
|
|
80
|
+
if (attempt.exitCode === null || terminalSignal) failed = true;
|
|
81
|
+
resolve();
|
|
82
|
+
};
|
|
83
|
+
onError = error => {
|
|
84
|
+
if (retired || finalized) return;
|
|
85
|
+
failed = true;
|
|
86
|
+
if (!started && ["ENOENT", "EACCES", "ENOTDIR"].includes(error.code ?? "")) {
|
|
87
|
+
attempt.state = "not-started"; settled = true; child.stdout?.destroy(); child.stderr?.destroy(); resolve();
|
|
88
|
+
} else { terminate(); }
|
|
89
|
+
};
|
|
90
|
+
child.once("spawn", onSpawn); child.once("close", onClose); child.on("error", onError);
|
|
91
|
+
});
|
|
92
|
+
const out = collect(child.stdout, 65536, "stdout"), err = collect(child.stderr, 16384, "stderr");
|
|
93
|
+
const onAbort = () => { if (retired || finalized) return; attempt.aborted = true; terminate(); };
|
|
94
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
95
|
+
const timer = schedule(() => { if (retired || finalized) return; attempt.timedOut = true; terminate(); }, Math.min(deps.timeoutMs ?? timeoutMs, timeoutMs));
|
|
96
|
+
if (signal?.aborted) onAbort();
|
|
97
|
+
try {
|
|
98
|
+
const [, stdout, stderr] = await Promise.all([exit, out, err]);
|
|
99
|
+
capture.stdoutRetainedBytes = stdout.length; capture.stderrRetainedBytes = stderr.length;
|
|
100
|
+
result.failed = failed;
|
|
101
|
+
try {
|
|
102
|
+
// Retain a literal UTF-8 BOM rather than silently removing source bytes.
|
|
103
|
+
result.stdout = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(stdout);
|
|
104
|
+
result.stderr = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(stderr);
|
|
105
|
+
capture.validUtf8 = true;
|
|
106
|
+
} catch { result.stdout = null; result.stderr = null; }
|
|
107
|
+
if (signal?.aborted) attempt.aborted = true;
|
|
108
|
+
capture.complete = !failed && !result.failed && !attempt.aborted && !attempt.timedOut && capture.validUtf8 && !capture.truncated && attempt.terminal === "observed";
|
|
109
|
+
return result;
|
|
110
|
+
} finally {
|
|
111
|
+
finalized = true;
|
|
112
|
+
cancel(timer); if (killTimer !== undefined) cancel(killTimer); if (retireTimer !== undefined) cancel(retireTimer);
|
|
113
|
+
signal?.removeEventListener("abort", onAbort);
|
|
114
|
+
child.removeListener("spawn", onSpawn); child.removeListener("close", onClose); child.removeListener("error", onError);
|
|
115
|
+
for (const dispose of disposers) dispose();
|
|
116
|
+
finishers.length = 0; disposers.length = 0;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import type { Readable } from "node:stream";
|
|
3
|
+
export const WORKSPACE_LIST_CAPTURE_LIMITS = { stdoutBytes: 1048576, stderrBytes: 65536, timeoutMs: 30000, records: 20000 } as const;
|
|
4
|
+
import { commandExecutionStorage, getActiveAbortSignal } from "./context";
|
|
5
|
+
import { getCmExecutable } from "./process";
|
|
6
|
+
export class WorkspaceListCommandError extends Error {
|
|
7
|
+
constructor(readonly code: "command_failed" | "aborted" | "capture_incomplete") { super(`Workspace list read failed (${code}).`); }
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
// Workspace-list-only strict byte transport. Native/default and status routes are unchanged.
|
|
11
|
+
export async function runWorkspaceListFieldsCommand(args: string[], workdir?: string): Promise<Buffer> {
|
|
12
|
+
const signal = getActiveAbortSignal();
|
|
13
|
+
if (signal?.aborted) throw new WorkspaceListCommandError("aborted");
|
|
14
|
+
const dependencies = commandExecutionStorage.getStore() ?? {};
|
|
15
|
+
let child;
|
|
16
|
+
try { child = (dependencies.spawn ?? spawn)(getCmExecutable(), args, { cwd: workdir ?? process.cwd(), shell: false, stdio: ["ignore", "pipe", "pipe"] }); }
|
|
17
|
+
catch { throw new WorkspaceListCommandError("command_failed"); }
|
|
18
|
+
const schedule = dependencies.setTimeout ?? setTimeout;
|
|
19
|
+
const cancel = dependencies.clearTimeout ?? clearTimeout;
|
|
20
|
+
let aborted = false, incomplete = false, failed = false, settled = false;
|
|
21
|
+
let killTimer: NodeJS.Timeout | undefined;
|
|
22
|
+
const terminate = () => {
|
|
23
|
+
if (settled) return;
|
|
24
|
+
try { child.kill("SIGTERM"); } catch { /* process may already have exited */ }
|
|
25
|
+
if (killTimer === undefined) killTimer = schedule(() => { if (!settled) { try { child.kill("SIGKILL"); } catch { /* process may already have exited */ } } }, dependencies.abortKillDelayMs ?? 5000);
|
|
26
|
+
};
|
|
27
|
+
const collectors: Array<{ dispose(): void }> = [];
|
|
28
|
+
const collect = (stream: Readable | null, cap: number) => {
|
|
29
|
+
let length = 0;
|
|
30
|
+
const chunks: Buffer[] = [];
|
|
31
|
+
if (!stream) { failed = true; return Promise.resolve(Buffer.alloc(0)); }
|
|
32
|
+
if (stream.readableEncoding) { failed = true; terminate(); }
|
|
33
|
+
return new Promise<Buffer>(resolve => {
|
|
34
|
+
let done = false;
|
|
35
|
+
const finish = () => { if (!done) { done = true; resolve(Buffer.concat(chunks, length)); } };
|
|
36
|
+
const data = (chunk: unknown) => {
|
|
37
|
+
// Already-decoded streams cannot attest exact source bytes.
|
|
38
|
+
if (!(chunk instanceof Uint8Array)) { failed = true; terminate(); return; }
|
|
39
|
+
const available = cap - length;
|
|
40
|
+
if (chunk.byteLength > available) { incomplete = true; terminate(); }
|
|
41
|
+
if (available > 0) { const part = Buffer.from(chunk.subarray(0, available)); chunks.push(part); length += part.length; }
|
|
42
|
+
// Keep draining beyond the cap until process/streams settle.
|
|
43
|
+
};
|
|
44
|
+
const error = () => { failed = true; terminate(); finish(); };
|
|
45
|
+
const closed = () => { if (!done && !stream.readableEnded) failed = true; finish(); };
|
|
46
|
+
stream.on("data", data); stream.once("end", finish); stream.once("error", error); stream.once("close", closed);
|
|
47
|
+
collectors.push({ dispose: () => { stream.removeListener("data", data); stream.removeListener("end", finish); stream.removeListener("error", error); stream.removeListener("close", closed); } });
|
|
48
|
+
});
|
|
49
|
+
};
|
|
50
|
+
let onClose: (code: number | null) => void;
|
|
51
|
+
let onError: () => void;
|
|
52
|
+
const exit = new Promise<number>(resolve => {
|
|
53
|
+
onClose = code => { settled = true; resolve(code ?? 1); };
|
|
54
|
+
onError = () => { failed = true; settled = true; child.stdout?.destroy(); child.stderr?.destroy(); resolve(1); };
|
|
55
|
+
child.once("close", onClose); child.once("error", onError);
|
|
56
|
+
});
|
|
57
|
+
const stdout = collect(child.stdout, WORKSPACE_LIST_CAPTURE_LIMITS.stdoutBytes);
|
|
58
|
+
const stderr = collect(child.stderr, WORKSPACE_LIST_CAPTURE_LIMITS.stderrBytes);
|
|
59
|
+
const onAbort = () => { aborted = true; terminate(); };
|
|
60
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
61
|
+
const timeout = schedule(() => { incomplete = true; terminate(); }, Math.min(dependencies.timeoutMs ?? WORKSPACE_LIST_CAPTURE_LIMITS.timeoutMs, WORKSPACE_LIST_CAPTURE_LIMITS.timeoutMs));
|
|
62
|
+
if (signal?.aborted) onAbort();
|
|
63
|
+
try {
|
|
64
|
+
const [code, bytes, errors] = await Promise.all([exit, stdout, stderr]);
|
|
65
|
+
if (aborted || signal?.aborted) throw new WorkspaceListCommandError("aborted");
|
|
66
|
+
if (incomplete) throw new WorkspaceListCommandError("capture_incomplete");
|
|
67
|
+
if (failed || code !== 0 || errors.length) throw new WorkspaceListCommandError("command_failed");
|
|
68
|
+
return bytes;
|
|
69
|
+
} finally {
|
|
70
|
+
cancel(timeout); if (killTimer !== undefined) cancel(killTimer);
|
|
71
|
+
signal?.removeEventListener("abort", onAbort);
|
|
72
|
+
child.removeListener("close", onClose!); child.removeListener("error", onError!);
|
|
73
|
+
for (const collector of collectors) collector.dispose();
|
|
74
|
+
}
|
|
75
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import type { Readable } from "node:stream";
|
|
3
|
+
import { commandExecutionStorage, getActiveAbortSignal } from "./context";
|
|
4
|
+
import { getCmExecutable } from "./process";
|
|
5
|
+
export type WorkspaceMergeAttempt = { state: "not-attempted" | "not-started" | "started" | "unknown"; terminal: "not-observed" | "observed"; exitCode: number | null; aborted: boolean; timedOut: boolean };
|
|
6
|
+
export const emptyWorkspaceMergeAttempt = (): WorkspaceMergeAttempt => ({ state: "not-attempted", terminal: "not-observed", exitCode: null, aborted: false, timedOut: false });
|
|
7
|
+
export type WorkspaceMergeCapture = { stdoutBytes: number; stderrBytes: number; stdoutRetainedBytes: number; stderrRetainedBytes: number; truncated: boolean; complete: boolean; validUtf8: boolean };
|
|
8
|
+
export type WorkspaceMergeCommandObservation = { attempt: WorkspaceMergeAttempt; capture: WorkspaceMergeCapture; stdout: string | null; stderr: string | null; failed: boolean };
|
|
9
|
+
// Forced-retirement guards have no per-command state or retained buffers.
|
|
10
|
+
const ignoreRetiredError = () => {};
|
|
11
|
+
/** Selected-tool byte capture; never retries, changes encoding, or discards effect uncertainty. */
|
|
12
|
+
export async function captureWorkspaceMergeCommand(args: string[], workdir?: string, timeoutMs = 30000): Promise<WorkspaceMergeCommandObservation> {
|
|
13
|
+
const attempt = emptyWorkspaceMergeAttempt();
|
|
14
|
+
const capture: WorkspaceMergeCapture = { stdoutBytes: 0, stderrBytes: 0, stdoutRetainedBytes: 0, stderrRetainedBytes: 0, truncated: false, complete: false, validUtf8: false };
|
|
15
|
+
const result: WorkspaceMergeCommandObservation = { attempt, capture, stdout: null, stderr: null, failed: false };
|
|
16
|
+
const signal = getActiveAbortSignal(), deps = commandExecutionStorage.getStore() ?? {};
|
|
17
|
+
if (signal?.aborted) { attempt.aborted = true; return result; }
|
|
18
|
+
let child: ReturnType<typeof spawn>;
|
|
19
|
+
try { child = (deps.spawn ?? spawn)(getCmExecutable(), args, { cwd: workdir ?? process.cwd(), shell: false, stdio: ["ignore", "pipe", "pipe"] }); }
|
|
20
|
+
catch { attempt.state = "not-started"; result.failed = true; return result; }
|
|
21
|
+
attempt.state = "unknown";
|
|
22
|
+
const schedule = deps.setTimeout ?? setTimeout, cancel = deps.clearTimeout ?? clearTimeout;
|
|
23
|
+
let settled = false, failed = false, started = false, retired = false, finalized = false;
|
|
24
|
+
let terminationRequested = false, killTimer: NodeJS.Timeout | undefined, retireTimer: NodeJS.Timeout | undefined;
|
|
25
|
+
let settleExit!: () => void;
|
|
26
|
+
const finishers: Array<() => void> = [], disposers: Array<() => void> = [];
|
|
27
|
+
const retire = () => {
|
|
28
|
+
if (retired || finalized) return;
|
|
29
|
+
retired = true; failed = true;
|
|
30
|
+
// Retirement is not an observed terminal event or proof that the child stopped.
|
|
31
|
+
for (const emitter of [child, child.stdout, child.stderr]) emitter?.on("error", ignoreRetiredError);
|
|
32
|
+
settleExit();
|
|
33
|
+
for (const finish of finishers) finish();
|
|
34
|
+
child.stdout?.destroy(); child.stderr?.destroy();
|
|
35
|
+
};
|
|
36
|
+
const terminate = () => {
|
|
37
|
+
if (terminationRequested || retired || finalized) return;
|
|
38
|
+
terminationRequested = true;
|
|
39
|
+
const grace = deps.abortKillDelayMs ?? 5000;
|
|
40
|
+
if (!settled) { try { child.kill("SIGTERM"); } catch { /* not termination proof */ } }
|
|
41
|
+
killTimer = schedule(() => {
|
|
42
|
+
if (finalized || retired) return;
|
|
43
|
+
if (!settled) { try { child.kill("SIGKILL"); } catch { /* not termination proof */ } }
|
|
44
|
+
// Same bounded drain grace after KILL; terminal state never gates retirement.
|
|
45
|
+
retireTimer = schedule(retire, grace);
|
|
46
|
+
}, grace);
|
|
47
|
+
};
|
|
48
|
+
const collect = (stream: Readable | null, cap: number, name: "stdout" | "stderr") => {
|
|
49
|
+
let length = 0; const parts: Buffer[] = [];
|
|
50
|
+
if (!stream) { failed = true; terminate(); return Promise.resolve(Buffer.alloc(0)); }
|
|
51
|
+
if (stream.readableEncoding) { failed = true; terminate(); }
|
|
52
|
+
return new Promise<Buffer>(resolve => {
|
|
53
|
+
let done = false;
|
|
54
|
+
const finish = () => { if (!done) { done = true; const bytes = Buffer.concat(parts, length); parts.length = 0; resolve(bytes); } };
|
|
55
|
+
finishers.push(finish);
|
|
56
|
+
const data = (chunk: unknown) => {
|
|
57
|
+
if (done || retired || finalized) return;
|
|
58
|
+
if (!(chunk instanceof Uint8Array)) { failed = true; terminate(); return; }
|
|
59
|
+
const key = name === "stdout" ? "stdoutBytes" : "stderrBytes";
|
|
60
|
+
capture[key] = Math.min(Number.MAX_SAFE_INTEGER, capture[key] + chunk.byteLength);
|
|
61
|
+
const available = cap - length;
|
|
62
|
+
if (chunk.byteLength > available) { capture.truncated = true; terminate(); }
|
|
63
|
+
if (available > 0) { const part = Buffer.from(chunk.subarray(0, available)); parts.push(part); length += part.length; }
|
|
64
|
+
};
|
|
65
|
+
const error = () => { if (retired || finalized) return; failed = true; terminate(); finish(); };
|
|
66
|
+
const close = () => { if (retired || finalized) return; if (!done && !stream.readableEnded) failed = true; finish(); };
|
|
67
|
+
stream.on("data", data); stream.once("end", finish); stream.on("error", error); stream.once("close", close);
|
|
68
|
+
disposers.push(() => { stream.removeListener("data", data); stream.removeListener("end", finish); stream.removeListener("error", error); stream.removeListener("close", close); });
|
|
69
|
+
});
|
|
70
|
+
};
|
|
71
|
+
let onClose!: (code: number | null, terminalSignal?: string | null) => void, onError!: (error: NodeJS.ErrnoException) => void;
|
|
72
|
+
const onSpawn = () => { if (retired || finalized) return; started = true; attempt.state = "started"; };
|
|
73
|
+
const exit = new Promise<void>(resolve => {
|
|
74
|
+
settleExit = resolve;
|
|
75
|
+
onClose = (code, terminalSignal) => {
|
|
76
|
+
if (settled || retired || finalized) return; settled = true; attempt.terminal = "observed";
|
|
77
|
+
attempt.exitCode = Number.isInteger(code) && code !== null && code >= 0 && code <= 2147483647 ? code : null;
|
|
78
|
+
// A terminal event is not a substitute for the native start event.
|
|
79
|
+
if (!started) failed = true;
|
|
80
|
+
if (attempt.exitCode === null || terminalSignal) failed = true;
|
|
81
|
+
resolve();
|
|
82
|
+
};
|
|
83
|
+
onError = error => {
|
|
84
|
+
if (retired || finalized) return;
|
|
85
|
+
failed = true;
|
|
86
|
+
if (!started && ["ENOENT", "EACCES", "ENOTDIR"].includes(error.code ?? "")) {
|
|
87
|
+
attempt.state = "not-started"; settled = true; child.stdout?.destroy(); child.stderr?.destroy(); resolve();
|
|
88
|
+
} else { terminate(); }
|
|
89
|
+
};
|
|
90
|
+
child.once("spawn", onSpawn); child.once("close", onClose); child.on("error", onError);
|
|
91
|
+
});
|
|
92
|
+
const out = collect(child.stdout, 65536, "stdout"), err = collect(child.stderr, 16384, "stderr");
|
|
93
|
+
const onAbort = () => { if (retired || finalized) return; attempt.aborted = true; terminate(); };
|
|
94
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
95
|
+
const timer = schedule(() => { if (retired || finalized) return; attempt.timedOut = true; terminate(); }, Math.min(deps.timeoutMs ?? timeoutMs, timeoutMs));
|
|
96
|
+
if (signal?.aborted) onAbort();
|
|
97
|
+
try {
|
|
98
|
+
const [, stdout, stderr] = await Promise.all([exit, out, err]);
|
|
99
|
+
capture.stdoutRetainedBytes = stdout.length; capture.stderrRetainedBytes = stderr.length;
|
|
100
|
+
result.failed = failed;
|
|
101
|
+
try {
|
|
102
|
+
// Retain a literal UTF-8 BOM rather than silently removing source bytes.
|
|
103
|
+
result.stdout = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(stdout);
|
|
104
|
+
result.stderr = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(stderr);
|
|
105
|
+
capture.validUtf8 = true;
|
|
106
|
+
} catch { result.stdout = null; result.stderr = null; }
|
|
107
|
+
if (signal?.aborted) attempt.aborted = true;
|
|
108
|
+
capture.complete = !failed && !result.failed && !attempt.aborted && !attempt.timedOut && capture.validUtf8 && !capture.truncated && attempt.terminal === "observed";
|
|
109
|
+
return result;
|
|
110
|
+
} finally {
|
|
111
|
+
finalized = true;
|
|
112
|
+
cancel(timer); if (killTimer !== undefined) cancel(killTimer); if (retireTimer !== undefined) cancel(retireTimer);
|
|
113
|
+
signal?.removeEventListener("abort", onAbort);
|
|
114
|
+
child.removeListener("spawn", onSpawn); child.removeListener("close", onClose); child.removeListener("error", onError);
|
|
115
|
+
for (const dispose of disposers) dispose();
|
|
116
|
+
finishers.length = 0; disposers.length = 0;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { captureAddCommand, emptyAddAttempt } from "../execution/add-command";
|
|
2
|
+
import { ADD_COMMAND, safeAddText, validAddOperands, type AddData, type AddError, type AddReceipt } from "../domain/add-contract";
|
|
3
|
+
export { ADD_COMMAND, safeAddText, validAddOperands } from "../domain/add-contract";
|
|
4
|
+
export type { AddData, AddError, AddReceipt, AddAttempt, AddCapture } from "../domain/add-contract";
|
|
5
|
+
export { presentAddReceipt } from "../presentation/add-results";
|
|
6
|
+
export function emptyAddData(): AddData {
|
|
7
|
+
return { intendedArgv: [ADD_COMMAND], requestedOperandCount: null, workingDirectory: null, attempt: emptyAddAttempt(), capture: null, observedWorkspaceIdentity: null, observedAddedItems: null, verification: "unverified", effect: "not-attempted" };
|
|
8
|
+
}
|
|
9
|
+
function addHeader(data: AddData) {
|
|
10
|
+
return { schemaVersion: 1 as const, action: "add" as const, provenance: { source: "plastic" as const, producer: "@aefree/pi-plastic" as const, contentTrust: "external" as const }, completeness: { capture: data.capture ? data.capture.complete ? "complete" as const : "incomplete" as const : "unknown" as const, projection: true as const }, data };
|
|
11
|
+
}
|
|
12
|
+
export function addFailure(data: AddData, code: AddError["code"], message: string): AddReceipt {
|
|
13
|
+
return { ...addHeader(data), ok: false, outcome: data.effect === "uncertain" ? "uncertain" : "failed", error: { code, message } };
|
|
14
|
+
}
|
|
15
|
+
export async function assembleAddReceipt(args: unknown): Promise<AddReceipt> {
|
|
16
|
+
const data = emptyAddData(); let dispatched = false;
|
|
17
|
+
try {
|
|
18
|
+
if (!args || typeof args !== "object" || Array.isArray(args) || Object.keys(args).some(key => key !== "workdir" && key !== "paths")) return addFailure(data, "invalid_request", "Add accepts paths and workdir only; preview and extra options are unsupported.");
|
|
19
|
+
const input = args as Record<string, unknown>;
|
|
20
|
+
const cwd = input.workdir === undefined ? process.cwd() : input.workdir;
|
|
21
|
+
if (!safeAddText(cwd)) return addFailure(data, "invalid_request", "Working directory must be nonblank bounded text without controls or invalid Unicode.");
|
|
22
|
+
const paths = Array.isArray(input.paths) && input.paths.length <= 256 ? Array.from(input.paths) : input.paths;
|
|
23
|
+
if (!validAddOperands(paths)) return addFailure(data, "invalid_request", "Provide 1–256 bounded path operands; CLI options and stdin sentinel are rejected, aggregate UTF8 limit 32768 bytes.");
|
|
24
|
+
data.workingDirectory = cwd; data.intendedArgv = [ADD_COMMAND, ...paths]; data.requestedOperandCount = paths.length; dispatched = true;
|
|
25
|
+
const observation = await captureAddCommand([...data.intendedArgv], cwd);
|
|
26
|
+
data.attempt = observation.attempt; data.capture = observation.capture;
|
|
27
|
+
if (observation.capture.complete && !observation.failed && observation.attempt.state === "started" && observation.attempt.exitCode === 0) {
|
|
28
|
+
data.effect = "not-proven";
|
|
29
|
+
return { ...addHeader(data), ok: true, outcome: "command-completed" };
|
|
30
|
+
}
|
|
31
|
+
data.effect = ["started", "unknown"].includes(data.attempt.state) ? "uncertain" : "not-attempted";
|
|
32
|
+
return addFailure(data, data.attempt.aborted ? "aborted" : data.effect === "uncertain" ? "uncertain" : "launch_failed", data.effect === "uncertain" ? "Add completion is not proven; partial added-item or workspace effects may exist." : "Add did not establish a process start.");
|
|
33
|
+
} catch {
|
|
34
|
+
if (dispatched) { data.attempt = { ...emptyAddAttempt(), state: "unknown" }; data.capture = null; data.effect = "uncertain"; }
|
|
35
|
+
return addFailure(data, "producer_failed", "Add observation failed; no rollback or safe retry is proven.");
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { resolveBranchCreationTarget, normalizeBranchSpecForComparison } from "../domain/branches";
|
|
2
|
+
import { emptyBranchCreateAttempt, safeBranchCreateText, parseBranchCreateParent, type BranchCreateRequest, type BranchCreateReceipt, type BranchCreateData, type BranchCreateError } from "../domain/branch-create-contract";
|
|
3
|
+
import { captureBranchCreateCommand } from "../execution/branch-create-command";
|
|
4
|
+
export { presentBranchCreateReceipt } from "../presentation/branch-create";
|
|
5
|
+
export { safeBranchCreateText } from "../domain/branch-create-contract";
|
|
6
|
+
export type { BranchCreateReceipt, BranchCreateAttempt, BranchCreateCapture } from "../domain/branch-create-contract";
|
|
7
|
+
class BranchCreateInputError extends Error {}
|
|
8
|
+
export function validateBranchCreateRequest(input: unknown): asserts input is BranchCreateRequest {
|
|
9
|
+
if (!input || typeof input !== "object" || Array.isArray(input)) throw new BranchCreateInputError("Branch creation input must be an object.");
|
|
10
|
+
const r = input as Record<string, unknown>;
|
|
11
|
+
const strings = ["branch", "parent", "changeset", "label", "comment", "commentsFile", "workdir"];
|
|
12
|
+
if (Object.keys(r).some(k => ![...strings, "allowRootBranch"].includes(k)) || r.allowRootBranch !== undefined && typeof r.allowRootBranch !== "boolean") throw new BranchCreateInputError("Branch creation input contains unsupported options.");
|
|
13
|
+
for (const key of strings) if (r[key] !== undefined && (typeof r[key] !== "string" || !safeBranchCreateText(r[key] as string))) throw new BranchCreateInputError("Branch creation strings must be bounded and free of control or malformed characters.");
|
|
14
|
+
if (typeof r.branch !== "string" || !r.branch.trim()) throw new BranchCreateInputError("Branch must be non-empty.");
|
|
15
|
+
if (r.changeset && r.label) throw new BranchCreateInputError("Provide either changeset or label, not both.");
|
|
16
|
+
if (r.comment && r.commentsFile) throw new BranchCreateInputError("Provide either comment or commentsFile, not both.");
|
|
17
|
+
if (r.comment !== undefined && !(r.comment as string).trim()) throw new BranchCreateInputError("Comment must be non-empty when provided.");
|
|
18
|
+
}
|
|
19
|
+
export async function assembleBranchCreateReceipt(input: unknown): Promise<BranchCreateReceipt> {
|
|
20
|
+
const data: BranchCreateData = { requested: null, resolvedTarget: null, parentResolution: { basis: "not-required", observedBranch: null, repositoryVerification: "unverified" }, parentCommands: [], command: null, attempt: emptyBranchCreateAttempt(), capture: null, observedCreatedIdentity: null, repositoryVerification: "unverified", workspaceSwitch: "not-requested" };
|
|
21
|
+
const completeness: BranchCreateReceipt["completeness"] = { capture: "unknown", projection: true };
|
|
22
|
+
let stage: BranchCreateError["stage"] = "input";
|
|
23
|
+
const finish = (outcome: BranchCreateReceipt["outcome"], code: BranchCreateError["code"] = "uncertain", message = "Branch creation evidence is unverified; do not retry automatically."): BranchCreateReceipt => ({ schemaVersion: 1, action: "branch-create", provenance: { source: "plastic", producer: "@aefree/pi-plastic", contentTrust: "external" }, completeness, ok: outcome === "command-completed", outcome, data: { ...data, effect: outcome === "command-completed" ? "not-proven" : outcome === "uncertain" ? "uncertain" : "not-attempted" }, ...(outcome === "command-completed" ? {} : { error: { code, stage, message } }) }) as BranchCreateReceipt;
|
|
24
|
+
try {
|
|
25
|
+
validateBranchCreateRequest(input);
|
|
26
|
+
const r = input;
|
|
27
|
+
data.requested = { branch: r.branch, parent: r.parent ?? null, changeset: r.changeset ?? null, label: r.label ?? null, comment: r.comment ?? null, commentsFile: r.commentsFile ?? null, allowRootBranch: r.allowRootBranch ?? false };
|
|
28
|
+
const relative = !normalizeBranchSpecForComparison(r.branch).startsWith("/");
|
|
29
|
+
// These forms formerly discarded qualification. Never dispatch a different scope.
|
|
30
|
+
if (relative && (r.branch.includes("@") || r.parent?.includes("@"))) return finish("unsupported", "ambiguous_qualifier", "Relative qualifier-bearing branch creation cannot resolve scope safely; provide a full hierarchical target.");
|
|
31
|
+
stage = "source";
|
|
32
|
+
if (process.platform !== "win32") return finish("unsupported", "unsupported_source", "Branch creation source is admitted only on Windows.");
|
|
33
|
+
let parent = r.parent;
|
|
34
|
+
if (relative && parent === undefined) {
|
|
35
|
+
stage = "parent"; data.parentResolution.basis = "unresolved";
|
|
36
|
+
for (const argv of [["status"], ["status", "--compact"]]) {
|
|
37
|
+
const obs = await captureBranchCreateCommand(argv, r.workdir);
|
|
38
|
+
data.parentCommands.push({ argv, attempt: obs.attempt, capture: obs.capture });
|
|
39
|
+
completeness.capture = obs.capture.complete ? "complete" : "incomplete";
|
|
40
|
+
if (!obs.capture.complete || obs.failed || obs.attempt.exitCode !== 0 || obs.capture.stderrBytes) return finish("failed", "parent_unresolved", "Current parent could not be observed; provide an explicit parent.");
|
|
41
|
+
// Only standard status has admitted original loaded-branch fidelity.
|
|
42
|
+
if (argv.length === 1) parent = parseBranchCreateParent(obs.stdout! ) ?? undefined;
|
|
43
|
+
if (parent !== undefined) break;
|
|
44
|
+
}
|
|
45
|
+
if (parent === undefined) return finish("unsupported", "parent_unresolved", "A changeset's owning branch does not identify the loaded branch; provide an explicit parent.");
|
|
46
|
+
data.parentResolution = { basis: "status", observedBranch: parent, repositoryVerification: "unverified" };
|
|
47
|
+
} else if (relative) data.parentResolution.basis = "explicit";
|
|
48
|
+
stage = "input";
|
|
49
|
+
let target: string;
|
|
50
|
+
try { target = resolveBranchCreationTarget(r.branch, parent, r.allowRootBranch); }
|
|
51
|
+
catch {
|
|
52
|
+
const root = normalizeBranchSpecForComparison(r.branch).startsWith("/") && normalizeBranchSpecForComparison(r.branch).split("/").filter(Boolean).length === 1;
|
|
53
|
+
throw new BranchCreateInputError(root ? "Refusing to create top-level branch. Provide a hierarchical target or allowRootBranch=true for intentional root creation." : "Parent branch must be a non-root hierarchical branch path.");
|
|
54
|
+
}
|
|
55
|
+
if (!safeBranchCreateText(target)) throw new BranchCreateInputError("Resolved branch exceeds the identity bound.");
|
|
56
|
+
const argv = ["branch", "create", target];
|
|
57
|
+
if (r.changeset) argv.push(`--changeset=${r.changeset}`);
|
|
58
|
+
if (r.label) argv.push(`--label=${r.label}`);
|
|
59
|
+
if (r.comment) argv.push(`-c=${r.comment}`);
|
|
60
|
+
if (r.commentsFile) argv.push(`-commentsfile=${r.commentsFile}`);
|
|
61
|
+
if (argv.length > 8 || argv.some(s => s.length > 8192) || Buffer.byteLength(JSON.stringify(argv), "utf8") > 65536) throw new BranchCreateInputError("Branch creation argv exceeds the aggregate bound.");
|
|
62
|
+
data.resolvedTarget = target; data.command = argv;
|
|
63
|
+
stage = "create";
|
|
64
|
+
const obs = await captureBranchCreateCommand(argv, r.workdir);
|
|
65
|
+
data.attempt = obs.attempt; data.capture = obs.capture;
|
|
66
|
+
completeness.capture = obs.capture.complete ? "complete" : "incomplete";
|
|
67
|
+
if (["not-attempted", "not-started"].includes(obs.attempt.state)) return finish("failed", obs.attempt.aborted ? "aborted" : "launch_failed");
|
|
68
|
+
if (!obs.failed && obs.capture.complete && obs.attempt.state === "started" && obs.attempt.terminal === "observed" && obs.attempt.exitCode === 0 && !obs.attempt.aborted && !obs.attempt.timedOut && !obs.capture.stderrBytes) return finish("command-completed");
|
|
69
|
+
return finish("uncertain");
|
|
70
|
+
} catch (error) {
|
|
71
|
+
if (stage === "create") { if (data.attempt.state === "not-attempted") data.attempt.state = "unknown"; return finish("uncertain", "producer_failed"); }
|
|
72
|
+
return finish("failed", stage === "input" ? "invalid_request" : "parent_unresolved", error instanceof BranchCreateInputError ? error.message : "Branch target or current parent could not be resolved safely.");
|
|
73
|
+
}
|
|
74
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { captureWorkspaceMergeCommand, emptyWorkspaceMergeAttempt } from "../execution/workspace-merge-command";
|
|
2
|
+
import { branchDeleteArgv, safeBranchDeleteText, validBranchDeleteOperand, type BranchDeleteData, type BranchDeleteError, type BranchDeleteReceipt } from "../domain/branch-delete-contract";
|
|
3
|
+
export { branchDeleteArgv, safeBranchDeleteText, validBranchDeleteOperand } from "../domain/branch-delete-contract";
|
|
4
|
+
export type { BranchDeleteReceipt } from "../domain/branch-delete-contract";
|
|
5
|
+
export { presentBranchDeleteReceipt } from "../presentation/branch-delete-results";
|
|
6
|
+
export function emptyBranchDeleteData(): BranchDeleteData { return { intendedArgv:["branch","delete"], requestedBranch:null, workingDirectory:null, deleteChangesetsRequested:null, previewRequested:null, outputFormatRequested:null, attempt:emptyWorkspaceMergeAttempt(), capture:null, observedDeletedBranch:null, observedDeletedChangesets:null, observedRepositoryIdentity:null, observedWorkspaceIdentity:null, verification:"unverified", rollback:"not-proven", effect:"not-attempted" }; }
|
|
7
|
+
function header(data:BranchDeleteData) { return { schemaVersion:1 as const, action:"branch-delete" as const, provenance:{source:"plastic" as const,producer:"@aefree/pi-plastic" as const,contentTrust:"external" as const},completeness:{capture:data.capture?data.capture.complete?"complete" as const:"incomplete" as const:"unknown" as const,projection:true as const},data }; }
|
|
8
|
+
export function branchDeleteFailure(data:BranchDeleteData,code:BranchDeleteError["code"],message:string):BranchDeleteReceipt { return {...header(data),ok:false,outcome:data.effect==="uncertain"?"uncertain":"failed",error:{code,message}}; }
|
|
9
|
+
export async function assembleBranchDeleteReceipt(args:unknown):Promise<BranchDeleteReceipt> {
|
|
10
|
+
const data=emptyBranchDeleteData();let entered=false;
|
|
11
|
+
try {
|
|
12
|
+
if(!args||typeof args!=="object"||Array.isArray(args)||Object.keys(args).some(k=>!["branch","deleteChangesets","preflight","format","workdir"].includes(k)))return branchDeleteFailure(data,"invalid_request","Use only declared bounded branch-delete controls.");
|
|
13
|
+
const i=Object.freeze({...args}) as Record<string,unknown>,branch=i.branch,cwd=i.workdir??(i.workdir===undefined?process.cwd():null),del=i.deleteChangesets===undefined?false:i.deleteChangesets,preview=i.preflight===undefined?false:i.preflight,format=i.format===undefined?"text":i.format;
|
|
14
|
+
if(!validBranchDeleteOperand(branch)||!safeBranchDeleteText(cwd)||typeof del!=="boolean"||typeof preview!=="boolean"||!["text","json"].includes(format as string))return branchDeleteFailure(data,"invalid_request","Use one bounded exact branch operand, strict booleans and text/json. Options/stdin are not branch operands.");
|
|
15
|
+
data.requestedBranch=branch;data.workingDirectory=cwd;data.deleteChangesetsRequested=del;data.previewRequested=preview;data.outputFormatRequested=format as "text"|"json";data.intendedArgv=branchDeleteArgv(branch,del);
|
|
16
|
+
if(preview)return {...header(data),ok:true,outcome:"preflight"};
|
|
17
|
+
entered=true;const o=await captureWorkspaceMergeCommand([...data.intendedArgv],cwd);data.attempt=o.attempt;data.capture=o.capture;
|
|
18
|
+
if(o.capture.complete&&!o.failed&&o.attempt.state==="started"&&o.attempt.exitCode===0){data.effect="not-proven";return {...header(data),ok:true,outcome:"command-completed"};}
|
|
19
|
+
data.effect=["started","unknown"].includes(data.attempt.state)?"uncertain":"not-attempted";
|
|
20
|
+
return branchDeleteFailure(data,data.attempt.aborted?"aborted":data.effect==="uncertain"?"uncertain":"launch_failed",data.effect==="uncertain"?"Branch deletion completion is uncertain; branch/history effects may already exist.":"Branch deletion did not establish process start.");
|
|
21
|
+
}catch{
|
|
22
|
+
if(entered){data.attempt={...emptyWorkspaceMergeAttempt(),state:"unknown"};data.capture=null;data.effect="uncertain";}
|
|
23
|
+
return branchDeleteFailure(data,"producer_failed","Branch deletion observation failed; no rollback or safe retry is proven.");
|
|
24
|
+
}
|
|
25
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { cmWhereEquals, cmWhereLike } from "../domain/branches";
|
|
2
|
+
import { runCm } from "../execution/cm";
|
|
3
|
+
import { spawnAndCollect, getCmExecutable } from "../execution/process";
|
|
4
|
+
import { commandExecutionStorage, getActiveAbortSignal } from "../execution/context";
|
|
5
|
+
import { runBranchListNamesCommand, BranchListCommandError, BRANCH_LIST_CAPTURE_LIMITS } from "../execution/branch-list-command";
|
|
6
|
+
|
|
7
|
+
export type BranchListArgs = { nameLike?: string; parent?: string; owner?: string; includeHidden?: boolean; limit?: number; orderBy?: "date" | "branchname"; descending?: boolean; workdir?: string; source?: "native" | "names"; format?: "text" | "json"; maxItems?: number };
|
|
8
|
+
export type BranchListQuery = { nameLike: string | null; parent: string | null; owner: string | null; includeHidden: boolean; limit: number | null; orderBy: "date" | "branchname" | null; descending: boolean };
|
|
9
|
+
export class BranchListError extends Error {
|
|
10
|
+
constructor(readonly code: "invalid_identity" | "malformed_output" | "unsupported_query" | "invalid_producer_data" | "output_overflow") { super(`Branch list observation failed (${code}); no reusable rows are available.`); }
|
|
11
|
+
}
|
|
12
|
+
export const validBranchQueryString = (value: string): boolean => value.length <= 4096 && !/[\u0000-\u001f\u007f-\u009f\uD800-\uDFFF]/u.test(value);
|
|
13
|
+
export const validBranchListIdentity = (value: string): boolean => value.length > 0 && value.length <= 4096
|
|
14
|
+
&& /^\/[^/@\\]+(?:\/[^/@\\]+)*$/u.test(value)
|
|
15
|
+
&& !/[\u0000-\u001f\u007f-\u009f\uD800-\uDFFF\uFFFD]/u.test(value)
|
|
16
|
+
&& !(process.platform === "win32" && value.includes("?"));
|
|
17
|
+
export function branchListRequest(args: BranchListArgs): { source: "native" | "names"; maxItems: number; query: BranchListQuery } {
|
|
18
|
+
for (const value of [args.nameLike, args.parent, args.owner]) if (value !== undefined && (typeof value !== "string" || !validBranchQueryString(value))) throw new BranchListError("invalid_identity");
|
|
19
|
+
if ((args.source !== undefined && args.source !== "native" && args.source !== "names")
|
|
20
|
+
|| (args.format !== undefined && args.format !== "text" && args.format !== "json")
|
|
21
|
+
|| (args.orderBy !== undefined && args.orderBy !== "date" && args.orderBy !== "branchname")
|
|
22
|
+
|| [args.includeHidden, args.descending].some(value => value !== undefined && typeof value !== "boolean")
|
|
23
|
+
|| (args.limit !== undefined && (!Number.isSafeInteger(args.limit) || args.limit < 1))
|
|
24
|
+
|| (args.maxItems !== undefined && (!Number.isSafeInteger(args.maxItems) || args.maxItems < 1 || args.maxItems > 500))) throw new BranchListError("invalid_producer_data");
|
|
25
|
+
const source = args.source ?? "native";
|
|
26
|
+
if (source === "names" && args.includeHidden === true) throw new BranchListError("unsupported_query");
|
|
27
|
+
return { source, maxItems: args.maxItems ?? 100, query: { nameLike: args.nameLike ?? null, parent: args.parent ?? null, owner: args.owner ?? null, includeHidden: args.includeHidden ?? false, limit: args.limit ?? null, orderBy: args.orderBy ?? null, descending: args.descending ?? false } };
|
|
28
|
+
}
|
|
29
|
+
export function branchListCommand(query: BranchListQuery, source: "native" | "names"): string[] {
|
|
30
|
+
const where: string[] = [];
|
|
31
|
+
if (query.nameLike) where.push(cmWhereLike("name", query.nameLike));
|
|
32
|
+
if (query.parent) where.push(cmWhereEquals("parent", query.parent));
|
|
33
|
+
if (query.owner) where.push(cmWhereEquals("owner", query.owner));
|
|
34
|
+
where.push(query.includeHidden ? "(hidden = 'true' or hidden = 'false')" : "hidden = 'false'");
|
|
35
|
+
return ["find", "branch", `where ${where.join(" and ")}`, ...(query.orderBy ? [`order by ${query.orderBy}${query.descending ? " desc" : " asc"}`] : []), ...(query.limit ? [`limit ${query.limit}`] : []), "--nototal", ...(source === "names" ? ["--format={name}", "--encoding=utf-8"] : [])];
|
|
36
|
+
}
|
|
37
|
+
export function parseBranchListNames(bytes: Uint8Array): string[] {
|
|
38
|
+
if (bytes.byteLength > BRANCH_LIST_CAPTURE_LIMITS.stdoutBytes) throw new BranchListCommandError("capture_incomplete");
|
|
39
|
+
let output: string;
|
|
40
|
+
// Preserve BOM as data: a foreign prefix must not silently disappear.
|
|
41
|
+
try { output = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(bytes); } catch { throw new BranchListError("invalid_identity"); }
|
|
42
|
+
if (!output) return [];
|
|
43
|
+
const rows = output.split(/\r?\n/u);
|
|
44
|
+
if (rows.at(-1) === "") rows.pop();
|
|
45
|
+
if (rows.length > BRANCH_LIST_CAPTURE_LIMITS.records) throw new BranchListCommandError("capture_incomplete");
|
|
46
|
+
const seen = new Set<string>();
|
|
47
|
+
for (const row of rows) {
|
|
48
|
+
if (!validBranchListIdentity(row)) throw new BranchListError("invalid_identity");
|
|
49
|
+
if (seen.has(row)) throw new BranchListError("malformed_output");
|
|
50
|
+
seen.add(row);
|
|
51
|
+
}
|
|
52
|
+
return rows;
|
|
53
|
+
}
|
|
54
|
+
export type BranchListObservation = ReturnType<typeof branchListRequest> & ({ source: "native"; rawResult: string } | { source: "names"; branches: string[] });
|
|
55
|
+
export async function assembleBranchListObservation(args: BranchListArgs, typedNative = false): Promise<BranchListObservation> {
|
|
56
|
+
const request = branchListRequest(args);
|
|
57
|
+
const command = branchListCommand(request.query, request.source);
|
|
58
|
+
if (request.source === "names") {
|
|
59
|
+
const bytes = await runBranchListNamesCommand(command, args.workdir);
|
|
60
|
+
if (getActiveAbortSignal()?.aborted) throw new BranchListCommandError("aborted");
|
|
61
|
+
const branches = parseBranchListNames(bytes);
|
|
62
|
+
if (getActiveAbortSignal()?.aborted) throw new BranchListCommandError("aborted");
|
|
63
|
+
return { ...request, source: "names", branches };
|
|
64
|
+
}
|
|
65
|
+
// Core default keeps runCm's legacy string/error presentation. The registered
|
|
66
|
+
// native adapter classifies observed process flags, never formatted errors.
|
|
67
|
+
if (!typedNative) return { ...request, source: "native", rawResult: await runCm(command, args.workdir) };
|
|
68
|
+
const signal = getActiveAbortSignal();
|
|
69
|
+
if (signal?.aborted) throw new BranchListCommandError("aborted");
|
|
70
|
+
let result;
|
|
71
|
+
try { result = await spawnAndCollect(getCmExecutable(), command, args.workdir ?? process.cwd(), undefined, signal, commandExecutionStorage.getStore()); }
|
|
72
|
+
catch { throw new BranchListCommandError(signal?.aborted ? "aborted" : "command_failed"); }
|
|
73
|
+
if (result.aborted || signal?.aborted) throw new BranchListCommandError("aborted");
|
|
74
|
+
if (result.timedOut || result.stdoutTruncated || result.stderrTruncated) throw new BranchListCommandError("capture_incomplete");
|
|
75
|
+
if (result.exitCode !== 0) throw new BranchListCommandError("command_failed");
|
|
76
|
+
return { ...request, source: "native", rawResult: [result.stdout, result.stderr].filter(Boolean).join("\n").trim() || "(no output)" };
|
|
77
|
+
}
|
|
78
|
+
export function presentBranchListObservation(observation: BranchListObservation): string {
|
|
79
|
+
if (observation.source === "native") return observation.rawResult;
|
|
80
|
+
const returned = observation.branches.slice(0, observation.maxItems);
|
|
81
|
+
const omitted = observation.branches.length - returned.length;
|
|
82
|
+
return [...(returned.length ? returned : ["(no observed query rows)"]), ...(omitted ? [`(${omitted} observed query rows omitted from presentation.)`] : [])].join("\n");
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export const BRANCH_LIST_MAX_BYTES = 131072;
|
|
86
|
+
export function branchListPayload(observation: BranchListObservation) {
|
|
87
|
+
const common = { schemaVersion: 1, action: "branch-list", provenance: { source: "plastic", producer: "@aefree/pi-plastic", contentTrust: "external" }, ok: true } as const;
|
|
88
|
+
const data = { scope: "workspace_repository", qualifierVerified: false, query: observation.query, limits: { maxItems: observation.maxItems, identityCodeUnits: 4096, compactUtf8Bytes: BRANCH_LIST_MAX_BYTES } } as const;
|
|
89
|
+
const payload = observation.source === "native"
|
|
90
|
+
? { ...common, completeness: { read: "unknown", capture: "unknown", projection: false }, data: { ...data, mode: "native", basis: "find_branch_native", rows: null, countBasis: "unavailable", counts: null } }
|
|
91
|
+
: { ...common, completeness: { read: "complete", capture: "complete", projection: observation.branches.length <= observation.maxItems }, data: { ...data, mode: "names", basis: "find_branch_name_utf8", rows: observation.branches.slice(0, observation.maxItems).map(branch => ({ branch })), countBasis: "observed_query_rows", counts: { observed: observation.branches.length, returned: Math.min(observation.branches.length, observation.maxItems), omitted: Math.max(0, observation.branches.length - observation.maxItems), excluded: 0 } } };
|
|
92
|
+
if (Buffer.byteLength(JSON.stringify(payload), "utf8") > BRANCH_LIST_MAX_BYTES) throw new BranchListError("output_overflow");
|
|
93
|
+
return payload;
|
|
94
|
+
}
|