pi-webdesk 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.
Files changed (64) hide show
  1. package/README.md +111 -0
  2. package/dist/apps/daemon/src/appearance-preferences.js +218 -0
  3. package/dist/apps/daemon/src/auth.js +88 -0
  4. package/dist/apps/daemon/src/bin.js +123 -0
  5. package/dist/apps/daemon/src/cli.js +48 -0
  6. package/dist/apps/daemon/src/event-hub.js +155 -0
  7. package/dist/apps/daemon/src/index.js +102 -0
  8. package/dist/apps/daemon/src/launcher-control.js +114 -0
  9. package/dist/apps/daemon/src/launcher.js +73 -0
  10. package/dist/apps/daemon/src/pi-auth.js +290 -0
  11. package/dist/apps/daemon/src/pi-resources.js +182 -0
  12. package/dist/apps/daemon/src/pi-runtime-factory.js +19 -0
  13. package/dist/apps/daemon/src/pi-sessions.js +265 -0
  14. package/dist/apps/daemon/src/runtime-process.js +241 -0
  15. package/dist/apps/daemon/src/secret.js +71 -0
  16. package/dist/apps/daemon/src/server.js +1662 -0
  17. package/dist/apps/daemon/src/session-projection.js +117 -0
  18. package/dist/apps/daemon/src/state-lock.js +31 -0
  19. package/dist/apps/daemon/src/static-web.js +53 -0
  20. package/dist/apps/daemon/src/task-archive.js +152 -0
  21. package/dist/apps/daemon/src/task-commit.js +503 -0
  22. package/dist/apps/daemon/src/task-merge.js +912 -0
  23. package/dist/apps/daemon/src/task-review.js +204 -0
  24. package/dist/apps/daemon/src/task-runtime.js +1124 -0
  25. package/dist/apps/daemon/src/task-validation.js +352 -0
  26. package/dist/apps/daemon/src/workspace-store.js +140 -0
  27. package/dist/apps/daemon/src/workspace.js +795 -0
  28. package/dist/extensions/webdesk.js +34 -0
  29. package/dist/packages/git/src/commit.js +675 -0
  30. package/dist/packages/git/src/errors.js +55 -0
  31. package/dist/packages/git/src/fingerprint.js +286 -0
  32. package/dist/packages/git/src/index.js +123 -0
  33. package/dist/packages/git/src/merge.js +1008 -0
  34. package/dist/packages/git/src/paths.js +58 -0
  35. package/dist/packages/git/src/repository.js +77 -0
  36. package/dist/packages/git/src/review.js +396 -0
  37. package/dist/packages/git/src/runner.js +110 -0
  38. package/dist/packages/git/src/validation.js +263 -0
  39. package/dist/packages/git/src/worktree.js +233 -0
  40. package/dist/packages/pi-bridge/extensions/pita-policy.js +117 -0
  41. package/dist/packages/pi-bridge/src/auth.js +80 -0
  42. package/dist/packages/pi-bridge/src/errors.js +19 -0
  43. package/dist/packages/pi-bridge/src/handshake.js +43 -0
  44. package/dist/packages/pi-bridge/src/index.js +76 -0
  45. package/dist/packages/pi-bridge/src/jsonl.js +105 -0
  46. package/dist/packages/pi-bridge/src/policy-approval.js +62 -0
  47. package/dist/packages/pi-bridge/src/resolve.js +59 -0
  48. package/dist/packages/pi-bridge/src/resources-child.mjs +23 -0
  49. package/dist/packages/pi-bridge/src/resources.js +481 -0
  50. package/dist/packages/pi-bridge/src/rpc/client.js +480 -0
  51. package/dist/packages/pi-bridge/src/rpc/runtime.js +496 -0
  52. package/dist/packages/pi-bridge/src/rpc/supervisor.mjs +129 -0
  53. package/dist/packages/pi-bridge/src/rpc/tool-events.js +78 -0
  54. package/dist/packages/pi-bridge/src/rpc/wire.js +263 -0
  55. package/dist/packages/pi-bridge/src/runtime.js +0 -0
  56. package/dist/packages/pi-bridge/src/sessions-child.mjs +38 -0
  57. package/dist/packages/pi-bridge/src/sessions.js +314 -0
  58. package/dist/packages/pi-bridge/src/tool-activity.js +56 -0
  59. package/dist/packages/protocol/src/index.js +1863 -0
  60. package/dist/web/assets/index-BOw_fhvO.css +2 -0
  61. package/dist/web/assets/index-oXs7yAAo.js +119 -0
  62. package/dist/web/index.html +14 -0
  63. package/package.json +69 -0
  64. package/scripts/prepare.mjs +7 -0
