localptp 0.1.0
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/LICENSE +23 -0
- package/README.md +159 -0
- package/dist/cli.d.ts +24 -0
- package/dist/cli.js +344 -0
- package/dist/cli.js.map +1 -0
- package/dist/commands/config.d.ts +14 -0
- package/dist/commands/config.js +65 -0
- package/dist/commands/config.js.map +1 -0
- package/dist/commands/context.d.ts +12 -0
- package/dist/commands/context.js +176 -0
- package/dist/commands/context.js.map +1 -0
- package/dist/commands/doctor.d.ts +16 -0
- package/dist/commands/doctor.js +54 -0
- package/dist/commands/doctor.js.map +1 -0
- package/dist/commands/index.d.ts +13 -0
- package/dist/commands/index.js +70 -0
- package/dist/commands/index.js.map +1 -0
- package/dist/commands/init.d.ts +15 -0
- package/dist/commands/init.js +46 -0
- package/dist/commands/init.js.map +1 -0
- package/dist/commands/plan.d.ts +17 -0
- package/dist/commands/plan.js +170 -0
- package/dist/commands/plan.js.map +1 -0
- package/dist/commands/resume.d.ts +17 -0
- package/dist/commands/resume.js +75 -0
- package/dist/commands/resume.js.map +1 -0
- package/dist/commands/review.d.ts +17 -0
- package/dist/commands/review.js +67 -0
- package/dist/commands/review.js.map +1 -0
- package/dist/commands/run.d.ts +24 -0
- package/dist/commands/run.js +65 -0
- package/dist/commands/run.js.map +1 -0
- package/dist/commands/step.d.ts +44 -0
- package/dist/commands/step.js +50 -0
- package/dist/commands/step.js.map +1 -0
- package/dist/commands/summarize.d.ts +17 -0
- package/dist/commands/summarize.js +276 -0
- package/dist/commands/summarize.js.map +1 -0
- package/dist/commands/task.d.ts +13 -0
- package/dist/commands/task.js +53 -0
- package/dist/commands/task.js.map +1 -0
- package/dist/core/activePointer.d.ts +28 -0
- package/dist/core/activePointer.js +84 -0
- package/dist/core/activePointer.js.map +1 -0
- package/dist/core/approval.d.ts +12 -0
- package/dist/core/approval.js +34 -0
- package/dist/core/approval.js.map +1 -0
- package/dist/core/configManager.d.ts +32 -0
- package/dist/core/configManager.js +177 -0
- package/dist/core/configManager.js.map +1 -0
- package/dist/core/contextBuilder.d.ts +40 -0
- package/dist/core/contextBuilder.js +406 -0
- package/dist/core/contextBuilder.js.map +1 -0
- package/dist/core/memoryLoader.d.ts +4 -0
- package/dist/core/memoryLoader.js +35 -0
- package/dist/core/memoryLoader.js.map +1 -0
- package/dist/core/memoryManager.d.ts +41 -0
- package/dist/core/memoryManager.js +181 -0
- package/dist/core/memoryManager.js.map +1 -0
- package/dist/core/memoryPolicy.d.ts +23 -0
- package/dist/core/memoryPolicy.js +73 -0
- package/dist/core/memoryPolicy.js.map +1 -0
- package/dist/core/modelClient.d.ts +37 -0
- package/dist/core/modelClient.js +160 -0
- package/dist/core/modelClient.js.map +1 -0
- package/dist/core/patchManager.d.ts +89 -0
- package/dist/core/patchManager.js +801 -0
- package/dist/core/patchManager.js.map +1 -0
- package/dist/core/plannerJson.d.ts +23 -0
- package/dist/core/plannerJson.js +118 -0
- package/dist/core/plannerJson.js.map +1 -0
- package/dist/core/promptManager.d.ts +16 -0
- package/dist/core/promptManager.js +35 -0
- package/dist/core/promptManager.js.map +1 -0
- package/dist/core/prompts.d.ts +8 -0
- package/dist/core/prompts.js +18 -0
- package/dist/core/prompts.js.map +1 -0
- package/dist/core/repoIndexer.d.ts +21 -0
- package/dist/core/repoIndexer.js +557 -0
- package/dist/core/repoIndexer.js.map +1 -0
- package/dist/core/reviewEngine.d.ts +53 -0
- package/dist/core/reviewEngine.js +229 -0
- package/dist/core/reviewEngine.js.map +1 -0
- package/dist/core/runLoop.d.ts +26 -0
- package/dist/core/runLoop.js +103 -0
- package/dist/core/runLoop.js.map +1 -0
- package/dist/core/runStep.d.ts +42 -0
- package/dist/core/runStep.js +586 -0
- package/dist/core/runStep.js.map +1 -0
- package/dist/core/safetyManager.d.ts +7 -0
- package/dist/core/safetyManager.js +202 -0
- package/dist/core/safetyManager.js.map +1 -0
- package/dist/core/sessionManager.d.ts +35 -0
- package/dist/core/sessionManager.js +265 -0
- package/dist/core/sessionManager.js.map +1 -0
- package/dist/core/stopConditions.d.ts +24 -0
- package/dist/core/stopConditions.js +18 -0
- package/dist/core/stopConditions.js.map +1 -0
- package/dist/core/taskManager.d.ts +26 -0
- package/dist/core/taskManager.js +312 -0
- package/dist/core/taskManager.js.map +1 -0
- package/dist/core/testRunner.d.ts +27 -0
- package/dist/core/testRunner.js +71 -0
- package/dist/core/testRunner.js.map +1 -0
- package/dist/prompts/coder.d.ts +3 -0
- package/dist/prompts/coder.js +46 -0
- package/dist/prompts/coder.js.map +1 -0
- package/dist/prompts/planner.d.ts +3 -0
- package/dist/prompts/planner.js +44 -0
- package/dist/prompts/planner.js.map +1 -0
- package/dist/prompts/reviewer.d.ts +3 -0
- package/dist/prompts/reviewer.js +41 -0
- package/dist/prompts/reviewer.js.map +1 -0
- package/dist/prompts/summarizer.d.ts +3 -0
- package/dist/prompts/summarizer.js +65 -0
- package/dist/prompts/summarizer.js.map +1 -0
- package/dist/prompts/testFixer.d.ts +3 -0
- package/dist/prompts/testFixer.js +33 -0
- package/dist/prompts/testFixer.js.map +1 -0
- package/dist/repl/approver.d.ts +15 -0
- package/dist/repl/approver.js +21 -0
- package/dist/repl/approver.js.map +1 -0
- package/dist/repl/dispatch.d.ts +48 -0
- package/dist/repl/dispatch.js +164 -0
- package/dist/repl/dispatch.js.map +1 -0
- package/dist/repl/loop.d.ts +14 -0
- package/dist/repl/loop.js +124 -0
- package/dist/repl/loop.js.map +1 -0
- package/dist/repl/tokenize.d.ts +13 -0
- package/dist/repl/tokenize.js +56 -0
- package/dist/repl/tokenize.js.map +1 -0
- package/dist/templates/memory.d.ts +13 -0
- package/dist/templates/memory.js +32 -0
- package/dist/templates/memory.js.map +1 -0
- package/dist/types/config.d.ts +235 -0
- package/dist/types/config.js +66 -0
- package/dist/types/config.js.map +1 -0
- package/dist/types/context.d.ts +78 -0
- package/dist/types/context.js +141 -0
- package/dist/types/context.js.map +1 -0
- package/dist/types/index.d.ts +117 -0
- package/dist/types/index.js +24 -0
- package/dist/types/index.js.map +1 -0
- package/dist/types/model.d.ts +35 -0
- package/dist/types/model.js +16 -0
- package/dist/types/model.js.map +1 -0
- package/dist/types/patch.d.ts +38 -0
- package/dist/types/patch.js +11 -0
- package/dist/types/patch.js.map +1 -0
- package/dist/types/plan.d.ts +86 -0
- package/dist/types/plan.js +27 -0
- package/dist/types/plan.js.map +1 -0
- package/dist/types/review.d.ts +33 -0
- package/dist/types/review.js +24 -0
- package/dist/types/review.js.map +1 -0
- package/dist/types/run.d.ts +34 -0
- package/dist/types/run.js +2 -0
- package/dist/types/run.js.map +1 -0
- package/dist/types/session.d.ts +21 -0
- package/dist/types/session.js +2 -0
- package/dist/types/session.js.map +1 -0
- package/dist/types/summary.d.ts +65 -0
- package/dist/types/summary.js +26 -0
- package/dist/types/summary.js.map +1 -0
- package/dist/types/task.d.ts +31 -0
- package/dist/types/task.js +10 -0
- package/dist/types/task.js.map +1 -0
- package/dist/types/test.d.ts +16 -0
- package/dist/types/test.js +2 -0
- package/dist/types/test.js.map +1 -0
- package/dist/utils/fs.d.ts +13 -0
- package/dist/utils/fs.js +65 -0
- package/dist/utils/fs.js.map +1 -0
- package/dist/utils/gitRoot.d.ts +5 -0
- package/dist/utils/gitRoot.js +21 -0
- package/dist/utils/gitRoot.js.map +1 -0
- package/dist/utils/logger.d.ts +15 -0
- package/dist/utils/logger.js +60 -0
- package/dist/utils/logger.js.map +1 -0
- package/dist/utils/paths.d.ts +13 -0
- package/dist/utils/paths.js +24 -0
- package/dist/utils/paths.js.map +1 -0
- package/dist/utils/tokenEstimate.d.ts +5 -0
- package/dist/utils/tokenEstimate.js +8 -0
- package/dist/utils/tokenEstimate.js.map +1 -0
- package/package.json +44 -0
|
@@ -0,0 +1,801 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Patch Manager (HLD-SRD §3.10).
|
|
3
|
+
*
|
|
4
|
+
* Turns raw model output into an applied, audited patch — safely and never
|
|
5
|
+
* partially. The pipeline the `step` command drives:
|
|
6
|
+
* extractUnifiedDiff(raw) → parsePatch(diff) → validate(plan,config,root)
|
|
7
|
+
* → apply(plan,root) [git apply --check then git apply; new-file fallback]
|
|
8
|
+
* → savePatch(diff,stepId,orchestratorDir).
|
|
9
|
+
*
|
|
10
|
+
* Invariants enforced here:
|
|
11
|
+
* - extraction is tolerant of fences/prose but yields null for empty output
|
|
12
|
+
* and for a `needs_context` JSON response (so the command never tries to
|
|
13
|
+
* apply a non-diff);
|
|
14
|
+
* - validation refuses an `add` over an existing file (never overwrite), a
|
|
15
|
+
* missing modify/delete target, an ignored/generated file, and any path
|
|
16
|
+
* that escapes the repo root — including escape *through* a symlinked
|
|
17
|
+
* directory (realpath/lstat, not just `path.resolve`);
|
|
18
|
+
* - `apply` pre-flights with `git apply --check`; a failing check never
|
|
19
|
+
* mutates the tree. In a non-git dir only an add-only patch applies (via a
|
|
20
|
+
* controlled write); any existing-file edit there is refused (no rollback
|
|
21
|
+
* layer).
|
|
22
|
+
*/
|
|
23
|
+
import { promises as fs } from "node:fs";
|
|
24
|
+
import path from "node:path";
|
|
25
|
+
import { execa } from "execa";
|
|
26
|
+
import { simpleGit } from "simple-git";
|
|
27
|
+
import { ensureDir } from "../utils/fs.js";
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
// Errors
|
|
30
|
+
// ---------------------------------------------------------------------------
|
|
31
|
+
/** A path/target/ignore validation failure (§3.10, §13). Carries an exit code. */
|
|
32
|
+
export class PatchValidationError extends Error {
|
|
33
|
+
exitCode = 1;
|
|
34
|
+
constructor(message) {
|
|
35
|
+
super(message);
|
|
36
|
+
this.name = "PatchValidationError";
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
/** A patch that does not cleanly apply, or an apply mechanism failure (§12.3). */
|
|
40
|
+
export class PatchApplyError extends Error {
|
|
41
|
+
exitCode = 1;
|
|
42
|
+
constructor(message) {
|
|
43
|
+
super(message);
|
|
44
|
+
this.name = "PatchApplyError";
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
/** The working tree is not safe to apply onto (e.g. mid-merge/rebase) (§11.2). */
|
|
48
|
+
export class WorkingTreeUnsafeError extends Error {
|
|
49
|
+
exitCode = 1;
|
|
50
|
+
constructor(message) {
|
|
51
|
+
super(message);
|
|
52
|
+
this.name = "WorkingTreeUnsafeError";
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
// ---------------------------------------------------------------------------
|
|
56
|
+
// extractUnifiedDiff
|
|
57
|
+
// ---------------------------------------------------------------------------
|
|
58
|
+
/** A line that begins a unified diff. */
|
|
59
|
+
function isDiffStartLine(line) {
|
|
60
|
+
return /^diff --git /.test(line) || /^--- /.test(line);
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Tolerantly extract a unified diff from raw model output. Strips code fences
|
|
64
|
+
* and surrounding prose, returning the diff block (from its first `diff --git`
|
|
65
|
+
* or `---` line through its last diff-shaped line). Returns null when there is
|
|
66
|
+
* no diff — empty/whitespace output, a `needs_context` JSON response, or plain
|
|
67
|
+
* prose.
|
|
68
|
+
*/
|
|
69
|
+
export function extractUnifiedDiff(raw) {
|
|
70
|
+
if (raw === undefined || raw === null)
|
|
71
|
+
return null;
|
|
72
|
+
const trimmed = raw.trim();
|
|
73
|
+
if (trimmed.length === 0)
|
|
74
|
+
return null;
|
|
75
|
+
// A `needs_context` JSON response is explicitly not a diff. Detect it tolerant
|
|
76
|
+
// of fences/prose by scanning for the status token in the raw text — the
|
|
77
|
+
// command handles the structured parse; here we only need to NOT treat it as
|
|
78
|
+
// a diff.
|
|
79
|
+
if (/"status"\s*:\s*"needs_context"/.test(trimmed))
|
|
80
|
+
return null;
|
|
81
|
+
// Drop a single fenced block's fences if the diff lives inside one. We scan
|
|
82
|
+
// line-by-line instead of regex-extracting so prose outside the fence is
|
|
83
|
+
// discarded and the fence markers never leak into the diff body.
|
|
84
|
+
const lines = raw.split(/\r?\n/);
|
|
85
|
+
let start = -1;
|
|
86
|
+
for (let i = 0; i < lines.length; i++) {
|
|
87
|
+
if (isDiffStartLine(lines[i])) {
|
|
88
|
+
start = i;
|
|
89
|
+
break;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
if (start === -1)
|
|
93
|
+
return null;
|
|
94
|
+
// Collect from the first diff line to the last line that still looks like part
|
|
95
|
+
// of a unified diff. Diff body lines start with one of: `diff `, `index `,
|
|
96
|
+
// `--- `, `+++ `, `@@ `, `+`, `-`, ` ` (context), `\` (no-newline marker),
|
|
97
|
+
// `new file`, `deleted file`, `old mode`, `new mode`, `similarity`, `rename`,
|
|
98
|
+
// `copy`, `GIT binary patch`, or base85 binary literal lines. We stop at the
|
|
99
|
+
// first line after `start` that is clearly prose or a closing fence and is not
|
|
100
|
+
// a diff body line — but only after we have seen at least one hunk/header, so
|
|
101
|
+
// a blank line inside a diff does not prematurely end it.
|
|
102
|
+
const out = [];
|
|
103
|
+
for (let i = start; i < lines.length; i++) {
|
|
104
|
+
const line = lines[i];
|
|
105
|
+
if (/^```/.test(line))
|
|
106
|
+
break; // closing fence ends the diff block
|
|
107
|
+
out.push(line);
|
|
108
|
+
}
|
|
109
|
+
// Trim trailing blank lines and any trailing prose that slipped in after the
|
|
110
|
+
// diff (a line that is not a plausible diff body line, scanning from the end).
|
|
111
|
+
while (out.length > 0) {
|
|
112
|
+
const last = out[out.length - 1];
|
|
113
|
+
if (last.trim().length === 0) {
|
|
114
|
+
out.pop();
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
if (isDiffBodyLine(last))
|
|
118
|
+
break;
|
|
119
|
+
out.pop();
|
|
120
|
+
}
|
|
121
|
+
if (out.length === 0)
|
|
122
|
+
return null;
|
|
123
|
+
return out.join("\n") + "\n";
|
|
124
|
+
}
|
|
125
|
+
/** Whether a line is plausibly part of a unified-diff body (for trailing trim). */
|
|
126
|
+
function isDiffBodyLine(line) {
|
|
127
|
+
return (/^diff /.test(line) ||
|
|
128
|
+
/^index /.test(line) ||
|
|
129
|
+
/^--- /.test(line) ||
|
|
130
|
+
/^\+\+\+ /.test(line) ||
|
|
131
|
+
/^@@ /.test(line) ||
|
|
132
|
+
/^[+\- ]/.test(line) ||
|
|
133
|
+
/^\\ No newline/.test(line) ||
|
|
134
|
+
/^(new|deleted) file mode /.test(line) ||
|
|
135
|
+
/^(old|new) mode /.test(line) ||
|
|
136
|
+
/^(similarity|dissimilarity) index /.test(line) ||
|
|
137
|
+
/^(rename|copy) (from|to) /.test(line) ||
|
|
138
|
+
/^GIT binary patch/.test(line) ||
|
|
139
|
+
/^Binary files .* differ$/.test(line) ||
|
|
140
|
+
/^(literal|delta) \d+/.test(line) ||
|
|
141
|
+
/^[0-9A-Za-z]{1,}$/.test(line) // base85 binary literal line
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
// ---------------------------------------------------------------------------
|
|
145
|
+
// parsePatch
|
|
146
|
+
// ---------------------------------------------------------------------------
|
|
147
|
+
function toPosix(p) {
|
|
148
|
+
return p.replace(/\\/g, "/");
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* Decode git's C-style quoted path form (`"a/x y"`, `"a/\303\251.txt"`). Git
|
|
152
|
+
* quotes a header path when it contains a space or a special byte; leaving the
|
|
153
|
+
* quotes/escapes in place would make the safety checks inspect a different path
|
|
154
|
+
* than `git apply` actually writes. Returns the input unchanged when it is not
|
|
155
|
+
* quoted.
|
|
156
|
+
*/
|
|
157
|
+
function unquoteGitPath(p) {
|
|
158
|
+
const t = p.trim();
|
|
159
|
+
if (!(t.startsWith('"') && t.endsWith('"') && t.length >= 2))
|
|
160
|
+
return t;
|
|
161
|
+
const body = t.slice(1, -1);
|
|
162
|
+
// Accumulate raw bytes so a run of octal escapes (git emits one escape per
|
|
163
|
+
// UTF-8 byte, e.g. `\303\251` for `é`) decodes as the original multi-byte
|
|
164
|
+
// character, not per-byte mojibake. Plain ASCII chars are pushed as their
|
|
165
|
+
// single byte; non-ASCII source chars (already decoded) are encoded to UTF-8.
|
|
166
|
+
const bytes = [];
|
|
167
|
+
const pushChar = (ch) => {
|
|
168
|
+
for (const b of Buffer.from(ch, "utf8"))
|
|
169
|
+
bytes.push(b);
|
|
170
|
+
};
|
|
171
|
+
const simple = {
|
|
172
|
+
n: "\n",
|
|
173
|
+
t: "\t",
|
|
174
|
+
r: "\r",
|
|
175
|
+
'"': '"',
|
|
176
|
+
"\\": "\\",
|
|
177
|
+
};
|
|
178
|
+
for (let i = 0; i < body.length; i++) {
|
|
179
|
+
if (body[i] !== "\\") {
|
|
180
|
+
pushChar(body[i]);
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
183
|
+
const next = body[i + 1];
|
|
184
|
+
// Octal escape (\NNN) — git emits one per raw byte.
|
|
185
|
+
if (next !== undefined && next >= "0" && next <= "7") {
|
|
186
|
+
const m = /^[0-7]{1,3}/.exec(body.slice(i + 1));
|
|
187
|
+
if (m) {
|
|
188
|
+
bytes.push(parseInt(m[0], 8) & 0xff);
|
|
189
|
+
i += m[0].length;
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
if (next !== undefined && next in simple) {
|
|
194
|
+
pushChar(simple[next]);
|
|
195
|
+
i += 1;
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
198
|
+
if (next !== undefined)
|
|
199
|
+
pushChar(next);
|
|
200
|
+
i += 1;
|
|
201
|
+
}
|
|
202
|
+
return Buffer.from(bytes).toString("utf8");
|
|
203
|
+
}
|
|
204
|
+
/** Strip a leading `a/` or `b/` diff prefix; map `/dev/null` to undefined. */
|
|
205
|
+
function stripPrefix(p) {
|
|
206
|
+
const posix = toPosix(unquoteGitPath(p));
|
|
207
|
+
if (posix === "/dev/null")
|
|
208
|
+
return undefined;
|
|
209
|
+
return posix.replace(/^[ab]\//, "");
|
|
210
|
+
}
|
|
211
|
+
/**
|
|
212
|
+
* Parse the two paths from a `diff --git <a> <b>` header tail. Handles both the
|
|
213
|
+
* unquoted form (`a/x b/x`) and git's quoted form for paths with spaces/special
|
|
214
|
+
* bytes (`"a/x y" "b/x y"`). Returns prefix-stripped, unquoted `[a, b]`. A naive
|
|
215
|
+
* whitespace split is wrong for spaced paths — and getting the path wrong here
|
|
216
|
+
* (for a metadata-only empty-file section that has no `---`/`+++` lines) would
|
|
217
|
+
* leave the file out of the plan entirely, skipping every plan-level safety
|
|
218
|
+
* gate while `git apply` still writes it.
|
|
219
|
+
*/
|
|
220
|
+
function parseGitHeaderPaths(tail) {
|
|
221
|
+
const t = tail.trim();
|
|
222
|
+
// Quoted first path: `"a/..." "b/..."` (either or both sides may be quoted).
|
|
223
|
+
const m = /^("(?:\\.|[^"\\])*"|\S+)\s+("(?:\\.|[^"\\])*"|.+)$/.exec(t);
|
|
224
|
+
if (!m)
|
|
225
|
+
return [undefined, undefined];
|
|
226
|
+
return [stripPrefix(m[1]), stripPrefix(m[2])];
|
|
227
|
+
}
|
|
228
|
+
/**
|
|
229
|
+
* Parse a unified diff into a classified `PatchPlan`. Each `diff --git` (or, for
|
|
230
|
+
* a header-less diff, each `---`/`+++` pair) is one file section. Classification:
|
|
231
|
+
* - `new file mode` / `--- /dev/null` → add
|
|
232
|
+
* - `deleted file mode` / `+++ /dev/null` → delete
|
|
233
|
+
* - otherwise → modify
|
|
234
|
+
* `GIT binary patch` in any section sets `isBinary`.
|
|
235
|
+
*/
|
|
236
|
+
export function parsePatch(diff) {
|
|
237
|
+
const lines = diff.split(/\r?\n/);
|
|
238
|
+
const adds = [];
|
|
239
|
+
const modifies = [];
|
|
240
|
+
const deletes = [];
|
|
241
|
+
let isBinary = false;
|
|
242
|
+
const sections = [];
|
|
243
|
+
let cur;
|
|
244
|
+
const startSection = (fromGit, a, b) => {
|
|
245
|
+
cur = {
|
|
246
|
+
gitA: a,
|
|
247
|
+
gitB: b,
|
|
248
|
+
isNew: false,
|
|
249
|
+
isDeleted: false,
|
|
250
|
+
minusDevNull: false,
|
|
251
|
+
plusDevNull: false,
|
|
252
|
+
binary: false,
|
|
253
|
+
sawMinus: false,
|
|
254
|
+
sawHunk: false,
|
|
255
|
+
fromGit,
|
|
256
|
+
};
|
|
257
|
+
sections.push(cur);
|
|
258
|
+
};
|
|
259
|
+
for (let li = 0; li < lines.length; li++) {
|
|
260
|
+
const line = lines[li];
|
|
261
|
+
// Binary markers set the global flag regardless of whether a section is
|
|
262
|
+
// open: a binary diff with a quoted/spaced `diff --git` path (which the
|
|
263
|
+
// header regex below cannot match) must still be flagged binary so the
|
|
264
|
+
// safety gate refuses it rather than letting `git apply` apply it.
|
|
265
|
+
if (/^GIT binary patch/.test(line) || /^Binary files .* differ$/.test(line)) {
|
|
266
|
+
isBinary = true;
|
|
267
|
+
if (cur)
|
|
268
|
+
cur.binary = true;
|
|
269
|
+
continue;
|
|
270
|
+
}
|
|
271
|
+
if (/^diff --git /.test(line)) {
|
|
272
|
+
const [a, b] = parseGitHeaderPaths(line.slice("diff --git ".length));
|
|
273
|
+
startSection(true, a, b);
|
|
274
|
+
continue;
|
|
275
|
+
}
|
|
276
|
+
// A `--- ` / `+++ ` line is only a FILE HEADER before the section's first
|
|
277
|
+
// hunk. Once inside a hunk, an added/removed content line such as `+++ x`
|
|
278
|
+
// (the `+` marker plus literal `++ x` content) must NOT be mistaken for a
|
|
279
|
+
// header — that would let hunk content overwrite the section's real path and
|
|
280
|
+
// bypass the ignore/risky-path checks. EXCEPTIONS that DO start a new
|
|
281
|
+
// header-less section even mid-hunk:
|
|
282
|
+
// - a `--- /dev/null` (unambiguous: a deletion content line is `-…`, never
|
|
283
|
+
// that sentinel), and
|
|
284
|
+
// - a `--- <path>` immediately followed by a `+++ <path>` line AND THEN a
|
|
285
|
+
// `@@ ` hunk line: that triple is the header of the NEXT file in a
|
|
286
|
+
// header-less multi-file diff. Matching only `--- `+`+++ ` is NOT enough
|
|
287
|
+
// — hunk CONTENT can look like that pair (a removed line whose text is
|
|
288
|
+
// `-- x` renders as `--- x`, and an added `++ y` renders as `+++ y`), so
|
|
289
|
+
// we also require a bare `@@ ` hunk header on the third line. A bare
|
|
290
|
+
// `@@ ` at column 0 only ever occurs as a real hunk header (inside a hunk
|
|
291
|
+
// it would be prefixed by `+`/`-`/space), so the triple disambiguates a
|
|
292
|
+
// true next-file header from look-alike content. Without this, a
|
|
293
|
+
// header-less diff's 2nd+ files were swallowed as hunk content and
|
|
294
|
+
// bypassed every plan-level safety gate while `git apply` still wrote
|
|
295
|
+
// them.
|
|
296
|
+
const minusP = /^--- /.test(line) ? line.slice(4) : undefined;
|
|
297
|
+
const minusIsDevNull = minusP !== undefined && toPosix(minusP.trim()) === "/dev/null";
|
|
298
|
+
const nextIsHeaderTriple = minusP !== undefined &&
|
|
299
|
+
/^\+\+\+ /.test(lines[li + 1] ?? "") &&
|
|
300
|
+
/^@@ /.test(lines[li + 2] ?? "");
|
|
301
|
+
if (minusP !== undefined &&
|
|
302
|
+
(minusIsDevNull || nextIsHeaderTriple || !(cur && cur.sawHunk))) {
|
|
303
|
+
// Attach to the current section unless none exists, its `--- ` line was
|
|
304
|
+
// already consumed, or we are mid-hunk on a new `--- /dev/null` (each of
|
|
305
|
+
// those begins a new header-less section).
|
|
306
|
+
if (!cur || cur.sawMinus || cur.sawHunk) {
|
|
307
|
+
startSection(false);
|
|
308
|
+
}
|
|
309
|
+
cur.sawMinus = true;
|
|
310
|
+
if (minusIsDevNull) {
|
|
311
|
+
cur.minusDevNull = true;
|
|
312
|
+
}
|
|
313
|
+
else {
|
|
314
|
+
cur.minus = stripPrefix(minusP);
|
|
315
|
+
}
|
|
316
|
+
continue;
|
|
317
|
+
}
|
|
318
|
+
if (!cur)
|
|
319
|
+
continue;
|
|
320
|
+
if (/^\+\+\+ /.test(line) && !cur.sawHunk) {
|
|
321
|
+
const p = line.slice(4);
|
|
322
|
+
if (toPosix(p.trim()) === "/dev/null") {
|
|
323
|
+
cur.plusDevNull = true;
|
|
324
|
+
}
|
|
325
|
+
else {
|
|
326
|
+
cur.plus = stripPrefix(p);
|
|
327
|
+
}
|
|
328
|
+
continue;
|
|
329
|
+
}
|
|
330
|
+
if (/^@@ /.test(line)) {
|
|
331
|
+
cur.sawHunk = true;
|
|
332
|
+
continue;
|
|
333
|
+
}
|
|
334
|
+
if (/^new file mode /.test(line)) {
|
|
335
|
+
cur.isNew = true;
|
|
336
|
+
continue;
|
|
337
|
+
}
|
|
338
|
+
if (/^deleted file mode /.test(line)) {
|
|
339
|
+
cur.isDeleted = true;
|
|
340
|
+
continue;
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
for (const s of sections) {
|
|
344
|
+
if (s.binary)
|
|
345
|
+
isBinary = true;
|
|
346
|
+
// Resolve the canonical repo-relative path for this section.
|
|
347
|
+
const path_ = s.isDeleted || s.plusDevNull
|
|
348
|
+
? s.minus ?? s.gitA ?? s.gitB
|
|
349
|
+
: s.plus ?? s.gitB ?? s.gitA ?? s.minus;
|
|
350
|
+
if (path_ === undefined)
|
|
351
|
+
continue;
|
|
352
|
+
if (s.isNew || s.minusDevNull) {
|
|
353
|
+
adds.push(path_);
|
|
354
|
+
}
|
|
355
|
+
else if (s.isDeleted || s.plusDevNull) {
|
|
356
|
+
deletes.push(path_);
|
|
357
|
+
}
|
|
358
|
+
else {
|
|
359
|
+
modifies.push(path_);
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
const touchedFiles = [...new Set([...adds, ...modifies, ...deletes])];
|
|
363
|
+
return { touchedFiles, adds, modifies, deletes, isBinary, diff };
|
|
364
|
+
}
|
|
365
|
+
// ---------------------------------------------------------------------------
|
|
366
|
+
// Path / ignore helpers (shared by validate and the Safety Manager)
|
|
367
|
+
// ---------------------------------------------------------------------------
|
|
368
|
+
/**
|
|
369
|
+
* Normalize an ALREADY-prefix-stripped touched path (the paths in a `PatchPlan`
|
|
370
|
+
* have had their `a/`/`b/` diff prefix removed by `parsePatch`) to repo-relative
|
|
371
|
+
* POSIX form: convert Windows `\` separators and drop a `./` prefix. It does NOT
|
|
372
|
+
* re-strip an `a/`/`b/` prefix — doing so would corrupt a legitimate path whose
|
|
373
|
+
* first segment is literally `a` or `b` (e.g. `a/secure.txt`), causing the
|
|
374
|
+
* ignore / risky-path checks to inspect the wrong path. Absolute markers are
|
|
375
|
+
* preserved for the escape check.
|
|
376
|
+
*/
|
|
377
|
+
export function normalizeTouchedPath(p) {
|
|
378
|
+
return toPosix(p).replace(/^\.\//, "");
|
|
379
|
+
}
|
|
380
|
+
/** Is `rel` matched by the config ignore list (same semantics as the indexer)? */
|
|
381
|
+
function isIgnored(rel, config) {
|
|
382
|
+
const parts = rel.split("/");
|
|
383
|
+
const basename = parts[parts.length - 1];
|
|
384
|
+
// Baseline well-known generated/dependency trees at any depth.
|
|
385
|
+
const baseline = new Set([
|
|
386
|
+
".git",
|
|
387
|
+
"node_modules",
|
|
388
|
+
"dist",
|
|
389
|
+
"build",
|
|
390
|
+
".next",
|
|
391
|
+
"coverage",
|
|
392
|
+
".ai-orchestrator",
|
|
393
|
+
]);
|
|
394
|
+
for (const seg of parts) {
|
|
395
|
+
if (baseline.has(seg))
|
|
396
|
+
return true;
|
|
397
|
+
}
|
|
398
|
+
for (const pat of config.ignore) {
|
|
399
|
+
if (rel === pat)
|
|
400
|
+
return true;
|
|
401
|
+
if (rel.startsWith(pat + "/"))
|
|
402
|
+
return true;
|
|
403
|
+
if (basename === pat)
|
|
404
|
+
return true;
|
|
405
|
+
// A directory name anywhere in the path (e.g. `dist` matching `pkg/dist/x`).
|
|
406
|
+
if (parts.includes(pat))
|
|
407
|
+
return true;
|
|
408
|
+
}
|
|
409
|
+
return false;
|
|
410
|
+
}
|
|
411
|
+
/**
|
|
412
|
+
* Whether `rel` (a touched path string from the diff) escapes `root`.
|
|
413
|
+
*
|
|
414
|
+
* Refuses outright: an absolute path, or a path whose lexical normalization
|
|
415
|
+
* leaves the root (a leading `..`). Then, to catch escape *through* a symlinked
|
|
416
|
+
* directory, it realpaths the deepest existing ancestor of the target and
|
|
417
|
+
* verifies the resolved location is still inside the realpath'd root.
|
|
418
|
+
*/
|
|
419
|
+
async function escapesRoot(rel, root) {
|
|
420
|
+
const raw = toPosix(rel);
|
|
421
|
+
// Absolute paths (POSIX `/x`, Windows `C:/x`) are refused outright.
|
|
422
|
+
if (path.isAbsolute(rel) || /^[A-Za-z]:[\\/]/.test(rel) || raw.startsWith("/")) {
|
|
423
|
+
return true;
|
|
424
|
+
}
|
|
425
|
+
// Lexical escape: normalize and check for a leading `..`.
|
|
426
|
+
const normalized = path.posix.normalize(raw);
|
|
427
|
+
if (normalized === ".." || normalized.startsWith("../"))
|
|
428
|
+
return true;
|
|
429
|
+
// Symlink escape: realpath the deepest existing ancestor.
|
|
430
|
+
const rootReal = await fs.realpath(root);
|
|
431
|
+
const target = path.resolve(root, normalized);
|
|
432
|
+
let probe = target;
|
|
433
|
+
// Walk up to the first existing ancestor.
|
|
434
|
+
for (;;) {
|
|
435
|
+
try {
|
|
436
|
+
const real = await fs.realpath(probe);
|
|
437
|
+
// `real` is the resolved existing ancestor. The remaining (non-existent)
|
|
438
|
+
// suffix cannot itself be a symlink, so containment of `real` decides.
|
|
439
|
+
const relToRoot = path.relative(rootReal, real);
|
|
440
|
+
if (relToRoot === "")
|
|
441
|
+
return false;
|
|
442
|
+
// Use `== ".." || startsWith("../")` (not a bare `startsWith("..")`) so a
|
|
443
|
+
// legitimate in-root name that merely begins with `..` (e.g. `..cache`) is
|
|
444
|
+
// not mistaken for a parent-directory escape.
|
|
445
|
+
const posixRel = toPosix(relToRoot);
|
|
446
|
+
if (posixRel === ".." ||
|
|
447
|
+
posixRel.startsWith("../") ||
|
|
448
|
+
path.isAbsolute(relToRoot)) {
|
|
449
|
+
return true;
|
|
450
|
+
}
|
|
451
|
+
return false;
|
|
452
|
+
}
|
|
453
|
+
catch (err) {
|
|
454
|
+
if (err.code === "ENOENT") {
|
|
455
|
+
const parent = path.dirname(probe);
|
|
456
|
+
if (parent === probe)
|
|
457
|
+
return true; // reached fs root without resolving
|
|
458
|
+
probe = parent;
|
|
459
|
+
continue;
|
|
460
|
+
}
|
|
461
|
+
throw err;
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
// ---------------------------------------------------------------------------
|
|
466
|
+
// validate
|
|
467
|
+
// ---------------------------------------------------------------------------
|
|
468
|
+
async function pathExists(p) {
|
|
469
|
+
try {
|
|
470
|
+
await fs.lstat(p);
|
|
471
|
+
return true;
|
|
472
|
+
}
|
|
473
|
+
catch {
|
|
474
|
+
return false;
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
/**
|
|
478
|
+
* Validate a parsed plan against the repo root + config. Throws
|
|
479
|
+
* `PatchValidationError` on the first violation:
|
|
480
|
+
* - any touched path escaping the root (absolute, `../`, or symlink escape);
|
|
481
|
+
* - any touched path matching the ignore list;
|
|
482
|
+
* - a modify/delete whose target does not exist;
|
|
483
|
+
* - an add whose target already exists (never overwrite).
|
|
484
|
+
* Resolves (undefined) when the plan is structurally safe to apply.
|
|
485
|
+
*/
|
|
486
|
+
export async function validate(plan, config, root) {
|
|
487
|
+
for (const raw of plan.touchedFiles) {
|
|
488
|
+
const rel = normalizeTouchedPath(raw);
|
|
489
|
+
if (await escapesRoot(raw, root)) {
|
|
490
|
+
throw new PatchValidationError(`Refusing patch: path escapes the repository root: ${raw}`);
|
|
491
|
+
}
|
|
492
|
+
if (isIgnored(rel, config)) {
|
|
493
|
+
throw new PatchValidationError(`Refusing patch: touches an ignored/generated file: ${rel}`);
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
for (const raw of plan.modifies) {
|
|
497
|
+
const rel = normalizeTouchedPath(raw);
|
|
498
|
+
if (!(await pathExists(path.join(root, rel)))) {
|
|
499
|
+
throw new PatchValidationError(`Refusing patch: modify target does not exist: ${rel}`);
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
for (const raw of plan.deletes) {
|
|
503
|
+
const rel = normalizeTouchedPath(raw);
|
|
504
|
+
if (!(await pathExists(path.join(root, rel)))) {
|
|
505
|
+
throw new PatchValidationError(`Refusing patch: delete target does not exist: ${rel}`);
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
for (const raw of plan.adds) {
|
|
509
|
+
const rel = normalizeTouchedPath(raw);
|
|
510
|
+
if (await pathExists(path.join(root, rel))) {
|
|
511
|
+
throw new PatchValidationError(`Refusing patch: add target already exists (never overwrite): ${rel}`);
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
// ---------------------------------------------------------------------------
|
|
516
|
+
// Git detection + working-tree safety
|
|
517
|
+
// ---------------------------------------------------------------------------
|
|
518
|
+
/** Is `root` the working tree of a git repo? */
|
|
519
|
+
async function isGitRepo(root) {
|
|
520
|
+
try {
|
|
521
|
+
return await simpleGit(root).checkIsRepo();
|
|
522
|
+
}
|
|
523
|
+
catch {
|
|
524
|
+
return false;
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
/**
|
|
528
|
+
* Refuse to apply onto an unsafe working tree (§11.2). Unsafe = a repo in the
|
|
529
|
+
* middle of a merge / rebase / cherry-pick / revert / bisect (detected by the
|
|
530
|
+
* marker files / dirs git writes). Unrelated dirty files are tolerated — the
|
|
531
|
+
* `git apply --check` pre-flight gates the exact apply. A non-git dir has no
|
|
532
|
+
* merge state, so this is a no-op there.
|
|
533
|
+
*/
|
|
534
|
+
export async function assertWorkingTreeSafe(root) {
|
|
535
|
+
if (!(await isGitRepo(root)))
|
|
536
|
+
return;
|
|
537
|
+
const gitDir = await resolveGitDir(root);
|
|
538
|
+
const markers = [
|
|
539
|
+
"MERGE_HEAD",
|
|
540
|
+
"rebase-merge",
|
|
541
|
+
"rebase-apply",
|
|
542
|
+
"CHERRY_PICK_HEAD",
|
|
543
|
+
"REVERT_HEAD",
|
|
544
|
+
"BISECT_LOG",
|
|
545
|
+
];
|
|
546
|
+
for (const m of markers) {
|
|
547
|
+
if (await pathExists(path.join(gitDir, m))) {
|
|
548
|
+
throw new WorkingTreeUnsafeError(`Refusing to apply: the repository is mid-operation (${m}). ` +
|
|
549
|
+
"Finish or abort it first (§11.2).");
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
/** Resolve the repo's git dir (handles worktrees where `.git` is a file). */
|
|
554
|
+
async function resolveGitDir(root) {
|
|
555
|
+
try {
|
|
556
|
+
const out = (await simpleGit(root).revparse(["--git-dir"])).trim();
|
|
557
|
+
return path.isAbsolute(out) ? out : path.join(root, out);
|
|
558
|
+
}
|
|
559
|
+
catch {
|
|
560
|
+
return path.join(root, ".git");
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
// ---------------------------------------------------------------------------
|
|
564
|
+
// apply
|
|
565
|
+
// ---------------------------------------------------------------------------
|
|
566
|
+
/** True when every touched file is a brand-new add (no modify/delete). */
|
|
567
|
+
function isAddOnly(plan) {
|
|
568
|
+
return (plan.adds.length > 0 &&
|
|
569
|
+
plan.modifies.length === 0 &&
|
|
570
|
+
plan.deletes.length === 0);
|
|
571
|
+
}
|
|
572
|
+
/**
|
|
573
|
+
* Pre-flight a diff with `git apply --check` (no mutation). Throws
|
|
574
|
+
* `PatchApplyError` when the patch would not apply cleanly. Git repos only —
|
|
575
|
+
* the command skips this for the non-git add-only path.
|
|
576
|
+
*/
|
|
577
|
+
export async function gitApplyCheck(plan, root) {
|
|
578
|
+
try {
|
|
579
|
+
await execa("git", ["apply", "--check", "--whitespace=nowarn", "-"], {
|
|
580
|
+
cwd: root,
|
|
581
|
+
input: plan.diff,
|
|
582
|
+
});
|
|
583
|
+
}
|
|
584
|
+
catch (err) {
|
|
585
|
+
throw new PatchApplyError("The patch does not apply cleanly (`git apply --check` failed). " +
|
|
586
|
+
"Nothing was applied (§12.3). " +
|
|
587
|
+
(err instanceof Error ? err.message : String(err)));
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
/**
|
|
591
|
+
* Apply a parsed plan to the working tree, never partially.
|
|
592
|
+
*
|
|
593
|
+
* Git repo: `git apply --check` then `git apply` (atomic per call). A failing
|
|
594
|
+
* check leaves the tree untouched.
|
|
595
|
+
*
|
|
596
|
+
* Non-git dir: only an add-only patch is applied, via a controlled write of the
|
|
597
|
+
* new files (the Git pre-check is skipped). Any existing-file modify/delete in a
|
|
598
|
+
* non-git dir is refused with `PatchApplyError` — there is no rollback layer.
|
|
599
|
+
*/
|
|
600
|
+
export async function apply(plan, root) {
|
|
601
|
+
const git = await isGitRepo(root);
|
|
602
|
+
if (git) {
|
|
603
|
+
await gitApplyCheck(plan, root);
|
|
604
|
+
try {
|
|
605
|
+
await execa("git", ["apply", "--whitespace=nowarn", "-"], {
|
|
606
|
+
cwd: root,
|
|
607
|
+
input: plan.diff,
|
|
608
|
+
});
|
|
609
|
+
}
|
|
610
|
+
catch (err) {
|
|
611
|
+
throw new PatchApplyError("`git apply` failed after a passing --check. Nothing was applied. " +
|
|
612
|
+
(err instanceof Error ? err.message : String(err)));
|
|
613
|
+
}
|
|
614
|
+
return;
|
|
615
|
+
}
|
|
616
|
+
// Non-git: add-only via controlled write; anything else is refused.
|
|
617
|
+
if (!isAddOnly(plan)) {
|
|
618
|
+
throw new PatchApplyError("Refusing to modify or delete files in a non-git directory: there is no " +
|
|
619
|
+
"Git rollback layer. Initialize a repo (`git init`) first.");
|
|
620
|
+
}
|
|
621
|
+
await controlledWriteAdds(plan, root);
|
|
622
|
+
}
|
|
623
|
+
/**
|
|
624
|
+
* Write the new files of an add-only plan by reconstructing each file's content
|
|
625
|
+
* from its `+` hunk lines. Used only for the non-git add-only fallback. Refuses
|
|
626
|
+
* to clobber an existing file (defense in depth; `validate` already checked).
|
|
627
|
+
*
|
|
628
|
+
* The non-git path has no Git rollback layer, so this enforces the "never apply
|
|
629
|
+
* a partial patch" invariant itself: it first refuses if ANY target already
|
|
630
|
+
* exists (before writing anything), then, if a later write fails mid-batch, it
|
|
631
|
+
* removes the files it already created so a multi-file add never leaves a partial
|
|
632
|
+
* result behind.
|
|
633
|
+
*/
|
|
634
|
+
async function controlledWriteAdds(plan, root) {
|
|
635
|
+
const files = reconstructAdds(plan.diff);
|
|
636
|
+
// Pre-flight: refuse the whole batch up front if any target already exists, so
|
|
637
|
+
// a collision on a later file never leaves an earlier file written.
|
|
638
|
+
for (const [rel] of files) {
|
|
639
|
+
if (await pathExists(path.join(root, rel))) {
|
|
640
|
+
throw new PatchApplyError(`Refusing to overwrite an existing file via the non-git fallback: ${rel}`);
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
const written = [];
|
|
644
|
+
try {
|
|
645
|
+
for (const [rel, content] of files) {
|
|
646
|
+
// Re-check containment IMMEDIATELY before the write (not just in `validate`,
|
|
647
|
+
// which ran before the approval prompt): a symlinked ancestor could have
|
|
648
|
+
// been swapped in during the await window, redirecting the write outside
|
|
649
|
+
// root. `escapesRoot` realpaths the deepest existing ancestor, so a parent
|
|
650
|
+
// now pointing outside root is caught here. (§13 path-escape invariant.)
|
|
651
|
+
if (await escapesRoot(rel, root)) {
|
|
652
|
+
throw new PatchApplyError(`Refusing to write outside the repository root (containment changed since validation): ${rel}`);
|
|
653
|
+
}
|
|
654
|
+
const full = path.join(root, rel);
|
|
655
|
+
await ensureDir(path.dirname(full));
|
|
656
|
+
// `flag: "wx"` keeps each write a never-overwrite create (defense in depth
|
|
657
|
+
// against a TOCTOU create between the pre-flight check and this write).
|
|
658
|
+
await fs.writeFile(full, content, { encoding: "utf8", flag: "wx" });
|
|
659
|
+
written.push(full);
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
catch (err) {
|
|
663
|
+
// Roll back the files this batch already created so the add is all-or-nothing.
|
|
664
|
+
for (const full of written.reverse()) {
|
|
665
|
+
try {
|
|
666
|
+
await fs.rm(full, { force: true });
|
|
667
|
+
}
|
|
668
|
+
catch {
|
|
669
|
+
// Best-effort cleanup; surface the original failure below regardless.
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
if (err instanceof PatchApplyError)
|
|
673
|
+
throw err;
|
|
674
|
+
throw new PatchApplyError("Failed to write the new files of a non-git add-only patch; rolled back " +
|
|
675
|
+
"the files already created. " +
|
|
676
|
+
(err instanceof Error ? err.message : String(err)));
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
/**
|
|
680
|
+
* Reconstruct the content of each added file from its `+` hunk lines. Returns a
|
|
681
|
+
* map of repo-relative POSIX path → file content. Only handles new-file
|
|
682
|
+
* sections (`--- /dev/null` or `new file mode`).
|
|
683
|
+
*/
|
|
684
|
+
function reconstructAdds(diff) {
|
|
685
|
+
const lines = diff.split(/\r?\n/);
|
|
686
|
+
const out = new Map();
|
|
687
|
+
let curPath; // from `+++` (most precise)
|
|
688
|
+
let headerPath; // from `diff --git` gitB (fallback)
|
|
689
|
+
let inHunk = false;
|
|
690
|
+
let buf = [];
|
|
691
|
+
let isNewFile = false;
|
|
692
|
+
let sawMinus = false; // a `--- ` header has been consumed for the current file
|
|
693
|
+
let trailingNewline = true; // false when a `` follows
|
|
694
|
+
const flush = () => {
|
|
695
|
+
const target = curPath ?? headerPath;
|
|
696
|
+
// Emit an entry for every new-file section, even one with no hunk/`+++`
|
|
697
|
+
// (a metadata-only empty-file add): otherwise the controlled write would
|
|
698
|
+
// silently skip a file the plan promised to create.
|
|
699
|
+
if (target !== undefined && isNewFile) {
|
|
700
|
+
const body = buf.join("\n");
|
|
701
|
+
out.set(target, body + (buf.length > 0 && trailingNewline ? "\n" : ""));
|
|
702
|
+
}
|
|
703
|
+
buf = [];
|
|
704
|
+
inHunk = false;
|
|
705
|
+
isNewFile = false;
|
|
706
|
+
sawMinus = false;
|
|
707
|
+
curPath = undefined;
|
|
708
|
+
headerPath = undefined;
|
|
709
|
+
trailingNewline = true;
|
|
710
|
+
};
|
|
711
|
+
for (const line of lines) {
|
|
712
|
+
if (/^diff --git /.test(line)) {
|
|
713
|
+
flush();
|
|
714
|
+
// Capture the `b/` path as a fallback when there is no `+++` line
|
|
715
|
+
// (handles quoted/spaced paths via the shared header parser).
|
|
716
|
+
const [, b] = parseGitHeaderPaths(line.slice("diff --git ".length));
|
|
717
|
+
headerPath = b;
|
|
718
|
+
continue;
|
|
719
|
+
}
|
|
720
|
+
if (/^new file mode /.test(line)) {
|
|
721
|
+
isNewFile = true;
|
|
722
|
+
continue;
|
|
723
|
+
}
|
|
724
|
+
// A `--- /dev/null` line unambiguously starts a NEW added-file section (a
|
|
725
|
+
// hunk content line is `-…`, never the literal `--- /dev/null` sentinel), so
|
|
726
|
+
// flush the previous file even mid-hunk — otherwise a multi-file headerless
|
|
727
|
+
// add concatenates every file's content into the last path. A non-dev/null
|
|
728
|
+
// `--- ` is only treated as a header before the first hunk (mid-hunk it is
|
|
729
|
+
// content, mirroring the Q1 guard in parsePatch).
|
|
730
|
+
const isMinus = /^--- /.test(line);
|
|
731
|
+
const isMinusDevNull = isMinus && toPosix(line.slice(4).trim()) === "/dev/null";
|
|
732
|
+
if (isMinusDevNull || (isMinus && !inHunk)) {
|
|
733
|
+
if (sawMinus || curPath !== undefined || inHunk)
|
|
734
|
+
flush();
|
|
735
|
+
sawMinus = true;
|
|
736
|
+
if (isMinusDevNull)
|
|
737
|
+
isNewFile = true;
|
|
738
|
+
continue;
|
|
739
|
+
}
|
|
740
|
+
if (/^\+\+\+ /.test(line) && !inHunk) {
|
|
741
|
+
// Header `+++ ` line only before the hunk; once in a hunk a `+++ x` line
|
|
742
|
+
// is added CONTENT (`+` marker + `++ x`), captured by the `+` branch below.
|
|
743
|
+
const p = stripPrefix(line.slice(4));
|
|
744
|
+
if (p !== undefined)
|
|
745
|
+
curPath = p;
|
|
746
|
+
continue;
|
|
747
|
+
}
|
|
748
|
+
if (/^@@ /.test(line)) {
|
|
749
|
+
inHunk = true;
|
|
750
|
+
continue;
|
|
751
|
+
}
|
|
752
|
+
if (/^\/.test(line)) {
|
|
753
|
+
trailingNewline = false;
|
|
754
|
+
continue;
|
|
755
|
+
}
|
|
756
|
+
if (inHunk && line.startsWith("+")) {
|
|
757
|
+
buf.push(line.slice(1));
|
|
758
|
+
continue;
|
|
759
|
+
}
|
|
760
|
+
// Ignore context/`-` lines (an add has only `+` content).
|
|
761
|
+
}
|
|
762
|
+
flush();
|
|
763
|
+
return out;
|
|
764
|
+
}
|
|
765
|
+
// ---------------------------------------------------------------------------
|
|
766
|
+
// savePatch
|
|
767
|
+
// ---------------------------------------------------------------------------
|
|
768
|
+
function pad(n) {
|
|
769
|
+
return String(n).padStart(2, "0");
|
|
770
|
+
}
|
|
771
|
+
/**
|
|
772
|
+
* Save the raw diff as a patch artifact under
|
|
773
|
+
* `<orchestratorDir>/patches/YYYY-MM-DD_HHMM_<step-id>.patch`. Returns the
|
|
774
|
+
* absolute path written. Never overwrites: a name collision appends `-2`, `-3`…
|
|
775
|
+
*/
|
|
776
|
+
export async function savePatch(diff, stepId, orchestratorDir, opts = {}) {
|
|
777
|
+
const now = opts.now ?? new Date();
|
|
778
|
+
const stamp = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}` +
|
|
779
|
+
`_${pad(now.getHours())}${pad(now.getMinutes())}`;
|
|
780
|
+
const safeId = stepId.replace(/[^A-Za-z0-9._-]/g, "_");
|
|
781
|
+
const patchesDir = path.join(orchestratorDir, "patches");
|
|
782
|
+
await ensureDir(patchesDir);
|
|
783
|
+
const base = `${stamp}_${safeId}`;
|
|
784
|
+
let attempt = 0;
|
|
785
|
+
for (;;) {
|
|
786
|
+
const name = attempt === 0 ? `${base}.patch` : `${base}-${attempt + 1}.patch`;
|
|
787
|
+
const full = path.join(patchesDir, name);
|
|
788
|
+
try {
|
|
789
|
+
await fs.writeFile(full, diff, { encoding: "utf8", flag: "wx" });
|
|
790
|
+
return full;
|
|
791
|
+
}
|
|
792
|
+
catch (err) {
|
|
793
|
+
if (err.code === "EEXIST") {
|
|
794
|
+
attempt += 1;
|
|
795
|
+
continue;
|
|
796
|
+
}
|
|
797
|
+
throw err;
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
//# sourceMappingURL=patchManager.js.map
|