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.
- package/README.md +111 -0
- package/dist/apps/daemon/src/appearance-preferences.js +218 -0
- package/dist/apps/daemon/src/auth.js +88 -0
- package/dist/apps/daemon/src/bin.js +123 -0
- package/dist/apps/daemon/src/cli.js +48 -0
- package/dist/apps/daemon/src/event-hub.js +155 -0
- package/dist/apps/daemon/src/index.js +102 -0
- package/dist/apps/daemon/src/launcher-control.js +114 -0
- package/dist/apps/daemon/src/launcher.js +73 -0
- package/dist/apps/daemon/src/pi-auth.js +290 -0
- package/dist/apps/daemon/src/pi-resources.js +182 -0
- package/dist/apps/daemon/src/pi-runtime-factory.js +19 -0
- package/dist/apps/daemon/src/pi-sessions.js +265 -0
- package/dist/apps/daemon/src/runtime-process.js +241 -0
- package/dist/apps/daemon/src/secret.js +71 -0
- package/dist/apps/daemon/src/server.js +1662 -0
- package/dist/apps/daemon/src/session-projection.js +117 -0
- package/dist/apps/daemon/src/state-lock.js +31 -0
- package/dist/apps/daemon/src/static-web.js +53 -0
- package/dist/apps/daemon/src/task-archive.js +152 -0
- package/dist/apps/daemon/src/task-commit.js +503 -0
- package/dist/apps/daemon/src/task-merge.js +912 -0
- package/dist/apps/daemon/src/task-review.js +204 -0
- package/dist/apps/daemon/src/task-runtime.js +1124 -0
- package/dist/apps/daemon/src/task-validation.js +352 -0
- package/dist/apps/daemon/src/workspace-store.js +140 -0
- package/dist/apps/daemon/src/workspace.js +795 -0
- package/dist/extensions/webdesk.js +34 -0
- package/dist/packages/git/src/commit.js +675 -0
- package/dist/packages/git/src/errors.js +55 -0
- package/dist/packages/git/src/fingerprint.js +286 -0
- package/dist/packages/git/src/index.js +123 -0
- package/dist/packages/git/src/merge.js +1008 -0
- package/dist/packages/git/src/paths.js +58 -0
- package/dist/packages/git/src/repository.js +77 -0
- package/dist/packages/git/src/review.js +396 -0
- package/dist/packages/git/src/runner.js +110 -0
- package/dist/packages/git/src/validation.js +263 -0
- package/dist/packages/git/src/worktree.js +233 -0
- package/dist/packages/pi-bridge/extensions/pita-policy.js +117 -0
- package/dist/packages/pi-bridge/src/auth.js +80 -0
- package/dist/packages/pi-bridge/src/errors.js +19 -0
- package/dist/packages/pi-bridge/src/handshake.js +43 -0
- package/dist/packages/pi-bridge/src/index.js +76 -0
- package/dist/packages/pi-bridge/src/jsonl.js +105 -0
- package/dist/packages/pi-bridge/src/policy-approval.js +62 -0
- package/dist/packages/pi-bridge/src/resolve.js +59 -0
- package/dist/packages/pi-bridge/src/resources-child.mjs +23 -0
- package/dist/packages/pi-bridge/src/resources.js +481 -0
- package/dist/packages/pi-bridge/src/rpc/client.js +480 -0
- package/dist/packages/pi-bridge/src/rpc/runtime.js +496 -0
- package/dist/packages/pi-bridge/src/rpc/supervisor.mjs +129 -0
- package/dist/packages/pi-bridge/src/rpc/tool-events.js +78 -0
- package/dist/packages/pi-bridge/src/rpc/wire.js +263 -0
- package/dist/packages/pi-bridge/src/runtime.js +0 -0
- package/dist/packages/pi-bridge/src/sessions-child.mjs +38 -0
- package/dist/packages/pi-bridge/src/sessions.js +314 -0
- package/dist/packages/pi-bridge/src/tool-activity.js +56 -0
- package/dist/packages/protocol/src/index.js +1863 -0
- package/dist/web/assets/index-BOw_fhvO.css +2 -0
- package/dist/web/assets/index-oXs7yAAo.js +119 -0
- package/dist/web/index.html +14 -0
- package/package.json +69 -0
- package/scripts/prepare.mjs +7 -0
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// extensions/webdesk.ts
|
|
2
|
+
import { bootstrapUrl, launchPita, launcherStateDir, openBrowser } from "../apps/daemon/src/launcher.js";
|
|
3
|
+
import { requestLauncherControl } from "../apps/daemon/src/launcher-control.js";
|
|
4
|
+
function webdeskExtension(pi) {
|
|
5
|
+
const command = {
|
|
6
|
+
description: "Open the Webdesk workspace, or /webdesk status / /webdesk stop",
|
|
7
|
+
handler: async (args, ctx) => {
|
|
8
|
+
const command2 = args.trim() || "open";
|
|
9
|
+
if (!["open", "status", "stop"].includes(command2)) {
|
|
10
|
+
ctx.ui.notify("Usage: /webdesk [open|status|stop]", "info");
|
|
11
|
+
return;
|
|
12
|
+
}
|
|
13
|
+
try {
|
|
14
|
+
if (command2 === "open") {
|
|
15
|
+
const url = bootstrapUrl(await launchPita());
|
|
16
|
+
const opened = await openBrowser(url);
|
|
17
|
+
ctx.ui.notify(`${opened ? "Webdesk opened." : "Open Webdesk in your browser:"}
|
|
18
|
+
${url}
|
|
19
|
+
The link expires in 15 minutes. /webdesk stop stops the daemon and task runtimes.`, "info");
|
|
20
|
+
} else {
|
|
21
|
+
const reply = await requestLauncherControl(launcherStateDir(), command2);
|
|
22
|
+
ctx.ui.notify(reply ? `Webdesk ${reply.status} at ${reply.origin}` : "Webdesk is not running.", "info");
|
|
23
|
+
}
|
|
24
|
+
} catch (error) {
|
|
25
|
+
ctx.ui.notify(error instanceof Error ? error.message : String(error), "error");
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
};
|
|
29
|
+
pi.registerCommand("webdesk", command);
|
|
30
|
+
pi.registerCommand("pita", { ...command, description: "Alias for /webdesk" });
|
|
31
|
+
}
|
|
32
|
+
export {
|
|
33
|
+
webdeskExtension as default
|
|
34
|
+
};
|
|
@@ -0,0 +1,675 @@
|
|
|
1
|
+
// packages/git/src/commit.ts
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { mkdtemp, rm } from "node:fs/promises";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { GitServiceError, boundedDetailText, isGitServiceError } from "./errors.js";
|
|
7
|
+
import { inspectRepository, COMMIT_SHA_PATTERN } from "./repository.js";
|
|
8
|
+
import {
|
|
9
|
+
READ_ONLY_GIT_FLAGS,
|
|
10
|
+
parseStatusOutput,
|
|
11
|
+
sameRepositoryIdentity
|
|
12
|
+
} from "./review.js";
|
|
13
|
+
import {
|
|
14
|
+
defaultGitRunner,
|
|
15
|
+
runGitExpectingSuccess
|
|
16
|
+
} from "./runner.js";
|
|
17
|
+
var DEFAULT_COMMIT_MAX_FILES = 200;
|
|
18
|
+
var DEFAULT_COMMIT_MAX_DIFF_CHARS_PER_FILE = 2e4;
|
|
19
|
+
var DEFAULT_COMMIT_MAX_TOTAL_DIFF_CHARS = 2e5;
|
|
20
|
+
var DEFAULT_COMMIT_MAX_DURATION_MS = 2e4;
|
|
21
|
+
var DEFAULT_COMMIT_MAX_ENTRIES = 2e3;
|
|
22
|
+
var DEFAULT_COMMIT_SIGNING_TIMEOUT_MS = 3e4;
|
|
23
|
+
var MAX_COMMIT_MESSAGE_CHARS = 1e5;
|
|
24
|
+
var COMMIT_DIFF_TIMEOUT_MS = 2e3;
|
|
25
|
+
var DIFF_FLAGS = [
|
|
26
|
+
"--no-color",
|
|
27
|
+
"--no-ext-diff",
|
|
28
|
+
"--no-textconv",
|
|
29
|
+
"--unified=3"
|
|
30
|
+
];
|
|
31
|
+
async function publicationState(runner, cwd, branch, commit, parentCommit) {
|
|
32
|
+
try {
|
|
33
|
+
const result = await runner(
|
|
34
|
+
["rev-parse", "--verify", `refs/heads/${branch}`],
|
|
35
|
+
{ cwd }
|
|
36
|
+
);
|
|
37
|
+
if (result.exitCode !== 0) return "unknown";
|
|
38
|
+
const tip = singleOid(result.stdout, "branch tip");
|
|
39
|
+
if (tip === commit) return "published";
|
|
40
|
+
if (tip === parentCommit) return "unmoved";
|
|
41
|
+
return "moved";
|
|
42
|
+
} catch {
|
|
43
|
+
return "unknown";
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
function positiveBound(value, label) {
|
|
47
|
+
if (!Number.isSafeInteger(value) || value < 1) {
|
|
48
|
+
throw new GitServiceError("GIT_STATE_TOO_LARGE", `${label} must be a positive safe integer`);
|
|
49
|
+
}
|
|
50
|
+
return value;
|
|
51
|
+
}
|
|
52
|
+
var OID_PATTERN = COMMIT_SHA_PATTERN;
|
|
53
|
+
function singleOid(stdout, label) {
|
|
54
|
+
const value = stdout.endsWith("\n") ? stdout.slice(0, -1) : stdout;
|
|
55
|
+
if (!OID_PATTERN.test(value)) {
|
|
56
|
+
throw new GitServiceError("GIT_COMMAND_FAILED", `git returned an unexpected ${label}`, {
|
|
57
|
+
details: { value: boundedDetailText(value, 80) }
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
return value;
|
|
61
|
+
}
|
|
62
|
+
function isBinaryDiff(diff) {
|
|
63
|
+
return diff.split("\n").some((line) => line.startsWith("Binary files ") && line.endsWith(" differ"));
|
|
64
|
+
}
|
|
65
|
+
function withDeadline(runner, deadlineMs, now, perCommandLimitMs) {
|
|
66
|
+
return async (args, options = {}) => {
|
|
67
|
+
const remainingMs = Math.floor(deadlineMs - now());
|
|
68
|
+
if (remainingMs < 1) {
|
|
69
|
+
throw new GitServiceError(
|
|
70
|
+
"GIT_STATE_DEADLINE_EXCEEDED",
|
|
71
|
+
"The commit preflight exceeded its overall time budget"
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
const callerLimitMs = options.timeoutMs ?? Number.POSITIVE_INFINITY;
|
|
75
|
+
const commandLimitMs = perCommandLimitMs ?? Number.POSITIVE_INFINITY;
|
|
76
|
+
const aggregateIsLimit = remainingMs <= callerLimitMs && remainingMs <= commandLimitMs;
|
|
77
|
+
try {
|
|
78
|
+
return await runner(args, {
|
|
79
|
+
...options,
|
|
80
|
+
timeoutMs: Math.min(remainingMs, callerLimitMs, commandLimitMs)
|
|
81
|
+
});
|
|
82
|
+
} catch (error) {
|
|
83
|
+
if (aggregateIsLimit && isGitServiceError(error, "GIT_COMMAND_TERMINATED")) {
|
|
84
|
+
throw new GitServiceError(
|
|
85
|
+
"GIT_STATE_DEADLINE_EXCEEDED",
|
|
86
|
+
"The commit preflight exceeded its overall time budget",
|
|
87
|
+
{ cause: error }
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
throw error;
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
async function requireCommitIdentity(runner, cwd) {
|
|
95
|
+
for (const key of ["user.name", "user.email"]) {
|
|
96
|
+
const result = await runner(["config", "--get", key], { cwd });
|
|
97
|
+
if (result.exitCode === 1 || result.exitCode === 0 && result.stdout.trim() === "") {
|
|
98
|
+
throw new GitServiceError(
|
|
99
|
+
"GIT_COMMIT_IDENTITY_MISSING",
|
|
100
|
+
`Git identity is not configured (${key}); set user.name and user.email before committing`,
|
|
101
|
+
{ details: { key } }
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
if (result.exitCode !== 0) {
|
|
105
|
+
throw new GitServiceError("GIT_COMMAND_FAILED", "git config exited unexpectedly", {
|
|
106
|
+
details: { key, exitCode: result.exitCode, stderr: boundedDetailText(result.stderr) }
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
async function readGpgSignConfig(runner, cwd) {
|
|
112
|
+
const result = await runner(["config", "--type=bool", "--get", "commit.gpgsign"], { cwd });
|
|
113
|
+
if (result.exitCode === 1) return false;
|
|
114
|
+
if (result.exitCode !== 0) {
|
|
115
|
+
throw new GitServiceError(
|
|
116
|
+
"GIT_COMMAND_FAILED",
|
|
117
|
+
"git config could not read commit.gpgSign as a boolean",
|
|
118
|
+
{ details: { exitCode: result.exitCode, stderr: boundedDetailText(result.stderr) } }
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
return result.stdout.trim() === "true";
|
|
122
|
+
}
|
|
123
|
+
var STATUS_KIND = {
|
|
124
|
+
A: "added",
|
|
125
|
+
M: "modified",
|
|
126
|
+
D: "deleted",
|
|
127
|
+
R: "renamed",
|
|
128
|
+
C: "copied",
|
|
129
|
+
T: "type-changed"
|
|
130
|
+
};
|
|
131
|
+
function diffTreeParseFailure(reason, sample) {
|
|
132
|
+
return new GitServiceError(
|
|
133
|
+
"GIT_COMMAND_FAILED",
|
|
134
|
+
`git diff-tree returned unexpected output: ${reason}`,
|
|
135
|
+
{ details: { sample: boundedDetailText(sample, 200) } }
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
function parseDiffTreeOutput(stdout) {
|
|
139
|
+
const tokens = stdout.split("\0");
|
|
140
|
+
const records = [];
|
|
141
|
+
for (let index = 0; index < tokens.length; index++) {
|
|
142
|
+
const meta = tokens[index];
|
|
143
|
+
if (meta === "") continue;
|
|
144
|
+
if (!meta.startsWith(":")) {
|
|
145
|
+
throw diffTreeParseFailure("record does not start with ':'", meta);
|
|
146
|
+
}
|
|
147
|
+
const fields = meta.slice(1).split(" ");
|
|
148
|
+
const oldMode = fields[0];
|
|
149
|
+
const newMode = fields[1];
|
|
150
|
+
const statusField = fields[4];
|
|
151
|
+
if (fields.length < 5 || statusField === void 0 || statusField.length === 0 || oldMode === void 0 || newMode === void 0 || !/^[0-7]{6}$/.test(oldMode) || !/^[0-7]{6}$/.test(newMode)) {
|
|
152
|
+
throw diffTreeParseFailure("missing status or mode field", meta);
|
|
153
|
+
}
|
|
154
|
+
const letter = statusField[0];
|
|
155
|
+
const kind = STATUS_KIND[letter];
|
|
156
|
+
if (kind === void 0) {
|
|
157
|
+
throw diffTreeParseFailure("unsupported status letter", meta);
|
|
158
|
+
}
|
|
159
|
+
const first = tokens[++index];
|
|
160
|
+
if (first === void 0 || first === "") {
|
|
161
|
+
throw diffTreeParseFailure("missing path field", meta);
|
|
162
|
+
}
|
|
163
|
+
if (letter === "R" || letter === "C") {
|
|
164
|
+
const second = tokens[++index];
|
|
165
|
+
if (second === void 0 || second === "") {
|
|
166
|
+
throw diffTreeParseFailure("rename record is missing its destination path", meta);
|
|
167
|
+
}
|
|
168
|
+
records.push({ status: kind, path: second, previousPath: first, oldMode, newMode, raw: meta });
|
|
169
|
+
} else {
|
|
170
|
+
records.push({ status: kind, path: first, previousPath: null, oldMode, newMode, raw: meta });
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
return records;
|
|
174
|
+
}
|
|
175
|
+
function commitEntryId(record) {
|
|
176
|
+
return createHash("sha256").update(record.raw).update("\0").update(record.path).update("\0").update(record.previousPath ?? "").digest("hex");
|
|
177
|
+
}
|
|
178
|
+
function validateCommitEntries(entries, maxEntries) {
|
|
179
|
+
if (entries.length === 0) {
|
|
180
|
+
throw new GitServiceError(
|
|
181
|
+
"GIT_COMMIT_NOTHING_TO_COMMIT",
|
|
182
|
+
"The worktree has no staged, unstaged, or untracked changes to commit"
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
if (entries.length > maxEntries) {
|
|
186
|
+
throw new GitServiceError(
|
|
187
|
+
"GIT_STATE_TOO_LARGE",
|
|
188
|
+
`The worktree has ${entries.length} changed entries, more than the ${maxEntries}-entry commit bound`,
|
|
189
|
+
{ details: { entries: entries.length, maxEntries } }
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
for (const entry of entries) {
|
|
193
|
+
if (entry.record === "unmerged") {
|
|
194
|
+
throw new GitServiceError(
|
|
195
|
+
"GIT_COMMIT_CONFLICTED",
|
|
196
|
+
"The worktree has unresolved merge conflicts; resolve them before committing",
|
|
197
|
+
{ details: { path: boundedDetailText(entry.path, 200) } }
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
if (entry.sub.startsWith("S")) {
|
|
201
|
+
throw new GitServiceError(
|
|
202
|
+
"GIT_COMMIT_STATE_UNSUPPORTED",
|
|
203
|
+
"Dirty or moved submodules cannot be committed safely by Webdesk",
|
|
204
|
+
{ details: { path: boundedDetailText(entry.path, 200) } }
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
if (entry.path.endsWith("/")) {
|
|
208
|
+
throw new GitServiceError(
|
|
209
|
+
"GIT_COMMIT_STATE_UNSUPPORTED",
|
|
210
|
+
"An embedded repository or directory-shaped change cannot be committed safely by Webdesk",
|
|
211
|
+
{ details: { path: boundedDetailText(entry.path, 200) } }
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
async function prepareCommitTree(options) {
|
|
217
|
+
const maxFiles = positiveBound(options.maxFiles ?? DEFAULT_COMMIT_MAX_FILES, "Commit file limit");
|
|
218
|
+
const maxDiffCharsPerFile = positiveBound(
|
|
219
|
+
options.maxDiffCharsPerFile ?? DEFAULT_COMMIT_MAX_DIFF_CHARS_PER_FILE,
|
|
220
|
+
"Per-file diff limit"
|
|
221
|
+
);
|
|
222
|
+
const maxTotalDiffChars = positiveBound(
|
|
223
|
+
options.maxTotalDiffChars ?? DEFAULT_COMMIT_MAX_TOTAL_DIFF_CHARS,
|
|
224
|
+
"Total diff limit"
|
|
225
|
+
);
|
|
226
|
+
const maxDurationMs = positiveBound(
|
|
227
|
+
options.maxDurationMs ?? DEFAULT_COMMIT_MAX_DURATION_MS,
|
|
228
|
+
"Commit preflight duration limit"
|
|
229
|
+
);
|
|
230
|
+
const maxEntries = positiveBound(
|
|
231
|
+
options.maxEntries ?? DEFAULT_COMMIT_MAX_ENTRIES,
|
|
232
|
+
"Commit entry limit"
|
|
233
|
+
);
|
|
234
|
+
const now = options.now ?? Date.now;
|
|
235
|
+
const deadlineMs = now() + maxDurationMs;
|
|
236
|
+
const runner = withDeadline(options.runner ?? defaultGitRunner, deadlineMs, now);
|
|
237
|
+
const diffRunner = withDeadline(
|
|
238
|
+
options.runner ?? defaultGitRunner,
|
|
239
|
+
deadlineMs,
|
|
240
|
+
now,
|
|
241
|
+
COMMIT_DIFF_TIMEOUT_MS
|
|
242
|
+
);
|
|
243
|
+
const worktree = await inspectRepository(options.worktreePath, { runner });
|
|
244
|
+
if (worktree.branch === null) {
|
|
245
|
+
throw new GitServiceError(
|
|
246
|
+
"GIT_COMMIT_DETACHED_HEAD",
|
|
247
|
+
"The worktree is on a detached HEAD; Webdesk only commits to the task branch"
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
if (worktree.branch !== options.expectedBranch) {
|
|
251
|
+
throw new GitServiceError(
|
|
252
|
+
"GIT_COMMIT_BRANCH_MISMATCH",
|
|
253
|
+
"The worktree is not on the task's recorded branch",
|
|
254
|
+
{
|
|
255
|
+
details: {
|
|
256
|
+
expectedBranch: boundedDetailText(options.expectedBranch, 200),
|
|
257
|
+
actualBranch: boundedDetailText(worktree.branch, 200)
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
);
|
|
261
|
+
}
|
|
262
|
+
await requireCommitIdentity(runner, worktree.root);
|
|
263
|
+
const status = await runGitExpectingSuccess(
|
|
264
|
+
runner,
|
|
265
|
+
[...READ_ONLY_GIT_FLAGS, "status", "--porcelain=v2", "-z", "--untracked-files=all"],
|
|
266
|
+
{ cwd: worktree.root }
|
|
267
|
+
);
|
|
268
|
+
const entries = parseStatusOutput(status.stdout);
|
|
269
|
+
validateCommitEntries(entries, maxEntries);
|
|
270
|
+
const stagedPathSet = /* @__PURE__ */ new Set();
|
|
271
|
+
for (const entry of entries) {
|
|
272
|
+
stagedPathSet.add(entry.path);
|
|
273
|
+
if (entry.previousPath !== null) stagedPathSet.add(entry.previousPath);
|
|
274
|
+
}
|
|
275
|
+
const stagedPaths = [...stagedPathSet].sort();
|
|
276
|
+
const stagedDivergence = entries.some(
|
|
277
|
+
(entry) => entry.record === "untracked" || entry.y !== "."
|
|
278
|
+
);
|
|
279
|
+
const parentCommit = worktree.headCommit;
|
|
280
|
+
const parentTree = singleOid(
|
|
281
|
+
(await runGitExpectingSuccess(runner, ["rev-parse", "--verify", `${parentCommit}^{tree}`], {
|
|
282
|
+
cwd: worktree.root
|
|
283
|
+
})).stdout,
|
|
284
|
+
"parent tree id"
|
|
285
|
+
);
|
|
286
|
+
const tempDir = await mkdtemp(path.join(os.tmpdir(), "pita-commit-index-"));
|
|
287
|
+
let treeOid;
|
|
288
|
+
try {
|
|
289
|
+
const indexEnv = { GIT_INDEX_FILE: path.join(tempDir, "index") };
|
|
290
|
+
await runGitExpectingSuccess(runner, [...READ_ONLY_GIT_FLAGS, "read-tree", parentTree], {
|
|
291
|
+
cwd: worktree.root,
|
|
292
|
+
extraEnv: indexEnv
|
|
293
|
+
});
|
|
294
|
+
await runGitExpectingSuccess(
|
|
295
|
+
runner,
|
|
296
|
+
[...READ_ONLY_GIT_FLAGS, "update-index", "--add", "--remove", "-z", "--stdin"],
|
|
297
|
+
{
|
|
298
|
+
cwd: worktree.root,
|
|
299
|
+
extraEnv: indexEnv,
|
|
300
|
+
stdin: stagedPaths.map((entryPath) => `${entryPath}\0`).join("")
|
|
301
|
+
}
|
|
302
|
+
);
|
|
303
|
+
treeOid = singleOid(
|
|
304
|
+
(await runGitExpectingSuccess(runner, [...READ_ONLY_GIT_FLAGS, "write-tree"], {
|
|
305
|
+
cwd: worktree.root,
|
|
306
|
+
extraEnv: indexEnv
|
|
307
|
+
})).stdout,
|
|
308
|
+
"tree id"
|
|
309
|
+
);
|
|
310
|
+
} finally {
|
|
311
|
+
await rm(tempDir, { recursive: true, force: true }).catch(() => void 0);
|
|
312
|
+
}
|
|
313
|
+
if (treeOid === parentTree) {
|
|
314
|
+
throw new GitServiceError(
|
|
315
|
+
"GIT_COMMIT_NOTHING_TO_COMMIT",
|
|
316
|
+
"After clean filters, the changeset produces exactly the parent commit's tree; there is nothing to commit"
|
|
317
|
+
);
|
|
318
|
+
}
|
|
319
|
+
const rawDiff = await runGitExpectingSuccess(
|
|
320
|
+
runner,
|
|
321
|
+
[...READ_ONLY_GIT_FLAGS, "diff-tree", "-r", "-z", "--find-renames", parentCommit, treeOid],
|
|
322
|
+
{ cwd: worktree.root }
|
|
323
|
+
);
|
|
324
|
+
const records = parseDiffTreeOutput(rawDiff.stdout);
|
|
325
|
+
records.sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0);
|
|
326
|
+
const totalFiles = records.length;
|
|
327
|
+
const selected = records.slice(0, maxFiles);
|
|
328
|
+
let remainingChars = maxTotalDiffChars;
|
|
329
|
+
let totalDiffTruncated = false;
|
|
330
|
+
const files = [];
|
|
331
|
+
for (const record of records.slice(0, maxFiles)) {
|
|
332
|
+
const base = {
|
|
333
|
+
entryId: commitEntryId(record),
|
|
334
|
+
path: record.path,
|
|
335
|
+
previousPath: record.previousPath,
|
|
336
|
+
kind: record.status
|
|
337
|
+
};
|
|
338
|
+
if (remainingChars <= 0) {
|
|
339
|
+
totalDiffTruncated = true;
|
|
340
|
+
files.push({
|
|
341
|
+
...base,
|
|
342
|
+
binary: false,
|
|
343
|
+
diff: null,
|
|
344
|
+
diffTruncated: false,
|
|
345
|
+
diffOmittedReason: "total-budget"
|
|
346
|
+
});
|
|
347
|
+
continue;
|
|
348
|
+
}
|
|
349
|
+
let produced;
|
|
350
|
+
try {
|
|
351
|
+
produced = await diffRunner(
|
|
352
|
+
[
|
|
353
|
+
...READ_ONLY_GIT_FLAGS,
|
|
354
|
+
"diff-tree",
|
|
355
|
+
"-r",
|
|
356
|
+
"-p",
|
|
357
|
+
...DIFF_FLAGS,
|
|
358
|
+
"--find-renames",
|
|
359
|
+
parentCommit,
|
|
360
|
+
treeOid,
|
|
361
|
+
"--",
|
|
362
|
+
record.path,
|
|
363
|
+
...record.previousPath === null ? [] : [record.previousPath]
|
|
364
|
+
],
|
|
365
|
+
{ cwd: worktree.root }
|
|
366
|
+
);
|
|
367
|
+
} catch (error) {
|
|
368
|
+
if (isGitServiceError(error, "GIT_OUTPUT_OVERFLOW")) {
|
|
369
|
+
files.push({
|
|
370
|
+
...base,
|
|
371
|
+
binary: false,
|
|
372
|
+
diff: null,
|
|
373
|
+
diffTruncated: false,
|
|
374
|
+
diffOmittedReason: "too-large"
|
|
375
|
+
});
|
|
376
|
+
continue;
|
|
377
|
+
}
|
|
378
|
+
if (isGitServiceError(error, "GIT_STATE_DEADLINE_EXCEEDED") || isGitServiceError(error, "GIT_COMMAND_TERMINATED")) {
|
|
379
|
+
totalDiffTruncated = true;
|
|
380
|
+
files.push({
|
|
381
|
+
...base,
|
|
382
|
+
binary: false,
|
|
383
|
+
diff: null,
|
|
384
|
+
diffTruncated: false,
|
|
385
|
+
diffOmittedReason: "review-budget"
|
|
386
|
+
});
|
|
387
|
+
continue;
|
|
388
|
+
}
|
|
389
|
+
throw error;
|
|
390
|
+
}
|
|
391
|
+
if (produced.exitCode !== 0) {
|
|
392
|
+
files.push({
|
|
393
|
+
...base,
|
|
394
|
+
binary: false,
|
|
395
|
+
diff: null,
|
|
396
|
+
diffTruncated: false,
|
|
397
|
+
diffOmittedReason: "diff-failed"
|
|
398
|
+
});
|
|
399
|
+
continue;
|
|
400
|
+
}
|
|
401
|
+
if (isBinaryDiff(produced.stdout)) {
|
|
402
|
+
files.push({
|
|
403
|
+
...base,
|
|
404
|
+
binary: true,
|
|
405
|
+
diff: null,
|
|
406
|
+
diffTruncated: false,
|
|
407
|
+
diffOmittedReason: "binary"
|
|
408
|
+
});
|
|
409
|
+
continue;
|
|
410
|
+
}
|
|
411
|
+
const perFileLimit = Math.min(maxDiffCharsPerFile, remainingChars);
|
|
412
|
+
let text = produced.stdout;
|
|
413
|
+
let truncated = false;
|
|
414
|
+
if (text.length > perFileLimit) {
|
|
415
|
+
if (perFileLimit < maxDiffCharsPerFile) totalDiffTruncated = true;
|
|
416
|
+
text = text.slice(0, perFileLimit);
|
|
417
|
+
truncated = true;
|
|
418
|
+
}
|
|
419
|
+
remainingChars -= text.length;
|
|
420
|
+
files.push({
|
|
421
|
+
...base,
|
|
422
|
+
binary: false,
|
|
423
|
+
diff: text,
|
|
424
|
+
diffTruncated: truncated,
|
|
425
|
+
diffOmittedReason: null
|
|
426
|
+
});
|
|
427
|
+
}
|
|
428
|
+
const verified = await inspectRepository(worktree.root, { runner });
|
|
429
|
+
if (!sameRepositoryIdentity(worktree, verified)) {
|
|
430
|
+
throw new GitServiceError(
|
|
431
|
+
"GIT_REPOSITORY_CHANGED_DURING_CAPTURE",
|
|
432
|
+
"The worktree branch or HEAD changed during commit preflight; retry to observe the new state",
|
|
433
|
+
{
|
|
434
|
+
details: {
|
|
435
|
+
beforeHead: worktree.headCommit,
|
|
436
|
+
afterHead: verified.headCommit,
|
|
437
|
+
beforeBranch: worktree.branch,
|
|
438
|
+
afterBranch: verified.branch
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
);
|
|
442
|
+
}
|
|
443
|
+
return {
|
|
444
|
+
worktree,
|
|
445
|
+
branch: worktree.branch,
|
|
446
|
+
parentCommit,
|
|
447
|
+
treeOid,
|
|
448
|
+
stagedPaths,
|
|
449
|
+
files,
|
|
450
|
+
totalFiles,
|
|
451
|
+
omittedFiles: totalFiles - selected.length,
|
|
452
|
+
totalDiffTruncated,
|
|
453
|
+
stagedDivergence
|
|
454
|
+
};
|
|
455
|
+
}
|
|
456
|
+
async function commitPreparedTree(options) {
|
|
457
|
+
const runner = options.runner ?? defaultGitRunner;
|
|
458
|
+
const signingTimeoutMs = positiveBound(
|
|
459
|
+
options.signingTimeoutMs ?? DEFAULT_COMMIT_SIGNING_TIMEOUT_MS,
|
|
460
|
+
"Signing timeout"
|
|
461
|
+
);
|
|
462
|
+
if (!OID_PATTERN.test(options.parentCommit) || !OID_PATTERN.test(options.treeOid)) {
|
|
463
|
+
throw new GitServiceError("GIT_COMMAND_FAILED", "Commit execution requires exact object ids");
|
|
464
|
+
}
|
|
465
|
+
if (typeof options.message !== "string" || options.message.trim().length === 0 || options.message.length > MAX_COMMIT_MESSAGE_CHARS) {
|
|
466
|
+
throw new GitServiceError(
|
|
467
|
+
"GIT_COMMAND_FAILED",
|
|
468
|
+
"Commit execution requires a non-empty bounded message"
|
|
469
|
+
);
|
|
470
|
+
}
|
|
471
|
+
if (options.reflogReason.length === 0 || options.reflogReason.length > 200 || [...options.reflogReason].some((character) => {
|
|
472
|
+
const code = character.codePointAt(0) ?? 0;
|
|
473
|
+
return code < 32 || code === 127;
|
|
474
|
+
})) {
|
|
475
|
+
throw new GitServiceError(
|
|
476
|
+
"GIT_COMMAND_FAILED",
|
|
477
|
+
"Commit execution requires a bounded, control-character-free reflog reason"
|
|
478
|
+
);
|
|
479
|
+
}
|
|
480
|
+
const worktree = await inspectRepository(options.worktreePath, { runner });
|
|
481
|
+
if (worktree.branch === null) {
|
|
482
|
+
throw new GitServiceError(
|
|
483
|
+
"GIT_COMMIT_DETACHED_HEAD",
|
|
484
|
+
"The worktree is on a detached HEAD; Webdesk only commits to the task branch"
|
|
485
|
+
);
|
|
486
|
+
}
|
|
487
|
+
if (worktree.branch !== options.branch) {
|
|
488
|
+
throw new GitServiceError(
|
|
489
|
+
"GIT_COMMIT_BRANCH_MISMATCH",
|
|
490
|
+
"The worktree is no longer on the branch that was approved",
|
|
491
|
+
{
|
|
492
|
+
details: {
|
|
493
|
+
expectedBranch: boundedDetailText(options.branch, 200),
|
|
494
|
+
actualBranch: boundedDetailText(worktree.branch, 200)
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
);
|
|
498
|
+
}
|
|
499
|
+
if (worktree.headCommit !== options.parentCommit) {
|
|
500
|
+
throw new GitServiceError(
|
|
501
|
+
"GIT_COMMIT_PARENT_MOVED",
|
|
502
|
+
"The branch moved since the approved preflight; review the current state and preflight again",
|
|
503
|
+
{
|
|
504
|
+
details: {
|
|
505
|
+
approvedParent: options.parentCommit,
|
|
506
|
+
currentHead: worktree.headCommit
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
);
|
|
510
|
+
}
|
|
511
|
+
await requireCommitIdentity(runner, worktree.root);
|
|
512
|
+
const sign = await readGpgSignConfig(runner, worktree.root);
|
|
513
|
+
const message = options.message.endsWith("\n") ? options.message : `${options.message}
|
|
514
|
+
`;
|
|
515
|
+
const commitResult = await runner(
|
|
516
|
+
[
|
|
517
|
+
"commit-tree",
|
|
518
|
+
...sign ? ["-S"] : [],
|
|
519
|
+
"-p",
|
|
520
|
+
options.parentCommit,
|
|
521
|
+
options.treeOid
|
|
522
|
+
],
|
|
523
|
+
{
|
|
524
|
+
cwd: worktree.root,
|
|
525
|
+
stdin: message,
|
|
526
|
+
...sign ? { timeoutMs: signingTimeoutMs } : {}
|
|
527
|
+
}
|
|
528
|
+
).catch((error) => {
|
|
529
|
+
if (sign && isGitServiceError(error, "GIT_COMMAND_TERMINATED")) {
|
|
530
|
+
throw new GitServiceError(
|
|
531
|
+
"GIT_COMMIT_SIGNING_FAILED",
|
|
532
|
+
"Commit signing did not complete within its time bound; no ref was moved",
|
|
533
|
+
{ cause: error }
|
|
534
|
+
);
|
|
535
|
+
}
|
|
536
|
+
throw error;
|
|
537
|
+
});
|
|
538
|
+
if (commitResult.exitCode !== 0) {
|
|
539
|
+
if (sign) {
|
|
540
|
+
throw new GitServiceError(
|
|
541
|
+
"GIT_COMMIT_SIGNING_FAILED",
|
|
542
|
+
"Commit signing failed; no ref was moved",
|
|
543
|
+
{ details: { stderr: boundedDetailText(commitResult.stderr) } }
|
|
544
|
+
);
|
|
545
|
+
}
|
|
546
|
+
throw new GitServiceError("GIT_COMMAND_FAILED", "git commit-tree exited unexpectedly", {
|
|
547
|
+
details: {
|
|
548
|
+
exitCode: commitResult.exitCode,
|
|
549
|
+
stderr: boundedDetailText(commitResult.stderr)
|
|
550
|
+
}
|
|
551
|
+
});
|
|
552
|
+
}
|
|
553
|
+
const commit = singleOid(commitResult.stdout, "commit id");
|
|
554
|
+
const commitTree = singleOid(
|
|
555
|
+
(await runGitExpectingSuccess(runner, ["rev-parse", "--verify", `${commit}^{tree}`], {
|
|
556
|
+
cwd: worktree.root
|
|
557
|
+
})).stdout,
|
|
558
|
+
"commit tree id"
|
|
559
|
+
);
|
|
560
|
+
if (commitTree !== options.treeOid) {
|
|
561
|
+
throw new GitServiceError(
|
|
562
|
+
"GIT_COMMIT_TREE_MISMATCH",
|
|
563
|
+
"The created commit does not carry the approved tree; no ref was moved",
|
|
564
|
+
{ details: { approvedTree: options.treeOid, commitTree } }
|
|
565
|
+
);
|
|
566
|
+
}
|
|
567
|
+
let publishFailure = null;
|
|
568
|
+
try {
|
|
569
|
+
const publish = await runner(
|
|
570
|
+
[
|
|
571
|
+
"update-ref",
|
|
572
|
+
"-m",
|
|
573
|
+
options.reflogReason,
|
|
574
|
+
`refs/heads/${options.branch}`,
|
|
575
|
+
commit,
|
|
576
|
+
options.parentCommit
|
|
577
|
+
],
|
|
578
|
+
{ cwd: worktree.root }
|
|
579
|
+
);
|
|
580
|
+
if (publish.exitCode !== 0) {
|
|
581
|
+
publishFailure = new GitServiceError(
|
|
582
|
+
"GIT_COMMIT_REF_CAS_FAILED",
|
|
583
|
+
"git update-ref did not report a successful publication",
|
|
584
|
+
{ details: { stderr: boundedDetailText(publish.stderr) } }
|
|
585
|
+
);
|
|
586
|
+
}
|
|
587
|
+
} catch (error) {
|
|
588
|
+
publishFailure = error;
|
|
589
|
+
}
|
|
590
|
+
if (publishFailure !== null) {
|
|
591
|
+
const state = await publicationState(
|
|
592
|
+
runner,
|
|
593
|
+
worktree.root,
|
|
594
|
+
options.branch,
|
|
595
|
+
commit,
|
|
596
|
+
options.parentCommit
|
|
597
|
+
);
|
|
598
|
+
if (state !== "published") {
|
|
599
|
+
if (state === "unknown") {
|
|
600
|
+
throw new GitServiceError(
|
|
601
|
+
"GIT_COMMIT_PUBLICATION_UNKNOWN",
|
|
602
|
+
"Git stopped before Webdesk could determine whether the approved commit was published; inspect the branch before retrying",
|
|
603
|
+
{
|
|
604
|
+
cause: publishFailure,
|
|
605
|
+
details: { branch: options.branch, commit }
|
|
606
|
+
}
|
|
607
|
+
);
|
|
608
|
+
}
|
|
609
|
+
throw new GitServiceError(
|
|
610
|
+
"GIT_COMMIT_REF_CAS_FAILED",
|
|
611
|
+
state === "unmoved" ? "The approved parent is still current; Webdesk did not publish the commit" : "The branch moved before publication; Webdesk did not replace the newer tip",
|
|
612
|
+
{ cause: publishFailure }
|
|
613
|
+
);
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
let headVerified = false;
|
|
617
|
+
try {
|
|
618
|
+
const verified = await inspectRepository(worktree.root, { runner });
|
|
619
|
+
headVerified = verified.headCommit === commit && verified.branch === options.branch;
|
|
620
|
+
} catch {
|
|
621
|
+
headVerified = false;
|
|
622
|
+
}
|
|
623
|
+
let indexSynced = false;
|
|
624
|
+
if (headVerified) {
|
|
625
|
+
try {
|
|
626
|
+
const sync = await runner(
|
|
627
|
+
[
|
|
628
|
+
...READ_ONLY_GIT_FLAGS,
|
|
629
|
+
"restore",
|
|
630
|
+
"--staged",
|
|
631
|
+
`--source=${commit}`,
|
|
632
|
+
"--pathspec-from-file=-",
|
|
633
|
+
"--pathspec-file-nul"
|
|
634
|
+
],
|
|
635
|
+
{
|
|
636
|
+
cwd: worktree.root,
|
|
637
|
+
stdin: options.stagedPaths.map((entryPath) => `${entryPath}\0`).join("")
|
|
638
|
+
}
|
|
639
|
+
);
|
|
640
|
+
indexSynced = sync.exitCode === 0;
|
|
641
|
+
} catch {
|
|
642
|
+
indexSynced = false;
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
try {
|
|
646
|
+
const verified = await inspectRepository(worktree.root, { runner });
|
|
647
|
+
headVerified = verified.headCommit === commit && verified.branch === options.branch;
|
|
648
|
+
} catch {
|
|
649
|
+
headVerified = false;
|
|
650
|
+
}
|
|
651
|
+
return {
|
|
652
|
+
commit,
|
|
653
|
+
treeOid: options.treeOid,
|
|
654
|
+
parentCommit: options.parentCommit,
|
|
655
|
+
branch: options.branch,
|
|
656
|
+
signed: sign,
|
|
657
|
+
indexSynced,
|
|
658
|
+
headVerified
|
|
659
|
+
};
|
|
660
|
+
}
|
|
661
|
+
export {
|
|
662
|
+
DEFAULT_COMMIT_MAX_DIFF_CHARS_PER_FILE,
|
|
663
|
+
DEFAULT_COMMIT_MAX_DURATION_MS,
|
|
664
|
+
DEFAULT_COMMIT_MAX_ENTRIES,
|
|
665
|
+
DEFAULT_COMMIT_MAX_FILES,
|
|
666
|
+
DEFAULT_COMMIT_MAX_TOTAL_DIFF_CHARS,
|
|
667
|
+
DEFAULT_COMMIT_SIGNING_TIMEOUT_MS,
|
|
668
|
+
MAX_COMMIT_MESSAGE_CHARS,
|
|
669
|
+
commitEntryId,
|
|
670
|
+
commitPreparedTree,
|
|
671
|
+
parseDiffTreeOutput,
|
|
672
|
+
prepareCommitTree,
|
|
673
|
+
readGpgSignConfig,
|
|
674
|
+
requireCommitIdentity
|
|
675
|
+
};
|