@agimon-ai/doompi-git 0.0.1-alpha.35 → 0.0.1-alpha.38
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 +21 -1
- package/dist/apiContracts.cjs +249 -60
- package/dist/apiContracts.cjs.map +1 -1
- package/dist/apiContracts.d.mts.map +1 -1
- package/dist/apiContracts.mjs +249 -60
- package/dist/apiContracts.mjs.map +1 -1
- package/dist/extensions/pi.cjs +2 -2
- package/dist/extensions/pi.mjs +2 -2
- package/dist/extensions/server.cjs +1279 -39
- package/dist/extensions/server.cjs.map +1 -1
- package/dist/extensions/server.d.mts.map +1 -1
- package/dist/extensions/server.mjs +1277 -40
- package/dist/extensions/server.mjs.map +1 -1
- package/dist/extensions/web.mjs +1625 -23
- package/dist/extensions/web.mjs.map +1 -1
- package/dist/{gitDependencies-CZLZ_YwU.mjs → gitDependencies-CtVubYvU.mjs} +2 -2
- package/dist/{gitDependencies-CZLZ_YwU.mjs.map → gitDependencies-CtVubYvU.mjs.map} +1 -1
- package/dist/{gitDependencies-Bv_1StiU.cjs → gitDependencies-Dqsz2HjK.cjs} +2 -2
- package/dist/{gitDependencies-Bv_1StiU.cjs.map → gitDependencies-Dqsz2HjK.cjs.map} +1 -1
- package/dist/index-D79MSCee.d.mts.map +1 -1
- package/dist/index.cjs +1 -1
- package/dist/index.mjs +1 -1
- package/dist/{runWorktree-CtdyNIpZ.cjs → runWorktree-BE795LxL.cjs} +25 -2
- package/dist/runWorktree-BE795LxL.cjs.map +1 -0
- package/dist/{runWorktree-x0pLorlB.mjs → runWorktree-SPzJvIBt.mjs} +8 -3
- package/dist/runWorktree-SPzJvIBt.mjs.map +1 -0
- package/dist/{worktreeOperations-CEbq7iUE.cjs → worktreeOperations-BYmVU0UV.cjs} +106 -9
- package/dist/worktreeOperations-BYmVU0UV.cjs.map +1 -0
- package/dist/{worktreeOperations-BgE26zO3.mjs → worktreeOperations-BrWG2muV.mjs} +41 -10
- package/dist/worktreeOperations-BrWG2muV.mjs.map +1 -0
- package/package.json +8 -6
- package/dist/runWorktree-CtdyNIpZ.cjs.map +0 -1
- package/dist/runWorktree-x0pLorlB.mjs.map +0 -1
- package/dist/worktreeOperations-BgE26zO3.mjs.map +0 -1
- package/dist/worktreeOperations-CEbq7iUE.cjs.map +0 -1
|
@@ -1,6 +1,9 @@
|
|
|
1
|
-
import { a as
|
|
2
|
-
import { a as RunWorktreeToolSchema, i as validateParams, n as RUN_WORKTREE_TOOL_NAME, r as executeRunWorktreeTool, t as RUN_WORKTREE_DESCRIPTION } from "../runWorktree-
|
|
3
|
-
import
|
|
1
|
+
import { _ as NETWORK_TIMEOUT_MS, a as GIT_WORKTREE_LIFECYCLE_EVENT, b as createWorktreeGit, c as isWorktreeLifecycleEvent, d as registryFile, f as readJson, g as invalidRequest, h as HubUnavailableError, i as GIT_SESSION_CHANGED_EVENT, l as credentialsFile, m as DoomGitExpectedError, n as createWorktreeRegistry, o as createWorktreeMessageInbox, p as writeJsonAtomic, r as validRefName, s as isSessionChangedEvent, t as createWorktreeOperations, u as doomGitRoot, v as READ_TIMEOUT_MS, x as runGit, y as REBASE_TIMEOUT_MS } from "../worktreeOperations-BrWG2muV.mjs";
|
|
2
|
+
import { a as RunWorktreeToolSchema, i as validateParams, n as RUN_WORKTREE_TOOL_NAME, r as executeRunWorktreeTool, t as RUN_WORKTREE_DESCRIPTION } from "../runWorktree-SPzJvIBt.mjs";
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import os from "node:os";
|
|
6
|
+
import { composeRootHooks, defineChannel, defineHook, defineResource, defineRoot, defineRoute, defineServerTool } from "@agimon-ai/doompi-core/extensionFile";
|
|
4
7
|
import { defineServerPlugin, readPackageResource } from "@agimon-ai/doompi-core/serverFacet";
|
|
5
8
|
import { readDoomSessionDelivery } from "@agimon-ai/doompi-session";
|
|
6
9
|
//#region src/extensions/workspaces/sessions/(backend)/_lib/root.server.ts
|
|
@@ -25,39 +28,51 @@ function createGitSession({ context, host: serverHost }) {
|
|
|
25
28
|
messageInbox,
|
|
26
29
|
sessionDelivery: () => readDoomSessionDelivery(context)
|
|
27
30
|
});
|
|
31
|
+
const resources = [{
|
|
32
|
+
when: {
|
|
33
|
+
state: { "minor-mode": "help" },
|
|
34
|
+
attribution: {
|
|
35
|
+
kind: "minor",
|
|
36
|
+
mode: "help"
|
|
37
|
+
}
|
|
38
|
+
},
|
|
39
|
+
name: "doompi-git",
|
|
40
|
+
kind: "context",
|
|
41
|
+
read: () => readPackageResource(import.meta.url, "llms.txt")
|
|
42
|
+
}, {
|
|
43
|
+
name: "doompi-use-git",
|
|
44
|
+
kind: "skill",
|
|
45
|
+
read: () => readPackageResource(import.meta.url, "src/prompts/doompi-use-git/SKILL.md")
|
|
46
|
+
}];
|
|
47
|
+
const tool = {
|
|
48
|
+
name: RUN_WORKTREE_TOOL_NAME,
|
|
49
|
+
label: "Worktree",
|
|
50
|
+
description: RUN_WORKTREE_DESCRIPTION,
|
|
51
|
+
parameters: RunWorktreeToolSchema,
|
|
52
|
+
promptSnippet: "Create and manage Git worktree sessions",
|
|
53
|
+
executionMode: "serial",
|
|
54
|
+
async execute(_toolCallId, parameters, signal, onUpdate, execution) {
|
|
55
|
+
const params = validateParams(parameters);
|
|
56
|
+
return executeRunWorktreeTool(params, operations, execution, {
|
|
57
|
+
...signal === void 0 ? {} : { signal },
|
|
58
|
+
onProgress: (label) => onUpdate?.(progressResult(params.action, label))
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
const changed = (_event, execution) => {
|
|
63
|
+
directEvents.publish(GIT_SESSION_CHANGED_EVENT, execution.sessionId, { version: 1 });
|
|
64
|
+
};
|
|
28
65
|
return {
|
|
29
66
|
value: {
|
|
30
|
-
resources
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
mode: "help"
|
|
36
|
-
}
|
|
37
|
-
},
|
|
38
|
-
name: "doompi-git",
|
|
39
|
-
kind: "context",
|
|
40
|
-
read: () => readPackageResource(import.meta.url, "llms.txt")
|
|
67
|
+
resources,
|
|
68
|
+
tool,
|
|
69
|
+
hooks: [{
|
|
70
|
+
event: "tool_execution_end",
|
|
71
|
+
handle: changed
|
|
41
72
|
}, {
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
}],
|
|
46
|
-
tool: {
|
|
47
|
-
name: RUN_WORKTREE_TOOL_NAME,
|
|
48
|
-
label: "Worktree",
|
|
49
|
-
description: RUN_WORKTREE_DESCRIPTION,
|
|
50
|
-
parameters: RunWorktreeToolSchema,
|
|
51
|
-
promptSnippet: "Create and manage Git worktree sessions",
|
|
52
|
-
executionMode: "serial",
|
|
53
|
-
async execute(_toolCallId, parameters, signal, onUpdate, execution) {
|
|
54
|
-
const params = validateParams(parameters);
|
|
55
|
-
return executeRunWorktreeTool(params, operations, execution, {
|
|
56
|
-
...signal === void 0 ? {} : { signal },
|
|
57
|
-
onProgress: (label) => onUpdate?.(progressResult(params.action, label))
|
|
58
|
-
});
|
|
59
|
-
}
|
|
60
|
-
}
|
|
73
|
+
event: "agent_settled",
|
|
74
|
+
handle: changed
|
|
75
|
+
}]
|
|
61
76
|
},
|
|
62
77
|
onDispose: () => messageInbox.close()
|
|
63
78
|
};
|
|
@@ -67,6 +82,678 @@ const root = defineRoot(createGitSession);
|
|
|
67
82
|
//#region src/extensions/workspaces/sessions/(backend)/root.server.ts
|
|
68
83
|
var root_server_default = defineRoot(root);
|
|
69
84
|
//#endregion
|
|
85
|
+
//#region src/services/branchDiff/index.ts
|
|
86
|
+
/**
|
|
87
|
+
* What a session's checkout changed against its base, the way a pull request
|
|
88
|
+
* shows it: every commit since the branch left its base, plus staged, unstaged
|
|
89
|
+
* and untracked work.
|
|
90
|
+
*
|
|
91
|
+
* DESIGN PATTERNS:
|
|
92
|
+
* - git computes every diff. The counts come from `--numstat` and the hunks
|
|
93
|
+
* from the same unified diff, so the two always agree.
|
|
94
|
+
* - The base is the worktree's own recorded base when there is one, then the
|
|
95
|
+
* remote's default branch, then the branch's upstream, and with none of those
|
|
96
|
+
* only uncommitted work counts. It is always named in the UI.
|
|
97
|
+
* - Untracked files are read only when they are small regular files; a symlink
|
|
98
|
+
* shows its link text and is never followed.
|
|
99
|
+
*
|
|
100
|
+
* AVOID:
|
|
101
|
+
* - Passing a browser path to git without checking it against the change set.
|
|
102
|
+
*/
|
|
103
|
+
/** git's well-known empty tree, the base of a repository with no commits. */
|
|
104
|
+
const EMPTY_TREE = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
|
|
105
|
+
const MAX_LISTED_FILES = 500;
|
|
106
|
+
const MAX_UNTRACKED_COUNTED = 200;
|
|
107
|
+
const MAX_FILE_BYTES = 1048576;
|
|
108
|
+
const MAX_ROWS = 5e3;
|
|
109
|
+
const BINARY_PROBE_BYTES = 8192;
|
|
110
|
+
const SHORT_SHA = 7;
|
|
111
|
+
const DEFAULT_BRANCHES = ["main", "master"];
|
|
112
|
+
async function read(cwd, args) {
|
|
113
|
+
const result = await runGit(cwd, args, { timeout: READ_TIMEOUT_MS });
|
|
114
|
+
return result.code === 0 ? result.stdout : void 0;
|
|
115
|
+
}
|
|
116
|
+
async function readLine$1(cwd, args) {
|
|
117
|
+
const out = (await read(cwd, args))?.trim();
|
|
118
|
+
return out === void 0 || out === "" ? void 0 : out;
|
|
119
|
+
}
|
|
120
|
+
async function commitExists(cwd, ref) {
|
|
121
|
+
return await readLine$1(cwd, [
|
|
122
|
+
"rev-parse",
|
|
123
|
+
"--verify",
|
|
124
|
+
"--quiet",
|
|
125
|
+
`${ref}^{commit}`
|
|
126
|
+
]) !== void 0;
|
|
127
|
+
}
|
|
128
|
+
/** A local branch's upstream, so the review measures against what fetch keeps current. */
|
|
129
|
+
async function upstreamOf(cwd, ref) {
|
|
130
|
+
return readLine$1(cwd, [
|
|
131
|
+
"rev-parse",
|
|
132
|
+
"--abbrev-ref",
|
|
133
|
+
"--symbolic-full-name",
|
|
134
|
+
`${ref}@{upstream}`
|
|
135
|
+
]);
|
|
136
|
+
}
|
|
137
|
+
/** The ref the review is measured against. Exported for sync, whose rebase uses the same base. */
|
|
138
|
+
async function resolveBaseRef(cwd, branch, recordedBaseRef) {
|
|
139
|
+
if (!(recordedBaseRef === void 0 || branch !== void 0 && (recordedBaseRef === branch || recordedBaseRef.endsWith(`/${branch}`))) && recordedBaseRef !== void 0 && await commitExists(cwd, recordedBaseRef)) return (await readLine$1(cwd, [
|
|
140
|
+
"rev-parse",
|
|
141
|
+
"--verify",
|
|
142
|
+
"--quiet",
|
|
143
|
+
`refs/heads/${recordedBaseRef}`
|
|
144
|
+
]) !== void 0 ? await upstreamOf(cwd, recordedBaseRef) : void 0) ?? recordedBaseRef;
|
|
145
|
+
const originHead = await readLine$1(cwd, [
|
|
146
|
+
"symbolic-ref",
|
|
147
|
+
"--quiet",
|
|
148
|
+
"--short",
|
|
149
|
+
"refs/remotes/origin/HEAD"
|
|
150
|
+
]);
|
|
151
|
+
if (originHead !== void 0) return originHead;
|
|
152
|
+
for (const name of DEFAULT_BRANCHES) if (await commitExists(cwd, `origin/${name}`)) return `origin/${name}`;
|
|
153
|
+
return branch === void 0 ? void 0 : upstreamOf(cwd, branch);
|
|
154
|
+
}
|
|
155
|
+
async function resolveBase(cwd, branch, recordedBaseRef) {
|
|
156
|
+
const ref = await resolveBaseRef(cwd, branch, recordedBaseRef);
|
|
157
|
+
const head = await commitExists(cwd, "HEAD");
|
|
158
|
+
if (ref !== void 0 && head) {
|
|
159
|
+
const mergeBase = await readLine$1(cwd, [
|
|
160
|
+
"merge-base",
|
|
161
|
+
ref,
|
|
162
|
+
"HEAD"
|
|
163
|
+
]);
|
|
164
|
+
if (mergeBase !== void 0) return {
|
|
165
|
+
ref,
|
|
166
|
+
from: mergeBase,
|
|
167
|
+
mergeBase: mergeBase.slice(0, SHORT_SHA)
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
return { from: head ? "HEAD" : EMPTY_TREE };
|
|
171
|
+
}
|
|
172
|
+
/** NUL-separated fields from a `-z` listing, without the trailing empty one. */
|
|
173
|
+
function zFields(out) {
|
|
174
|
+
if (out === void 0 || out === "") return [];
|
|
175
|
+
const fields = out.split("\0");
|
|
176
|
+
if (fields.at(-1) === "") fields.pop();
|
|
177
|
+
return fields;
|
|
178
|
+
}
|
|
179
|
+
const NAME_STATUS = {
|
|
180
|
+
A: "added",
|
|
181
|
+
D: "deleted",
|
|
182
|
+
M: "modified",
|
|
183
|
+
T: "modified"
|
|
184
|
+
};
|
|
185
|
+
/** Line count of a small regular file, or undefined when it is binary, large, or not a regular file. */
|
|
186
|
+
function untrackedLines(root, relPath) {
|
|
187
|
+
const absolute = path.join(root, relPath);
|
|
188
|
+
let stat;
|
|
189
|
+
try {
|
|
190
|
+
stat = fs.lstatSync(absolute);
|
|
191
|
+
} catch {
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
if (stat.isSymbolicLink()) return {
|
|
195
|
+
lines: 1,
|
|
196
|
+
binary: false
|
|
197
|
+
};
|
|
198
|
+
if (!stat.isFile() || stat.size > MAX_FILE_BYTES) return void 0;
|
|
199
|
+
const content = fs.readFileSync(absolute);
|
|
200
|
+
if (content.subarray(0, BINARY_PROBE_BYTES).includes(0)) return {
|
|
201
|
+
lines: 0,
|
|
202
|
+
binary: true
|
|
203
|
+
};
|
|
204
|
+
if (content.length === 0) return {
|
|
205
|
+
lines: 0,
|
|
206
|
+
binary: false
|
|
207
|
+
};
|
|
208
|
+
const text = content.toString("utf8");
|
|
209
|
+
const newlines = text.split("\n").length - 1;
|
|
210
|
+
return {
|
|
211
|
+
lines: text.endsWith("\n") ? newlines : newlines + 1,
|
|
212
|
+
binary: false
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
/** The hunks of a unified diff, numbering each row by the file it lives in. */
|
|
216
|
+
function parseUnifiedDiff(diff) {
|
|
217
|
+
const hunks = [];
|
|
218
|
+
let current;
|
|
219
|
+
let oldLine = 0;
|
|
220
|
+
let newLine = 0;
|
|
221
|
+
let rows = 0;
|
|
222
|
+
let binary = false;
|
|
223
|
+
for (const line of diff.split("\n")) {
|
|
224
|
+
const header = /^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/u.exec(line);
|
|
225
|
+
if (header !== null) {
|
|
226
|
+
oldLine = Number(header[1]);
|
|
227
|
+
newLine = Number(header[2]);
|
|
228
|
+
current = {
|
|
229
|
+
start: newLine,
|
|
230
|
+
rows: []
|
|
231
|
+
};
|
|
232
|
+
hunks.push(current);
|
|
233
|
+
continue;
|
|
234
|
+
}
|
|
235
|
+
if (current === void 0) {
|
|
236
|
+
if (line.startsWith("Binary files ") || line === "GIT binary patch") binary = true;
|
|
237
|
+
continue;
|
|
238
|
+
}
|
|
239
|
+
const marker = line[0];
|
|
240
|
+
let row;
|
|
241
|
+
if (marker === " ") row = {
|
|
242
|
+
marker: " ",
|
|
243
|
+
line: newLine,
|
|
244
|
+
content: line.slice(1)
|
|
245
|
+
};
|
|
246
|
+
else if (marker === "-") row = {
|
|
247
|
+
marker: "-",
|
|
248
|
+
line: oldLine,
|
|
249
|
+
content: line.slice(1)
|
|
250
|
+
};
|
|
251
|
+
else if (marker === "+") row = {
|
|
252
|
+
marker: "+",
|
|
253
|
+
line: newLine,
|
|
254
|
+
content: line.slice(1)
|
|
255
|
+
};
|
|
256
|
+
if (row === void 0) continue;
|
|
257
|
+
if (marker !== "+") oldLine += 1;
|
|
258
|
+
if (marker !== "-") newLine += 1;
|
|
259
|
+
current.rows.push(row);
|
|
260
|
+
rows += 1;
|
|
261
|
+
}
|
|
262
|
+
return {
|
|
263
|
+
hunks,
|
|
264
|
+
binary,
|
|
265
|
+
rows
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
function createBranchDiff() {
|
|
269
|
+
return {
|
|
270
|
+
async review(cwd, options = {}) {
|
|
271
|
+
const root = await readLine$1(cwd, ["rev-parse", "--show-toplevel"]);
|
|
272
|
+
if (root === void 0) return void 0;
|
|
273
|
+
const branch = await readLine$1(root, [
|
|
274
|
+
"symbolic-ref",
|
|
275
|
+
"--quiet",
|
|
276
|
+
"--short",
|
|
277
|
+
"HEAD"
|
|
278
|
+
]);
|
|
279
|
+
const base = await resolveBase(root, branch, options.recordedBaseRef);
|
|
280
|
+
const numstat = zFields(await read(root, [
|
|
281
|
+
"diff",
|
|
282
|
+
"--numstat",
|
|
283
|
+
"-z",
|
|
284
|
+
"--no-renames",
|
|
285
|
+
base.from
|
|
286
|
+
]));
|
|
287
|
+
const nameStatus = zFields(await read(root, [
|
|
288
|
+
"diff",
|
|
289
|
+
"--name-status",
|
|
290
|
+
"-z",
|
|
291
|
+
"--no-renames",
|
|
292
|
+
base.from
|
|
293
|
+
]));
|
|
294
|
+
const unmerged = new Set(zFields(await read(root, [
|
|
295
|
+
"diff",
|
|
296
|
+
"--name-only",
|
|
297
|
+
"--diff-filter=U",
|
|
298
|
+
"-z"
|
|
299
|
+
])));
|
|
300
|
+
const statusOf = /* @__PURE__ */ new Map();
|
|
301
|
+
for (let index = 0; index + 1 < nameStatus.length; index += 2) statusOf.set(nameStatus[index + 1] ?? "", NAME_STATUS[(nameStatus[index] ?? "").charAt(0)] ?? "modified");
|
|
302
|
+
const entries = [];
|
|
303
|
+
for (const field of numstat) {
|
|
304
|
+
const [added = "", removed = "", ...rest] = field.split(" ");
|
|
305
|
+
const filePath = rest.join(" ");
|
|
306
|
+
if (filePath === "") continue;
|
|
307
|
+
const binary = added === "-" && removed === "-";
|
|
308
|
+
entries.push({
|
|
309
|
+
path: filePath,
|
|
310
|
+
status: unmerged.has(filePath) ? "conflicted" : statusOf.get(filePath) ?? "modified",
|
|
311
|
+
added: binary ? 0 : Number(added),
|
|
312
|
+
removed: binary ? 0 : Number(removed),
|
|
313
|
+
...binary ? { binary: true } : {}
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
const untracked = zFields(await read(root, [
|
|
317
|
+
"ls-files",
|
|
318
|
+
"--others",
|
|
319
|
+
"--exclude-standard",
|
|
320
|
+
"-z"
|
|
321
|
+
]));
|
|
322
|
+
untracked.forEach((relPath, index) => {
|
|
323
|
+
const counted = index < MAX_UNTRACKED_COUNTED ? untrackedLines(root, relPath) : void 0;
|
|
324
|
+
entries.push({
|
|
325
|
+
path: relPath,
|
|
326
|
+
status: "untracked",
|
|
327
|
+
added: counted?.lines ?? 0,
|
|
328
|
+
removed: 0,
|
|
329
|
+
...counted?.binary ? { binary: true } : {}
|
|
330
|
+
});
|
|
331
|
+
});
|
|
332
|
+
entries.sort((left, right) => left.path.localeCompare(right.path));
|
|
333
|
+
const conflicts = [...unmerged];
|
|
334
|
+
const rebasing = [await readLine$1(root, [
|
|
335
|
+
"rev-parse",
|
|
336
|
+
"--git-path",
|
|
337
|
+
"rebase-merge"
|
|
338
|
+
]), await readLine$1(root, [
|
|
339
|
+
"rev-parse",
|
|
340
|
+
"--git-path",
|
|
341
|
+
"rebase-apply"
|
|
342
|
+
])].some((gitPath) => gitPath !== void 0 && fs.existsSync(path.resolve(root, gitPath)));
|
|
343
|
+
const upstreamRef = branch === void 0 ? void 0 : await upstreamOf(root, branch);
|
|
344
|
+
let upstream;
|
|
345
|
+
if (upstreamRef !== void 0) {
|
|
346
|
+
const [behind, ahead] = (await readLine$1(root, [
|
|
347
|
+
"rev-list",
|
|
348
|
+
"--left-right",
|
|
349
|
+
"--count",
|
|
350
|
+
`${upstreamRef}...HEAD`
|
|
351
|
+
]) ?? "").split(/\s+/u).map(Number);
|
|
352
|
+
if (Number.isInteger(ahead) && Number.isInteger(behind)) upstream = {
|
|
353
|
+
ref: upstreamRef,
|
|
354
|
+
ahead,
|
|
355
|
+
behind
|
|
356
|
+
};
|
|
357
|
+
}
|
|
358
|
+
const truncated = entries.length > MAX_LISTED_FILES || untracked.length > MAX_UNTRACKED_COUNTED;
|
|
359
|
+
return {
|
|
360
|
+
root,
|
|
361
|
+
base,
|
|
362
|
+
changes: {
|
|
363
|
+
added: entries.reduce((sum, entry) => sum + entry.added, 0),
|
|
364
|
+
removed: entries.reduce((sum, entry) => sum + entry.removed, 0),
|
|
365
|
+
files: entries.length,
|
|
366
|
+
...branch === void 0 ? {} : { branch },
|
|
367
|
+
...base.ref === void 0 ? {} : { base: base.ref },
|
|
368
|
+
...truncated ? { truncated: true } : {},
|
|
369
|
+
...upstream === void 0 ? {} : { upstream },
|
|
370
|
+
...rebasing ? { rebase: { conflicts } } : {}
|
|
371
|
+
},
|
|
372
|
+
summary: {
|
|
373
|
+
repository: true,
|
|
374
|
+
files: entries.slice(0, MAX_LISTED_FILES),
|
|
375
|
+
...branch === void 0 ? {} : { branch },
|
|
376
|
+
...base.ref === void 0 ? {} : { base: base.ref },
|
|
377
|
+
...base.mergeBase === void 0 ? {} : { mergeBase: base.mergeBase },
|
|
378
|
+
...truncated ? { truncated: true } : {}
|
|
379
|
+
}
|
|
380
|
+
};
|
|
381
|
+
},
|
|
382
|
+
async fileDiff(review, filePath) {
|
|
383
|
+
const entry = review.summary.files.find((file) => file.path === filePath);
|
|
384
|
+
if (entry === void 0) throw new Error("That path is not in this review.");
|
|
385
|
+
if (entry.status === "untracked") {
|
|
386
|
+
const absolute = path.join(review.root, entry.path);
|
|
387
|
+
const stat = fs.lstatSync(absolute);
|
|
388
|
+
if (stat.isSymbolicLink()) return {
|
|
389
|
+
path: entry.path,
|
|
390
|
+
hunks: [{
|
|
391
|
+
start: 1,
|
|
392
|
+
rows: [{
|
|
393
|
+
marker: "+",
|
|
394
|
+
line: 1,
|
|
395
|
+
content: fs.readlinkSync(absolute)
|
|
396
|
+
}]
|
|
397
|
+
}]
|
|
398
|
+
};
|
|
399
|
+
if (!stat.isFile() || stat.size > MAX_FILE_BYTES) return {
|
|
400
|
+
path: entry.path,
|
|
401
|
+
hunks: [],
|
|
402
|
+
tooLarge: true
|
|
403
|
+
};
|
|
404
|
+
const content = fs.readFileSync(absolute);
|
|
405
|
+
if (content.subarray(0, BINARY_PROBE_BYTES).includes(0)) return {
|
|
406
|
+
path: entry.path,
|
|
407
|
+
hunks: [],
|
|
408
|
+
binary: true
|
|
409
|
+
};
|
|
410
|
+
const lines = content.toString("utf8").split("\n");
|
|
411
|
+
if (lines.at(-1) === "") lines.pop();
|
|
412
|
+
if (lines.length > MAX_ROWS) return {
|
|
413
|
+
path: entry.path,
|
|
414
|
+
hunks: [],
|
|
415
|
+
tooLarge: true
|
|
416
|
+
};
|
|
417
|
+
return {
|
|
418
|
+
path: entry.path,
|
|
419
|
+
hunks: lines.length === 0 ? [] : [{
|
|
420
|
+
start: 1,
|
|
421
|
+
rows: lines.map((content, index) => ({
|
|
422
|
+
marker: "+",
|
|
423
|
+
line: index + 1,
|
|
424
|
+
content
|
|
425
|
+
}))
|
|
426
|
+
}]
|
|
427
|
+
};
|
|
428
|
+
}
|
|
429
|
+
const result = await runGit(review.root, [
|
|
430
|
+
"diff",
|
|
431
|
+
"-U3",
|
|
432
|
+
"--no-color",
|
|
433
|
+
"--no-ext-diff",
|
|
434
|
+
"--no-textconv",
|
|
435
|
+
"--no-renames",
|
|
436
|
+
review.base.from,
|
|
437
|
+
"--",
|
|
438
|
+
`:(top,literal)${entry.path}`
|
|
439
|
+
], {
|
|
440
|
+
timeout: READ_TIMEOUT_MS,
|
|
441
|
+
maxBuffer: MAX_FILE_BYTES
|
|
442
|
+
});
|
|
443
|
+
if (result.overflow) return {
|
|
444
|
+
path: entry.path,
|
|
445
|
+
hunks: [],
|
|
446
|
+
tooLarge: true
|
|
447
|
+
};
|
|
448
|
+
if (result.code !== 0) throw new Error(`git diff failed (exit ${String(result.code)}).`);
|
|
449
|
+
const parsed = parseUnifiedDiff(result.stdout);
|
|
450
|
+
if (parsed.binary || entry.binary === true) return {
|
|
451
|
+
path: entry.path,
|
|
452
|
+
hunks: [],
|
|
453
|
+
binary: true
|
|
454
|
+
};
|
|
455
|
+
if (parsed.rows > MAX_ROWS) return {
|
|
456
|
+
path: entry.path,
|
|
457
|
+
hunks: [],
|
|
458
|
+
tooLarge: true
|
|
459
|
+
};
|
|
460
|
+
return {
|
|
461
|
+
path: entry.path,
|
|
462
|
+
hunks: parsed.hunks
|
|
463
|
+
};
|
|
464
|
+
}
|
|
465
|
+
};
|
|
466
|
+
}
|
|
467
|
+
//#endregion
|
|
468
|
+
//#region src/services/gitAuth/index.ts
|
|
469
|
+
/**
|
|
470
|
+
* Per-workspace remote auth for doompi-git's own fetch, pull, push and rebase.
|
|
471
|
+
*
|
|
472
|
+
* DESIGN PATTERNS:
|
|
473
|
+
* - One file outside every repository, folder 0700 and file 0600, repaired on
|
|
474
|
+
* read if something loosened it. The token never leaves this module except
|
|
475
|
+
* into the environment of one git child process.
|
|
476
|
+
* - Config reaches git through GIT_CONFIG_COUNT environment entries, never
|
|
477
|
+
* argv, so nothing secret shows in `ps`. The first entry empties the
|
|
478
|
+
* credential helper list, which is what keeps macOS's osxkeychain helper from
|
|
479
|
+
* storing the token after a successful push.
|
|
480
|
+
* - The token is offered only to the host it was entered for. A repository
|
|
481
|
+
* whose remote points anywhere else gets this machine's own credentials.
|
|
482
|
+
*
|
|
483
|
+
* AVOID:
|
|
484
|
+
* - Touching process.env. The hub runs every session; anything set there
|
|
485
|
+
* would reach the agent's shell, which the user ruled out.
|
|
486
|
+
*/
|
|
487
|
+
const OWNER_ONLY_DIRECTORY = 448;
|
|
488
|
+
const OWNER_ONLY_FILE = 384;
|
|
489
|
+
const LOOSE_BITS = 63;
|
|
490
|
+
const HOST_PATTERN = /^[a-z0-9.-]{1,253}(:\d{1,5})?$/u;
|
|
491
|
+
const MAX_USERNAME = 256;
|
|
492
|
+
const MAX_TOKEN = 4096;
|
|
493
|
+
const SETTINGS_HINT = "Open settings, then repository, then git remote.";
|
|
494
|
+
/** Environment that would make git log or skip what this module relies on. */
|
|
495
|
+
const STRIPPED_ALWAYS = ["GIT_CURL_VERBOSE", "GIT_TRACE_REDACT"];
|
|
496
|
+
const STRIPPED_PREFIXES = ["GIT_TRACE"];
|
|
497
|
+
/** Environment that could redirect or override credentials when the workspace's own are in play. */
|
|
498
|
+
const STRIPPED_CREDENTIALED = [
|
|
499
|
+
"GIT_CONFIG_PARAMETERS",
|
|
500
|
+
"GIT_CONFIG_COUNT",
|
|
501
|
+
"GIT_SSL_NO_VERIFY",
|
|
502
|
+
"SSH_ASKPASS",
|
|
503
|
+
"GIT_SSH",
|
|
504
|
+
"GIT_SSH_COMMAND"
|
|
505
|
+
];
|
|
506
|
+
const CONFIG_ENTRY = /^GIT_CONFIG_(KEY|VALUE)_\d+$/u;
|
|
507
|
+
/** The inline helper: answers `get` from this child's environment, and ignores `store` and `erase`. */
|
|
508
|
+
const ENV_CREDENTIAL_HELPER = "!f(){ test \"$1\" = get && printf \"username=%s\\npassword=%s\\n\" \"$DOOMPI_GIT_USERNAME\" \"$DOOMPI_GIT_TOKEN\"; }; f";
|
|
509
|
+
/** Whether a value holds an ASCII control character; a newline in a credential field could add a line to git's credential protocol. */
|
|
510
|
+
function hasControl(value) {
|
|
511
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
512
|
+
const code = value.charCodeAt(index);
|
|
513
|
+
if (code < 32 || code === 127) return true;
|
|
514
|
+
}
|
|
515
|
+
return false;
|
|
516
|
+
}
|
|
517
|
+
function isRecord(value) {
|
|
518
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
519
|
+
}
|
|
520
|
+
/** The workspace key: the canonical root, so a symlinked path and its target share one entry. */
|
|
521
|
+
function workspaceKey(workspaceRoot) {
|
|
522
|
+
try {
|
|
523
|
+
return fs.realpathSync(workspaceRoot);
|
|
524
|
+
} catch {
|
|
525
|
+
return path.resolve(workspaceRoot);
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
function expandHome(keyPath, homeDir) {
|
|
529
|
+
return keyPath.startsWith("~/") ? path.join(homeDir, keyPath.slice(2)) : keyPath;
|
|
530
|
+
}
|
|
531
|
+
function parseConfig(value) {
|
|
532
|
+
if (!isRecord(value)) return void 0;
|
|
533
|
+
if (value.method === "none") return { method: "none" };
|
|
534
|
+
if (value.method === "ssh") {
|
|
535
|
+
if (value.keyPath === void 0) return { method: "ssh" };
|
|
536
|
+
return typeof value.keyPath === "string" && value.keyPath !== "" ? {
|
|
537
|
+
method: "ssh",
|
|
538
|
+
keyPath: value.keyPath
|
|
539
|
+
} : void 0;
|
|
540
|
+
}
|
|
541
|
+
if (value.method === "https" && typeof value.host === "string" && typeof value.username === "string" && typeof value.token === "string" && value.token !== "") return {
|
|
542
|
+
method: "https",
|
|
543
|
+
host: value.host,
|
|
544
|
+
username: value.username,
|
|
545
|
+
token: value.token
|
|
546
|
+
};
|
|
547
|
+
}
|
|
548
|
+
/** What the browser may see. The token becomes a yes or no. */
|
|
549
|
+
function publicView(config) {
|
|
550
|
+
if (config.method === "ssh") return config.keyPath === void 0 ? {
|
|
551
|
+
method: "ssh",
|
|
552
|
+
ssh: {}
|
|
553
|
+
} : {
|
|
554
|
+
method: "ssh",
|
|
555
|
+
ssh: { keyPath: config.keyPath }
|
|
556
|
+
};
|
|
557
|
+
if (config.method === "https") return {
|
|
558
|
+
method: "https",
|
|
559
|
+
https: {
|
|
560
|
+
host: config.host,
|
|
561
|
+
username: config.username,
|
|
562
|
+
hasToken: config.token !== ""
|
|
563
|
+
}
|
|
564
|
+
};
|
|
565
|
+
return { method: "none" };
|
|
566
|
+
}
|
|
567
|
+
/**
|
|
568
|
+
* A browser save request, checked field by field. An absent HTTPS token keeps
|
|
569
|
+
* the saved one, and only for the same host: a token never follows a host it
|
|
570
|
+
* was not entered for.
|
|
571
|
+
*/
|
|
572
|
+
function parseGitAuthSave(request, current, homeDir) {
|
|
573
|
+
if (!isRecord(request)) throw invalidRequest("The git auth request is not an object.", "Send method none, ssh or https.");
|
|
574
|
+
if (request.method === "none") return { method: "none" };
|
|
575
|
+
if (request.method === "ssh") {
|
|
576
|
+
if (request.keyPath === void 0 || request.keyPath === "") return { method: "ssh" };
|
|
577
|
+
if (typeof request.keyPath !== "string" || hasControl(request.keyPath)) throw invalidRequest("The key path is not a plain path.", "Give the key as an absolute path or one under ~/.");
|
|
578
|
+
const keyPath = request.keyPath.trim();
|
|
579
|
+
if (!keyPath.startsWith("/") && !keyPath.startsWith("~/")) throw invalidRequest("The key path is not absolute.", "Give the key as an absolute path or one under ~/.");
|
|
580
|
+
let isFile = false;
|
|
581
|
+
try {
|
|
582
|
+
isFile = fs.statSync(expandHome(keyPath, homeDir)).isFile();
|
|
583
|
+
} catch {
|
|
584
|
+
isFile = false;
|
|
585
|
+
}
|
|
586
|
+
if (!isFile) throw invalidRequest(`No key file at ${keyPath}.`, "Check the path, or leave it blank to use ssh-agent.");
|
|
587
|
+
return {
|
|
588
|
+
method: "ssh",
|
|
589
|
+
keyPath
|
|
590
|
+
};
|
|
591
|
+
}
|
|
592
|
+
if (request.method === "https") {
|
|
593
|
+
const host = typeof request.host === "string" ? request.host.trim().toLowerCase() : "";
|
|
594
|
+
if (!HOST_PATTERN.test(host)) throw invalidRequest("The host is not a host name.", "Use a host like github.com, with an optional :port.");
|
|
595
|
+
const username = typeof request.username === "string" ? request.username.trim() : "";
|
|
596
|
+
if (username === "" || username.length > MAX_USERNAME || hasControl(username)) throw invalidRequest("The username is empty or not plain text.", "Enter the username the host knows you by.");
|
|
597
|
+
if (request.token !== void 0 && request.token !== "") {
|
|
598
|
+
if (typeof request.token !== "string" || request.token.length > MAX_TOKEN || hasControl(request.token)) throw invalidRequest("The token is not plain text.", "Paste the token again without line breaks.");
|
|
599
|
+
return {
|
|
600
|
+
method: "https",
|
|
601
|
+
host,
|
|
602
|
+
username,
|
|
603
|
+
token: request.token
|
|
604
|
+
};
|
|
605
|
+
}
|
|
606
|
+
if (current.method === "https" && current.host === host && current.token !== "") return {
|
|
607
|
+
method: "https",
|
|
608
|
+
host,
|
|
609
|
+
username,
|
|
610
|
+
token: current.token
|
|
611
|
+
};
|
|
612
|
+
throw invalidRequest(current.method === "https" ? "The host changed, so the saved token cannot follow it." : "A token is required.", "Enter the personal access token for this host.");
|
|
613
|
+
}
|
|
614
|
+
throw invalidRequest("The git auth method is unknown.", "Send method none, ssh or https.");
|
|
615
|
+
}
|
|
616
|
+
/** Tightens a file or folder something left readable by group or others. */
|
|
617
|
+
function tighten(target, mode) {
|
|
618
|
+
try {
|
|
619
|
+
if ((fs.statSync(target).mode & LOOSE_BITS) !== 0) fs.chmodSync(target, mode);
|
|
620
|
+
} catch {}
|
|
621
|
+
}
|
|
622
|
+
function createGitAuthStore(homeDir = os.homedir()) {
|
|
623
|
+
const file = credentialsFile(homeDir);
|
|
624
|
+
const readFile = () => {
|
|
625
|
+
tighten(file, OWNER_ONLY_FILE);
|
|
626
|
+
const raw = readJson(file);
|
|
627
|
+
const workspaces = {};
|
|
628
|
+
if (isRecord(raw) && raw.version === 1 && isRecord(raw.workspaces)) for (const [key, value] of Object.entries(raw.workspaces)) {
|
|
629
|
+
const config = parseConfig(value);
|
|
630
|
+
if (config !== void 0) workspaces[key] = config;
|
|
631
|
+
}
|
|
632
|
+
return {
|
|
633
|
+
version: 1,
|
|
634
|
+
workspaces
|
|
635
|
+
};
|
|
636
|
+
};
|
|
637
|
+
const read = (workspaceRoot) => readFile().workspaces[workspaceKey(workspaceRoot)] ?? { method: "none" };
|
|
638
|
+
return {
|
|
639
|
+
read,
|
|
640
|
+
view: (workspaceRoot) => publicView(read(workspaceRoot)),
|
|
641
|
+
save(workspaceRoot, request) {
|
|
642
|
+
const key = workspaceKey(workspaceRoot);
|
|
643
|
+
const current = readFile();
|
|
644
|
+
const next = parseGitAuthSave(request, current.workspaces[key] ?? { method: "none" }, homeDir);
|
|
645
|
+
const root = doomGitRoot(homeDir);
|
|
646
|
+
fs.mkdirSync(root, {
|
|
647
|
+
recursive: true,
|
|
648
|
+
mode: OWNER_ONLY_DIRECTORY
|
|
649
|
+
});
|
|
650
|
+
fs.chmodSync(root, OWNER_ONLY_DIRECTORY);
|
|
651
|
+
const workspaces = { ...current.workspaces };
|
|
652
|
+
if (next.method === "none") delete workspaces[key];
|
|
653
|
+
else workspaces[key] = next;
|
|
654
|
+
writeJsonAtomic(file, {
|
|
655
|
+
version: 1,
|
|
656
|
+
workspaces
|
|
657
|
+
});
|
|
658
|
+
fs.chmodSync(file, OWNER_ONLY_FILE);
|
|
659
|
+
return publicView(next);
|
|
660
|
+
}
|
|
661
|
+
};
|
|
662
|
+
}
|
|
663
|
+
/** The transport and host a remote URL names, or undefined for a local path or anything else. */
|
|
664
|
+
function remoteTransport(url) {
|
|
665
|
+
const trimmed = url.trim();
|
|
666
|
+
if (/^[a-z][a-z0-9+.-]*:\/\//iu.test(trimmed)) try {
|
|
667
|
+
const parsed = new URL(trimmed);
|
|
668
|
+
const kind = parsed.protocol === "https:" ? "https" : parsed.protocol === "http:" ? "http" : parsed.protocol === "ssh:" ? "ssh" : void 0;
|
|
669
|
+
return kind === void 0 ? void 0 : {
|
|
670
|
+
kind,
|
|
671
|
+
host: parsed.host.toLowerCase()
|
|
672
|
+
};
|
|
673
|
+
} catch {
|
|
674
|
+
return;
|
|
675
|
+
}
|
|
676
|
+
const scp = /^(?:[^@/]+@)?([^:/]+):(?!\/\/)/u.exec(trimmed);
|
|
677
|
+
if (scp !== null && scp[1] !== void 0 && scp[1].length > 1) return {
|
|
678
|
+
kind: "ssh",
|
|
679
|
+
host: scp[1].toLowerCase()
|
|
680
|
+
};
|
|
681
|
+
}
|
|
682
|
+
/** Quotes one word for the POSIX shell git runs GIT_SSH_COMMAND through. */
|
|
683
|
+
function shellQuote(value) {
|
|
684
|
+
return `'${value.replaceAll("'", "'\\''")}'`;
|
|
685
|
+
}
|
|
686
|
+
/**
|
|
687
|
+
* The environment for one fetch or push against `remoteUrl`.
|
|
688
|
+
*
|
|
689
|
+
* Every call drops tracing (which can log credentials) and turns prompts off,
|
|
690
|
+
* because no terminal is attached. Only when the workspace's method matches the
|
|
691
|
+
* remote does anything secret go in, and then only into this child's env.
|
|
692
|
+
*/
|
|
693
|
+
function remoteEnv(config, remoteUrl, baseEnv, homeDir) {
|
|
694
|
+
const env = { ...baseEnv };
|
|
695
|
+
for (const key of Object.keys(env)) if (STRIPPED_ALWAYS.includes(key) || STRIPPED_PREFIXES.some((prefix) => key.startsWith(prefix))) delete env[key];
|
|
696
|
+
env.GIT_TERMINAL_PROMPT = "0";
|
|
697
|
+
env.GCM_INTERACTIVE = "never";
|
|
698
|
+
const transport = remoteTransport(remoteUrl);
|
|
699
|
+
const credentialed = () => {
|
|
700
|
+
for (const key of Object.keys(env)) if (STRIPPED_CREDENTIALED.includes(key) || CONFIG_ENTRY.test(key)) delete env[key];
|
|
701
|
+
env.GIT_ASKPASS = "true";
|
|
702
|
+
};
|
|
703
|
+
if (config.method === "https") {
|
|
704
|
+
if (transport?.kind !== "https" || transport.host !== config.host) return {
|
|
705
|
+
env,
|
|
706
|
+
credentialed: false,
|
|
707
|
+
mismatch: `This workspace's token is for https://${config.host}, and this remote is not.`
|
|
708
|
+
};
|
|
709
|
+
credentialed();
|
|
710
|
+
env.GIT_CONFIG_COUNT = "3";
|
|
711
|
+
env.GIT_CONFIG_KEY_0 = "credential.helper";
|
|
712
|
+
env.GIT_CONFIG_VALUE_0 = "";
|
|
713
|
+
env.GIT_CONFIG_KEY_1 = `credential.https://${config.host}.helper`;
|
|
714
|
+
env.GIT_CONFIG_VALUE_1 = ENV_CREDENTIAL_HELPER;
|
|
715
|
+
env.GIT_CONFIG_KEY_2 = "http.sslVerify";
|
|
716
|
+
env.GIT_CONFIG_VALUE_2 = "true";
|
|
717
|
+
env.GIT_ALLOW_PROTOCOL = "https";
|
|
718
|
+
env.DOOMPI_GIT_USERNAME = config.username;
|
|
719
|
+
env.DOOMPI_GIT_TOKEN = config.token;
|
|
720
|
+
return {
|
|
721
|
+
env,
|
|
722
|
+
credentialed: true
|
|
723
|
+
};
|
|
724
|
+
}
|
|
725
|
+
if (config.method === "ssh") {
|
|
726
|
+
if (transport?.kind !== "ssh") return {
|
|
727
|
+
env,
|
|
728
|
+
credentialed: false,
|
|
729
|
+
mismatch: "This workspace uses SSH, and this remote is not an SSH remote."
|
|
730
|
+
};
|
|
731
|
+
credentialed();
|
|
732
|
+
env.GIT_SSH_COMMAND = [
|
|
733
|
+
"ssh",
|
|
734
|
+
...config.keyPath === void 0 ? [] : [
|
|
735
|
+
"-i",
|
|
736
|
+
shellQuote(expandHome(config.keyPath, homeDir)),
|
|
737
|
+
"-o",
|
|
738
|
+
"IdentitiesOnly=yes"
|
|
739
|
+
],
|
|
740
|
+
"-o",
|
|
741
|
+
"BatchMode=yes",
|
|
742
|
+
"-o",
|
|
743
|
+
"ConnectTimeout=15"
|
|
744
|
+
].join(" ");
|
|
745
|
+
env.GIT_ALLOW_PROTOCOL = "ssh";
|
|
746
|
+
return {
|
|
747
|
+
env,
|
|
748
|
+
credentialed: true
|
|
749
|
+
};
|
|
750
|
+
}
|
|
751
|
+
return {
|
|
752
|
+
env,
|
|
753
|
+
credentialed: false
|
|
754
|
+
};
|
|
755
|
+
}
|
|
756
|
+
//#endregion
|
|
70
757
|
//#region src/services/newSessionRequest/index.ts
|
|
71
758
|
const MAX_NAME_LENGTH = 120;
|
|
72
759
|
/** Trimmed text, undefined when absent or blank, false when not text at all. */
|
|
@@ -111,7 +798,7 @@ function newSessionRequest(body) {
|
|
|
111
798
|
return { error: "The mode must be existing-branch or new-branch." };
|
|
112
799
|
}
|
|
113
800
|
//#endregion
|
|
114
|
-
//#region src/extensions/(backend)/api/_lib/route.server.ts
|
|
801
|
+
//#region src/extensions/(backend)/api/git/_lib/route.server.ts
|
|
115
802
|
/**
|
|
116
803
|
* The worktree surface the cockpit panel calls.
|
|
117
804
|
*
|
|
@@ -134,7 +821,7 @@ function failed(error) {
|
|
|
134
821
|
return Response.json({ error: "The worktree operation failed." }, { status: 500 });
|
|
135
822
|
}
|
|
136
823
|
//#endregion
|
|
137
|
-
//#region src/extensions/(backend)/api/route.server.ts
|
|
824
|
+
//#region src/extensions/(backend)/api/git/route.server.ts
|
|
138
825
|
var route_server_default = defineRoute({
|
|
139
826
|
basePath: "git",
|
|
140
827
|
start(context) {
|
|
@@ -199,14 +886,67 @@ var route_server_default = defineRoute({
|
|
|
199
886
|
if (root === void 0) return badRequest("That repository is not admitted to this cockpit.");
|
|
200
887
|
return root;
|
|
201
888
|
};
|
|
889
|
+
const homeDir = context.homeDirectory ?? os.homedir();
|
|
890
|
+
const gitAuth = createGitAuthStore(homeDir);
|
|
891
|
+
const branchDiff = createBranchDiff();
|
|
892
|
+
/**
|
|
893
|
+
* A workspace's remote auth. Workspace mounts only, so the step-up gate on
|
|
894
|
+
* `PUT /api/workspaces/<id>/plugins/git/auth` is the one way to write it.
|
|
895
|
+
* The view never carries the token.
|
|
896
|
+
*/
|
|
897
|
+
const auth = async (request, url) => {
|
|
898
|
+
if (url.pathname !== "/auth") return void 0;
|
|
899
|
+
if (context.scope !== "workspace") return Response.json({ error: "This route belongs to a workspace." }, { status: 404 });
|
|
900
|
+
if (request.method !== "GET" && request.method !== "PUT") return Response.json({ error: "Use GET or PUT." }, { status: 405 });
|
|
901
|
+
const root = rootOf(url);
|
|
902
|
+
if (root instanceof Response) return root;
|
|
903
|
+
if (request.method === "GET") return Response.json(gitAuth.view(root));
|
|
904
|
+
try {
|
|
905
|
+
return Response.json(gitAuth.save(root, await request.json().catch(() => void 0)));
|
|
906
|
+
} catch (error) {
|
|
907
|
+
if (error instanceof DoomGitExpectedError && error.code === "invalid_request") {
|
|
908
|
+
const text = error.message.replace(/^\[[a-z_]+\] /u, "").replace("\nRecovery: ", " ");
|
|
909
|
+
return Response.json({
|
|
910
|
+
error: text,
|
|
911
|
+
code: error.code
|
|
912
|
+
}, { status: 400 });
|
|
913
|
+
}
|
|
914
|
+
throw error;
|
|
915
|
+
}
|
|
916
|
+
};
|
|
917
|
+
/**
|
|
918
|
+
* The session's change against its base. Session mounts only, rooted at the
|
|
919
|
+
* session's own checkout; a file is served only when it is in the change set
|
|
920
|
+
* computed for the same request.
|
|
921
|
+
*/
|
|
922
|
+
const review = async (request, url) => {
|
|
923
|
+
if (url.pathname !== "/review" && url.pathname !== "/review/file") return void 0;
|
|
924
|
+
const cwd = context.scope === "session" ? context.cwd : void 0;
|
|
925
|
+
if (cwd === void 0) return Response.json({ error: "This route belongs to a session." }, { status: 404 });
|
|
926
|
+
if (request.method !== "GET") return Response.json({ error: "Use GET." }, { status: 405 });
|
|
927
|
+
let recordedBaseRef;
|
|
928
|
+
try {
|
|
929
|
+
recordedBaseRef = createWorktreeRegistry(registryFile(cwd, homeDir)).list().find((record) => record.sessionId === context.sessionId)?.baseRef;
|
|
930
|
+
} catch {
|
|
931
|
+
recordedBaseRef = void 0;
|
|
932
|
+
}
|
|
933
|
+
const result = await branchDiff.review(cwd, recordedBaseRef === void 0 ? {} : { recordedBaseRef });
|
|
934
|
+
if (url.pathname === "/review") return Response.json(result?.summary ?? {
|
|
935
|
+
repository: false,
|
|
936
|
+
files: []
|
|
937
|
+
});
|
|
938
|
+
const filePath = url.searchParams.get("path") ?? "";
|
|
939
|
+
if (result === void 0 || !result.summary.files.some((file) => file.path === filePath)) return Response.json({ error: "That file is not part of this review." }, { status: 404 });
|
|
940
|
+
return Response.json(await branchDiff.fileDiff(result, filePath));
|
|
941
|
+
};
|
|
202
942
|
return {
|
|
203
943
|
async fetch(request) {
|
|
204
944
|
const url = new URL(request.url);
|
|
205
945
|
try {
|
|
206
|
-
const answered = await newSession(request, url);
|
|
946
|
+
const answered = await newSession(request, url) ?? await auth(request, url) ?? await review(request, url);
|
|
207
947
|
if (answered !== void 0) return answered;
|
|
208
948
|
} catch (error) {
|
|
209
|
-
context.onNotice(`
|
|
949
|
+
context.onNotice(`git request failed: ${error.name}`);
|
|
210
950
|
return failed(error);
|
|
211
951
|
}
|
|
212
952
|
const root = rootOf(url);
|
|
@@ -246,6 +986,494 @@ var route_server_default = defineRoute({
|
|
|
246
986
|
}
|
|
247
987
|
});
|
|
248
988
|
//#endregion
|
|
989
|
+
//#region src/services/gitFailure/index.ts
|
|
990
|
+
const RULES = [
|
|
991
|
+
{
|
|
992
|
+
code: "host_key_changed",
|
|
993
|
+
phrases: ["remote host identification has changed"],
|
|
994
|
+
message: "The remote's SSH host key changed since this machine last saw it.",
|
|
995
|
+
recovery: "Check with the host that the change is expected, then update ~/.ssh/known_hosts from a terminal.",
|
|
996
|
+
retryable: false
|
|
997
|
+
},
|
|
998
|
+
{
|
|
999
|
+
code: "host_key_unknown",
|
|
1000
|
+
phrases: [
|
|
1001
|
+
"host key verification failed",
|
|
1002
|
+
"no ed25519 host key is known",
|
|
1003
|
+
"no rsa host key is known"
|
|
1004
|
+
],
|
|
1005
|
+
message: "This machine does not trust the remote's SSH host key yet.",
|
|
1006
|
+
recovery: "Run `ssh -T git@<host>` once in a terminal and accept the key, then try again.",
|
|
1007
|
+
retryable: false
|
|
1008
|
+
},
|
|
1009
|
+
{
|
|
1010
|
+
code: "auth_failed",
|
|
1011
|
+
phrases: [
|
|
1012
|
+
"authentication failed",
|
|
1013
|
+
"could not read username",
|
|
1014
|
+
"could not read password",
|
|
1015
|
+
"permission denied (publickey",
|
|
1016
|
+
"returned error: 401",
|
|
1017
|
+
"returned error: 403",
|
|
1018
|
+
"terminal prompts disabled",
|
|
1019
|
+
"load key",
|
|
1020
|
+
"unprotected private key",
|
|
1021
|
+
"invalid format"
|
|
1022
|
+
],
|
|
1023
|
+
message: "The remote refused the credentials.",
|
|
1024
|
+
recovery: `Check this workspace's git remote auth. ${SETTINGS_HINT}`,
|
|
1025
|
+
retryable: false
|
|
1026
|
+
},
|
|
1027
|
+
{
|
|
1028
|
+
code: "repository_not_found",
|
|
1029
|
+
phrases: ["repository not found", "does not appear to be a git repository"],
|
|
1030
|
+
message: "The remote repository was not found, or these credentials cannot see it.",
|
|
1031
|
+
recovery: `Check the remote URL and that the credentials have access. ${SETTINGS_HINT}`,
|
|
1032
|
+
retryable: false
|
|
1033
|
+
},
|
|
1034
|
+
{
|
|
1035
|
+
code: "remote_unreachable",
|
|
1036
|
+
phrases: [
|
|
1037
|
+
"could not resolve host",
|
|
1038
|
+
"connection refused",
|
|
1039
|
+
"connection timed out",
|
|
1040
|
+
"operation timed out",
|
|
1041
|
+
"unable to access",
|
|
1042
|
+
"network is unreachable"
|
|
1043
|
+
],
|
|
1044
|
+
message: "The remote could not be reached.",
|
|
1045
|
+
recovery: "Check the network and the remote URL, then try again.",
|
|
1046
|
+
retryable: true
|
|
1047
|
+
},
|
|
1048
|
+
{
|
|
1049
|
+
code: "push_lease_rejected",
|
|
1050
|
+
phrases: ["(stale info)"],
|
|
1051
|
+
message: "The remote branch moved since your last fetch, so the force push was refused.",
|
|
1052
|
+
recovery: "Pull to see the new commits, then decide whether to push again.",
|
|
1053
|
+
retryable: false
|
|
1054
|
+
},
|
|
1055
|
+
{
|
|
1056
|
+
code: "push_needs_force",
|
|
1057
|
+
phrases: ["(non-fast-forward)", "(fetch first)"],
|
|
1058
|
+
message: "The remote branch has commits yours replaced, usually after a rebase.",
|
|
1059
|
+
recovery: "Confirm a force push with lease, or pull first to keep the remote commits.",
|
|
1060
|
+
retryable: false
|
|
1061
|
+
},
|
|
1062
|
+
{
|
|
1063
|
+
code: "worktree_dirty",
|
|
1064
|
+
phrases: [
|
|
1065
|
+
"unstaged changes",
|
|
1066
|
+
"uncommitted changes",
|
|
1067
|
+
"would be overwritten",
|
|
1068
|
+
"your index contains uncommitted changes"
|
|
1069
|
+
],
|
|
1070
|
+
message: "There are uncommitted changes in the way.",
|
|
1071
|
+
recovery: "Commit or stash your changes first.",
|
|
1072
|
+
retryable: false
|
|
1073
|
+
}
|
|
1074
|
+
];
|
|
1075
|
+
function classifyGitFailure(operation, result, mismatch) {
|
|
1076
|
+
if (result.timedOut) return new DoomGitExpectedError("git_timeout", `git ${operation} took too long and was stopped.`, true, "Check the network, then try again.");
|
|
1077
|
+
const text = `${result.stderr}\n${result.stdout}`.toLowerCase();
|
|
1078
|
+
const rule = RULES.find((candidate) => candidate.phrases.some((phrase) => text.includes(phrase)));
|
|
1079
|
+
if (rule === void 0) return new DoomGitExpectedError("git_failed", `git ${operation} failed (exit ${String(result.code)}).`, false, "Run the same command in a terminal in this checkout to see what git says.");
|
|
1080
|
+
const recovery = rule.code === "auth_failed" && mismatch !== void 0 ? `${mismatch} ${rule.recovery}` : rule.recovery;
|
|
1081
|
+
return new DoomGitExpectedError(rule.code, rule.message, rule.retryable, recovery);
|
|
1082
|
+
}
|
|
1083
|
+
//#endregion
|
|
1084
|
+
//#region src/services/gitSync/index.ts
|
|
1085
|
+
/**
|
|
1086
|
+
* Manual pull, push and rebase for a session's checkout.
|
|
1087
|
+
*
|
|
1088
|
+
* DESIGN PATTERNS:
|
|
1089
|
+
* - The workspace's auth goes into the environment of the one fetch or push
|
|
1090
|
+
* that needs it, built fresh per call. Rebase itself runs locally and never
|
|
1091
|
+
* sees a credential.
|
|
1092
|
+
* - Fetch and rebase are separate commands, so a hung network can never leave a
|
|
1093
|
+
* rebase half applied.
|
|
1094
|
+
* - A conflicted rebase is not an error. It is left paused for the reader to
|
|
1095
|
+
* hand to the agent or abort, and git's own state reports it from then on.
|
|
1096
|
+
* - Pull and rebase refuse uncommitted tracked changes rather than stash them.
|
|
1097
|
+
* Untracked files only block when git says they would be overwritten.
|
|
1098
|
+
* - Push never forces on its own. A rejection comes back as `push_needs_force`,
|
|
1099
|
+
* and only a confirmed retry sends `--force-with-lease --force-if-includes`,
|
|
1100
|
+
* which git refuses if the remote holds commits this branch never integrated.
|
|
1101
|
+
*/
|
|
1102
|
+
const DONE = { outcome: "done" };
|
|
1103
|
+
function expected(code, message, recovery) {
|
|
1104
|
+
return new DoomGitExpectedError(code, message, false, recovery);
|
|
1105
|
+
}
|
|
1106
|
+
async function readLine(cwd, args) {
|
|
1107
|
+
const result = await runGit(cwd, args, { timeout: READ_TIMEOUT_MS });
|
|
1108
|
+
const out = result.stdout.trim();
|
|
1109
|
+
return result.code === 0 && out !== "" ? out : void 0;
|
|
1110
|
+
}
|
|
1111
|
+
async function toplevel(cwd) {
|
|
1112
|
+
const root = await readLine(cwd, ["rev-parse", "--show-toplevel"]);
|
|
1113
|
+
if (root === void 0) throw expected("not_a_repository", "This session is not in a git checkout.", "Open the session in a git repository.");
|
|
1114
|
+
return root;
|
|
1115
|
+
}
|
|
1116
|
+
async function rebasing(root) {
|
|
1117
|
+
for (const name of ["rebase-merge", "rebase-apply"]) {
|
|
1118
|
+
const gitPath = await readLine(root, [
|
|
1119
|
+
"rev-parse",
|
|
1120
|
+
"--git-path",
|
|
1121
|
+
name
|
|
1122
|
+
]);
|
|
1123
|
+
if (gitPath !== void 0 && fs.existsSync(path.resolve(root, gitPath))) return true;
|
|
1124
|
+
}
|
|
1125
|
+
return false;
|
|
1126
|
+
}
|
|
1127
|
+
async function conflicts(root) {
|
|
1128
|
+
return (await runGit(root, [
|
|
1129
|
+
"diff",
|
|
1130
|
+
"--name-only",
|
|
1131
|
+
"--diff-filter=U",
|
|
1132
|
+
"-z"
|
|
1133
|
+
], { timeout: READ_TIMEOUT_MS })).stdout.split("\0").filter((file) => file !== "");
|
|
1134
|
+
}
|
|
1135
|
+
async function currentBranch(root) {
|
|
1136
|
+
const branch = await readLine(root, [
|
|
1137
|
+
"symbolic-ref",
|
|
1138
|
+
"--quiet",
|
|
1139
|
+
"--short",
|
|
1140
|
+
"HEAD"
|
|
1141
|
+
]);
|
|
1142
|
+
if (branch === void 0) throw expected("detached_head", "HEAD is detached, so there is no branch to sync.", "Check out a branch first.");
|
|
1143
|
+
return branch;
|
|
1144
|
+
}
|
|
1145
|
+
async function guard(root, needsCleanTree) {
|
|
1146
|
+
if (await rebasing(root)) throw expected("rebase_in_progress", "A rebase is paused in this checkout.", "Ask the agent to resolve it, or abort the rebase.");
|
|
1147
|
+
const branch = await currentBranch(root);
|
|
1148
|
+
if (needsCleanTree) {
|
|
1149
|
+
if (await readLine(root, [
|
|
1150
|
+
"status",
|
|
1151
|
+
"--porcelain",
|
|
1152
|
+
"--untracked-files=no"
|
|
1153
|
+
]) !== void 0) throw expected("worktree_dirty", "There are uncommitted changes.", "Commit or stash your changes first.");
|
|
1154
|
+
}
|
|
1155
|
+
return branch;
|
|
1156
|
+
}
|
|
1157
|
+
function createGitSync(options) {
|
|
1158
|
+
const baseEnv = options.baseEnv ?? process.env;
|
|
1159
|
+
/** One credentialed network command against `remote`, classified on failure. */
|
|
1160
|
+
const network = async (root, context, remote, operation, args, urlArgs = []) => {
|
|
1161
|
+
const url = await readLine(root, [
|
|
1162
|
+
"remote",
|
|
1163
|
+
"get-url",
|
|
1164
|
+
...urlArgs,
|
|
1165
|
+
remote
|
|
1166
|
+
]);
|
|
1167
|
+
if (url === void 0) throw expected("no_remote", `There is no remote named ${remote}.`, "Add the remote in a terminal first.");
|
|
1168
|
+
const prepared = remoteEnv(context.auth, url, baseEnv, options.homeDir);
|
|
1169
|
+
const result = await runGit(root, args, {
|
|
1170
|
+
timeout: NETWORK_TIMEOUT_MS,
|
|
1171
|
+
env: prepared.env
|
|
1172
|
+
});
|
|
1173
|
+
if (result.code !== 0) throw classifyGitFailure(operation, result, prepared.mismatch);
|
|
1174
|
+
};
|
|
1175
|
+
/** Rebases onto `onto`; a stop on conflicts leaves the rebase paused and says so. */
|
|
1176
|
+
const rebaseOnto = async (root, onto) => {
|
|
1177
|
+
const result = await runGit(root, ["rebase", onto], {
|
|
1178
|
+
timeout: REBASE_TIMEOUT_MS,
|
|
1179
|
+
env: {
|
|
1180
|
+
...baseEnv,
|
|
1181
|
+
GIT_EDITOR: "true",
|
|
1182
|
+
GIT_TERMINAL_PROMPT: "0"
|
|
1183
|
+
}
|
|
1184
|
+
});
|
|
1185
|
+
if (result.code === 0) return DONE;
|
|
1186
|
+
if (await rebasing(root)) return {
|
|
1187
|
+
outcome: "paused",
|
|
1188
|
+
conflicts: await conflicts(root)
|
|
1189
|
+
};
|
|
1190
|
+
throw classifyGitFailure("rebase", result);
|
|
1191
|
+
};
|
|
1192
|
+
/** The remote a ref like `origin/main` or `refs/remotes/origin/main` belongs to. */
|
|
1193
|
+
const remoteOfRef = async (root, ref) => {
|
|
1194
|
+
const full = await readLine(root, [
|
|
1195
|
+
"rev-parse",
|
|
1196
|
+
"--symbolic-full-name",
|
|
1197
|
+
ref
|
|
1198
|
+
]);
|
|
1199
|
+
if (full === void 0 || !full.startsWith("refs/remotes/")) return void 0;
|
|
1200
|
+
const rest = full.slice(13);
|
|
1201
|
+
const cut = rest.indexOf("/");
|
|
1202
|
+
return cut > 0 ? rest.slice(0, cut) : void 0;
|
|
1203
|
+
};
|
|
1204
|
+
return {
|
|
1205
|
+
async pull(context) {
|
|
1206
|
+
const root = await toplevel(context.cwd);
|
|
1207
|
+
const branch = await guard(root, true);
|
|
1208
|
+
const remote = await readLine(root, ["config", `branch.${branch}.remote`]);
|
|
1209
|
+
const upstream = await readLine(root, [
|
|
1210
|
+
"rev-parse",
|
|
1211
|
+
"--abbrev-ref",
|
|
1212
|
+
"--symbolic-full-name",
|
|
1213
|
+
`${branch}@{upstream}`
|
|
1214
|
+
]);
|
|
1215
|
+
if (remote === void 0 || upstream === void 0) throw expected("no_upstream", `${branch} has no upstream to pull from.`, "Push once to set an upstream.");
|
|
1216
|
+
if (remote !== ".") await network(root, context, remote, "fetch", [
|
|
1217
|
+
"fetch",
|
|
1218
|
+
"--prune",
|
|
1219
|
+
remote
|
|
1220
|
+
]);
|
|
1221
|
+
return rebaseOnto(root, upstream);
|
|
1222
|
+
},
|
|
1223
|
+
async push(context, force) {
|
|
1224
|
+
const root = await toplevel(context.cwd);
|
|
1225
|
+
const branch = await guard(root, false);
|
|
1226
|
+
const configuredRemote = await readLine(root, ["config", `branch.${branch}.remote`]);
|
|
1227
|
+
const merge = await readLine(root, ["config", `branch.${branch}.merge`]);
|
|
1228
|
+
let remote = configuredRemote;
|
|
1229
|
+
let target = merge;
|
|
1230
|
+
const args = ["push", "--porcelain"];
|
|
1231
|
+
if (remote === void 0 || target === void 0 || remote === ".") {
|
|
1232
|
+
const remotes = (await readLine(root, ["remote"]) ?? "").split("\n").filter((name) => name !== "");
|
|
1233
|
+
remote = remotes.includes("origin") ? "origin" : remotes.length === 1 ? remotes[0] : void 0;
|
|
1234
|
+
if (remote === void 0) throw expected("no_remote", "There is no remote to push to.", remotes.length === 0 ? "Add a remote in a terminal first." : "Push once from a terminal to choose the remote.");
|
|
1235
|
+
target = `refs/heads/${branch}`;
|
|
1236
|
+
args.push("--set-upstream");
|
|
1237
|
+
}
|
|
1238
|
+
if (force) args.push(`--force-with-lease=${target}`, "--force-if-includes");
|
|
1239
|
+
args.push(remote, `HEAD:${target}`);
|
|
1240
|
+
await network(root, context, remote, "push", args, ["--push"]);
|
|
1241
|
+
return DONE;
|
|
1242
|
+
},
|
|
1243
|
+
async rebase(context) {
|
|
1244
|
+
const root = await toplevel(context.cwd);
|
|
1245
|
+
const base = await resolveBaseRef(root, await guard(root, true), context.recordedBaseRef);
|
|
1246
|
+
if (base === void 0) throw expected("no_base", "No base branch to rebase onto.", "Fetch the remote, or create the worktree from a base.");
|
|
1247
|
+
const remote = await remoteOfRef(root, base);
|
|
1248
|
+
if (remote !== void 0) await network(root, context, remote, "fetch", [
|
|
1249
|
+
"fetch",
|
|
1250
|
+
"--prune",
|
|
1251
|
+
remote
|
|
1252
|
+
]);
|
|
1253
|
+
return rebaseOnto(root, base);
|
|
1254
|
+
},
|
|
1255
|
+
async abortRebase(context) {
|
|
1256
|
+
const root = await toplevel(context.cwd);
|
|
1257
|
+
if (!await rebasing(root)) return DONE;
|
|
1258
|
+
const result = await runGit(root, ["rebase", "--abort"], { timeout: REBASE_TIMEOUT_MS });
|
|
1259
|
+
if (result.code !== 0) throw classifyGitFailure("rebase --abort", result);
|
|
1260
|
+
return DONE;
|
|
1261
|
+
}
|
|
1262
|
+
};
|
|
1263
|
+
}
|
|
1264
|
+
//#endregion
|
|
1265
|
+
//#region src/types/gitReview.ts
|
|
1266
|
+
/**
|
|
1267
|
+
* The cockpit's view of what this session changed against its base: the counts
|
|
1268
|
+
* the '# diff' group shows, what the review tab lists and draws, and the manual
|
|
1269
|
+
* sync that sits beside them.
|
|
1270
|
+
*
|
|
1271
|
+
* DESIGN PATTERNS:
|
|
1272
|
+
* - Declarations only, shared by the hub and the page.
|
|
1273
|
+
* - Its own channel, apart from worktrees. Worktrees are repository
|
|
1274
|
+
* management; the diff is this session's code change.
|
|
1275
|
+
* - A diff row carries one line number whose file follows from its marker: the
|
|
1276
|
+
* new file's for context and additions, the old file's for removals. It is
|
|
1277
|
+
* the same shape the shared DiffView draws.
|
|
1278
|
+
*
|
|
1279
|
+
* AVOID:
|
|
1280
|
+
* - Behaviour or node builtins. Browser code imports this file.
|
|
1281
|
+
*/
|
|
1282
|
+
/** The session channel carrying this checkout's changes and the sync around them. */
|
|
1283
|
+
const GIT_CHANGES_TYPE = "git_changes";
|
|
1284
|
+
//#endregion
|
|
1285
|
+
//#region src/extensions/(backend)/channel/_lib/git-changes.server.ts
|
|
1286
|
+
/**
|
|
1287
|
+
* The '# diff' data channel: this session's change against its base, and the
|
|
1288
|
+
* manual sync commands the review tab sends back through it.
|
|
1289
|
+
*
|
|
1290
|
+
* DESIGN PATTERNS:
|
|
1291
|
+
* - Apart from the worktrees channel. Worktrees are repository management;
|
|
1292
|
+
* this is the session's own code change.
|
|
1293
|
+
* - Push, not poll. The session facet publishes a "changed" event after every
|
|
1294
|
+
* tool call and settled turn; recounts are coalesced to one in flight plus
|
|
1295
|
+
* one rerun, so a burst of tool calls costs at most two.
|
|
1296
|
+
* - One sync command per session. A rebase and a push must never overlap.
|
|
1297
|
+
* - Auth is read fresh for each command from the session's workspace, and goes
|
|
1298
|
+
* only into that command's git child.
|
|
1299
|
+
*
|
|
1300
|
+
* AVOID:
|
|
1301
|
+
* - Echoing git output. Failures go through classifyGitFailure, which matches
|
|
1302
|
+
* stderr and drops it, because a remote URL in it can carry a token.
|
|
1303
|
+
*/
|
|
1304
|
+
function payloadOf$1(state) {
|
|
1305
|
+
return {
|
|
1306
|
+
...state.changes === void 0 ? {} : { changes: state.changes },
|
|
1307
|
+
...state.pending === void 0 ? {} : { pending: state.pending },
|
|
1308
|
+
...state.error === void 0 ? {} : { error: state.error },
|
|
1309
|
+
...state.errorTarget === void 0 ? {} : { errorTarget: state.errorTarget }
|
|
1310
|
+
};
|
|
1311
|
+
}
|
|
1312
|
+
/** A command the page sent, or undefined when the frame was not one. */
|
|
1313
|
+
function parseChangesCommand(payload) {
|
|
1314
|
+
if (typeof payload !== "object" || payload === null) return void 0;
|
|
1315
|
+
const value = payload;
|
|
1316
|
+
if (value.action === "pull" || value.action === "rebase" || value.action === "abort-rebase") return { action: value.action };
|
|
1317
|
+
if (value.action === "push") return value.force === true ? {
|
|
1318
|
+
action: "push",
|
|
1319
|
+
force: true
|
|
1320
|
+
} : { action: "push" };
|
|
1321
|
+
}
|
|
1322
|
+
const PENDING = {
|
|
1323
|
+
pull: "pulling…",
|
|
1324
|
+
push: "pushing…",
|
|
1325
|
+
rebase: "rebasing…",
|
|
1326
|
+
"abort-rebase": "aborting the rebase…"
|
|
1327
|
+
};
|
|
1328
|
+
/** The error target a failure maps to: a rejected plain push asks for a confirmed lease force. */
|
|
1329
|
+
function errorTargetFor$1(command, error) {
|
|
1330
|
+
if (command.action !== "push") return { action: command.action };
|
|
1331
|
+
return error instanceof DoomGitExpectedError && error.code === "push_needs_force" && command.force !== true ? {
|
|
1332
|
+
action: "push",
|
|
1333
|
+
forceRequired: true
|
|
1334
|
+
} : { action: "push" };
|
|
1335
|
+
}
|
|
1336
|
+
function failureText$1(error) {
|
|
1337
|
+
if (error instanceof DoomGitExpectedError) return error.message.replace(/^\[[a-z_]+\] /u, "").replace("\nRecovery: ", " ");
|
|
1338
|
+
return "The git command failed.";
|
|
1339
|
+
}
|
|
1340
|
+
function createGitChangesChannel(options = {}) {
|
|
1341
|
+
let receiveCommand;
|
|
1342
|
+
return {
|
|
1343
|
+
frameType: GIT_CHANGES_TYPE,
|
|
1344
|
+
start(host) {
|
|
1345
|
+
const homeDir = options.homeDir ?? os.homedir();
|
|
1346
|
+
const diff = options.diff ?? createBranchDiff();
|
|
1347
|
+
const sync = options.sync ?? createGitSync({ homeDir });
|
|
1348
|
+
const auth = options.auth ?? createGitAuthStore(homeDir);
|
|
1349
|
+
const latest = /* @__PURE__ */ new Map();
|
|
1350
|
+
const subscriptions = /* @__PURE__ */ new Map();
|
|
1351
|
+
const scopes = /* @__PURE__ */ new Map();
|
|
1352
|
+
const inFlight = /* @__PURE__ */ new Set();
|
|
1353
|
+
const rerun = /* @__PURE__ */ new Set();
|
|
1354
|
+
const busy = /* @__PURE__ */ new Set();
|
|
1355
|
+
/** The base the session's own worktree was created from, when it has one. */
|
|
1356
|
+
const recordedBaseRef = (scope) => {
|
|
1357
|
+
try {
|
|
1358
|
+
return createWorktreeRegistry(registryFile(scope.cwd, homeDir)).list().find((record) => record.sessionId === scope.sessionId)?.baseRef;
|
|
1359
|
+
} catch {
|
|
1360
|
+
return;
|
|
1361
|
+
}
|
|
1362
|
+
};
|
|
1363
|
+
/** Republishes a session, skipping a frame that would say nothing new. */
|
|
1364
|
+
const publish = (sessionId, next) => {
|
|
1365
|
+
const previous = latest.get(sessionId);
|
|
1366
|
+
latest.set(sessionId, next);
|
|
1367
|
+
if (previous !== void 0 && JSON.stringify(payloadOf$1(previous)) === JSON.stringify(payloadOf$1(next))) return;
|
|
1368
|
+
host.publish(sessionId, payloadOf$1(next));
|
|
1369
|
+
};
|
|
1370
|
+
const update = (sessionId, patch) => {
|
|
1371
|
+
const previous = latest.get(sessionId) ?? {
|
|
1372
|
+
changes: void 0,
|
|
1373
|
+
pending: void 0,
|
|
1374
|
+
error: void 0,
|
|
1375
|
+
errorTarget: void 0
|
|
1376
|
+
};
|
|
1377
|
+
publish(sessionId, {
|
|
1378
|
+
...previous,
|
|
1379
|
+
...patch
|
|
1380
|
+
});
|
|
1381
|
+
};
|
|
1382
|
+
/** Recounts one session; a request while one runs schedules exactly one more. */
|
|
1383
|
+
const refresh = (sessionId) => {
|
|
1384
|
+
const scope = scopes.get(sessionId);
|
|
1385
|
+
if (scope === void 0) return;
|
|
1386
|
+
if (inFlight.has(sessionId)) {
|
|
1387
|
+
rerun.add(sessionId);
|
|
1388
|
+
return;
|
|
1389
|
+
}
|
|
1390
|
+
inFlight.add(sessionId);
|
|
1391
|
+
const base = recordedBaseRef(scope);
|
|
1392
|
+
diff.review(scope.cwd, base === void 0 ? {} : { recordedBaseRef: base }).then((review) => {
|
|
1393
|
+
if (scopes.has(sessionId)) update(sessionId, { changes: review?.changes });
|
|
1394
|
+
}).catch((error) => {
|
|
1395
|
+
host.onNotice(`git diff refresh failed for ${sessionId} (${error instanceof Error ? error.name : "unknown"})`);
|
|
1396
|
+
}).finally(() => {
|
|
1397
|
+
inFlight.delete(sessionId);
|
|
1398
|
+
if (rerun.delete(sessionId)) refresh(sessionId);
|
|
1399
|
+
});
|
|
1400
|
+
};
|
|
1401
|
+
const run = (scope, command) => {
|
|
1402
|
+
const workspaceRoot = scope.sessionContext?.workspaceRoot ?? scope.cwd;
|
|
1403
|
+
const base = recordedBaseRef(scope);
|
|
1404
|
+
const context = {
|
|
1405
|
+
cwd: scope.cwd,
|
|
1406
|
+
auth: auth.read(workspaceRoot),
|
|
1407
|
+
...base === void 0 ? {} : { recordedBaseRef: base }
|
|
1408
|
+
};
|
|
1409
|
+
if (command.action === "pull") return sync.pull(context);
|
|
1410
|
+
if (command.action === "push") return sync.push(context, command.force === true);
|
|
1411
|
+
if (command.action === "rebase") return sync.rebase(context);
|
|
1412
|
+
return sync.abortRebase(context);
|
|
1413
|
+
};
|
|
1414
|
+
const source = {
|
|
1415
|
+
payloadFor(scope) {
|
|
1416
|
+
const state = latest.get(scope.sessionId);
|
|
1417
|
+
return state === void 0 ? void 0 : payloadOf$1(state);
|
|
1418
|
+
},
|
|
1419
|
+
sessionAdded(scope) {
|
|
1420
|
+
scopes.set(scope.sessionId, scope);
|
|
1421
|
+
refresh(scope.sessionId);
|
|
1422
|
+
const unsubscribe = host.directEvents.subscribe(GIT_SESSION_CHANGED_EVENT, scope.sessionId, (payload) => {
|
|
1423
|
+
if (isSessionChangedEvent(payload)) refresh(scope.sessionId);
|
|
1424
|
+
});
|
|
1425
|
+
subscriptions.set(scope.sessionId, unsubscribe);
|
|
1426
|
+
},
|
|
1427
|
+
sessionRemoved(sessionId) {
|
|
1428
|
+
subscriptions.get(sessionId)?.();
|
|
1429
|
+
subscriptions.delete(sessionId);
|
|
1430
|
+
scopes.delete(sessionId);
|
|
1431
|
+
latest.delete(sessionId);
|
|
1432
|
+
rerun.delete(sessionId);
|
|
1433
|
+
busy.delete(sessionId);
|
|
1434
|
+
},
|
|
1435
|
+
close() {
|
|
1436
|
+
for (const unsubscribe of subscriptions.values()) unsubscribe();
|
|
1437
|
+
subscriptions.clear();
|
|
1438
|
+
scopes.clear();
|
|
1439
|
+
latest.clear();
|
|
1440
|
+
rerun.clear();
|
|
1441
|
+
busy.clear();
|
|
1442
|
+
}
|
|
1443
|
+
};
|
|
1444
|
+
receiveCommand = (scope, payload) => {
|
|
1445
|
+
const command = parseChangesCommand(payload);
|
|
1446
|
+
if (command === void 0 || !scopes.has(scope.sessionId)) return;
|
|
1447
|
+
if (busy.has(scope.sessionId)) return;
|
|
1448
|
+
busy.add(scope.sessionId);
|
|
1449
|
+
update(scope.sessionId, {
|
|
1450
|
+
pending: PENDING[command.action],
|
|
1451
|
+
error: void 0,
|
|
1452
|
+
errorTarget: void 0
|
|
1453
|
+
});
|
|
1454
|
+
run(scope, command).then(() => update(scope.sessionId, { pending: void 0 })).catch((error) => {
|
|
1455
|
+
host.onNotice(`git ${command.action} failed for ${scope.sessionId} (${error instanceof DoomGitExpectedError ? error.code : "unexpected"})`);
|
|
1456
|
+
update(scope.sessionId, {
|
|
1457
|
+
pending: void 0,
|
|
1458
|
+
error: failureText$1(error),
|
|
1459
|
+
errorTarget: errorTargetFor$1(command, error)
|
|
1460
|
+
});
|
|
1461
|
+
}).finally(() => {
|
|
1462
|
+
busy.delete(scope.sessionId);
|
|
1463
|
+
refresh(scope.sessionId);
|
|
1464
|
+
});
|
|
1465
|
+
};
|
|
1466
|
+
return source;
|
|
1467
|
+
},
|
|
1468
|
+
receive(scope, payload) {
|
|
1469
|
+
receiveCommand?.(scope, payload);
|
|
1470
|
+
}
|
|
1471
|
+
};
|
|
1472
|
+
}
|
|
1473
|
+
//#endregion
|
|
1474
|
+
//#region src/extensions/(backend)/channel/git-changes.server.ts
|
|
1475
|
+
var git_changes_server_default = defineChannel(createGitChangesChannel);
|
|
1476
|
+
//#endregion
|
|
249
1477
|
//#region src/types/webWorktrees.ts
|
|
250
1478
|
/**
|
|
251
1479
|
* The cockpit's view of this session's worktrees.
|
|
@@ -501,6 +1729,12 @@ function createWorktreesChannel(options = {}) {
|
|
|
501
1729
|
//#region src/extensions/(backend)/channel/git-worktrees.server.ts
|
|
502
1730
|
var git_worktrees_server_default = defineChannel(createWorktreesChannel);
|
|
503
1731
|
//#endregion
|
|
1732
|
+
//#region src/extensions/workspaces/sessions/(backend)/hook/agent-settled.server.ts
|
|
1733
|
+
var agent_settled_server_default = defineHook((context) => context.root.hooks[1]);
|
|
1734
|
+
//#endregion
|
|
1735
|
+
//#region src/extensions/workspaces/sessions/(backend)/hook/tool-execution-end.server.ts
|
|
1736
|
+
var tool_execution_end_server_default = defineHook((context) => context.root.hooks[0]);
|
|
1737
|
+
//#endregion
|
|
504
1738
|
//#region src/extensions/workspaces/sessions/(backend)/resource/_lib/doompiGit.server.ts
|
|
505
1739
|
var doompiGit_server_default$1 = (context) => context.root.resources[0];
|
|
506
1740
|
//#endregion
|
|
@@ -535,7 +1769,7 @@ const facet = defineServerPlugin({
|
|
|
535
1769
|
return [at(route_server_default, context)];
|
|
536
1770
|
},
|
|
537
1771
|
get channels() {
|
|
538
|
-
return [() => via({ frameType: "git_worktrees" }, git_worktrees_server_default())];
|
|
1772
|
+
return [() => via({ frameType: "git_changes" }, git_changes_server_default()), () => via({ frameType: "git_worktrees" }, git_worktrees_server_default())];
|
|
539
1773
|
}
|
|
540
1774
|
}),
|
|
541
1775
|
workspace: (context) => ({
|
|
@@ -543,7 +1777,7 @@ const facet = defineServerPlugin({
|
|
|
543
1777
|
return [at(route_server_default, context)];
|
|
544
1778
|
},
|
|
545
1779
|
get channels() {
|
|
546
|
-
return [() => via({ frameType: "git_worktrees" }, git_worktrees_server_default())];
|
|
1780
|
+
return [() => via({ frameType: "git_changes" }, git_changes_server_default()), () => via({ frameType: "git_worktrees" }, git_worktrees_server_default())];
|
|
547
1781
|
}
|
|
548
1782
|
}),
|
|
549
1783
|
session: async (context) => {
|
|
@@ -558,7 +1792,10 @@ const facet = defineServerPlugin({
|
|
|
558
1792
|
return [at(route_server_default, context)];
|
|
559
1793
|
},
|
|
560
1794
|
get channels() {
|
|
561
|
-
return [() => via({ frameType: "git_worktrees" }, git_worktrees_server_default())];
|
|
1795
|
+
return [() => via({ frameType: "git_changes" }, git_changes_server_default()), () => via({ frameType: "git_worktrees" }, git_worktrees_server_default())];
|
|
1796
|
+
},
|
|
1797
|
+
get hooks() {
|
|
1798
|
+
return [via({ event: "agent_settled" }, at(agent_settled_server_default, contextSession)), via({ event: "tool_execution_end" }, at(tool_execution_end_server_default, contextSession))];
|
|
562
1799
|
},
|
|
563
1800
|
get resources() {
|
|
564
1801
|
return [
|