@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.
Files changed (49) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +86 -0
  3. package/dist/adapters/claude.d.ts +19 -0
  4. package/dist/adapters/claude.js +631 -0
  5. package/dist/adapters/codex-home.d.ts +61 -0
  6. package/dist/adapters/codex-home.js +234 -0
  7. package/dist/adapters/codex-protocol.d.ts +59 -0
  8. package/dist/adapters/codex-protocol.js +204 -0
  9. package/dist/adapters/codex.d.ts +61 -0
  10. package/dist/adapters/codex.js +1406 -0
  11. package/dist/adapters/types.d.ts +183 -0
  12. package/dist/adapters/types.js +5 -0
  13. package/dist/async-queue.d.ts +11 -0
  14. package/dist/async-queue.js +50 -0
  15. package/dist/attachments.d.ts +72 -0
  16. package/dist/attachments.js +149 -0
  17. package/dist/auth-relay.d.ts +57 -0
  18. package/dist/auth-relay.js +289 -0
  19. package/dist/config.d.ts +96 -0
  20. package/dist/config.js +73 -0
  21. package/dist/fsview.d.ts +20 -0
  22. package/dist/fsview.js +122 -0
  23. package/dist/git.d.ts +54 -0
  24. package/dist/git.js +168 -0
  25. package/dist/gitops.d.ts +136 -0
  26. package/dist/gitops.js +596 -0
  27. package/dist/index.d.ts +3 -0
  28. package/dist/index.js +352 -0
  29. package/dist/journal.d.ts +118 -0
  30. package/dist/journal.js +300 -0
  31. package/dist/log.d.ts +7 -0
  32. package/dist/log.js +19 -0
  33. package/dist/paths.d.ts +7 -0
  34. package/dist/paths.js +33 -0
  35. package/dist/policy.d.ts +17 -0
  36. package/dist/policy.js +272 -0
  37. package/dist/protocol.d.ts +754 -0
  38. package/dist/protocol.js +154 -0
  39. package/dist/self-update.d.ts +75 -0
  40. package/dist/self-update.js +221 -0
  41. package/dist/status-file.d.ts +14 -0
  42. package/dist/status-file.js +29 -0
  43. package/dist/supervisor.d.ts +216 -0
  44. package/dist/supervisor.js +1648 -0
  45. package/dist/version.d.ts +2 -0
  46. package/dist/version.js +3 -0
  47. package/dist/ws-client.d.ts +30 -0
  48. package/dist/ws-client.js +171 -0
  49. 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
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env node
2
+ export {};
3
+ //# sourceMappingURL=index.d.ts.map