@@ -0,0 +1,1008 @@
1
+ // packages/git/src/merge.ts
2
+ import {
3
+ GitMergeConflictError,
4
+ GitServiceError,
5
+ boundedDetailText,
6
+ isGitServiceError
7
+ } from "./errors.js";
8
+ import { inspectRepository, COMMIT_SHA_PATTERN } from "./repository.js";
9
+ import {
10
+ commitEntryId,
11
+ parseDiffTreeOutput,
12
+ readGpgSignConfig,
13
+ requireCommitIdentity
14
+ } from "./commit.js";
15
+ import {
16
+ READ_ONLY_GIT_FLAGS,
17
+ parseStatusOutput,
18
+ validateReportedPath
19
+ } from "./review.js";
20
+ import {
21
+ defaultGitRunner,
22
+ runGitExpectingSuccess
23
+ } from "./runner.js";
24
+ var DEFAULT_MERGE_MAX_FILES = 200;
25
+ var DEFAULT_MERGE_MAX_DIFF_CHARS_PER_FILE = 2e4;
26
+ var DEFAULT_MERGE_MAX_TOTAL_DIFF_CHARS = 2e5;
27
+ var DEFAULT_MERGE_MAX_DURATION_MS = 2e4;
28
+ var DEFAULT_MERGE_SIGNING_TIMEOUT_MS = 3e4;
29
+ var DEFAULT_MERGE_MAX_COMMIT_SUBJECTS = 20;
30
+ var DEFAULT_MERGE_MAX_ENTRIES = 2e3;
31
+ var DEFAULT_MERGE_MAX_GUARD_PATH_CHARS = 96e3;
32
+ var DEFAULT_MERGE_GUARD_TIMEOUT_MS = 2e3;
33
+ var MAX_MERGE_MESSAGE_CHARS = 1e5;
34
+ var MAX_MERGE_CONFLICT_PATHS = 20;
35
+ var MIN_MERGE_GIT_VERSION = "2.38";
36
+ var MERGE_DIFF_TIMEOUT_MS = 2e3;
37
+ var DIFF_FLAGS = [
38
+ "--no-color",
39
+ "--no-ext-diff",
40
+ "--no-textconv",
41
+ "--unified=3"
42
+ ];
43
+ function positiveBound(value, label) {
44
+ if (!Number.isSafeInteger(value) || value < 1) {
45
+ throw new GitServiceError("GIT_STATE_TOO_LARGE", `${label} must be a positive safe integer`);
46
+ }
47
+ return value;
48
+ }
49
+ var OID_PATTERN = COMMIT_SHA_PATTERN;
50
+ function singleOid(stdout, label) {
51
+ const value = stdout.endsWith("\n") ? stdout.slice(0, -1) : stdout;
52
+ if (!OID_PATTERN.test(value)) {
53
+ throw new GitServiceError("GIT_COMMAND_FAILED", `git returned an unexpected ${label}`, {
54
+ details: { value: boundedDetailText(value, 80) }
55
+ });
56
+ }
57
+ return value;
58
+ }
59
+ function requireExactOid(value, label) {
60
+ if (!OID_PATTERN.test(value)) {
61
+ throw new GitServiceError("GIT_COMMAND_FAILED", `Merge requires an exact ${label}`);
62
+ }
63
+ }
64
+ function isBinaryDiff(diff) {
65
+ return diff.split("\n").some((line) => line.startsWith("Binary files ") && line.endsWith(" differ"));
66
+ }
67
+ function withDeadline(runner, deadlineMs, now, perCommandLimitMs) {
68
+ return async (args, options = {}) => {
69
+ const remainingMs = Math.floor(deadlineMs - now());
70
+ if (remainingMs < 1) {
71
+ throw new GitServiceError(
72
+ "GIT_STATE_DEADLINE_EXCEEDED",
73
+ "The merge preflight exceeded its overall time budget"
74
+ );
75
+ }
76
+ const callerLimitMs = options.timeoutMs ?? Number.POSITIVE_INFINITY;
77
+ const commandLimitMs = perCommandLimitMs ?? Number.POSITIVE_INFINITY;
78
+ const aggregateIsLimit = remainingMs <= callerLimitMs && remainingMs <= commandLimitMs;
79
+ try {
80
+ return await runner(args, {
81
+ ...options,
82
+ timeoutMs: Math.min(remainingMs, callerLimitMs, commandLimitMs)
83
+ });
84
+ } catch (error) {
85
+ if (aggregateIsLimit && isGitServiceError(error, "GIT_COMMAND_TERMINATED")) {
86
+ throw new GitServiceError(
87
+ "GIT_STATE_DEADLINE_EXCEEDED",
88
+ "The merge preflight exceeded its overall time budget",
89
+ { cause: error }
90
+ );
91
+ }
92
+ throw error;
93
+ }
94
+ };
95
+ }
96
+ async function ensureMergeCapableGit(runner = defaultGitRunner, cwd) {
97
+ const result = await runGitExpectingSuccess(
98
+ runner,
99
+ ["version"],
100
+ cwd === void 0 ? {} : { cwd }
101
+ );
102
+ const match = /git version (\d+)\.(\d+)/.exec(result.stdout);
103
+ if (match === null) {
104
+ throw new GitServiceError("GIT_COMMAND_FAILED", "git version returned unexpected output", {
105
+ details: { value: boundedDetailText(result.stdout, 80) }
106
+ });
107
+ }
108
+ const major = Number(match[1]);
109
+ const minor = Number(match[2]);
110
+ if (major > 2 || major === 2 && minor >= 38) return;
111
+ throw new GitServiceError(
112
+ "GIT_MERGE_VERSION_UNSUPPORTED",
113
+ `Webdesk merges require Git ${MIN_MERGE_GIT_VERSION} or newer (merge-tree --write-tree); found ${major}.${minor}`,
114
+ { details: { major, minor } }
115
+ );
116
+ }
117
+ async function classifyAncestry(runner, cwd, targetHead, taskHead) {
118
+ const taskReachable = await runner(
119
+ ["merge-base", "--is-ancestor", "--end-of-options", taskHead, targetHead],
120
+ { cwd }
121
+ );
122
+ if (taskReachable.exitCode === 0) return "already-merged";
123
+ if (taskReachable.exitCode !== 1) {
124
+ throw new GitServiceError("GIT_COMMAND_FAILED", "git merge-base exited unexpectedly", {
125
+ details: { exitCode: taskReachable.exitCode, stderr: boundedDetailText(taskReachable.stderr) }
126
+ });
127
+ }
128
+ const targetReachable = await runner(
129
+ ["merge-base", "--is-ancestor", "--end-of-options", targetHead, taskHead],
130
+ { cwd }
131
+ );
132
+ if (targetReachable.exitCode === 0) return "fast-forward";
133
+ if (targetReachable.exitCode !== 1) {
134
+ throw new GitServiceError("GIT_COMMAND_FAILED", "git merge-base exited unexpectedly", {
135
+ details: {
136
+ exitCode: targetReachable.exitCode,
137
+ stderr: boundedDetailText(targetReachable.stderr)
138
+ }
139
+ });
140
+ }
141
+ const base = await runner(["merge-base", "--end-of-options", targetHead, taskHead], { cwd });
142
+ if (base.exitCode === 0) return "diverged";
143
+ if (base.exitCode === 1) return "unrelated";
144
+ throw new GitServiceError("GIT_COMMAND_FAILED", "git merge-base exited unexpectedly", {
145
+ details: { exitCode: base.exitCode, stderr: boundedDetailText(base.stderr) }
146
+ });
147
+ }
148
+ async function requireRecordedBase(runner, cwd, baseCommit, targetHead, taskHead) {
149
+ for (const [label, head] of [
150
+ ["task", taskHead],
151
+ ["target", targetHead]
152
+ ]) {
153
+ const result = await runner(
154
+ ["merge-base", "--is-ancestor", "--end-of-options", baseCommit, head],
155
+ { cwd }
156
+ );
157
+ if (result.exitCode === 0) continue;
158
+ if (result.exitCode === 1) {
159
+ throw new GitServiceError(
160
+ "GIT_MERGE_WRONG_BASE",
161
+ `The recorded task base is no longer an ancestor of the ${label} branch; Webdesk refuses to merge a rebased or wrong-base task`
162
+ );
163
+ }
164
+ throw new GitServiceError(
165
+ "GIT_COMMAND_FAILED",
166
+ "git merge-base could not verify the recorded task base",
167
+ {
168
+ details: {
169
+ side: label,
170
+ exitCode: result.exitCode,
171
+ stderr: boundedDetailText(result.stderr)
172
+ }
173
+ }
174
+ );
175
+ }
176
+ }
177
+ async function writeMergeTree(runner, cwd, targetHead, taskHead) {
178
+ const result = await runner(
179
+ [
180
+ "merge-tree",
181
+ "--write-tree",
182
+ "-z",
183
+ "--name-only",
184
+ "--no-messages",
185
+ "--end-of-options",
186
+ targetHead,
187
+ taskHead
188
+ ],
189
+ { cwd }
190
+ );
191
+ if (result.exitCode !== 0 && result.exitCode !== 1) {
192
+ throw new GitServiceError("GIT_COMMAND_FAILED", "git merge-tree exited unexpectedly", {
193
+ details: { exitCode: result.exitCode, stderr: boundedDetailText(result.stderr) }
194
+ });
195
+ }
196
+ const tokens = result.stdout.split("\0");
197
+ const tree = tokens[0];
198
+ if (tree === void 0 || !OID_PATTERN.test(tree)) {
199
+ throw new GitServiceError("GIT_COMMAND_FAILED", "git merge-tree returned an unexpected tree id", {
200
+ details: { value: boundedDetailText(tree ?? "", 80) }
201
+ });
202
+ }
203
+ if (result.exitCode === 0) return tree;
204
+ const conflicted = [];
205
+ const seen = /* @__PURE__ */ new Set();
206
+ for (const token of tokens.slice(1)) {
207
+ if (token === "") continue;
208
+ const validated = validateReportedPath(token);
209
+ if (seen.has(validated)) continue;
210
+ seen.add(validated);
211
+ conflicted.push(validated);
212
+ }
213
+ const retained = conflicted.slice(0, MAX_MERGE_CONFLICT_PATHS);
214
+ throw new GitMergeConflictError(
215
+ "The merge has content conflicts; resolve them in the task worktree (merge the target branch into the task) and commit before merging",
216
+ retained,
217
+ conflicted.length - retained.length
218
+ );
219
+ }
220
+ function parseCommitSubjects(stdout) {
221
+ const lines = stdout.split("\n");
222
+ if (lines[lines.length - 1] === "") lines.pop();
223
+ if (lines.length % 2 !== 0) {
224
+ throw new GitServiceError("GIT_COMMAND_FAILED", "git rev-list returned unexpected output", {
225
+ details: { sample: boundedDetailText(stdout, 120) }
226
+ });
227
+ }
228
+ const subjects = [];
229
+ for (let index = 0; index < lines.length; index += 2) {
230
+ const header = lines[index];
231
+ const body = lines[index + 1];
232
+ const separator = body.indexOf("");
233
+ if (!header.startsWith("commit ") || separator === -1 || !OID_PATTERN.test(body.slice(0, separator))) {
234
+ throw new GitServiceError("GIT_COMMAND_FAILED", "git rev-list returned unexpected output", {
235
+ details: { sample: boundedDetailText(`${header}
236
+ ${body}`, 120) }
237
+ });
238
+ }
239
+ subjects.push(body.slice(separator + 1));
240
+ }
241
+ return subjects;
242
+ }
243
+ async function deriveMergePaths(runner, cwd, previousHead, newTreeish) {
244
+ const rawDiff = await runGitExpectingSuccess(
245
+ runner,
246
+ [
247
+ ...READ_ONLY_GIT_FLAGS,
248
+ "diff-tree",
249
+ "-r",
250
+ "-z",
251
+ "--find-renames",
252
+ previousHead,
253
+ newTreeish
254
+ ],
255
+ { cwd }
256
+ );
257
+ const records = parseDiffTreeOutput(rawDiff.stdout);
258
+ if (records.length > DEFAULT_MERGE_MAX_ENTRIES) {
259
+ throw new GitServiceError(
260
+ "GIT_STATE_TOO_LARGE",
261
+ "The recovered merge changes too many paths to inspect safely",
262
+ { details: { entries: records.length, limit: DEFAULT_MERGE_MAX_ENTRIES } }
263
+ );
264
+ }
265
+ const addedPaths = [];
266
+ const changedPaths = /* @__PURE__ */ new Set();
267
+ let pathChars = 0;
268
+ function addChangedPath(candidate) {
269
+ if (changedPaths.has(candidate)) return;
270
+ pathChars += candidate.length + 1;
271
+ if (pathChars > DEFAULT_MERGE_MAX_GUARD_PATH_CHARS) {
272
+ throw new GitServiceError(
273
+ "GIT_STATE_TOO_LARGE",
274
+ "The recovered merge path set is too large to inspect safely",
275
+ { details: { pathChars, limit: DEFAULT_MERGE_MAX_GUARD_PATH_CHARS } }
276
+ );
277
+ }
278
+ changedPaths.add(candidate);
279
+ }
280
+ for (const record of records) {
281
+ validateReportedPath(record.path);
282
+ if (record.previousPath !== null) validateReportedPath(record.previousPath);
283
+ addChangedPath(record.path);
284
+ if (record.previousPath !== null) addChangedPath(record.previousPath);
285
+ if (record.status === "added" || record.previousPath !== null) {
286
+ addedPaths.push(record.path);
287
+ }
288
+ }
289
+ return { addedPaths, changedPaths: [...changedPaths] };
290
+ }
291
+ async function prepareMergeTree(options) {
292
+ const maxFiles = positiveBound(options.maxFiles ?? DEFAULT_MERGE_MAX_FILES, "Merge file limit");
293
+ const maxDiffCharsPerFile = positiveBound(
294
+ options.maxDiffCharsPerFile ?? DEFAULT_MERGE_MAX_DIFF_CHARS_PER_FILE,
295
+ "Per-file diff limit"
296
+ );
297
+ const maxTotalDiffChars = positiveBound(
298
+ options.maxTotalDiffChars ?? DEFAULT_MERGE_MAX_TOTAL_DIFF_CHARS,
299
+ "Total diff limit"
300
+ );
301
+ const maxDurationMs = positiveBound(
302
+ options.maxDurationMs ?? DEFAULT_MERGE_MAX_DURATION_MS,
303
+ "Merge preflight duration limit"
304
+ );
305
+ const maxCommitSubjects = positiveBound(
306
+ options.maxCommitSubjects ?? DEFAULT_MERGE_MAX_COMMIT_SUBJECTS,
307
+ "Commit subject limit"
308
+ );
309
+ const maxEntries = positiveBound(
310
+ options.maxEntries ?? DEFAULT_MERGE_MAX_ENTRIES,
311
+ "Merge entry limit"
312
+ );
313
+ requireExactOid(options.targetHead, "target head");
314
+ requireExactOid(options.taskHead, "task head");
315
+ requireExactOid(options.baseCommit, "recorded task base");
316
+ const now = options.now ?? Date.now;
317
+ const deadlineMs = now() + maxDurationMs;
318
+ const runner = withDeadline(options.runner ?? defaultGitRunner, deadlineMs, now);
319
+ const diffRunner = withDeadline(
320
+ options.runner ?? defaultGitRunner,
321
+ deadlineMs,
322
+ now,
323
+ MERGE_DIFF_TIMEOUT_MS
324
+ );
325
+ const cwd = options.targetRootPath;
326
+ const ancestry = await classifyAncestry(runner, cwd, options.targetHead, options.taskHead);
327
+ if (ancestry === "already-merged") {
328
+ throw new GitServiceError(
329
+ "GIT_MERGE_ALREADY_MERGED",
330
+ "The task branch is already contained in the target branch; archive the task instead of merging"
331
+ );
332
+ }
333
+ if (ancestry === "unrelated") {
334
+ throw new GitServiceError(
335
+ "GIT_MERGE_UNRELATED_HISTORIES",
336
+ "The task and target branches share no history; Webdesk refuses to merge unrelated histories"
337
+ );
338
+ }
339
+ await requireRecordedBase(
340
+ runner,
341
+ cwd,
342
+ options.baseCommit,
343
+ options.targetHead,
344
+ options.taskHead
345
+ );
346
+ const mode = ancestry === "fast-forward" ? "fast-forward" : "merge-commit";
347
+ const targetTree = singleOid(
348
+ (await runGitExpectingSuccess(
349
+ runner,
350
+ ["rev-parse", "--verify", `${options.targetHead}^{tree}`],
351
+ { cwd }
352
+ )).stdout,
353
+ "target tree id"
354
+ );
355
+ let mergedTree;
356
+ if (mode === "fast-forward") {
357
+ mergedTree = singleOid(
358
+ (await runGitExpectingSuccess(
359
+ runner,
360
+ ["rev-parse", "--verify", `${options.taskHead}^{tree}`],
361
+ { cwd }
362
+ )).stdout,
363
+ "task tree id"
364
+ );
365
+ } else {
366
+ mergedTree = await writeMergeTree(runner, cwd, options.targetHead, options.taskHead);
367
+ }
368
+ if (mergedTree === targetTree) {
369
+ throw new GitServiceError(
370
+ "GIT_MERGE_NOTHING_TO_MERGE",
371
+ "The merge result is exactly the current target tree (the changes are already present); archive the task instead of merging"
372
+ );
373
+ }
374
+ const rawDiff = await runGitExpectingSuccess(
375
+ runner,
376
+ [
377
+ ...READ_ONLY_GIT_FLAGS,
378
+ "diff-tree",
379
+ "-r",
380
+ "-z",
381
+ "--find-renames",
382
+ options.targetHead,
383
+ mergedTree
384
+ ],
385
+ { cwd }
386
+ );
387
+ const records = parseDiffTreeOutput(rawDiff.stdout);
388
+ if (records.length > maxEntries) {
389
+ throw new GitServiceError(
390
+ "GIT_STATE_TOO_LARGE",
391
+ `The merge changes ${records.length} paths, exceeding Webdesk's safety limit of ${maxEntries}`,
392
+ { details: { entries: records.length, limit: maxEntries } }
393
+ );
394
+ }
395
+ const addedPaths = [];
396
+ let mergePathChars = 0;
397
+ for (const record of records) {
398
+ validateReportedPath(record.path);
399
+ if (record.previousPath !== null) validateReportedPath(record.previousPath);
400
+ mergePathChars += record.path.length + 1 + (record.previousPath === null ? 0 : record.previousPath.length + 1);
401
+ if (mergePathChars > DEFAULT_MERGE_MAX_GUARD_PATH_CHARS) {
402
+ throw new GitServiceError(
403
+ "GIT_STATE_TOO_LARGE",
404
+ "The merge path set is too large to inspect safely",
405
+ { details: { pathChars: mergePathChars, limit: DEFAULT_MERGE_MAX_GUARD_PATH_CHARS } }
406
+ );
407
+ }
408
+ if (record.oldMode === "160000" || record.newMode === "160000") {
409
+ throw new GitServiceError(
410
+ "GIT_MERGE_STATE_UNSUPPORTED",
411
+ "The merge changes a gitlink (submodule pointer), which Webdesk cannot publish and sync safely yet",
412
+ { details: { path: boundedDetailText(record.path, 200) } }
413
+ );
414
+ }
415
+ if (record.status === "added" || record.previousPath !== null) {
416
+ addedPaths.push(record.path);
417
+ }
418
+ }
419
+ records.sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0);
420
+ const totalFiles = records.length;
421
+ const selected = records.slice(0, maxFiles);
422
+ let remainingChars = maxTotalDiffChars;
423
+ let totalDiffTruncated = false;
424
+ const files = [];
425
+ for (const record of selected) {
426
+ const base = {
427
+ entryId: commitEntryId(record),
428
+ path: record.path,
429
+ previousPath: record.previousPath,
430
+ kind: record.status
431
+ };
432
+ if (remainingChars <= 0) {
433
+ totalDiffTruncated = true;
434
+ files.push({
435
+ ...base,
436
+ binary: false,
437
+ diff: null,
438
+ diffTruncated: false,
439
+ diffOmittedReason: "total-budget"
440
+ });
441
+ continue;
442
+ }
443
+ let produced;
444
+ try {
445
+ produced = await diffRunner(
446
+ [
447
+ ...READ_ONLY_GIT_FLAGS,
448
+ "diff-tree",
449
+ "-r",
450
+ "-p",
451
+ ...DIFF_FLAGS,
452
+ "--find-renames",
453
+ options.targetHead,
454
+ mergedTree,
455
+ "--",
456
+ record.path,
457
+ ...record.previousPath === null ? [] : [record.previousPath]
458
+ ],
459
+ { cwd }
460
+ );
461
+ } catch (error) {
462
+ if (isGitServiceError(error, "GIT_OUTPUT_OVERFLOW")) {
463
+ files.push({
464
+ ...base,
465
+ binary: false,
466
+ diff: null,
467
+ diffTruncated: false,
468
+ diffOmittedReason: "too-large"
469
+ });
470
+ continue;
471
+ }
472
+ if (isGitServiceError(error, "GIT_STATE_DEADLINE_EXCEEDED") || isGitServiceError(error, "GIT_COMMAND_TERMINATED")) {
473
+ totalDiffTruncated = true;
474
+ files.push({
475
+ ...base,
476
+ binary: false,
477
+ diff: null,
478
+ diffTruncated: false,
479
+ diffOmittedReason: "review-budget"
480
+ });
481
+ continue;
482
+ }
483
+ throw error;
484
+ }
485
+ if (produced.exitCode !== 0) {
486
+ files.push({
487
+ ...base,
488
+ binary: false,
489
+ diff: null,
490
+ diffTruncated: false,
491
+ diffOmittedReason: "diff-failed"
492
+ });
493
+ continue;
494
+ }
495
+ if (isBinaryDiff(produced.stdout)) {
496
+ files.push({
497
+ ...base,
498
+ binary: true,
499
+ diff: null,
500
+ diffTruncated: false,
501
+ diffOmittedReason: "binary"
502
+ });
503
+ continue;
504
+ }
505
+ const perFileLimit = Math.min(maxDiffCharsPerFile, remainingChars);
506
+ let text = produced.stdout;
507
+ let truncated = false;
508
+ if (text.length > perFileLimit) {
509
+ if (perFileLimit < maxDiffCharsPerFile) totalDiffTruncated = true;
510
+ text = text.slice(0, perFileLimit);
511
+ truncated = true;
512
+ }
513
+ remainingChars -= text.length;
514
+ files.push({
515
+ ...base,
516
+ binary: false,
517
+ diff: text,
518
+ diffTruncated: truncated,
519
+ diffOmittedReason: null
520
+ });
521
+ }
522
+ const countResult = await runGitExpectingSuccess(
523
+ runner,
524
+ ["rev-list", "--count", "--end-of-options", `${options.targetHead}..${options.taskHead}`],
525
+ { cwd }
526
+ );
527
+ const commitCount = Number.parseInt(countResult.stdout.trim(), 10);
528
+ if (!Number.isSafeInteger(commitCount) || commitCount < 1) {
529
+ throw new GitServiceError("GIT_COMMAND_FAILED", "git rev-list returned an unexpected count", {
530
+ details: { value: boundedDetailText(countResult.stdout, 80) }
531
+ });
532
+ }
533
+ const subjectsResult = await runGitExpectingSuccess(
534
+ runner,
535
+ [
536
+ "rev-list",
537
+ `--max-count=${maxCommitSubjects}`,
538
+ "--format=%H%x01%s",
539
+ "--end-of-options",
540
+ `${options.targetHead}..${options.taskHead}`
541
+ ],
542
+ { cwd }
543
+ );
544
+ const commitSubjects = parseCommitSubjects(subjectsResult.stdout);
545
+ return {
546
+ mode,
547
+ targetHead: options.targetHead,
548
+ taskHead: options.taskHead,
549
+ mergedTree,
550
+ files,
551
+ totalFiles,
552
+ omittedFiles: totalFiles - selected.length,
553
+ totalDiffTruncated,
554
+ addedPaths,
555
+ commitCount,
556
+ commitSubjects
557
+ };
558
+ }
559
+ async function assertMergeTargetPathsUnoccupied(targetRootPath, addedPaths, options = {}) {
560
+ const maxDurationMs = positiveBound(
561
+ options.maxDurationMs ?? DEFAULT_MERGE_GUARD_TIMEOUT_MS,
562
+ "Merge occupancy duration limit"
563
+ );
564
+ const maxPathChars = positiveBound(
565
+ options.maxPathChars ?? DEFAULT_MERGE_MAX_GUARD_PATH_CHARS,
566
+ "Merge occupancy path limit"
567
+ );
568
+ const now = options.now ?? Date.now;
569
+ const runner = withDeadline(
570
+ options.runner ?? defaultGitRunner,
571
+ now() + maxDurationMs,
572
+ now
573
+ );
574
+ const candidates = /* @__PURE__ */ new Set();
575
+ let pathChars = 0;
576
+ for (const addedPath of addedPaths) {
577
+ const segments = validateReportedPath(addedPath).split("/");
578
+ for (let depth = 1; depth <= segments.length; depth++) {
579
+ const candidate = segments.slice(0, depth).join("/");
580
+ if (candidates.has(candidate)) continue;
581
+ const nextPathChars = pathChars + candidate.length + 1;
582
+ if (nextPathChars > maxPathChars) {
583
+ throw new GitServiceError(
584
+ "GIT_STATE_TOO_LARGE",
585
+ "The merge-created path set is too large to inspect safely",
586
+ { details: { pathChars: nextPathChars, limit: maxPathChars } }
587
+ );
588
+ }
589
+ candidates.add(candidate);
590
+ pathChars = nextPathChars;
591
+ }
592
+ }
593
+ const pathspecs = [...candidates];
594
+ if (pathspecs.length === 0) return;
595
+ async function firstOccupant(ignored) {
596
+ const result = await runGitExpectingSuccess(
597
+ runner,
598
+ [
599
+ ...READ_ONLY_GIT_FLAGS,
600
+ "ls-files",
601
+ "--others",
602
+ ...ignored ? ["--ignored"] : [],
603
+ "--exclude-standard",
604
+ "-z",
605
+ "--",
606
+ ...pathspecs
607
+ ],
608
+ { cwd: targetRootPath }
609
+ );
610
+ const reported = result.stdout.split("\0").find((candidate) => candidate.length > 0);
611
+ return reported === void 0 ? null : validateReportedPath(reported);
612
+ }
613
+ const occupied = await firstOccupant(false) ?? await firstOccupant(true);
614
+ if (occupied !== null) {
615
+ throw new GitServiceError(
616
+ "GIT_MERGE_TARGET_PATH_OCCUPIED",
617
+ "An untracked or ignored path already occupies a location the merge would create; move it away and preflight again",
618
+ { details: { path: boundedDetailText(occupied, 200) } }
619
+ );
620
+ }
621
+ }
622
+ async function isMergeCommitAncestor(targetRootPath, ancestor, descendant, runner = defaultGitRunner) {
623
+ requireExactOid(ancestor, "ancestor commit");
624
+ requireExactOid(descendant, "descendant commit");
625
+ const result = await runner(
626
+ ["merge-base", "--is-ancestor", "--end-of-options", ancestor, descendant],
627
+ { cwd: targetRootPath }
628
+ );
629
+ if (result.exitCode === 0) return true;
630
+ if (result.exitCode === 1) return false;
631
+ throw new GitServiceError("GIT_COMMAND_FAILED", "Git could not verify merge ancestry", {
632
+ details: { exitCode: result.exitCode, stderr: boundedDetailText(result.stderr) }
633
+ });
634
+ }
635
+ function validateBranchName(branch, label) {
636
+ if (branch.length === 0 || branch.length > 1024) {
637
+ throw new GitServiceError("GIT_COMMAND_FAILED", `Merge requires a bounded ${label}`);
638
+ }
639
+ }
640
+ async function readBranchTip(runner, cwd, branch) {
641
+ try {
642
+ const result = await runner(
643
+ ["rev-parse", "--verify", "--end-of-options", `refs/heads/${branch}`],
644
+ { cwd }
645
+ );
646
+ if (result.exitCode !== 0) return null;
647
+ return singleOid(result.stdout, "branch tip");
648
+ } catch {
649
+ return null;
650
+ }
651
+ }
652
+ async function publishMerge(options) {
653
+ const runner = options.runner ?? defaultGitRunner;
654
+ const signingTimeoutMs = positiveBound(
655
+ options.signingTimeoutMs ?? DEFAULT_MERGE_SIGNING_TIMEOUT_MS,
656
+ "Signing timeout"
657
+ );
658
+ requireExactOid(options.taskHead, "task head");
659
+ requireExactOid(options.targetHead, "target head");
660
+ requireExactOid(options.mergedTree, "merged tree");
661
+ validateBranchName(options.taskBranch, "task branch");
662
+ validateBranchName(options.targetBranch, "target branch");
663
+ if (options.taskBranch === options.targetBranch) {
664
+ throw new GitServiceError(
665
+ "GIT_COMMAND_FAILED",
666
+ "Merge publication requires distinct task and target branches"
667
+ );
668
+ }
669
+ if (options.reflogReason.length === 0 || options.reflogReason.length > 200 || [...options.reflogReason].some((character) => {
670
+ const code = character.codePointAt(0) ?? 0;
671
+ return code < 32 || code === 127;
672
+ })) {
673
+ throw new GitServiceError(
674
+ "GIT_COMMAND_FAILED",
675
+ "Merge publication requires a bounded, control-character-free reflog reason"
676
+ );
677
+ }
678
+ if (options.mode === "fast-forward" !== (options.message === null)) {
679
+ throw new GitServiceError(
680
+ "GIT_COMMAND_FAILED",
681
+ "Exactly the merge-commit mode carries a merge message"
682
+ );
683
+ }
684
+ const cwd = options.targetRootPath;
685
+ let newCommit;
686
+ let signed = false;
687
+ if (options.mode === "fast-forward") {
688
+ const taskTree = singleOid(
689
+ (await runGitExpectingSuccess(runner, ["rev-parse", "--verify", `${options.taskHead}^{tree}`], {
690
+ cwd
691
+ })).stdout,
692
+ "task tree id"
693
+ );
694
+ if (taskTree !== options.mergedTree) {
695
+ throw new GitServiceError(
696
+ "GIT_MERGE_TREE_MISMATCH",
697
+ "The fast-forward head no longer carries the approved tree; no ref was moved",
698
+ { details: { approvedTree: options.mergedTree, taskTree } }
699
+ );
700
+ }
701
+ newCommit = options.taskHead;
702
+ } else {
703
+ const message = options.message;
704
+ if (message.trim().length === 0 || message.length > MAX_MERGE_MESSAGE_CHARS) {
705
+ throw new GitServiceError(
706
+ "GIT_COMMAND_FAILED",
707
+ "Merge publication requires a non-empty bounded message"
708
+ );
709
+ }
710
+ await requireCommitIdentity(runner, cwd);
711
+ const sign = await readGpgSignConfig(runner, cwd);
712
+ const body = message.endsWith("\n") ? message : `${message}
713
+ `;
714
+ const commitResult = await runner(
715
+ [
716
+ "commit-tree",
717
+ ...sign ? ["-S"] : [],
718
+ "-p",
719
+ options.targetHead,
720
+ "-p",
721
+ options.taskHead,
722
+ options.mergedTree
723
+ ],
724
+ {
725
+ cwd,
726
+ stdin: body,
727
+ ...sign ? { timeoutMs: signingTimeoutMs } : {}
728
+ }
729
+ ).catch((error) => {
730
+ if (sign && isGitServiceError(error, "GIT_COMMAND_TERMINATED")) {
731
+ throw new GitServiceError(
732
+ "GIT_MERGE_SIGNING_FAILED",
733
+ "Merge-commit signing did not complete within its time bound; no ref was moved",
734
+ { cause: error }
735
+ );
736
+ }
737
+ throw error;
738
+ });
739
+ if (commitResult.exitCode !== 0) {
740
+ if (sign) {
741
+ throw new GitServiceError(
742
+ "GIT_MERGE_SIGNING_FAILED",
743
+ "Merge-commit signing failed; no ref was moved",
744
+ { details: { stderr: boundedDetailText(commitResult.stderr) } }
745
+ );
746
+ }
747
+ throw new GitServiceError("GIT_COMMAND_FAILED", "git commit-tree exited unexpectedly", {
748
+ details: {
749
+ exitCode: commitResult.exitCode,
750
+ stderr: boundedDetailText(commitResult.stderr)
751
+ }
752
+ });
753
+ }
754
+ newCommit = singleOid(commitResult.stdout, "merge commit id");
755
+ signed = sign;
756
+ const commitTree = singleOid(
757
+ (await runGitExpectingSuccess(runner, ["rev-parse", "--verify", `${newCommit}^{tree}`], {
758
+ cwd
759
+ })).stdout,
760
+ "merge commit tree id"
761
+ );
762
+ if (commitTree !== options.mergedTree) {
763
+ throw new GitServiceError(
764
+ "GIT_MERGE_TREE_MISMATCH",
765
+ "The created merge commit does not carry the approved tree; no ref was moved",
766
+ { details: { approvedTree: options.mergedTree, commitTree } }
767
+ );
768
+ }
769
+ const parents = (await runGitExpectingSuccess(
770
+ runner,
771
+ ["rev-list", "--parents", "--max-count=1", "--end-of-options", newCommit],
772
+ { cwd }
773
+ )).stdout.trim().split(" ");
774
+ if (parents.length !== 3 || parents[0] !== newCommit || parents[1] !== options.targetHead || parents[2] !== options.taskHead) {
775
+ throw new GitServiceError(
776
+ "GIT_MERGE_TREE_MISMATCH",
777
+ "The created merge commit does not carry the approved parent order; no ref was moved",
778
+ { details: { commit: newCommit } }
779
+ );
780
+ }
781
+ }
782
+ await options.recordIntent?.(newCommit);
783
+ const transactionStdin = `start\0verify refs/heads/${options.taskBranch}\0${options.taskHead}\0update refs/heads/${options.targetBranch}\0${newCommit}\0${options.targetHead}\0prepare\0commit\0`;
784
+ let publishFailure = null;
785
+ let publicationIndeterminate = false;
786
+ try {
787
+ const transaction = await runner(
788
+ ["update-ref", "-m", options.reflogReason, "--stdin", "-z"],
789
+ { cwd, stdin: transactionStdin }
790
+ );
791
+ if (transaction.exitCode !== 0) {
792
+ publishFailure = new GitServiceError(
793
+ "GIT_MERGE_REF_CAS_FAILED",
794
+ "git update-ref did not report a successful publication",
795
+ { details: { stderr: boundedDetailText(transaction.stderr) } }
796
+ );
797
+ }
798
+ } catch (error) {
799
+ publishFailure = error;
800
+ publicationIndeterminate = true;
801
+ }
802
+ if (publishFailure !== null) {
803
+ const targetTip = await readBranchTip(runner, cwd, options.targetBranch);
804
+ if (targetTip === null) {
805
+ throw new GitServiceError(
806
+ "GIT_MERGE_PUBLICATION_UNKNOWN",
807
+ `Git stopped before Webdesk could determine whether commit ${newCommit.slice(0, 12)} was published to the target branch; inspect the repository before retrying`,
808
+ {
809
+ cause: publishFailure,
810
+ details: { branch: options.targetBranch, commit: newCommit }
811
+ }
812
+ );
813
+ }
814
+ if (targetTip !== newCommit) {
815
+ if (publicationIndeterminate) {
816
+ throw new GitServiceError(
817
+ "GIT_MERGE_PUBLICATION_UNKNOWN",
818
+ `Git stopped before Webdesk could determine whether commit ${newCommit.slice(0, 12)} was published`,
819
+ {
820
+ cause: publishFailure,
821
+ details: { branch: options.targetBranch, commit: newCommit }
822
+ }
823
+ );
824
+ }
825
+ if (targetTip !== options.targetHead) {
826
+ throw new GitServiceError(
827
+ "GIT_MERGE_TARGET_MOVED",
828
+ "The target branch moved before publication; Webdesk did not replace the newer tip",
829
+ { cause: publishFailure }
830
+ );
831
+ }
832
+ const sourceTip = await readBranchTip(runner, cwd, options.taskBranch);
833
+ if (sourceTip !== options.taskHead) {
834
+ throw new GitServiceError(
835
+ "GIT_MERGE_SOURCE_MOVED",
836
+ "The task branch changed underneath the approved merge; nothing was published",
837
+ {
838
+ cause: publishFailure,
839
+ details: { sourceReadable: sourceTip !== null }
840
+ }
841
+ );
842
+ }
843
+ throw new GitServiceError(
844
+ "GIT_MERGE_REF_CAS_FAILED",
845
+ "The ref transaction was refused (a repository hook may have aborted it); nothing was published",
846
+ { cause: publishFailure }
847
+ );
848
+ }
849
+ }
850
+ return {
851
+ mode: options.mode,
852
+ newCommit,
853
+ treeOid: options.mergedTree,
854
+ previousTargetHead: options.targetHead,
855
+ taskHead: options.taskHead,
856
+ signed
857
+ };
858
+ }
859
+ async function assertMergePathsUseOrdinaryIndexEntries(runner, cwd, changedPaths) {
860
+ if (changedPaths.length === 0) return;
861
+ const result = await runGitExpectingSuccess(
862
+ runner,
863
+ [
864
+ ...READ_ONLY_GIT_FLAGS,
865
+ "ls-files",
866
+ "-v",
867
+ "-z",
868
+ "--",
869
+ ...changedPaths
870
+ ],
871
+ { cwd }
872
+ );
873
+ for (const token of result.stdout.split("\0")) {
874
+ if (token.length === 0) continue;
875
+ if (token.length < 3 || token[1] !== " ") {
876
+ throw new GitServiceError(
877
+ "GIT_COMMAND_FAILED",
878
+ "Git returned unexpected index flags before checkout sync"
879
+ );
880
+ }
881
+ const tag = token[0];
882
+ const reportedPath = validateReportedPath(token.slice(2));
883
+ if (tag !== "H") {
884
+ throw new GitServiceError(
885
+ "GIT_MERGE_SYNC_REFUSED",
886
+ "A merge-modified target path uses skip-worktree, assume-unchanged, or another non-ordinary index mode; clear the index flag before reconciling",
887
+ { details: { path: boundedDetailText(reportedPath, 200), indexTag: tag } }
888
+ );
889
+ }
890
+ }
891
+ }
892
+ async function syncTargetCheckout(options) {
893
+ const runner = options.runner ?? defaultGitRunner;
894
+ requireExactOid(options.previousTargetHead, "previous target head");
895
+ requireExactOid(options.newTargetHead, "new target head");
896
+ validateBranchName(options.targetBranch, "target branch");
897
+ const cwd = options.targetRootPath;
898
+ const inspected = await inspectRepository(cwd, { runner });
899
+ if (inspected.root !== cwd || inspected.branch !== options.targetBranch || inspected.headCommit !== options.newTargetHead) {
900
+ throw new GitServiceError(
901
+ "GIT_MERGE_TARGET_MISMATCHED",
902
+ "The target checkout is no longer on the published branch and head; Webdesk refuses to touch it",
903
+ {
904
+ details: {
905
+ expectedBranch: boundedDetailText(options.targetBranch, 200),
906
+ actualBranch: inspected.branch === null ? null : boundedDetailText(inspected.branch, 200),
907
+ expectedHead: options.newTargetHead,
908
+ actualHead: inspected.headCommit
909
+ }
910
+ }
911
+ );
912
+ }
913
+ const mergePaths = await deriveMergePaths(
914
+ runner,
915
+ cwd,
916
+ options.previousTargetHead,
917
+ options.newTargetHead
918
+ );
919
+ await assertMergePathsUseOrdinaryIndexEntries(runner, cwd, mergePaths.changedPaths);
920
+ for (const [args, label] of [
921
+ [
922
+ [
923
+ ...READ_ONLY_GIT_FLAGS,
924
+ "diff-index",
925
+ "--cached",
926
+ "--quiet",
927
+ "--ignore-submodules=none",
928
+ options.previousTargetHead,
929
+ "--"
930
+ ],
931
+ "index"
932
+ ],
933
+ [
934
+ [
935
+ ...READ_ONLY_GIT_FLAGS,
936
+ "diff-files",
937
+ "--quiet",
938
+ "--ignore-submodules=none",
939
+ "--"
940
+ ],
941
+ "worktree"
942
+ ]
943
+ ]) {
944
+ const clean = await runner(args, { cwd });
945
+ if (clean.exitCode === 0) continue;
946
+ if (clean.exitCode === 1) {
947
+ throw new GitServiceError(
948
+ "GIT_MERGE_SYNC_REFUSED",
949
+ `The target ${label} changed after publication; Webdesk refuses to overwrite it`
950
+ );
951
+ }
952
+ throw new GitServiceError(
953
+ "GIT_COMMAND_FAILED",
954
+ `Git could not verify the target ${label} before checkout sync`,
955
+ { details: { exitCode: clean.exitCode, stderr: boundedDetailText(clean.stderr) } }
956
+ );
957
+ }
958
+ await assertMergeTargetPathsUnoccupied(cwd, mergePaths.addedPaths, { runner });
959
+ const sync = await runner(
960
+ ["read-tree", "-m", "-u", "--end-of-options", options.previousTargetHead, options.newTargetHead],
961
+ { cwd }
962
+ );
963
+ if (sync.exitCode !== 0) {
964
+ throw new GitServiceError(
965
+ "GIT_MERGE_SYNC_REFUSED",
966
+ "Git refused to update the target checkout (local edits, an untracked file, or a held index lock); nothing was modified and nothing was rolled back",
967
+ { details: { stderr: boundedDetailText(sync.stderr) } }
968
+ );
969
+ }
970
+ let statusClean = false;
971
+ try {
972
+ const status = await runner(
973
+ [...READ_ONLY_GIT_FLAGS, "status", "--porcelain=v2", "-z", "--untracked-files=all"],
974
+ { cwd }
975
+ );
976
+ statusClean = status.exitCode === 0 && parseStatusOutput(status.stdout).length === 0;
977
+ } catch {
978
+ statusClean = false;
979
+ }
980
+ let headVerified = false;
981
+ try {
982
+ const verified = await inspectRepository(cwd, { runner });
983
+ headVerified = verified.branch === options.targetBranch && verified.headCommit === options.newTargetHead;
984
+ } catch {
985
+ headVerified = false;
986
+ }
987
+ return { statusClean, headVerified };
988
+ }
989
+ export {
990
+ DEFAULT_MERGE_GUARD_TIMEOUT_MS,
991
+ DEFAULT_MERGE_MAX_COMMIT_SUBJECTS,
992
+ DEFAULT_MERGE_MAX_DIFF_CHARS_PER_FILE,
993
+ DEFAULT_MERGE_MAX_DURATION_MS,
994
+ DEFAULT_MERGE_MAX_ENTRIES,
995
+ DEFAULT_MERGE_MAX_FILES,
996
+ DEFAULT_MERGE_MAX_GUARD_PATH_CHARS,
997
+ DEFAULT_MERGE_MAX_TOTAL_DIFF_CHARS,
998
+ DEFAULT_MERGE_SIGNING_TIMEOUT_MS,
999
+ MAX_MERGE_CONFLICT_PATHS,
1000
+ MAX_MERGE_MESSAGE_CHARS,
1001
+ MIN_MERGE_GIT_VERSION,
1002
+ assertMergeTargetPathsUnoccupied,
1003
+ ensureMergeCapableGit,
1004
+ isMergeCommitAncestor,
1005
+ prepareMergeTree,
1006
+ publishMerge,
1007
+ syncTargetCheckout
1008
+ };