@agimon-ai/po-git 0.0.0-alpha.9 → 0.0.0-alpha.91
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +149 -21
- package/README.md +16 -4
- package/dist/apiContracts.cjs +34 -1
- package/dist/apiContracts.cjs.map +1 -1
- package/dist/apiContracts.mjs +34 -1
- package/dist/apiContracts.mjs.map +1 -1
- package/dist/extensions/native.mjs +2159 -520
- package/dist/extensions/native.mjs.map +1 -1
- package/dist/extensions/server.cjs +512 -275
- package/dist/extensions/server.cjs.map +1 -1
- package/dist/extensions/server.d.mts +2 -2
- package/dist/extensions/server.d.mts.map +1 -1
- package/dist/extensions/server.mjs +509 -272
- package/dist/extensions/server.mjs.map +1 -1
- package/dist/extensions/web.mjs +1549 -1294
- package/dist/extensions/web.mjs.map +1 -1
- package/dist/index.cjs +1 -1
- package/dist/index.d.mts +2 -0
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +1 -1
- package/dist/{worktreeOperations-BSeyxUai.mjs → worktreeOperations-B3E4JY4H.mjs} +258 -85
- package/dist/worktreeOperations-B3E4JY4H.mjs.map +1 -0
- package/dist/{worktreeOperations-DGUbUnYU.cjs → worktreeOperations-CD7s0YnO.cjs} +288 -85
- package/dist/worktreeOperations-CD7s0YnO.cjs.map +1 -0
- package/llms.txt +1 -1
- package/package.json +21 -19
- package/src/prompts/{po-use-git → codemode-how-to-git}/SKILL.md +35 -32
- package/dist/worktreeOperations-BSeyxUai.mjs.map +0 -1
- package/dist/worktreeOperations-DGUbUnYU.cjs.map +0 -1
|
@@ -1,11 +1,14 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import fs from "node:fs";
|
|
3
|
-
import os from "node:os";
|
|
1
|
+
import { C as READ_TIMEOUT_MS, E as runGit, S as NETWORK_TIMEOUT_MS, T as createWorktreeGit, a as GIT_WORKTREE_LIFECYCLE_EVENT, c as isWorktreeLifecycleEvent, d as registryFile, f as readJson, g as invalidRequest, h as PoGitExpectedError, i as GIT_SESSION_CHANGED_EVENT, l as credentialsFile, m as HubUnavailableError, n as createWorktreeRegistry, o as createWorktreeMessageInbox, p as writeJsonAtomic, r as validRefName, s as isSessionChangedEvent, t as createWorktreeOperations, u as poGitRoot, w as REBASE_TIMEOUT_MS } from "../worktreeOperations-B3E4JY4H.mjs";
|
|
2
|
+
import fs from "node:fs/promises";
|
|
4
3
|
import path from "node:path";
|
|
5
|
-
import
|
|
6
|
-
import {
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import { composeServerProviderOverrides, defineServerPlugin, packageResourcePath, readPackageResource } from "@agimon-ai/po-core/serverPlugin";
|
|
6
|
+
import { composeRootHooks, composeRootInjects, defineChannel, defineHook, defineResource, defineRoot, defineRoute, defineServerTool } from "@agimon-ai/po-core/extensionFile";
|
|
7
|
+
import { PO_CODEMODE_SERVICE, readPoCodemode } from "@agimon-ai/po-core/codemode";
|
|
7
8
|
import { readPoSessionDelivery } from "@agimon-ai/po-session";
|
|
8
9
|
import { Type } from "typebox";
|
|
10
|
+
import { BACKGROUND_CONTEXT, PO_SHELL_SERVICE, requirePoShell, withAbortSignal } from "@agimon-ai/po-core/platform";
|
|
11
|
+
import { poApiCallerFrom } from "@agimon-ai/po-core/packageApi";
|
|
9
12
|
//#region src/schemas/runWorktreeTool.ts
|
|
10
13
|
/**
|
|
11
14
|
* The tool's action vocabulary.
|
|
@@ -247,7 +250,8 @@ async function executeRunWorktreeTool(params, operations, context, options = {})
|
|
|
247
250
|
content: text(describe(record, context.sessionId), `Session: ${record.sessionId}`, dirtyFiles.length === 0 ? "Clean." : `${String(dirtyFiles.length)} uncommitted file(s): ${dirtyFiles.slice(0, 20).join(", ")}`),
|
|
248
251
|
details: {
|
|
249
252
|
action: params.action,
|
|
250
|
-
records: [record]
|
|
253
|
+
records: [record],
|
|
254
|
+
dirtyFiles
|
|
251
255
|
}
|
|
252
256
|
};
|
|
253
257
|
}
|
|
@@ -271,7 +275,10 @@ async function executeRunWorktreeTool(params, operations, context, options = {})
|
|
|
271
275
|
const waiting = await operations.messages(context, params.id);
|
|
272
276
|
return {
|
|
273
277
|
content: waiting.length === 0 ? text("No messages.") : text(`${String(waiting.length)} message(s):`, ...waiting.map((entry) => `[${entry.sentAt}] ${entry.from}: ${entry.text}`)),
|
|
274
|
-
details: {
|
|
278
|
+
details: {
|
|
279
|
+
action: params.action,
|
|
280
|
+
messages: waiting
|
|
281
|
+
}
|
|
275
282
|
};
|
|
276
283
|
}
|
|
277
284
|
case WORKTREE_ACTIONS.prune: {
|
|
@@ -286,12 +293,172 @@ async function executeRunWorktreeTool(params, operations, context, options = {})
|
|
|
286
293
|
`Untracked directories, left alone: ${plan.untracked.length === 0 ? "none" : plan.untracked.join(", ")}`,
|
|
287
294
|
...plan.keptBranches.length === 0 ? [] : [`Branches kept, they hold commits: ${plan.keptBranches.join(", ")}`]
|
|
288
295
|
]),
|
|
289
|
-
details: {
|
|
296
|
+
details: {
|
|
297
|
+
action: params.action,
|
|
298
|
+
plan
|
|
299
|
+
}
|
|
290
300
|
};
|
|
291
301
|
}
|
|
292
302
|
}
|
|
293
303
|
}
|
|
294
304
|
//#endregion
|
|
305
|
+
//#region src/services/worktreeNamespace/index.ts
|
|
306
|
+
const DECLARATIONS = `type WorktreeStatus = 'spawning' | 'running' | 'closing' | 'orphaned';
|
|
307
|
+
|
|
308
|
+
interface Worktree {
|
|
309
|
+
id: string;
|
|
310
|
+
branch: string;
|
|
311
|
+
baseRef: string;
|
|
312
|
+
/** Absolute path of the checkout, under ~/.po/git/worktrees. */
|
|
313
|
+
path: string;
|
|
314
|
+
repositoryRoot: string;
|
|
315
|
+
/** The session running inside the worktree. */
|
|
316
|
+
sessionId: string;
|
|
317
|
+
parentSessionId?: string;
|
|
318
|
+
status: WorktreeStatus;
|
|
319
|
+
createdAt: string;
|
|
320
|
+
/** How your session relates to it: 'parent' spawned it, 'child' runs inside it, 'other' is neither. */
|
|
321
|
+
relationship: 'parent' | 'child' | 'other';
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
interface WorktreeMessage {
|
|
325
|
+
worktreeId: string;
|
|
326
|
+
fromSessionId: string;
|
|
327
|
+
from: 'parent' | 'child';
|
|
328
|
+
text: string;
|
|
329
|
+
sentAt: string;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
interface PrunePlan {
|
|
333
|
+
remove: Worktree[];
|
|
334
|
+
forget: Worktree[];
|
|
335
|
+
/** Kept because they hold uncommitted work. */
|
|
336
|
+
keptDirty: Worktree[];
|
|
337
|
+
untracked: string[];
|
|
338
|
+
/** Branches kept because they hold commits. */
|
|
339
|
+
keptBranches: string[];
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
declare const worktrees: {
|
|
343
|
+
/** Every worktree for this repository. */
|
|
344
|
+
list(): Promise<Worktree[]>;
|
|
345
|
+
/** One worktree and its uncommitted files. */
|
|
346
|
+
status(id: string): Promise<{ worktree: Worktree; dirtyFiles: string[] }>;
|
|
347
|
+
/**
|
|
348
|
+
* Create a worktree on a new branch and start a Po session in it, nested under this one. baseRef defaults to
|
|
349
|
+
* the current branch's remote-tracking ref, then the remote default branch; name labels it in the rail; task is
|
|
350
|
+
* the new session's first message. Setup can be slow, so give exec a larger timeoutMs.
|
|
351
|
+
*/
|
|
352
|
+
spawn(request: { branch: string; baseRef?: string; name?: string; task?: string }): Promise<Worktree>;
|
|
353
|
+
/** Stop the worktree's session and delete its checkout. Refused on a dirty tree unless force is true. */
|
|
354
|
+
close(id: string, options?: { force?: boolean }): Promise<Worktree>;
|
|
355
|
+
/** Merge the worktree's branch into this checkout. Refused when this checkout is dirty. */
|
|
356
|
+
merge(id: string, options?: { message?: string }): Promise<Worktree>;
|
|
357
|
+
/** Remove orphaned worktrees. With dryRun, only return the plan. */
|
|
358
|
+
prune(options?: { dryRun?: boolean }): Promise<PrunePlan>;
|
|
359
|
+
/**
|
|
360
|
+
* Message the other side of a worktree. Use the record with relationship 'child' to report to your parent,
|
|
361
|
+
* and 'parent' to reach your own children. 'other' records are not communication edges.
|
|
362
|
+
*/
|
|
363
|
+
send(id: string, message: string): Promise<void>;
|
|
364
|
+
/** Messages waiting for you from a worktree. */
|
|
365
|
+
messages(id: string): Promise<WorktreeMessage[]>;
|
|
366
|
+
};`;
|
|
367
|
+
const json = (value) => JSON.parse(JSON.stringify(value));
|
|
368
|
+
/** Git worktrees for code mode: the actions of `run_worktree` as methods, returning records instead of text. */
|
|
369
|
+
function createWorktreeNamespace(options) {
|
|
370
|
+
return {
|
|
371
|
+
name: "worktrees",
|
|
372
|
+
summary: "Git worktrees, each with its own Po session nested under this one: worktrees.list, status, spawn, close, merge, prune, send and messages.",
|
|
373
|
+
declarations: DECLARATIONS,
|
|
374
|
+
bind(run) {
|
|
375
|
+
if (run.sessionId !== options.execution.sessionId) throw new Error("Worktrees namespace belongs to another session.");
|
|
376
|
+
let disposed = false;
|
|
377
|
+
run.onDispose(() => {
|
|
378
|
+
disposed = true;
|
|
379
|
+
});
|
|
380
|
+
const view = (record) => ({
|
|
381
|
+
...record,
|
|
382
|
+
relationship: record.parentSessionId === options.execution.sessionId ? "parent" : record.sessionId === options.execution.sessionId ? "child" : "other"
|
|
383
|
+
});
|
|
384
|
+
/** One `run_worktree` action through the tool's own code, so the tool and the namespace cannot drift. */
|
|
385
|
+
const act = async (name, action, input) => {
|
|
386
|
+
run.signal.throwIfAborted();
|
|
387
|
+
if (disposed) throw new Error("Worktrees namespace run is disposed.");
|
|
388
|
+
options.assertAvailable();
|
|
389
|
+
const params = validateParams({
|
|
390
|
+
action,
|
|
391
|
+
...typeof input === "object" && input !== null ? input : {}
|
|
392
|
+
});
|
|
393
|
+
const context = {
|
|
394
|
+
cwd: options.execution.cwd,
|
|
395
|
+
sessionId: options.execution.sessionId
|
|
396
|
+
};
|
|
397
|
+
const started = Date.now();
|
|
398
|
+
try {
|
|
399
|
+
const result = await executeRunWorktreeTool(params, options.operations, context, { signal: run.signal });
|
|
400
|
+
options.assertAvailable();
|
|
401
|
+
run.record?.({
|
|
402
|
+
name: `worktrees.${name}`,
|
|
403
|
+
tool: RUN_WORKTREE_TOOL_NAME,
|
|
404
|
+
args: params,
|
|
405
|
+
ok: true,
|
|
406
|
+
ms: Date.now() - started,
|
|
407
|
+
result
|
|
408
|
+
});
|
|
409
|
+
return result;
|
|
410
|
+
} catch (error) {
|
|
411
|
+
run.record?.({
|
|
412
|
+
name: `worktrees.${name}`,
|
|
413
|
+
tool: RUN_WORKTREE_TOOL_NAME,
|
|
414
|
+
args: params,
|
|
415
|
+
ok: false,
|
|
416
|
+
ms: Date.now() - started,
|
|
417
|
+
error: error instanceof Error ? error.message : String(error)
|
|
418
|
+
});
|
|
419
|
+
throw error;
|
|
420
|
+
}
|
|
421
|
+
};
|
|
422
|
+
const record = (result) => view(result.details.records[0]);
|
|
423
|
+
const members = Object.create(null);
|
|
424
|
+
members.list = async () => json((await act("list", "list", {})).details.records.map(view));
|
|
425
|
+
members.status = async (id) => {
|
|
426
|
+
const { details } = await act("status", "status", { id });
|
|
427
|
+
return json({
|
|
428
|
+
worktree: view(details.records[0]),
|
|
429
|
+
dirtyFiles: details.dirtyFiles ?? []
|
|
430
|
+
});
|
|
431
|
+
};
|
|
432
|
+
members.spawn = async (request) => json(record(await act("spawn", "spawn_worktree", request)));
|
|
433
|
+
members.close = async (id, settings) => json(record(await act("close", "close_worktree", {
|
|
434
|
+
...settings,
|
|
435
|
+
id
|
|
436
|
+
})));
|
|
437
|
+
members.merge = async (id, settings) => json(record(await act("merge", "merge", {
|
|
438
|
+
...settings,
|
|
439
|
+
id
|
|
440
|
+
})));
|
|
441
|
+
members.prune = async (settings) => {
|
|
442
|
+
const { plan } = (await act("prune", "prune", settings)).details;
|
|
443
|
+
return json({
|
|
444
|
+
...plan,
|
|
445
|
+
remove: plan.remove.map(view),
|
|
446
|
+
forget: plan.forget.map(view),
|
|
447
|
+
keptDirty: plan.keptDirty.map(view)
|
|
448
|
+
});
|
|
449
|
+
};
|
|
450
|
+
members.send = async (id, message) => {
|
|
451
|
+
await act("send", "send", {
|
|
452
|
+
id,
|
|
453
|
+
message
|
|
454
|
+
});
|
|
455
|
+
};
|
|
456
|
+
members.messages = async (id) => json((await act("messages", "messages", { id })).details.messages);
|
|
457
|
+
return members;
|
|
458
|
+
}
|
|
459
|
+
};
|
|
460
|
+
}
|
|
461
|
+
//#endregion
|
|
295
462
|
//#region src/extensions/workspaces/sessions/(backend)/_lib/root.server.ts
|
|
296
463
|
function progressResult(action, label) {
|
|
297
464
|
return {
|
|
@@ -302,9 +469,9 @@ function progressResult(action, label) {
|
|
|
302
469
|
details: { action }
|
|
303
470
|
};
|
|
304
471
|
}
|
|
305
|
-
function createGitSession({ context, host: serverHost }) {
|
|
306
|
-
if (serverHost.context.directEvents === void 0) throw new Error("Git
|
|
307
|
-
if (serverHost.context.sessionId === void 0) throw new Error("Git
|
|
472
|
+
function createGitSession({ context, host: serverHost, agent, signal }) {
|
|
473
|
+
if (serverHost.context.directEvents === void 0) throw new Error("Git server plugin requires the session direct event bus.");
|
|
474
|
+
if (serverHost.context.sessionId === void 0) throw new Error("Git server plugin requires a session identity.");
|
|
308
475
|
const sessionId = serverHost.context.sessionId;
|
|
309
476
|
const directEvents = serverHost.context.directEvents;
|
|
310
477
|
const messageInbox = createWorktreeMessageInbox(directEvents, sessionId);
|
|
@@ -315,20 +482,12 @@ function createGitSession({ context, host: serverHost }) {
|
|
|
315
482
|
sessionDelivery: () => readPoSessionDelivery(context)
|
|
316
483
|
});
|
|
317
484
|
const resources = [{
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
attribution: {
|
|
321
|
-
kind: "minor",
|
|
322
|
-
mode: "help"
|
|
323
|
-
}
|
|
324
|
-
},
|
|
325
|
-
name: "po-git",
|
|
326
|
-
kind: "context",
|
|
327
|
-
read: () => readPackageResource(import.meta.url, "llms.txt")
|
|
328
|
-
}, {
|
|
329
|
-
name: "po-use-git",
|
|
485
|
+
name: "codemode-how-to-git",
|
|
486
|
+
description: "Hand a branch of work to a separate session in its own checkout, keeping the current checkout untouched. Use for a long refactor, a risky migration, or parallel features that touch the same files; not for parallel work on one branch.",
|
|
330
487
|
kind: "skill",
|
|
331
|
-
|
|
488
|
+
pinned: true,
|
|
489
|
+
path: packageResourcePath(import.meta.url, "src/prompts/codemode-how-to-git/SKILL.md"),
|
|
490
|
+
read: () => readPackageResource(import.meta.url, "src/prompts/codemode-how-to-git/SKILL.md")
|
|
332
491
|
}];
|
|
333
492
|
const tool = {
|
|
334
493
|
name: RUN_WORKTREE_TOOL_NAME,
|
|
@@ -348,17 +507,37 @@ function createGitSession({ context, host: serverHost }) {
|
|
|
348
507
|
const changed = (_event, execution) => {
|
|
349
508
|
directEvents.publish(GIT_SESSION_CHANGED_EVENT, execution.sessionId, { version: 1 });
|
|
350
509
|
};
|
|
510
|
+
const hooks = [{
|
|
511
|
+
event: "tool_execution_end",
|
|
512
|
+
handle: changed
|
|
513
|
+
}, {
|
|
514
|
+
event: "agent_settled",
|
|
515
|
+
handle: changed
|
|
516
|
+
}];
|
|
517
|
+
if (agent) {
|
|
518
|
+
const namespace = createWorktreeNamespace({
|
|
519
|
+
operations,
|
|
520
|
+
execution: agent.context,
|
|
521
|
+
assertAvailable: () => {
|
|
522
|
+
if (signal?.aborted) throw new Error("Git session is unavailable.");
|
|
523
|
+
}
|
|
524
|
+
});
|
|
525
|
+
context.inject([PO_CODEMODE_SERVICE], (scoped) => {
|
|
526
|
+
const codemode = readPoCodemode(scoped);
|
|
527
|
+
if (!codemode) return void 0;
|
|
528
|
+
const registration = codemode.register("@agimon-ai/po-git", namespace);
|
|
529
|
+
const restriction = agent.registerToolRestriction({ excludedTools: [RUN_WORKTREE_TOOL_NAME] });
|
|
530
|
+
return () => {
|
|
531
|
+
restriction.dispose();
|
|
532
|
+
registration.dispose();
|
|
533
|
+
};
|
|
534
|
+
});
|
|
535
|
+
}
|
|
351
536
|
return {
|
|
352
537
|
value: {
|
|
353
538
|
resources,
|
|
354
539
|
tool,
|
|
355
|
-
hooks
|
|
356
|
-
event: "tool_execution_end",
|
|
357
|
-
handle: changed
|
|
358
|
-
}, {
|
|
359
|
-
event: "agent_settled",
|
|
360
|
-
handle: changed
|
|
361
|
-
}]
|
|
540
|
+
hooks
|
|
362
541
|
},
|
|
363
542
|
onDispose: () => messageInbox.close()
|
|
364
543
|
};
|
|
@@ -469,11 +648,11 @@ const NAME_STATUS = {
|
|
|
469
648
|
T: "modified"
|
|
470
649
|
};
|
|
471
650
|
/** Line count of a small regular file, or undefined when it is binary, large, or not a regular file. */
|
|
472
|
-
function untrackedLines(root, relPath) {
|
|
651
|
+
async function untrackedLines(root, relPath) {
|
|
473
652
|
const absolute = path.join(root, relPath);
|
|
474
653
|
let stat;
|
|
475
654
|
try {
|
|
476
|
-
stat = fs.
|
|
655
|
+
stat = await fs.lstat(absolute);
|
|
477
656
|
} catch {
|
|
478
657
|
return;
|
|
479
658
|
}
|
|
@@ -482,7 +661,7 @@ function untrackedLines(root, relPath) {
|
|
|
482
661
|
binary: false
|
|
483
662
|
};
|
|
484
663
|
if (!stat.isFile() || stat.size > MAX_FILE_BYTES) return void 0;
|
|
485
|
-
const content = fs.
|
|
664
|
+
const content = await fs.readFile(absolute);
|
|
486
665
|
if (content.subarray(0, BINARY_PROBE_BYTES).includes(0)) return {
|
|
487
666
|
lines: 0,
|
|
488
667
|
binary: true
|
|
@@ -605,8 +784,8 @@ function createBranchDiff() {
|
|
|
605
784
|
"--exclude-standard",
|
|
606
785
|
"-z"
|
|
607
786
|
]));
|
|
608
|
-
untracked.
|
|
609
|
-
const counted = index < MAX_UNTRACKED_COUNTED ? untrackedLines(root, relPath) : void 0;
|
|
787
|
+
for (const [index, relPath] of untracked.entries()) {
|
|
788
|
+
const counted = index < MAX_UNTRACKED_COUNTED ? await untrackedLines(root, relPath) : void 0;
|
|
610
789
|
entries.push({
|
|
611
790
|
path: relPath,
|
|
612
791
|
status: "untracked",
|
|
@@ -614,18 +793,20 @@ function createBranchDiff() {
|
|
|
614
793
|
removed: 0,
|
|
615
794
|
...counted?.binary ? { binary: true } : {}
|
|
616
795
|
});
|
|
617
|
-
}
|
|
796
|
+
}
|
|
618
797
|
entries.sort((left, right) => left.path.localeCompare(right.path));
|
|
619
798
|
const conflicts = [...unmerged];
|
|
620
|
-
const
|
|
799
|
+
const rebaseMerge = await readLine$1(root, [
|
|
621
800
|
"rev-parse",
|
|
622
801
|
"--git-path",
|
|
623
802
|
"rebase-merge"
|
|
624
|
-
])
|
|
803
|
+
]);
|
|
804
|
+
const rebaseApply = await readLine$1(root, [
|
|
625
805
|
"rev-parse",
|
|
626
806
|
"--git-path",
|
|
627
807
|
"rebase-apply"
|
|
628
|
-
])
|
|
808
|
+
]);
|
|
809
|
+
const rebasing = (await Promise.all([rebaseMerge, rebaseApply].map(async (gitPath) => gitPath === void 0 ? false : fs.access(path.resolve(root, gitPath)).then(() => true, () => false)))).some(Boolean);
|
|
629
810
|
const upstreamRef = branch === void 0 ? void 0 : await upstreamOf(root, branch);
|
|
630
811
|
let upstream;
|
|
631
812
|
if (upstreamRef !== void 0) {
|
|
@@ -670,7 +851,7 @@ function createBranchDiff() {
|
|
|
670
851
|
if (entry === void 0) throw new Error("That path is not in this review.");
|
|
671
852
|
if (entry.status === "untracked") {
|
|
672
853
|
const absolute = path.join(review.root, entry.path);
|
|
673
|
-
const stat = fs.
|
|
854
|
+
const stat = await fs.lstat(absolute);
|
|
674
855
|
if (stat.isSymbolicLink()) return {
|
|
675
856
|
path: entry.path,
|
|
676
857
|
hunks: [{
|
|
@@ -678,7 +859,7 @@ function createBranchDiff() {
|
|
|
678
859
|
rows: [{
|
|
679
860
|
marker: "+",
|
|
680
861
|
line: 1,
|
|
681
|
-
content: fs.
|
|
862
|
+
content: await fs.readlink(absolute)
|
|
682
863
|
}]
|
|
683
864
|
}]
|
|
684
865
|
};
|
|
@@ -687,7 +868,7 @@ function createBranchDiff() {
|
|
|
687
868
|
hunks: [],
|
|
688
869
|
tooLarge: true
|
|
689
870
|
};
|
|
690
|
-
const content = fs.
|
|
871
|
+
const content = await fs.readFile(absolute);
|
|
691
872
|
if (content.subarray(0, BINARY_PROBE_BYTES).includes(0)) return {
|
|
692
873
|
path: entry.path,
|
|
693
874
|
hunks: [],
|
|
@@ -804,9 +985,9 @@ function isRecord(value) {
|
|
|
804
985
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
805
986
|
}
|
|
806
987
|
/** The workspace key: the canonical root, so a symlinked path and its target share one entry. */
|
|
807
|
-
function workspaceKey(workspaceRoot) {
|
|
988
|
+
async function workspaceKey(workspaceRoot) {
|
|
808
989
|
try {
|
|
809
|
-
return fs.
|
|
990
|
+
return await fs.realpath(workspaceRoot);
|
|
810
991
|
} catch {
|
|
811
992
|
return path.resolve(workspaceRoot);
|
|
812
993
|
}
|
|
@@ -855,7 +1036,7 @@ function publicView(config) {
|
|
|
855
1036
|
* the saved one, and only for the same host: a token never follows a host it
|
|
856
1037
|
* was not entered for.
|
|
857
1038
|
*/
|
|
858
|
-
function parseGitAuthSave(request, current, homeDir) {
|
|
1039
|
+
async function parseGitAuthSave(request, current, homeDir) {
|
|
859
1040
|
if (!isRecord(request)) throw invalidRequest("The git auth request is not an object.", "Send method none, ssh or https.");
|
|
860
1041
|
if (request.method === "none") return { method: "none" };
|
|
861
1042
|
if (request.method === "ssh") {
|
|
@@ -865,7 +1046,7 @@ function parseGitAuthSave(request, current, homeDir) {
|
|
|
865
1046
|
if (!keyPath.startsWith("/") && !keyPath.startsWith("~/")) throw invalidRequest("The key path is not absolute.", "Give the key as an absolute path or one under ~/.");
|
|
866
1047
|
let isFile = false;
|
|
867
1048
|
try {
|
|
868
|
-
isFile = fs.
|
|
1049
|
+
isFile = (await fs.stat(expandHome(keyPath, homeDir))).isFile();
|
|
869
1050
|
} catch {
|
|
870
1051
|
isFile = false;
|
|
871
1052
|
}
|
|
@@ -900,16 +1081,16 @@ function parseGitAuthSave(request, current, homeDir) {
|
|
|
900
1081
|
throw invalidRequest("The git auth method is unknown.", "Send method none, ssh or https.");
|
|
901
1082
|
}
|
|
902
1083
|
/** Tightens a file or folder something left readable by group or others. */
|
|
903
|
-
function tighten(target, mode) {
|
|
1084
|
+
async function tighten(target, mode) {
|
|
904
1085
|
try {
|
|
905
|
-
if ((fs.
|
|
1086
|
+
if (((await fs.stat(target)).mode & LOOSE_BITS) !== 0) await fs.chmod(target, mode);
|
|
906
1087
|
} catch {}
|
|
907
1088
|
}
|
|
908
1089
|
function createGitAuthStore(homeDir = os.homedir()) {
|
|
909
1090
|
const file = credentialsFile(homeDir);
|
|
910
|
-
const readFile = () => {
|
|
911
|
-
tighten(file, OWNER_ONLY_FILE);
|
|
912
|
-
const raw = readJson(file);
|
|
1091
|
+
const readFile = async () => {
|
|
1092
|
+
await tighten(file, OWNER_ONLY_FILE);
|
|
1093
|
+
const raw = await readJson(file);
|
|
913
1094
|
const workspaces = {};
|
|
914
1095
|
if (isRecord(raw) && raw.version === 1 && isRecord(raw.workspaces)) for (const [key, value] of Object.entries(raw.workspaces)) {
|
|
915
1096
|
const config = parseConfig(value);
|
|
@@ -920,28 +1101,28 @@ function createGitAuthStore(homeDir = os.homedir()) {
|
|
|
920
1101
|
workspaces
|
|
921
1102
|
};
|
|
922
1103
|
};
|
|
923
|
-
const read = (workspaceRoot) => readFile().workspaces[workspaceKey(workspaceRoot)] ?? { method: "none" };
|
|
1104
|
+
const read = async (workspaceRoot) => (await readFile()).workspaces[await workspaceKey(workspaceRoot)] ?? { method: "none" };
|
|
924
1105
|
return {
|
|
925
1106
|
read,
|
|
926
|
-
view: (workspaceRoot) => publicView(read(workspaceRoot)),
|
|
927
|
-
save(workspaceRoot, request) {
|
|
928
|
-
const key = workspaceKey(workspaceRoot);
|
|
929
|
-
const current = readFile();
|
|
930
|
-
const next = parseGitAuthSave(request, current.workspaces[key] ?? { method: "none" }, homeDir);
|
|
1107
|
+
view: async (workspaceRoot) => publicView(await read(workspaceRoot)),
|
|
1108
|
+
async save(workspaceRoot, request) {
|
|
1109
|
+
const key = await workspaceKey(workspaceRoot);
|
|
1110
|
+
const current = await readFile();
|
|
1111
|
+
const next = await parseGitAuthSave(request, current.workspaces[key] ?? { method: "none" }, homeDir);
|
|
931
1112
|
const root = poGitRoot(homeDir);
|
|
932
|
-
fs.
|
|
1113
|
+
await fs.mkdir(root, {
|
|
933
1114
|
recursive: true,
|
|
934
1115
|
mode: OWNER_ONLY_DIRECTORY
|
|
935
1116
|
});
|
|
936
|
-
fs.
|
|
1117
|
+
await fs.chmod(root, OWNER_ONLY_DIRECTORY);
|
|
937
1118
|
const workspaces = { ...current.workspaces };
|
|
938
1119
|
if (next.method === "none") delete workspaces[key];
|
|
939
1120
|
else workspaces[key] = next;
|
|
940
|
-
writeJsonAtomic(file, {
|
|
1121
|
+
await writeJsonAtomic(file, {
|
|
941
1122
|
version: 1,
|
|
942
1123
|
workspaces
|
|
943
1124
|
});
|
|
944
|
-
fs.
|
|
1125
|
+
await fs.chmod(file, OWNER_ONLY_FILE);
|
|
945
1126
|
return publicView(next);
|
|
946
1127
|
}
|
|
947
1128
|
};
|
|
@@ -1106,177 +1287,236 @@ function failed(error) {
|
|
|
1106
1287
|
}, { status: error.retryable ? 503 : 409 });
|
|
1107
1288
|
return Response.json({ error: "The worktree operation failed." }, { status: 500 });
|
|
1108
1289
|
}
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1290
|
+
function createGitApi(openDirectory) {
|
|
1291
|
+
return {
|
|
1292
|
+
basePath: "git",
|
|
1293
|
+
start(context) {
|
|
1294
|
+
if (context.directEvents === void 0) throw new Error("Git hub API requires the direct event bus.");
|
|
1295
|
+
const directEvents = context.directEvents;
|
|
1296
|
+
const publishLifecycle = (sessionId, repositoryRoot) => {
|
|
1297
|
+
directEvents.publish(GIT_WORKTREE_LIFECYCLE_EVENT, sessionId, {
|
|
1298
|
+
version: 1,
|
|
1299
|
+
repositoryRoot
|
|
1300
|
+
});
|
|
1301
|
+
};
|
|
1302
|
+
const git = createWorktreeGit();
|
|
1303
|
+
const operations = createWorktreeOperations({
|
|
1304
|
+
git,
|
|
1305
|
+
sessionService: context.sessionService,
|
|
1306
|
+
...context.homeDirectory === void 0 ? {} : { homeDir: context.homeDirectory }
|
|
1120
1307
|
});
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
}
|
|
1146
|
-
|
|
1147
|
-
|
|
1308
|
+
/**
|
|
1309
|
+
* The new-session dialog's routes: the workspace's branches, and a top-level
|
|
1310
|
+
* worktree session on one. Workspace-mounted only, and always rooted at the
|
|
1311
|
+
* mount's own workspace, so a request can never name another directory.
|
|
1312
|
+
*/
|
|
1313
|
+
const newSession = async (request, url) => {
|
|
1314
|
+
const isBranches = request.method === "GET" && url.pathname === "/branches";
|
|
1315
|
+
const isCreate = request.method === "POST" && url.pathname === "/sessions";
|
|
1316
|
+
const isDelete = request.method === "DELETE" && url.pathname === "/branches";
|
|
1317
|
+
const isOpen = request.method === "POST" && url.pathname === `/branches/open`;
|
|
1318
|
+
if (!isBranches && !isCreate && !isDelete && !isOpen) return void 0;
|
|
1319
|
+
const workspaceRoot = context.scope === "workspace" ? context.workspaceRoot : void 0;
|
|
1320
|
+
if (workspaceRoot === void 0) return Response.json({ error: "This route belongs to a workspace." }, { status: 404 });
|
|
1321
|
+
if (isBranches) {
|
|
1322
|
+
const repositoryRoot = await git.repositoryRoot(workspaceRoot);
|
|
1323
|
+
const branches = repositoryRoot === void 0 ? {
|
|
1324
|
+
repository: false,
|
|
1325
|
+
local: [],
|
|
1326
|
+
remote: []
|
|
1327
|
+
} : {
|
|
1328
|
+
repository: true,
|
|
1329
|
+
...await git.listBranches(repositoryRoot)
|
|
1330
|
+
};
|
|
1331
|
+
return Response.json(branches);
|
|
1332
|
+
}
|
|
1333
|
+
if (isDelete || isOpen) {
|
|
1334
|
+
if (isOpen && poApiCallerFrom(request.headers)?.locality !== "local") return Response.json({ error: "Opening Finder is only available on the host." }, { status: 403 });
|
|
1335
|
+
const body = await request.json().catch(() => void 0);
|
|
1336
|
+
const branch = typeof body === "object" && body !== null ? body.branch : void 0;
|
|
1337
|
+
if (typeof branch !== "string" || !validRefName(branch)) return badRequest("A valid branch name is required.");
|
|
1338
|
+
const repositoryRoot = await git.repositoryRoot(workspaceRoot);
|
|
1339
|
+
if (repositoryRoot === void 0) return badRequest("This workspace is not a git checkout.");
|
|
1340
|
+
const branches = await git.listBranches(repositoryRoot);
|
|
1341
|
+
const local = branches.local.find((item) => item.name === branch);
|
|
1342
|
+
if (local === void 0) return Response.json({ error: "That local branch no longer exists." }, { status: 404 });
|
|
1343
|
+
if (isOpen) {
|
|
1344
|
+
if (local.checkedOutAt === void 0) return Response.json({ error: "This branch has no checked-out folder." }, { status: 409 });
|
|
1345
|
+
if (openDirectory === void 0) return Response.json({ error: "Finder is not available on this host." }, { status: 409 });
|
|
1346
|
+
try {
|
|
1347
|
+
await openDirectory(local.checkedOutAt);
|
|
1348
|
+
return Response.json({ opened: true });
|
|
1349
|
+
} catch {
|
|
1350
|
+
return Response.json({ error: "The branch folder could not be opened in Finder." }, { status: 409 });
|
|
1351
|
+
}
|
|
1352
|
+
}
|
|
1353
|
+
if (local.checkedOutAt !== void 0 || branches.current === branch) return Response.json({ error: "Close this branch’s worktree before deleting it." }, { status: 409 });
|
|
1354
|
+
if (!await git.deleteBranch({
|
|
1355
|
+
repositoryRoot,
|
|
1356
|
+
branch
|
|
1357
|
+
})) return Response.json({ error: "Git refused to delete this branch. It may contain unmerged commits or be checked out." }, { status: 409 });
|
|
1358
|
+
return Response.json({ deleted: true });
|
|
1359
|
+
}
|
|
1360
|
+
const parsed = newSessionRequest(await request.json().catch(() => void 0));
|
|
1361
|
+
if ("error" in parsed) return badRequest(parsed.error);
|
|
1362
|
+
const record = await operations.spawn({ cwd: workspaceRoot }, {
|
|
1363
|
+
branch: parsed.branch,
|
|
1364
|
+
checkout: parsed.mode === "existing-branch" ? "existing" : "new",
|
|
1365
|
+
...parsed.mode === "existing-branch" && parsed.remote !== void 0 ? { remote: parsed.remote } : {},
|
|
1366
|
+
...parsed.mode === "new-branch" && parsed.baseRef !== void 0 ? { baseRef: parsed.baseRef } : {},
|
|
1367
|
+
...parsed.name === void 0 ? {} : { name: parsed.name }
|
|
1368
|
+
});
|
|
1369
|
+
publishLifecycle(record.sessionId, record.repositoryRoot);
|
|
1370
|
+
const created = {
|
|
1371
|
+
sessionId: record.sessionId,
|
|
1372
|
+
worktreeId: record.id
|
|
1148
1373
|
};
|
|
1149
|
-
return Response.json(
|
|
1150
|
-
}
|
|
1151
|
-
const parsed = newSessionRequest(await request.json().catch(() => void 0));
|
|
1152
|
-
if ("error" in parsed) return badRequest(parsed.error);
|
|
1153
|
-
const record = await operations.spawn({ cwd: workspaceRoot }, {
|
|
1154
|
-
branch: parsed.branch,
|
|
1155
|
-
checkout: parsed.mode === "existing-branch" ? "existing" : "new",
|
|
1156
|
-
...parsed.mode === "existing-branch" && parsed.remote !== void 0 ? { remote: parsed.remote } : {},
|
|
1157
|
-
...parsed.mode === "new-branch" && parsed.baseRef !== void 0 ? { baseRef: parsed.baseRef } : {},
|
|
1158
|
-
...parsed.name === void 0 ? {} : { name: parsed.name }
|
|
1159
|
-
});
|
|
1160
|
-
publishLifecycle(record.sessionId, record.repositoryRoot);
|
|
1161
|
-
const created = {
|
|
1162
|
-
sessionId: record.sessionId,
|
|
1163
|
-
worktreeId: record.id
|
|
1374
|
+
return Response.json(created, { status: 201 });
|
|
1164
1375
|
};
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
if (request.method === "GET") return Response.json(gitAuth.view(root));
|
|
1191
|
-
try {
|
|
1192
|
-
return Response.json(gitAuth.save(root, await request.json().catch(() => void 0)));
|
|
1193
|
-
} catch (error) {
|
|
1194
|
-
if (error instanceof PoGitExpectedError && error.code === "invalid_request") {
|
|
1195
|
-
const text = error.message.replace(/^\[[a-z_]+\] /u, "").replace("\nRecovery: ", " ");
|
|
1196
|
-
return Response.json({
|
|
1197
|
-
error: text,
|
|
1198
|
-
code: error.code
|
|
1199
|
-
}, { status: 400 });
|
|
1200
|
-
}
|
|
1201
|
-
throw error;
|
|
1202
|
-
}
|
|
1203
|
-
};
|
|
1204
|
-
/**
|
|
1205
|
-
* The session's change against its base. Session mounts only, rooted at the
|
|
1206
|
-
* session's own checkout; a file is served only when it is in the change set
|
|
1207
|
-
* computed for the request. Overlapping requests share an in-flight snapshot.
|
|
1208
|
-
*/
|
|
1209
|
-
const review = async (request, url) => {
|
|
1210
|
-
if (url.pathname !== "/review" && url.pathname !== "/review/file") return void 0;
|
|
1211
|
-
const cwd = context.scope === "session" ? context.cwd : void 0;
|
|
1212
|
-
if (cwd === void 0) return Response.json({ error: "This route belongs to a session." }, { status: 404 });
|
|
1213
|
-
if (request.method !== "GET") return Response.json({ error: "Use GET." }, { status: 405 });
|
|
1214
|
-
let recordedBaseRef;
|
|
1215
|
-
try {
|
|
1216
|
-
recordedBaseRef = createWorktreeRegistry(registryFile(cwd, homeDir)).list().find((record) => record.sessionId === context.sessionId)?.baseRef;
|
|
1217
|
-
} catch {
|
|
1218
|
-
recordedBaseRef = void 0;
|
|
1219
|
-
}
|
|
1220
|
-
const key = JSON.stringify([cwd, recordedBaseRef ?? null]);
|
|
1221
|
-
let pending = pendingReviews.get(key);
|
|
1222
|
-
if (pending === void 0) {
|
|
1223
|
-
pending = branchDiff.review(cwd, recordedBaseRef === void 0 ? {} : { recordedBaseRef }).finally(() => pendingReviews.delete(key));
|
|
1224
|
-
pendingReviews.set(key, pending);
|
|
1225
|
-
}
|
|
1226
|
-
const result = await pending;
|
|
1227
|
-
if (url.pathname === "/review") return Response.json(result?.summary ?? {
|
|
1228
|
-
repository: false,
|
|
1229
|
-
files: []
|
|
1230
|
-
});
|
|
1231
|
-
const filePath = url.searchParams.get("path") ?? "";
|
|
1232
|
-
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 });
|
|
1233
|
-
return Response.json(await branchDiff.fileDiff(result, filePath));
|
|
1234
|
-
};
|
|
1235
|
-
return {
|
|
1236
|
-
async fetch(request) {
|
|
1237
|
-
const url = new URL(request.url);
|
|
1376
|
+
/** The admitted root for a request, or a response explaining why not. */
|
|
1377
|
+
const rootOf = (url) => {
|
|
1378
|
+
const repositoryId = url.searchParams.get("repositoryId");
|
|
1379
|
+
if (repositoryId === null || repositoryId === "") return badRequest("A repositoryId is required.");
|
|
1380
|
+
const root = context.resolveRepository?.(repositoryId);
|
|
1381
|
+
if (root === void 0) return badRequest("That repository is not admitted to this cockpit.");
|
|
1382
|
+
return root;
|
|
1383
|
+
};
|
|
1384
|
+
const homeDir = context.homeDirectory ?? context.environment?.HOME;
|
|
1385
|
+
const gitAuth = homeDir === void 0 ? void 0 : createGitAuthStore(homeDir);
|
|
1386
|
+
const branchDiff = createBranchDiff();
|
|
1387
|
+
const pendingReviews = /* @__PURE__ */ new Map();
|
|
1388
|
+
/**
|
|
1389
|
+
* A workspace's remote auth. Workspace mounts only, so the step-up gate on
|
|
1390
|
+
* `PUT /api/workspaces/<id>/plugins/git/auth` is the one way to write it.
|
|
1391
|
+
* The view never carries the token.
|
|
1392
|
+
*/
|
|
1393
|
+
const auth = async (request, url) => {
|
|
1394
|
+
if (url.pathname !== "/auth") return void 0;
|
|
1395
|
+
if (context.scope !== "workspace") return Response.json({ error: "This route belongs to a workspace." }, { status: 404 });
|
|
1396
|
+
if (request.method !== "GET" && request.method !== "PUT") return Response.json({ error: "Use GET or PUT." }, { status: 405 });
|
|
1397
|
+
if (gitAuth === void 0) throw new Error("Git auth requires the admitted host home directory.");
|
|
1398
|
+
const root = rootOf(url);
|
|
1399
|
+
if (root instanceof Response) return root;
|
|
1400
|
+
if (request.method === "GET") return Response.json(await gitAuth.view(root));
|
|
1238
1401
|
try {
|
|
1239
|
-
|
|
1240
|
-
if (answered !== void 0) return answered;
|
|
1402
|
+
return Response.json(await gitAuth.save(root, await request.json().catch(() => void 0)));
|
|
1241
1403
|
} catch (error) {
|
|
1242
|
-
|
|
1243
|
-
|
|
1404
|
+
if (error instanceof PoGitExpectedError && error.code === "invalid_request") {
|
|
1405
|
+
const text = error.message.replace(/^\[[a-z_]+\] /u, "").replace("\nRecovery: ", " ");
|
|
1406
|
+
return Response.json({
|
|
1407
|
+
error: text,
|
|
1408
|
+
code: error.code
|
|
1409
|
+
}, { status: 400 });
|
|
1410
|
+
}
|
|
1411
|
+
throw error;
|
|
1244
1412
|
}
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1413
|
+
};
|
|
1414
|
+
/**
|
|
1415
|
+
* The session's change against its base. Session mounts only, rooted at the
|
|
1416
|
+
* session's own checkout; a file is served only when it is in the change set
|
|
1417
|
+
* computed for the request. Overlapping requests share an in-flight snapshot.
|
|
1418
|
+
*/
|
|
1419
|
+
const review = async (request, url) => {
|
|
1420
|
+
if (url.pathname !== "/review" && url.pathname !== "/review/file") return void 0;
|
|
1421
|
+
const cwd = context.scope === "session" ? context.cwd : void 0;
|
|
1422
|
+
if (cwd === void 0) return Response.json({ error: "This route belongs to a session." }, { status: 404 });
|
|
1423
|
+
if (request.method !== "GET") return Response.json({ error: "Use GET." }, { status: 405 });
|
|
1424
|
+
if (homeDir === void 0) throw new Error("Git review requires the admitted host home directory.");
|
|
1425
|
+
let recordedBaseRef;
|
|
1252
1426
|
try {
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1427
|
+
recordedBaseRef = (await createWorktreeRegistry(await registryFile(cwd, homeDir)).list()).find((record) => record.sessionId === context.sessionId)?.baseRef;
|
|
1428
|
+
} catch {
|
|
1429
|
+
recordedBaseRef = void 0;
|
|
1430
|
+
}
|
|
1431
|
+
const key = JSON.stringify([cwd, recordedBaseRef ?? null]);
|
|
1432
|
+
let pending = pendingReviews.get(key);
|
|
1433
|
+
if (pending === void 0) {
|
|
1434
|
+
pending = branchDiff.review(cwd, recordedBaseRef === void 0 ? {} : { recordedBaseRef }).finally(() => pendingReviews.delete(key));
|
|
1435
|
+
pendingReviews.set(key, pending);
|
|
1436
|
+
}
|
|
1437
|
+
const result = await pending;
|
|
1438
|
+
if (url.pathname === "/review") return Response.json(result?.summary ?? {
|
|
1439
|
+
repository: false,
|
|
1440
|
+
files: []
|
|
1441
|
+
});
|
|
1442
|
+
const filePath = url.searchParams.get("path") ?? "";
|
|
1443
|
+
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 });
|
|
1444
|
+
return Response.json(await branchDiff.fileDiff(result, filePath));
|
|
1445
|
+
};
|
|
1446
|
+
return {
|
|
1447
|
+
async fetch(request) {
|
|
1448
|
+
const url = new URL(request.url);
|
|
1449
|
+
try {
|
|
1450
|
+
const answered = await newSession(request, url) ?? await auth(request, url) ?? await review(request, url);
|
|
1451
|
+
if (answered !== void 0) return answered;
|
|
1452
|
+
} catch (error) {
|
|
1453
|
+
context.onNotice(`git request failed: ${error.name}`);
|
|
1454
|
+
return failed(error);
|
|
1264
1455
|
}
|
|
1265
|
-
const
|
|
1266
|
-
if (
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1456
|
+
const root = rootOf(url);
|
|
1457
|
+
if (root instanceof Response) return root;
|
|
1458
|
+
const callerContext = {
|
|
1459
|
+
cwd: root,
|
|
1460
|
+
sessionId: url.searchParams.get("sessionId") ?? ""
|
|
1461
|
+
};
|
|
1462
|
+
if ((request.method === "POST" && url.pathname === "/worktrees" || request.method === "DELETE" && /^\/worktrees\/[^/]+$/u.test(url.pathname)) && callerContext.sessionId === "") return badRequest("A sessionId is required for worktree mutations.");
|
|
1463
|
+
try {
|
|
1464
|
+
if (request.method === "GET" && url.pathname === "/worktrees") return Response.json({ worktrees: await operations.list(callerContext) });
|
|
1465
|
+
if (request.method === "POST" && url.pathname === "/worktrees") {
|
|
1466
|
+
const body = await request.json();
|
|
1467
|
+
if (typeof body.branch !== "string" || body.branch.trim() === "") return badRequest("A branch is required.");
|
|
1468
|
+
const record = await operations.spawn(callerContext, {
|
|
1469
|
+
branch: body.branch,
|
|
1470
|
+
...body.baseRef === void 0 ? {} : { baseRef: body.baseRef },
|
|
1471
|
+
...body.name === void 0 ? {} : { name: body.name }
|
|
1472
|
+
});
|
|
1473
|
+
publishLifecycle(callerContext.sessionId, record.repositoryRoot);
|
|
1474
|
+
return Response.json({ worktree: record }, { status: 201 });
|
|
1475
|
+
}
|
|
1476
|
+
const closing = /^\/worktrees\/([^/]+)$/u.exec(url.pathname);
|
|
1477
|
+
if (request.method === "DELETE" && closing) {
|
|
1478
|
+
const record = await operations.close(callerContext, decodeURIComponent(String(closing[1])), url.searchParams.get("force") === "true");
|
|
1479
|
+
publishLifecycle(callerContext.sessionId, record.repositoryRoot);
|
|
1480
|
+
return Response.json({ worktree: record });
|
|
1481
|
+
}
|
|
1482
|
+
return Response.json({ error: "No such worktree route." }, { status: 404 });
|
|
1483
|
+
} catch (error) {
|
|
1484
|
+
context.onNotice(`worktree request failed: ${error.name}`);
|
|
1485
|
+
return failed(error);
|
|
1270
1486
|
}
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1487
|
+
},
|
|
1488
|
+
close() {}
|
|
1489
|
+
};
|
|
1490
|
+
}
|
|
1491
|
+
};
|
|
1492
|
+
}
|
|
1493
|
+
//#endregion
|
|
1494
|
+
//#region src/extensions/(backend)/api/git/route.server.ts
|
|
1495
|
+
var route_server_default = defineRoute(({ context, host, signal }) => {
|
|
1496
|
+
let openDirectory;
|
|
1497
|
+
context.inject([PO_SHELL_SERVICE], (child) => {
|
|
1498
|
+
const shell = requirePoShell(child);
|
|
1499
|
+
const env = Object.fromEntries(Object.entries(host.context.environment ?? {}).filter((entry) => entry[1] !== void 0));
|
|
1500
|
+
openDirectory = async (path) => {
|
|
1501
|
+
const result = await shell.exec([
|
|
1502
|
+
"/usr/bin/open",
|
|
1503
|
+
"-a",
|
|
1504
|
+
"Finder",
|
|
1505
|
+
path
|
|
1506
|
+
], {
|
|
1507
|
+
env,
|
|
1508
|
+
inheritEnv: false
|
|
1509
|
+
}, withAbortSignal(AbortSignal.any([signal, AbortSignal.timeout(5e3)]), BACKGROUND_CONTEXT));
|
|
1510
|
+
if (!result.ok || result.value.exitCode !== 0) throw new Error("Finder could not be opened.");
|
|
1278
1511
|
};
|
|
1279
|
-
|
|
1512
|
+
return () => {
|
|
1513
|
+
openDirectory = void 0;
|
|
1514
|
+
};
|
|
1515
|
+
});
|
|
1516
|
+
return createGitApi(async (path) => {
|
|
1517
|
+
if (openDirectory === void 0) throw new Error("The host shell is unavailable.");
|
|
1518
|
+
await openDirectory(path);
|
|
1519
|
+
});
|
|
1280
1520
|
});
|
|
1281
1521
|
//#endregion
|
|
1282
1522
|
//#region src/services/gitFailure/index.ts
|
|
@@ -1413,7 +1653,7 @@ async function rebasing(root) {
|
|
|
1413
1653
|
"--git-path",
|
|
1414
1654
|
name
|
|
1415
1655
|
]);
|
|
1416
|
-
if (gitPath !== void 0 && fs.
|
|
1656
|
+
if (gitPath !== void 0 && await fs.access(path.resolve(root, gitPath)).then(() => true, () => false)) return true;
|
|
1417
1657
|
}
|
|
1418
1658
|
return false;
|
|
1419
1659
|
}
|
|
@@ -1583,9 +1823,9 @@ const GIT_CHANGES_TYPE = "git_changes";
|
|
|
1583
1823
|
* DESIGN PATTERNS:
|
|
1584
1824
|
* - Apart from the worktrees channel. Worktrees are repository management;
|
|
1585
1825
|
* this is the session's own code change.
|
|
1586
|
-
* - Push, not poll. The session
|
|
1587
|
-
* tool call and settled turn; recounts are coalesced to one in
|
|
1588
|
-
* one rerun, so a burst of tool calls costs at most two.
|
|
1826
|
+
* - Push, not poll. The session's server plugin publishes a "changed" event
|
|
1827
|
+
* after every tool call and settled turn; recounts are coalesced to one in
|
|
1828
|
+
* flight plus one rerun, so a burst of tool calls costs at most two.
|
|
1589
1829
|
* - One sync command per session. A rebase and a push must never overlap.
|
|
1590
1830
|
* - Auth is read fresh for each command from the session's workspace, and goes
|
|
1591
1831
|
* only into that command's git child.
|
|
@@ -1646,9 +1886,9 @@ function createGitChangesChannel(options = {}) {
|
|
|
1646
1886
|
const rerun = /* @__PURE__ */ new Set();
|
|
1647
1887
|
const busy = /* @__PURE__ */ new Set();
|
|
1648
1888
|
/** The base the session's own worktree was created from, when it has one. */
|
|
1649
|
-
const recordedBaseRef = (scope) => {
|
|
1889
|
+
const recordedBaseRef = async (scope) => {
|
|
1650
1890
|
try {
|
|
1651
|
-
return createWorktreeRegistry(registryFile(scope.cwd, homeDir)).list().find((record) => record.sessionId === scope.sessionId)?.baseRef;
|
|
1891
|
+
return (await createWorktreeRegistry(await registryFile(scope.cwd, homeDir)).list()).find((record) => record.sessionId === scope.sessionId)?.baseRef;
|
|
1652
1892
|
} catch {
|
|
1653
1893
|
return;
|
|
1654
1894
|
}
|
|
@@ -1681,8 +1921,7 @@ function createGitChangesChannel(options = {}) {
|
|
|
1681
1921
|
return;
|
|
1682
1922
|
}
|
|
1683
1923
|
inFlight.add(sessionId);
|
|
1684
|
-
|
|
1685
|
-
diff.review(scope.cwd, base === void 0 ? {} : { recordedBaseRef: base }).then((review) => {
|
|
1924
|
+
recordedBaseRef(scope).then((base) => diff.review(scope.cwd, base === void 0 ? {} : { recordedBaseRef: base })).then((review) => {
|
|
1686
1925
|
if (scopes.has(sessionId)) update(sessionId, { changes: review?.changes });
|
|
1687
1926
|
}).catch((error) => {
|
|
1688
1927
|
host.onNotice(`git diff refresh failed for ${sessionId} (${error instanceof Error ? error.name : "unknown"})`);
|
|
@@ -1691,12 +1930,12 @@ function createGitChangesChannel(options = {}) {
|
|
|
1691
1930
|
if (rerun.delete(sessionId)) refresh(sessionId);
|
|
1692
1931
|
});
|
|
1693
1932
|
};
|
|
1694
|
-
const run = (scope, command) => {
|
|
1933
|
+
const run = async (scope, command) => {
|
|
1695
1934
|
const workspaceRoot = scope.sessionContext?.workspaceRoot ?? scope.cwd;
|
|
1696
|
-
const base = recordedBaseRef(scope);
|
|
1935
|
+
const base = await recordedBaseRef(scope);
|
|
1697
1936
|
const context = {
|
|
1698
1937
|
cwd: scope.cwd,
|
|
1699
|
-
auth: auth.read(workspaceRoot),
|
|
1938
|
+
auth: await auth.read(workspaceRoot),
|
|
1700
1939
|
...base === void 0 ? {} : { recordedBaseRef: base }
|
|
1701
1940
|
};
|
|
1702
1941
|
if (command.action === "pull") return sync.pull(context);
|
|
@@ -1794,9 +2033,9 @@ function payloadOf(state) {
|
|
|
1794
2033
|
};
|
|
1795
2034
|
}
|
|
1796
2035
|
/** Worktrees visible to this session, using the hub's live session table for ownership. */
|
|
1797
|
-
function viewsFor(scope, deps) {
|
|
2036
|
+
async function viewsFor(scope, deps) {
|
|
1798
2037
|
try {
|
|
1799
|
-
return createWorktreeRegistry(registryFile(scope.cwd, deps.homeDir)).list().flatMap((record) => {
|
|
2038
|
+
return (await createWorktreeRegistry(await registryFile(scope.cwd, deps.homeDir)).list()).flatMap((record) => {
|
|
1800
2039
|
if (record.parentSessionId === void 0 && record.sessionId !== scope.sessionId) return [];
|
|
1801
2040
|
const owned = record.parentSessionId === void 0 || record.parentSessionId === scope.sessionId;
|
|
1802
2041
|
const unowned = !owned && !deps.sessionService.isLive(record.parentSessionId);
|
|
@@ -1821,11 +2060,15 @@ function sameList(left, right) {
|
|
|
1821
2060
|
return other !== void 0 && entry.id === other.id && entry.branch === other.branch && entry.sessionId === other.sessionId && entry.orphaned === other.orphaned && entry.unowned === other.unowned;
|
|
1822
2061
|
});
|
|
1823
2062
|
}
|
|
1824
|
-
function errorTargetFor(command) {
|
|
1825
|
-
|
|
2063
|
+
function errorTargetFor(command, error) {
|
|
2064
|
+
if (command.action === "close") return {
|
|
1826
2065
|
action: "close",
|
|
1827
2066
|
id: command.id
|
|
1828
2067
|
};
|
|
2068
|
+
return error instanceof PoGitExpectedError && error.code === "rift_missing" ? {
|
|
2069
|
+
action: "create",
|
|
2070
|
+
riftMissing: true
|
|
2071
|
+
} : { action: "create" };
|
|
1829
2072
|
}
|
|
1830
2073
|
/** A command the page sent, or undefined when the frame was not one. */
|
|
1831
2074
|
function parseCommand(payload) {
|
|
@@ -1853,11 +2096,15 @@ function parseCommand(payload) {
|
|
|
1853
2096
|
/**
|
|
1854
2097
|
* What the reader is told when a command fails.
|
|
1855
2098
|
*
|
|
1856
|
-
* An expected failure explains itself
|
|
1857
|
-
*
|
|
2099
|
+
* An expected failure explains itself, minus the `[code]` tag and recovery line
|
|
2100
|
+
* written for the agent's tool result. A dirty close is said in the person's
|
|
2101
|
+
* terms, because its message tells the agent to pass `force`. Anything else is
|
|
2102
|
+
* summarised, never echoed: a git error can carry a remote URL with a token in it.
|
|
1858
2103
|
*/
|
|
1859
2104
|
function failureText(error) {
|
|
1860
|
-
|
|
2105
|
+
if (!(error instanceof PoGitExpectedError)) return "The worktree operation failed.";
|
|
2106
|
+
if (error.code === "worktree_dirty") return "The worktree has uncommitted changes.";
|
|
2107
|
+
return error.message.replace(/^\[[a-z_]+\] /u, "").split("\nRecovery:")[0] ?? error.message;
|
|
1861
2108
|
}
|
|
1862
2109
|
/** Starts one direct-event subscription per managed session. */
|
|
1863
2110
|
function createWorktreesChannel(options = {}) {
|
|
@@ -1910,10 +2157,13 @@ function createWorktreesChannel(options = {}) {
|
|
|
1910
2157
|
return generation;
|
|
1911
2158
|
};
|
|
1912
2159
|
/** Republishes a session, skipping a frame that would say nothing new. */
|
|
1913
|
-
const publish = (scope, force) => {
|
|
2160
|
+
const publish = async (scope, force) => {
|
|
2161
|
+
const generation = generations.get(scope.sessionId);
|
|
2162
|
+
const worktreeViews = await viewsFor(scope, views);
|
|
2163
|
+
if (generation === void 0 || generations.get(scope.sessionId) !== generation) return;
|
|
1914
2164
|
const previous = latest.get(scope.sessionId);
|
|
1915
2165
|
const next = {
|
|
1916
|
-
worktrees:
|
|
2166
|
+
worktrees: worktreeViews,
|
|
1917
2167
|
pending: previous?.pending,
|
|
1918
2168
|
error: previous?.error,
|
|
1919
2169
|
errorTarget: previous?.errorTarget
|
|
@@ -1925,7 +2175,7 @@ function createWorktreesChannel(options = {}) {
|
|
|
1925
2175
|
const mark = (scope, pending, error, errorTarget) => {
|
|
1926
2176
|
const previous = latest.get(scope.sessionId);
|
|
1927
2177
|
latest.set(scope.sessionId, {
|
|
1928
|
-
worktrees: previous?.worktrees ??
|
|
2178
|
+
worktrees: previous?.worktrees ?? [],
|
|
1929
2179
|
pending,
|
|
1930
2180
|
error,
|
|
1931
2181
|
errorTarget
|
|
@@ -1965,15 +2215,15 @@ function createWorktreesChannel(options = {}) {
|
|
|
1965
2215
|
sessionAdded(scope) {
|
|
1966
2216
|
newGeneration(scope.sessionId);
|
|
1967
2217
|
publish(scope, false);
|
|
1968
|
-
const unsubscribe = host.directEvents.subscribe(GIT_WORKTREE_LIFECYCLE_EVENT, scope.sessionId, (payload) => {
|
|
2218
|
+
const unsubscribe = host.directEvents.subscribe(GIT_WORKTREE_LIFECYCLE_EVENT, scope.sessionId, async (payload) => {
|
|
1969
2219
|
if (!isWorktreeLifecycleEvent(payload)) return;
|
|
1970
2220
|
let sameRepository = false;
|
|
1971
2221
|
try {
|
|
1972
|
-
sameRepository = registryFile(scope.cwd, options.homeDir) === registryFile(payload.repositoryRoot, options.homeDir);
|
|
2222
|
+
sameRepository = await registryFile(scope.cwd, options.homeDir) === await registryFile(payload.repositoryRoot, options.homeDir);
|
|
1973
2223
|
} catch {
|
|
1974
2224
|
sameRepository = false;
|
|
1975
2225
|
}
|
|
1976
|
-
if (sameRepository) publish(scope, false);
|
|
2226
|
+
if (sameRepository) await publish(scope, false);
|
|
1977
2227
|
});
|
|
1978
2228
|
subscriptions.set(scope.sessionId, unsubscribe);
|
|
1979
2229
|
},
|
|
@@ -2006,7 +2256,7 @@ function createWorktreesChannel(options = {}) {
|
|
|
2006
2256
|
}).catch((error) => {
|
|
2007
2257
|
if (generations.get(scope.sessionId) !== generation) return;
|
|
2008
2258
|
host.onNotice(`worktree command failed for ${scope.sessionId} (${failureText(error)})`);
|
|
2009
|
-
mark(scope, void 0, failureText(error), errorTargetFor(command));
|
|
2259
|
+
mark(scope, void 0, failureText(error), errorTargetFor(command, error));
|
|
2010
2260
|
}).finally(() => {
|
|
2011
2261
|
if (generations.get(scope.sessionId) === generation) busy.delete(scope.sessionId);
|
|
2012
2262
|
});
|
|
@@ -2028,20 +2278,8 @@ var agent_settled_server_default = defineHook((context) => context.root.hooks[1]
|
|
|
2028
2278
|
//#region src/extensions/workspaces/sessions/(backend)/hook/tool-execution-end.server.ts
|
|
2029
2279
|
var tool_execution_end_server_default = defineHook((context) => context.root.hooks[0]);
|
|
2030
2280
|
//#endregion
|
|
2031
|
-
//#region src/extensions/workspaces/sessions/(backend)/resource/_lib/poGit.server.ts
|
|
2032
|
-
var poGit_server_default$1 = (context) => context.root.resources[0];
|
|
2033
|
-
//#endregion
|
|
2034
|
-
//#region src/extensions/workspaces/sessions/(backend)/resource/poGit.server.ts
|
|
2035
|
-
var poGit_server_default = defineResource(poGit_server_default$1);
|
|
2036
|
-
//#endregion
|
|
2037
|
-
//#region src/extensions/workspaces/sessions/(backend)/resource/_lib/poGitReadme.server.ts
|
|
2038
|
-
var poGitReadme_server_default$1 = (context) => context.root.resources[2];
|
|
2039
|
-
//#endregion
|
|
2040
|
-
//#region src/extensions/workspaces/sessions/(backend)/resource/poGitReadme.server.ts
|
|
2041
|
-
var poGitReadme_server_default = defineResource(poGitReadme_server_default$1);
|
|
2042
|
-
//#endregion
|
|
2043
2281
|
//#region src/extensions/workspaces/sessions/(backend)/resource/_lib/poUseGit.server.ts
|
|
2044
|
-
var poUseGit_server_default$1 = (context) => context.root.resources[
|
|
2282
|
+
var poUseGit_server_default$1 = (context) => context.root.resources[0];
|
|
2045
2283
|
//#endregion
|
|
2046
2284
|
//#region src/extensions/workspaces/sessions/(backend)/resource/poUseGit.server.ts
|
|
2047
2285
|
var poUseGit_server_default = defineResource(poUseGit_server_default$1);
|
|
@@ -2051,19 +2289,22 @@ var runWorktree_server_default = defineServerTool((context) => context.root.tool
|
|
|
2051
2289
|
//#endregion
|
|
2052
2290
|
//#region generated/server.ts
|
|
2053
2291
|
const at = (value, context) => typeof value === "function" ? value(context) : value;
|
|
2292
|
+
/* v8 ignore next -- generated normalization branch */
|
|
2293
|
+
const defined = (values) => values.filter((value) => value !== void 0);
|
|
2054
2294
|
const via = (identity, value) => value === void 0 ? void 0 : {
|
|
2055
2295
|
...identity,
|
|
2056
2296
|
...value
|
|
2057
2297
|
};
|
|
2058
|
-
const
|
|
2298
|
+
const serverPlugin = defineServerPlugin({
|
|
2059
2299
|
name: "@agimon-ai/po-git",
|
|
2060
2300
|
providerOverrides: composeServerProviderOverrides(root_server_default),
|
|
2301
|
+
injects: composeRootInjects({ session: [root_server_default] }),
|
|
2061
2302
|
global: (context) => ({
|
|
2062
2303
|
get api() {
|
|
2063
2304
|
return [at(route_server_default, context)];
|
|
2064
2305
|
},
|
|
2065
2306
|
get channels() {
|
|
2066
|
-
return [() => via({ frameType: "git_changes" }, git_changes_server_default
|
|
2307
|
+
return [() => via({ frameType: "git_changes" }, at(git_changes_server_default, context)), () => via({ frameType: "git_worktrees" }, at(git_worktrees_server_default, context))];
|
|
2067
2308
|
}
|
|
2068
2309
|
}),
|
|
2069
2310
|
workspace: (context) => ({
|
|
@@ -2071,11 +2312,11 @@ const facet = defineServerPlugin({
|
|
|
2071
2312
|
return [at(route_server_default, context)];
|
|
2072
2313
|
},
|
|
2073
2314
|
get channels() {
|
|
2074
|
-
return [() => via({ frameType: "git_changes" }, git_changes_server_default
|
|
2315
|
+
return defined([context.hosted("global") ? void 0 : () => via({ frameType: "git_changes" }, at(git_changes_server_default, context)), context.hosted("global") ? void 0 : () => via({ frameType: "git_worktrees" }, at(git_worktrees_server_default, context))]);
|
|
2075
2316
|
}
|
|
2076
2317
|
}),
|
|
2077
2318
|
session: async (context) => {
|
|
2078
|
-
const scopeSession = await root_server_default
|
|
2319
|
+
const scopeSession = await context.scopeRoot("session", root_server_default, context);
|
|
2079
2320
|
const contextSession = {
|
|
2080
2321
|
...context,
|
|
2081
2322
|
root: scopeSession.value
|
|
@@ -2086,17 +2327,13 @@ const facet = defineServerPlugin({
|
|
|
2086
2327
|
return [at(route_server_default, context)];
|
|
2087
2328
|
},
|
|
2088
2329
|
get channels() {
|
|
2089
|
-
return [() => via({ frameType: "git_changes" }, git_changes_server_default
|
|
2330
|
+
return defined([context.hosted("global") ? void 0 : () => via({ frameType: "git_changes" }, at(git_changes_server_default, context)), context.hosted("global") ? void 0 : () => via({ frameType: "git_worktrees" }, at(git_worktrees_server_default, context))]);
|
|
2090
2331
|
},
|
|
2091
2332
|
get hooks() {
|
|
2092
2333
|
return [via({ event: "agent_settled" }, at(agent_settled_server_default, contextSession)), via({ event: "tool_execution_end" }, at(tool_execution_end_server_default, contextSession))];
|
|
2093
2334
|
},
|
|
2094
2335
|
get resources() {
|
|
2095
|
-
return [
|
|
2096
|
-
at(poGit_server_default, contextSession),
|
|
2097
|
-
at(poGitReadme_server_default, contextSession),
|
|
2098
|
-
at(poUseGit_server_default, contextSession)
|
|
2099
|
-
];
|
|
2336
|
+
return [at(poUseGit_server_default, contextSession)];
|
|
2100
2337
|
},
|
|
2101
2338
|
get tools() {
|
|
2102
2339
|
return [via({ name: "run_worktree" }, at(runWorktree_server_default, contextSession))];
|
|
@@ -2109,6 +2346,6 @@ const facet = defineServerPlugin({
|
|
|
2109
2346
|
}
|
|
2110
2347
|
});
|
|
2111
2348
|
//#endregion
|
|
2112
|
-
export {
|
|
2349
|
+
export { serverPlugin as default, serverPlugin };
|
|
2113
2350
|
|
|
2114
2351
|
//# sourceMappingURL=server.mjs.map
|