@bridge4dev/runner 0.11.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 +21 -0
- package/README.md +86 -0
- package/dist/adapters/claude.d.ts +19 -0
- package/dist/adapters/claude.js +631 -0
- package/dist/adapters/codex-home.d.ts +61 -0
- package/dist/adapters/codex-home.js +234 -0
- package/dist/adapters/codex-protocol.d.ts +59 -0
- package/dist/adapters/codex-protocol.js +204 -0
- package/dist/adapters/codex.d.ts +61 -0
- package/dist/adapters/codex.js +1406 -0
- package/dist/adapters/types.d.ts +183 -0
- package/dist/adapters/types.js +5 -0
- package/dist/async-queue.d.ts +11 -0
- package/dist/async-queue.js +50 -0
- package/dist/attachments.d.ts +72 -0
- package/dist/attachments.js +149 -0
- package/dist/auth-relay.d.ts +57 -0
- package/dist/auth-relay.js +289 -0
- package/dist/config.d.ts +96 -0
- package/dist/config.js +73 -0
- package/dist/fsview.d.ts +20 -0
- package/dist/fsview.js +122 -0
- package/dist/git.d.ts +54 -0
- package/dist/git.js +168 -0
- package/dist/gitops.d.ts +136 -0
- package/dist/gitops.js +596 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +352 -0
- package/dist/journal.d.ts +118 -0
- package/dist/journal.js +300 -0
- package/dist/log.d.ts +7 -0
- package/dist/log.js +19 -0
- package/dist/paths.d.ts +7 -0
- package/dist/paths.js +33 -0
- package/dist/policy.d.ts +17 -0
- package/dist/policy.js +272 -0
- package/dist/protocol.d.ts +754 -0
- package/dist/protocol.js +154 -0
- package/dist/self-update.d.ts +75 -0
- package/dist/self-update.js +221 -0
- package/dist/status-file.d.ts +14 -0
- package/dist/status-file.js +29 -0
- package/dist/supervisor.d.ts +216 -0
- package/dist/supervisor.js +1648 -0
- package/dist/version.d.ts +2 -0
- package/dist/version.js +3 -0
- package/dist/ws-client.d.ts +30 -0
- package/dist/ws-client.js +171 -0
- package/package.json +52 -0
package/dist/gitops.js
ADDED
|
@@ -0,0 +1,596 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { promisify } from 'node:util';
|
|
5
|
+
import { sanitizeBranch } from './git.js';
|
|
6
|
+
import { isSecretPath, maskString } from './policy.js';
|
|
7
|
+
const execFileAsync = promisify(execFile);
|
|
8
|
+
const GIT_TIMEOUT_MS = 30_000;
|
|
9
|
+
const MERGE_TIMEOUT_MS = 60_000;
|
|
10
|
+
const DIFF_CAP_BYTES = 200_000;
|
|
11
|
+
/**
|
|
12
|
+
* Global git switches every call in this module needs.
|
|
13
|
+
*
|
|
14
|
+
* `--literal-pathspecs` is a security control, not a nicety: `--` stops git
|
|
15
|
+
* from reading an argument as an OPTION, but it does not stop it from reading
|
|
16
|
+
* it as a PATTERN. `git show <sha> -- '*'` or `-- ':(glob)**'` prints the whole
|
|
17
|
+
* commit — including the `.env` the file list carefully marked as protected.
|
|
18
|
+
* With this flag a path is a path (QA-105 MAJOR-1; the same hole had been open
|
|
19
|
+
* in `gitDiff` since session 4).
|
|
20
|
+
*
|
|
21
|
+
* `core.quotePath=false` keeps non-ASCII file names readable: by default git
|
|
22
|
+
* returns `"\321\204..."`, which is both unreadable in the panel and unusable
|
|
23
|
+
* as the path of the follow-up diff request.
|
|
24
|
+
*/
|
|
25
|
+
const GIT_GLOBAL_ARGS = ['--literal-pathspecs', '-c', 'core.quotePath=false'];
|
|
26
|
+
async function git(cwd, ...args) {
|
|
27
|
+
const { stdout } = await execFileAsync('git', [...GIT_GLOBAL_ARGS, ...args], {
|
|
28
|
+
cwd,
|
|
29
|
+
timeout: GIT_TIMEOUT_MS,
|
|
30
|
+
maxBuffer: 8 * 1024 * 1024,
|
|
31
|
+
});
|
|
32
|
+
return stdout.replace(/\n$/, '');
|
|
33
|
+
}
|
|
34
|
+
/** Pathspec magic characters — refused outright, belt to the flag's braces. */
|
|
35
|
+
const PATHSPEC_MAGIC = /[*?[\]]|^:/;
|
|
36
|
+
function shortError(error) {
|
|
37
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
38
|
+
return maskString(message).slice(0, 500);
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* True only for a genuine content conflict. `git merge --squash` also exits
|
|
42
|
+
* non-zero for a dirty index, a refused overwrite or a failing hook — those
|
|
43
|
+
* must not be reported as "conflict" (which auto-tasks the agent).
|
|
44
|
+
*/
|
|
45
|
+
function isMergeConflict(error) {
|
|
46
|
+
const text = error instanceof Error ? `${error.message}` : String(error);
|
|
47
|
+
const output = `${text} ${String(error?.stdout ?? '')} ${String(error?.stderr ?? '')}`;
|
|
48
|
+
return /CONFLICT \(|Automatic merge failed|fix conflicts/i.test(output);
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* The base the session will be applied onto: the branch the workspace repo is
|
|
52
|
+
* currently on (its HEAD commit is what merge-base is computed against). If
|
|
53
|
+
* the workspace somehow sits on the session branch itself, fall back to
|
|
54
|
+
* main/master.
|
|
55
|
+
*/
|
|
56
|
+
async function resolveBase(workspacePath, sessionBranch) {
|
|
57
|
+
const current = await git(workspacePath, 'rev-parse', '--abbrev-ref', 'HEAD');
|
|
58
|
+
if (current !== sessionBranch && current !== 'HEAD') {
|
|
59
|
+
return { baseBranch: current, baseRef: current };
|
|
60
|
+
}
|
|
61
|
+
for (const candidate of ['main', 'master']) {
|
|
62
|
+
const exists = await git(workspacePath, 'rev-parse', '--verify', '--quiet', candidate)
|
|
63
|
+
.then(() => true)
|
|
64
|
+
.catch(() => false);
|
|
65
|
+
if (exists)
|
|
66
|
+
return { baseBranch: candidate, baseRef: candidate };
|
|
67
|
+
}
|
|
68
|
+
throw new Error(`Cannot determine the base branch (workspace is on ${current})`);
|
|
69
|
+
}
|
|
70
|
+
/** Merge-base of the session worktree against the workspace's base branch. */
|
|
71
|
+
async function mergeBase(worktreePath, baseRef) {
|
|
72
|
+
return git(worktreePath, 'merge-base', 'HEAD', baseRef);
|
|
73
|
+
}
|
|
74
|
+
export async function gitStatus(worktreePath, workspacePath, sessionBranch) {
|
|
75
|
+
const { baseBranch, baseRef } = await resolveBase(workspacePath, sessionBranch);
|
|
76
|
+
const base = await mergeBase(worktreePath, baseRef);
|
|
77
|
+
// One diff of the working tree against the merge-base covers both committed
|
|
78
|
+
// and uncommitted changes — exactly what «Применить» would bring to main.
|
|
79
|
+
const [numstatRaw, nameStatusRaw, porcelainRaw, commitCountRaw] = await Promise.all([
|
|
80
|
+
git(worktreePath, 'diff', '--numstat', '-M', base),
|
|
81
|
+
git(worktreePath, 'diff', '--name-status', '-M', base),
|
|
82
|
+
git(worktreePath, 'status', '--porcelain'),
|
|
83
|
+
git(worktreePath, 'rev-list', '--count', `${base}..HEAD`),
|
|
84
|
+
]);
|
|
85
|
+
const numstat = new Map();
|
|
86
|
+
for (const line of numstatRaw.split('\n').filter(Boolean)) {
|
|
87
|
+
const [add, del, ...rest] = line.split('\t');
|
|
88
|
+
const path = normalizeNumstatPath(rest.join('\t'));
|
|
89
|
+
if (!path)
|
|
90
|
+
continue;
|
|
91
|
+
numstat.set(path, {
|
|
92
|
+
additions: add === '-' ? null : Number(add),
|
|
93
|
+
deletions: del === '-' ? null : Number(del),
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
const uncommittedPaths = new Set();
|
|
97
|
+
const untracked = [];
|
|
98
|
+
for (const line of porcelainRaw.split('\n').filter(Boolean)) {
|
|
99
|
+
const state = line.slice(0, 2);
|
|
100
|
+
// Rename entries look like "R old -> new" — track the new path.
|
|
101
|
+
const rawPath = line.slice(3);
|
|
102
|
+
const path = rawPath.includes(' -> ') ? (rawPath.split(' -> ')[1] ?? rawPath) : rawPath;
|
|
103
|
+
uncommittedPaths.add(path);
|
|
104
|
+
if (state === '??')
|
|
105
|
+
untracked.push(path);
|
|
106
|
+
}
|
|
107
|
+
const files = [];
|
|
108
|
+
for (const line of nameStatusRaw.split('\n').filter(Boolean)) {
|
|
109
|
+
const [status, ...rest] = line.split('\t');
|
|
110
|
+
if (!status)
|
|
111
|
+
continue;
|
|
112
|
+
const path = rest[rest.length - 1] ?? '';
|
|
113
|
+
const stats = numstat.get(path) ?? { additions: null, deletions: null };
|
|
114
|
+
files.push({
|
|
115
|
+
path,
|
|
116
|
+
status: status.charAt(0),
|
|
117
|
+
additions: stats.additions,
|
|
118
|
+
deletions: stats.deletions,
|
|
119
|
+
uncommitted: uncommittedPaths.has(path),
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
// Untracked files never show up in a diff against the base.
|
|
123
|
+
for (const path of untracked) {
|
|
124
|
+
if (!files.some((f) => f.path === path)) {
|
|
125
|
+
files.push({ path, status: 'A', additions: null, deletions: null, uncommitted: true });
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
files.sort((a, b) => a.path.localeCompare(b.path));
|
|
129
|
+
return {
|
|
130
|
+
branch: sessionBranch,
|
|
131
|
+
baseBranch,
|
|
132
|
+
files,
|
|
133
|
+
additions: files.reduce((sum, f) => sum + (f.additions ?? 0), 0),
|
|
134
|
+
deletions: files.reduce((sum, f) => sum + (f.deletions ?? 0), 0),
|
|
135
|
+
agentCommits: Number(commitCountRaw) || 0,
|
|
136
|
+
uncommittedFiles: uncommittedPaths.size,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* Constrain a user-supplied diff path to a relative path inside the worktree
|
|
141
|
+
* that is not on the secret denylist (QA-98 F1: the `--no-index` fallback
|
|
142
|
+
* would otherwise read ANY file — .env included — verbatim).
|
|
143
|
+
*/
|
|
144
|
+
function safeDiffPath(worktreePath, filePath) {
|
|
145
|
+
if (path.isAbsolute(filePath) || filePath.split(/[\\/]/).includes('..')) {
|
|
146
|
+
throw new Error('Diff path must be relative to the worktree');
|
|
147
|
+
}
|
|
148
|
+
if (PATHSPEC_MAGIC.test(filePath)) {
|
|
149
|
+
throw new Error('Diff path must name one file, not a pattern');
|
|
150
|
+
}
|
|
151
|
+
const resolved = path.resolve(worktreePath, filePath);
|
|
152
|
+
const rel = path.relative(path.resolve(worktreePath), resolved);
|
|
153
|
+
if (rel.startsWith('..') || path.isAbsolute(rel)) {
|
|
154
|
+
throw new Error('Diff path escapes the worktree');
|
|
155
|
+
}
|
|
156
|
+
if (rel.split(path.sep).includes('.git')) {
|
|
157
|
+
throw new Error('Diff path is protected by runner policy');
|
|
158
|
+
}
|
|
159
|
+
// Symlinks must not smuggle a read outside the worktree or into a secret.
|
|
160
|
+
let real = resolved;
|
|
161
|
+
try {
|
|
162
|
+
real = fs.realpathSync(resolved);
|
|
163
|
+
}
|
|
164
|
+
catch {
|
|
165
|
+
// Deleted file — lexical checks above are all we can do.
|
|
166
|
+
}
|
|
167
|
+
const realRel = path.relative(fs.realpathSync(worktreePath), real);
|
|
168
|
+
if (realRel.startsWith('..') || path.isAbsolute(realRel)) {
|
|
169
|
+
throw new Error('Diff path escapes the worktree');
|
|
170
|
+
}
|
|
171
|
+
if (isSecretPath(resolved) || isSecretPath(real)) {
|
|
172
|
+
throw new Error('Diff path is protected by runner policy');
|
|
173
|
+
}
|
|
174
|
+
// A directory pathspec matches everything under it, denylisted files
|
|
175
|
+
// included — the panel asks for one file at a time and gets one file.
|
|
176
|
+
try {
|
|
177
|
+
if (fs.statSync(real).isDirectory()) {
|
|
178
|
+
throw new Error('Diff path must name one file, not a directory');
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
catch (error) {
|
|
182
|
+
if (error instanceof Error && error.message.includes('not a directory'))
|
|
183
|
+
throw error;
|
|
184
|
+
// Deleted file — nothing to stat; the header check in capDiff still applies.
|
|
185
|
+
}
|
|
186
|
+
return rel;
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* `maxBuffer` is a hard wall BEFORE our own caps get a chance to trim: a
|
|
190
|
+
* commit that vendors a whole tree makes execFile reject with ENOBUFS, and the
|
|
191
|
+
* dashboard used to show the raw node error. Say the true thing instead.
|
|
192
|
+
*/
|
|
193
|
+
function isOutputTooLarge(error) {
|
|
194
|
+
const code = error?.code;
|
|
195
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
196
|
+
return code === 'ENOBUFS' || /maxBuffer length exceeded/i.test(message);
|
|
197
|
+
}
|
|
198
|
+
/** Turn a maxBuffer overflow into a sentence the panel can show. */
|
|
199
|
+
async function tooLargeAware(work) {
|
|
200
|
+
try {
|
|
201
|
+
return await work;
|
|
202
|
+
}
|
|
203
|
+
catch (error) {
|
|
204
|
+
if (isOutputTooLarge(error)) {
|
|
205
|
+
throw new Error('This commit is too large to display', { cause: error });
|
|
206
|
+
}
|
|
207
|
+
throw error;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
/**
|
|
211
|
+
* The last line of defence: refuse a diff that ended up containing a protected
|
|
212
|
+
* file, whatever pathspec produced it.
|
|
213
|
+
*
|
|
214
|
+
* Path validation alone is not enough, and QA-105 proved it twice. `--` stops
|
|
215
|
+
* git reading an argument as an option but not as a pattern; `--literal-pathspecs`
|
|
216
|
+
* stops the pattern but not the fact that a literal DIRECTORY pathspec matches
|
|
217
|
+
* everything beneath it — `-- svc` returns `svc/.env`. Rather than keep guessing
|
|
218
|
+
* which pathspec forms exist, this checks the ANSWER: every `diff --git a/X b/Y`
|
|
219
|
+
* header goes through the same denylist as the file list.
|
|
220
|
+
*/
|
|
221
|
+
function assertNoProtectedFiles(diff) {
|
|
222
|
+
for (const match of diff.matchAll(/^diff --git a\/(.+?) b\/(.+)$/gm)) {
|
|
223
|
+
const before = match[1] ?? '';
|
|
224
|
+
const after = match[2] ?? '';
|
|
225
|
+
if (isSecretPath(before) || isSecretPath(after)) {
|
|
226
|
+
throw new Error('Diff path is protected by runner policy');
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
/** Exposed for tests: the header check is the control worth pinning directly. */
|
|
231
|
+
export function capDiffForTest(diff) {
|
|
232
|
+
return capDiff(diff);
|
|
233
|
+
}
|
|
234
|
+
function capDiff(diff) {
|
|
235
|
+
assertNoProtectedFiles(diff);
|
|
236
|
+
// Mask FIRST, then cap — a secret straddling the cap boundary must not
|
|
237
|
+
// survive half-masked (QA-98 F6).
|
|
238
|
+
const masked = maskString(diff);
|
|
239
|
+
const truncated = masked.length > DIFF_CAP_BYTES;
|
|
240
|
+
return { diff: truncated ? masked.slice(0, DIFF_CAP_BYTES) : masked, truncated };
|
|
241
|
+
}
|
|
242
|
+
export async function gitDiff(worktreePath, workspacePath, sessionBranch, filePath) {
|
|
243
|
+
const relPath = safeDiffPath(worktreePath, filePath);
|
|
244
|
+
const { baseRef } = await resolveBase(workspacePath, sessionBranch);
|
|
245
|
+
const base = await mergeBase(worktreePath, baseRef);
|
|
246
|
+
const diff = await git(worktreePath, 'diff', '-M', base, '--', relPath);
|
|
247
|
+
if (diff)
|
|
248
|
+
return capDiff(diff);
|
|
249
|
+
// No diff against the base. The /dev/null fallback shows file CONTENT, so
|
|
250
|
+
// it is allowed only for genuinely UNTRACKED files (status `??`) — never
|
|
251
|
+
// for unchanged tracked files and never for gitignored ones (QA-98 F1).
|
|
252
|
+
const porcelain = await git(worktreePath, 'status', '--porcelain', '--', relPath);
|
|
253
|
+
if (!porcelain.startsWith('??'))
|
|
254
|
+
return { diff: '', truncated: false };
|
|
255
|
+
const fallback = await execFileAsync('git', [...GIT_GLOBAL_ARGS, 'diff', '--no-index', '--', '/dev/null', relPath], { cwd: worktreePath, timeout: GIT_TIMEOUT_MS, maxBuffer: 8 * 1024 * 1024 })
|
|
256
|
+
.then((r) => r.stdout)
|
|
257
|
+
.catch((error) => error?.code === 1 && typeof error.stdout === 'string' ? error.stdout : '');
|
|
258
|
+
return capDiff(fallback);
|
|
259
|
+
}
|
|
260
|
+
export async function gitCommit(worktreePath, message) {
|
|
261
|
+
const porcelain = await git(worktreePath, 'status', '--porcelain');
|
|
262
|
+
if (!porcelain)
|
|
263
|
+
return { committed: false, reason: 'nothing-to-commit' };
|
|
264
|
+
await git(worktreePath, 'add', '-A');
|
|
265
|
+
await git(worktreePath, 'commit', '-m', message);
|
|
266
|
+
return { committed: true, sha: await git(worktreePath, 'rev-parse', 'HEAD') };
|
|
267
|
+
}
|
|
268
|
+
export async function applySession(workspacePath, worktreePath, sessionBranch, message) {
|
|
269
|
+
const workspaceDirty = await git(workspacePath, 'status', '--porcelain');
|
|
270
|
+
if (workspaceDirty) {
|
|
271
|
+
return {
|
|
272
|
+
applied: false,
|
|
273
|
+
error: 'The workspace working copy has uncommitted changes — commit or stash them on the server first',
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
const workspaceBranch = await git(workspacePath, 'rev-parse', '--abbrev-ref', 'HEAD');
|
|
277
|
+
if (workspaceBranch === sessionBranch) {
|
|
278
|
+
return { applied: false, error: 'The workspace is checked out on the session branch itself' };
|
|
279
|
+
}
|
|
280
|
+
// Fold whatever the agent left uncommitted into the branch first, so
|
|
281
|
+
// «Применить» always takes exactly what the user reviewed.
|
|
282
|
+
const worktreeDirty = await git(worktreePath, 'status', '--porcelain');
|
|
283
|
+
if (worktreeDirty) {
|
|
284
|
+
await git(worktreePath, 'add', '-A');
|
|
285
|
+
await git(worktreePath, 'commit', '-m', 'chore(devbridge): изменения сессии перед применением');
|
|
286
|
+
}
|
|
287
|
+
const base = await git(workspacePath, 'merge-base', 'HEAD', sessionBranch);
|
|
288
|
+
const commits = Number(await git(workspacePath, 'rev-list', '--count', `${base}..${sessionBranch}`));
|
|
289
|
+
if (!commits)
|
|
290
|
+
return { applied: false, error: 'The session branch has no changes to apply' };
|
|
291
|
+
try {
|
|
292
|
+
await execFileAsync('git', [...GIT_GLOBAL_ARGS, 'merge', '--squash', sessionBranch], {
|
|
293
|
+
cwd: workspacePath,
|
|
294
|
+
timeout: MERGE_TIMEOUT_MS,
|
|
295
|
+
maxBuffer: 8 * 1024 * 1024,
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
catch (error) {
|
|
299
|
+
// Restore a pristine workspace either way.
|
|
300
|
+
await git(workspacePath, 'reset', '--merge').catch(() => undefined);
|
|
301
|
+
// Only a real content conflict is worth handing back to the agent — a
|
|
302
|
+
// dirty index or a failing hook would send it on a pointless rebase
|
|
303
|
+
// errand (QA-99 MINOR-7).
|
|
304
|
+
if (isMergeConflict(error)) {
|
|
305
|
+
return { applied: false, conflict: true, baseBranch: workspaceBranch };
|
|
306
|
+
}
|
|
307
|
+
return { applied: false, error: `Merge failed: ${shortError(error)}` };
|
|
308
|
+
}
|
|
309
|
+
try {
|
|
310
|
+
await git(workspacePath, 'commit', '-m', message);
|
|
311
|
+
}
|
|
312
|
+
catch (error) {
|
|
313
|
+
await git(workspacePath, 'reset', '--merge').catch(() => undefined);
|
|
314
|
+
return { applied: false, error: `Commit failed: ${shortError(error)}` };
|
|
315
|
+
}
|
|
316
|
+
return { applied: true, commitSha: await git(workspacePath, 'rev-parse', 'HEAD') };
|
|
317
|
+
}
|
|
318
|
+
const LOG_FORMAT = '%H%x00%P%x00%an%x00%aI%x00%D%x00%s';
|
|
319
|
+
const SHOW_META_FORMAT = '%H%x00%P%x00%an%x00%ae%x00%aI%x00%D%x00%s%x00%b';
|
|
320
|
+
const MAX_LOG_LIMIT = 200;
|
|
321
|
+
const MAX_COMMIT_FILES = 500;
|
|
322
|
+
const SUBJECT_CAP = 500;
|
|
323
|
+
const BODY_CAP = 4_000;
|
|
324
|
+
/** A full or abbreviated commit hash — never a ref name, never an option. */
|
|
325
|
+
export function isCommitSha(value) {
|
|
326
|
+
return /^[0-9a-f]{7,64}$/i.test(value);
|
|
327
|
+
}
|
|
328
|
+
/**
|
|
329
|
+
* A ref that came out of git itself (the workspace's current branch). It still
|
|
330
|
+
* must not be read as an option — every call also ends with `--`, but a lone
|
|
331
|
+
* `-f` reaching `git log` would be a bug worth failing on rather than passing.
|
|
332
|
+
*/
|
|
333
|
+
function assertRefArgument(ref) {
|
|
334
|
+
if (!ref || ref.startsWith('-'))
|
|
335
|
+
throw new Error('Invalid git ref');
|
|
336
|
+
return ref;
|
|
337
|
+
}
|
|
338
|
+
/** The session branch, as strictly as `git worktree add -b` accepted it. */
|
|
339
|
+
function requireSessionBranch(branch) {
|
|
340
|
+
const safe = sanitizeBranch(branch);
|
|
341
|
+
if (!safe)
|
|
342
|
+
throw new Error('Invalid branch name');
|
|
343
|
+
return safe;
|
|
344
|
+
}
|
|
345
|
+
/**
|
|
346
|
+
* Constrain a path from an old commit. Deliberately lexical: the file may have
|
|
347
|
+
* been deleted since, so `safeDiffPath`'s `realpathSync` — which is what makes
|
|
348
|
+
* it safe against symlinks in the *working tree* — has nothing to resolve. That
|
|
349
|
+
* is not a weakness here: git reads the blob out of the object store by path,
|
|
350
|
+
* so there is no symlink to follow in the first place.
|
|
351
|
+
*/
|
|
352
|
+
function safeHistoryPath(filePath) {
|
|
353
|
+
const normalized = filePath.replace(/\\/g, '/');
|
|
354
|
+
if (!normalized)
|
|
355
|
+
throw new Error('Diff path is required');
|
|
356
|
+
if (path.isAbsolute(normalized) || normalized.split('/').includes('..')) {
|
|
357
|
+
throw new Error('Diff path must be relative to the repository');
|
|
358
|
+
}
|
|
359
|
+
if (PATHSPEC_MAGIC.test(normalized)) {
|
|
360
|
+
throw new Error('Diff path must name one file, not a pattern');
|
|
361
|
+
}
|
|
362
|
+
// `.`, `./` and a trailing slash all name a directory, and a directory
|
|
363
|
+
// pathspec matches everything under it (QA-105 security round).
|
|
364
|
+
if (normalized === '.' || normalized.endsWith('/') || normalized.split('/').includes('.')) {
|
|
365
|
+
throw new Error('Diff path must name one file, not a directory');
|
|
366
|
+
}
|
|
367
|
+
if (normalized.split('/').includes('.git')) {
|
|
368
|
+
throw new Error('Diff path is protected by runner policy');
|
|
369
|
+
}
|
|
370
|
+
if (isSecretPath(normalized)) {
|
|
371
|
+
throw new Error('Diff path is protected by runner policy');
|
|
372
|
+
}
|
|
373
|
+
return normalized;
|
|
374
|
+
}
|
|
375
|
+
function parseRefs(decoration) {
|
|
376
|
+
return decoration
|
|
377
|
+
.split(',')
|
|
378
|
+
.map((entry) => entry.trim())
|
|
379
|
+
.filter(Boolean)
|
|
380
|
+
.map((entry) => (entry.startsWith('HEAD -> ') ? entry.slice('HEAD -> '.length) : entry))
|
|
381
|
+
.filter((entry) => entry !== 'HEAD')
|
|
382
|
+
.slice(0, 10)
|
|
383
|
+
.map((entry) => entry.slice(0, 200));
|
|
384
|
+
}
|
|
385
|
+
/**
|
|
386
|
+
* `--numstat` names a rename as `old => new` (or `dir/{old => new}/file`),
|
|
387
|
+
* while `--name-status` gives the plain new path. Keying the two maps by
|
|
388
|
+
* different strings is why every renamed file showed `null` additions.
|
|
389
|
+
*/
|
|
390
|
+
export function normalizeNumstatPath(raw) {
|
|
391
|
+
const braced = /^(.*)\{(.*) => (.*)\}(.*)$/.exec(raw);
|
|
392
|
+
if (braced) {
|
|
393
|
+
return `${braced[1] ?? ''}${braced[3] ?? ''}${braced[4] ?? ''}`.replace(/\/{2,}/g, '/');
|
|
394
|
+
}
|
|
395
|
+
const arrow = raw.split(' => ');
|
|
396
|
+
return (arrow.length > 1 ? arrow[arrow.length - 1] : raw) ?? raw;
|
|
397
|
+
}
|
|
398
|
+
function parseParents(raw) {
|
|
399
|
+
return raw.split(' ').filter(Boolean).slice(0, 16);
|
|
400
|
+
}
|
|
401
|
+
async function branchExists(workspacePath, ref) {
|
|
402
|
+
return git(workspacePath, 'rev-parse', '--verify', '--quiet', `${ref}^{commit}`)
|
|
403
|
+
.then(() => true)
|
|
404
|
+
.catch(() => false);
|
|
405
|
+
}
|
|
406
|
+
export async function gitLog(input) {
|
|
407
|
+
const branch = requireSessionBranch(input.branch);
|
|
408
|
+
const { baseBranch, baseRef } = await resolveBase(input.workspacePath, branch);
|
|
409
|
+
assertRefArgument(baseRef);
|
|
410
|
+
if (!(await branchExists(input.workspacePath, branch))) {
|
|
411
|
+
throw new Error(`Branch ${branch} no longer exists in this repository`);
|
|
412
|
+
}
|
|
413
|
+
// Between the BRANCH and the base — not between the workspace's HEAD and the
|
|
414
|
+
// base, which is what `mergeBase()` computes for the Changes panel from
|
|
415
|
+
// inside the worktree. A base branch that is gone (or a repo with a single
|
|
416
|
+
// commit) leaves the marker missing, not the whole panel failing.
|
|
417
|
+
const base = await git(input.workspacePath, 'merge-base', branch, baseRef).catch(() => null);
|
|
418
|
+
const limit = Math.min(Math.max(Math.trunc(input.limit ?? 50), 1), MAX_LOG_LIMIT);
|
|
419
|
+
const skip = Math.min(Math.max(Math.trunc(input.skip ?? 0), 0), 100_000);
|
|
420
|
+
const scope = input.scope ?? 'all';
|
|
421
|
+
// `branch --not baseRef` is exactly `base..branch`, but it does not depend on
|
|
422
|
+
// merge-base succeeding: a repository where the two histories never met would
|
|
423
|
+
// otherwise silently fall back to the WHOLE branch while the UI still said
|
|
424
|
+
// «only what the agent committed» (QA-105).
|
|
425
|
+
const range = scope === 'session'
|
|
426
|
+
? [branch, '--not', baseRef]
|
|
427
|
+
: scope === 'branch'
|
|
428
|
+
? [branch]
|
|
429
|
+
: [branch, baseRef];
|
|
430
|
+
// `--topo-order` is not cosmetic: the lane layout on the dashboard assumes a
|
|
431
|
+
// parent never appears before its child, and the default (date order) breaks
|
|
432
|
+
// exactly that on branches committed out of order.
|
|
433
|
+
const raw = await git(input.workspacePath, 'log', '--topo-order', `--max-count=${limit + 1}`, `--skip=${skip}`, `--format=${LOG_FORMAT}`, ...range, '--');
|
|
434
|
+
const lines = raw.split('\n').filter((line) => line.length > 0);
|
|
435
|
+
const hasMore = lines.length > limit;
|
|
436
|
+
const commits = [];
|
|
437
|
+
for (const line of lines.slice(0, limit)) {
|
|
438
|
+
const [sha, parents, author, date, decoration, subject] = line.split('\0');
|
|
439
|
+
if (!sha)
|
|
440
|
+
continue;
|
|
441
|
+
commits.push({
|
|
442
|
+
sha,
|
|
443
|
+
parents: parseParents(parents ?? ''),
|
|
444
|
+
author: maskString(author ?? '').slice(0, 200),
|
|
445
|
+
date: (date ?? '').slice(0, 40),
|
|
446
|
+
subject: maskString(subject ?? '').slice(0, SUBJECT_CAP),
|
|
447
|
+
refs: parseRefs(decoration ?? ''),
|
|
448
|
+
});
|
|
449
|
+
}
|
|
450
|
+
return { branch, baseBranch, mergeBase: base, commits, hasMore };
|
|
451
|
+
}
|
|
452
|
+
/**
|
|
453
|
+
* A path in a commit must name a FILE. A directory pathspec matches everything
|
|
454
|
+
* beneath it, denylisted files included (`-- svc` returns `svc/.env`), which is
|
|
455
|
+
* how the second half of QA-105 MAJOR-1 got through the first fix.
|
|
456
|
+
*
|
|
457
|
+
* `git cat-file -t` answers this from the object store, so it works for a file
|
|
458
|
+
* that no longer exists in the working copy; the parent is consulted as well so
|
|
459
|
+
* a deletion still opens.
|
|
460
|
+
*/
|
|
461
|
+
async function assertPathIsBlob(workspacePath, sha, relPath) {
|
|
462
|
+
for (const rev of [sha, `${sha}^`]) {
|
|
463
|
+
const type = await git(workspacePath, 'cat-file', '-t', `${rev}:${relPath}`).catch(() => null);
|
|
464
|
+
if (type === 'blob')
|
|
465
|
+
return;
|
|
466
|
+
if (type === 'tree')
|
|
467
|
+
throw new Error('Diff path must name one file, not a directory');
|
|
468
|
+
}
|
|
469
|
+
throw new Error('That file is not part of this commit');
|
|
470
|
+
}
|
|
471
|
+
/**
|
|
472
|
+
* Only history this session can legitimately see.
|
|
473
|
+
*
|
|
474
|
+
* Without it any object in the repository is readable through a session
|
|
475
|
+
* endpoint by naming its sha — including a commit the user believed they had
|
|
476
|
+
* thrown away with `reset --hard`, which is still in the object store
|
|
477
|
+
* (QA-105 security round).
|
|
478
|
+
*/
|
|
479
|
+
async function assertCommitIsVisible(workspacePath, sha, branch) {
|
|
480
|
+
const safeBranch = sanitizeBranch(branch);
|
|
481
|
+
if (!safeBranch)
|
|
482
|
+
throw new Error('Invalid branch name');
|
|
483
|
+
const { baseRef } = await resolveBase(workspacePath, safeBranch);
|
|
484
|
+
for (const ref of [safeBranch, assertRefArgument(baseRef)]) {
|
|
485
|
+
const reachable = await git(workspacePath, 'merge-base', '--is-ancestor', sha, ref)
|
|
486
|
+
.then(() => true)
|
|
487
|
+
.catch(() => false);
|
|
488
|
+
if (reachable)
|
|
489
|
+
return;
|
|
490
|
+
}
|
|
491
|
+
throw new Error('That commit is not part of this session history');
|
|
492
|
+
}
|
|
493
|
+
/**
|
|
494
|
+
* One commit: its metadata and the files it touched, or — when `filePath` is
|
|
495
|
+
* given — the diff of that one file.
|
|
496
|
+
*
|
|
497
|
+
* `--diff-merges=first-parent` on purpose: without it `git show` renders a
|
|
498
|
+
* merge as a combined diff that hides everything already present in one of the
|
|
499
|
+
* parents, and the answer to "what did this merge bring onto my branch" comes
|
|
500
|
+
* out empty. For an ordinary commit the flag changes nothing.
|
|
501
|
+
*/
|
|
502
|
+
export async function gitShow(workspacePath, sha, filePath, branch) {
|
|
503
|
+
if (!isCommitSha(sha))
|
|
504
|
+
throw new Error('Invalid commit sha');
|
|
505
|
+
if (branch !== undefined)
|
|
506
|
+
await assertCommitIsVisible(workspacePath, sha, branch);
|
|
507
|
+
if (filePath !== undefined) {
|
|
508
|
+
const relPath = safeHistoryPath(filePath);
|
|
509
|
+
await assertPathIsBlob(workspacePath, sha, relPath);
|
|
510
|
+
const diff = await git(workspacePath, 'show', '--format=', '-M', '--diff-merges=first-parent', sha, '--', relPath).catch((error) => {
|
|
511
|
+
if (isOutputTooLarge(error)) {
|
|
512
|
+
throw new Error('This diff is too large to display', { cause: error });
|
|
513
|
+
}
|
|
514
|
+
throw error;
|
|
515
|
+
});
|
|
516
|
+
return { kind: 'diff', ...capDiff(diff) };
|
|
517
|
+
}
|
|
518
|
+
const [metaRaw, numstatRaw, nameStatusRaw] = await tooLargeAware(Promise.all([
|
|
519
|
+
git(workspacePath, 'show', '-s', `--format=${SHOW_META_FORMAT}`, sha, '--'),
|
|
520
|
+
git(workspacePath, 'show', '--format=', '--numstat', '-M', '--diff-merges=first-parent', sha, '--'),
|
|
521
|
+
git(workspacePath, 'show', '--format=', '--name-status', '-M', '--diff-merges=first-parent', sha, '--'),
|
|
522
|
+
]));
|
|
523
|
+
const [fullSha, parents, author, email, date, decoration, subject, ...bodyParts] = metaRaw.split('\0');
|
|
524
|
+
if (!fullSha)
|
|
525
|
+
throw new Error('Commit not found');
|
|
526
|
+
const numstat = new Map();
|
|
527
|
+
for (const line of numstatRaw.split('\n').filter(Boolean)) {
|
|
528
|
+
const [add, del, ...rest] = line.split('\t');
|
|
529
|
+
const entryPath = normalizeNumstatPath(rest[rest.length - 1] ?? '');
|
|
530
|
+
if (!entryPath)
|
|
531
|
+
continue;
|
|
532
|
+
numstat.set(entryPath, {
|
|
533
|
+
additions: add === '-' ? null : Number(add),
|
|
534
|
+
deletions: del === '-' ? null : Number(del),
|
|
535
|
+
});
|
|
536
|
+
}
|
|
537
|
+
const nameStatusLines = nameStatusRaw.split('\n').filter(Boolean);
|
|
538
|
+
const truncated = nameStatusLines.length > MAX_COMMIT_FILES;
|
|
539
|
+
const files = [];
|
|
540
|
+
for (const line of nameStatusLines.slice(0, MAX_COMMIT_FILES)) {
|
|
541
|
+
const [status, ...rest] = line.split('\t');
|
|
542
|
+
if (!status)
|
|
543
|
+
continue;
|
|
544
|
+
const entryPath = rest[rest.length - 1] ?? '';
|
|
545
|
+
if (!entryPath)
|
|
546
|
+
continue;
|
|
547
|
+
const stats = numstat.get(entryPath) ?? { additions: null, deletions: null };
|
|
548
|
+
files.push({
|
|
549
|
+
path: entryPath.slice(0, 1_000),
|
|
550
|
+
status: status.charAt(0),
|
|
551
|
+
additions: stats.additions,
|
|
552
|
+
deletions: stats.deletions,
|
|
553
|
+
protected: isSecretPath(entryPath),
|
|
554
|
+
});
|
|
555
|
+
}
|
|
556
|
+
return {
|
|
557
|
+
kind: 'commit',
|
|
558
|
+
sha: fullSha,
|
|
559
|
+
parents: parseParents(parents ?? ''),
|
|
560
|
+
author: maskString(author ?? '').slice(0, 200),
|
|
561
|
+
authorEmail: maskString(email ?? '').slice(0, 200),
|
|
562
|
+
date: (date ?? '').slice(0, 40),
|
|
563
|
+
subject: maskString(subject ?? '').slice(0, SUBJECT_CAP),
|
|
564
|
+
// The body may itself contain NULs' worth of sections — everything after
|
|
565
|
+
// the seventh separator belongs to it.
|
|
566
|
+
body: maskString(bodyParts.join('\0')).slice(0, BODY_CAP).trimEnd(),
|
|
567
|
+
refs: parseRefs(decoration ?? ''),
|
|
568
|
+
files,
|
|
569
|
+
truncated,
|
|
570
|
+
};
|
|
571
|
+
}
|
|
572
|
+
export async function revertApply(workspacePath, commitSha) {
|
|
573
|
+
if (!/^[0-9a-f]{7,64}$/i.test(commitSha)) {
|
|
574
|
+
return { reverted: false, error: 'Invalid commit sha' };
|
|
575
|
+
}
|
|
576
|
+
const dirty = await git(workspacePath, 'status', '--porcelain');
|
|
577
|
+
if (dirty) {
|
|
578
|
+
return {
|
|
579
|
+
reverted: false,
|
|
580
|
+
error: 'The workspace working copy has uncommitted changes — commit or stash them on the server first',
|
|
581
|
+
};
|
|
582
|
+
}
|
|
583
|
+
try {
|
|
584
|
+
await execFileAsync('git', [...GIT_GLOBAL_ARGS, 'revert', '--no-edit', commitSha], {
|
|
585
|
+
cwd: workspacePath,
|
|
586
|
+
timeout: MERGE_TIMEOUT_MS,
|
|
587
|
+
maxBuffer: 8 * 1024 * 1024,
|
|
588
|
+
});
|
|
589
|
+
}
|
|
590
|
+
catch {
|
|
591
|
+
await git(workspacePath, 'revert', '--abort').catch(() => undefined);
|
|
592
|
+
return { reverted: false, conflict: true };
|
|
593
|
+
}
|
|
594
|
+
return { reverted: true, revertSha: await git(workspacePath, 'rev-parse', 'HEAD') };
|
|
595
|
+
}
|
|
596
|
+
//# sourceMappingURL=gitops.js.map
|
package/dist/index.d.ts
ADDED