@nredd/pi-subagents 0.0.0-stage → 0.22.0-nredd.10
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 +21 -0
- package/README.md +1444 -2
- package/dist/chunks/chunk-MSGGFB5Q.ts +6755 -0
- package/dist/chunks/chunk-MSGGFB5Q.ts.map +7 -0
- package/dist/chunks/conversation-viewer-A7AMRDVY.ts +15 -0
- package/dist/chunks/conversation-viewer-A7AMRDVY.ts.map +7 -0
- package/dist/index.ts +9874 -0
- package/dist/index.ts.map +7 -0
- package/package.json +82 -4
- package/src/abortable.ts +43 -0
- package/src/agent-color.ts +161 -0
- package/src/agent-file-toggle.ts +298 -0
- package/src/agent-manager.ts +2431 -0
- package/src/agent-runner.ts +1703 -0
- package/src/agent-source-badge.ts +75 -0
- package/src/agent-types.ts +346 -0
- package/src/child-context.ts +33 -0
- package/src/clock.ts +25 -0
- package/src/completion-outbox.ts +144 -0
- package/src/context.ts +58 -0
- package/src/cross-extension-rpc.ts +200 -0
- package/src/custom-agents.ts +441 -0
- package/src/default-agents.ts +126 -0
- package/src/detached-results.ts +150 -0
- package/src/enabled-models.ts +181 -0
- package/src/enterprise-local.ts +67 -0
- package/src/env.ts +33 -0
- package/src/error-message.ts +11 -0
- package/src/git-path.ts +66 -0
- package/src/group-join.ts +142 -0
- package/src/home-paths.ts +27 -0
- package/src/index.ts +5047 -0
- package/src/invocation-config.ts +158 -0
- package/src/memory.ts +179 -0
- package/src/mention-clone.ts +200 -0
- package/src/mention.ts +141 -0
- package/src/model-call.ts +83 -0
- package/src/model-resolver.ts +333 -0
- package/src/model-scope.ts +71 -0
- package/src/model-selection.ts +170 -0
- package/src/model-tiers.ts +176 -0
- package/src/nested-tools.ts +512 -0
- package/src/notification-result.ts +118 -0
- package/src/output-file.ts +186 -0
- package/src/package-resources.ts +639 -0
- package/src/plan-mode-children.ts +176 -0
- package/src/process-budget.ts +346 -0
- package/src/prompts.ts +161 -0
- package/src/quota-admission.ts +121 -0
- package/src/resource-loader.ts +65 -0
- package/src/schedule-store.ts +153 -0
- package/src/schedule.ts +514 -0
- package/src/settings.ts +851 -0
- package/src/skill-loader.ts +288 -0
- package/src/stale-ctx.ts +16 -0
- package/src/startup-error.ts +29 -0
- package/src/status-note.ts +113 -0
- package/src/structured-output.ts +130 -0
- package/src/types.ts +487 -0
- package/src/ui/agent-mention.ts +219 -0
- package/src/ui/agent-row.ts +245 -0
- package/src/ui/agent-widget.ts +722 -0
- package/src/ui/conversation-viewer.ts +837 -0
- package/src/ui/fallback-flag.ts +114 -0
- package/src/ui/fleet-list.ts +1020 -0
- package/src/ui/fleet-state.ts +154 -0
- package/src/ui/notification-view.ts +139 -0
- package/src/ui/schedule-menu.ts +105 -0
- package/src/ui/select-item.ts +45 -0
- package/src/ui/viewer-keys.ts +39 -0
- package/src/ui/workflow-card.ts +459 -0
- package/src/ui/workflow-dialog.ts +1138 -0
- package/src/ui/workflow-menu.ts +193 -0
- package/src/usage.ts +167 -0
- package/src/workflow/collisions.ts +123 -0
- package/src/workflow/entry.ts +47 -0
- package/src/workflow/host.ts +398 -0
- package/src/workflow/journal.ts +176 -0
- package/src/workflow/json-schema.ts +129 -0
- package/src/workflow/meta.ts +329 -0
- package/src/workflow/progress.ts +554 -0
- package/src/workflow/runtime.ts +1249 -0
- package/src/workflow/saved.ts +259 -0
- package/src/workflow/task.ts +317 -0
- package/src/workflow/tool-description.ts +200 -0
- package/src/workflow/worker-source.ts +903 -0
- package/src/worktree.ts +520 -0
- package/src/xml.ts +13 -0
package/src/worktree.ts
ADDED
|
@@ -0,0 +1,520 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* worktree.ts — Git worktree isolation for agents.
|
|
3
|
+
*
|
|
4
|
+
* Creates a temporary git worktree so the agent works on an isolated copy of the repo.
|
|
5
|
+
* On completion, if no changes were made and nothing in it would be lost, the worktree is cleaned up.
|
|
6
|
+
* If changes exist, a branch is created and returned in the result.
|
|
7
|
+
* If git cannot inspect, commit or remove, the worktree is kept and the result carries `error` and `path`.
|
|
8
|
+
*
|
|
9
|
+
* Every git call goes through `pi.exec` (async) rather than `execFileSync`: a
|
|
10
|
+
* worktree copy can take seconds, and a session that spawns several isolated
|
|
11
|
+
* agents at once would otherwise serialize them all on the TUI's event loop.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { randomUUID } from "node:crypto";
|
|
15
|
+
import { type BigIntStats, existsSync, lstatSync, readdirSync, readFileSync, realpathSync, statSync } from "node:fs";
|
|
16
|
+
import { tmpdir } from "node:os";
|
|
17
|
+
import { join, relative, resolve } from "node:path";
|
|
18
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
19
|
+
import { errorMessage } from "./error-message.js";
|
|
20
|
+
import { QUOTE_PATH_ARGS, unquoteGitPath } from "./git-path.js";
|
|
21
|
+
|
|
22
|
+
export interface WorktreeInfo {
|
|
23
|
+
/** Absolute path to the worktree directory (the copied repo's root). */
|
|
24
|
+
path: string;
|
|
25
|
+
/** Branch name created for this worktree (if changes exist). */
|
|
26
|
+
branch: string;
|
|
27
|
+
/** Commit SHA that the worktree was created from. */
|
|
28
|
+
baseSha: string;
|
|
29
|
+
/** Resolved toplevel of the repo the worktree was created from; the result branch lands here. */
|
|
30
|
+
repoRoot: string;
|
|
31
|
+
/**
|
|
32
|
+
* Where the agent should work inside the worktree: the equivalent of the
|
|
33
|
+
* cwd the worktree was created from. Equals `path` when that cwd was the
|
|
34
|
+
* repo root; points at the copied subdirectory when it was deeper (e.g. a
|
|
35
|
+
* monorepo package), so the requested scoping survives isolation.
|
|
36
|
+
*/
|
|
37
|
+
workPath: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Project-wide switch for worktree isolation (`worktreeIsolation` in
|
|
42
|
+
* subagents.json). Default `true` — unchanged behaviour.
|
|
43
|
+
*
|
|
44
|
+
* The `"off"` isolation value gives a model a legal way to decline a worktree,
|
|
45
|
+
* but it still depends on the model choosing it. This is the deterministic half
|
|
46
|
+
* of the same fix: on a large repo where every worktree costs real time and
|
|
47
|
+
* disk (#184), turning it off means no caller can create one, whatever it
|
|
48
|
+
* passes.
|
|
49
|
+
*/
|
|
50
|
+
let worktreeIsolationEnabled = true;
|
|
51
|
+
|
|
52
|
+
export function setWorktreeIsolationEnabled(enabled: boolean): void {
|
|
53
|
+
worktreeIsolationEnabled = enabled;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function isWorktreeIsolationEnabled(): boolean {
|
|
57
|
+
return worktreeIsolationEnabled;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface WorktreeCleanupResult {
|
|
61
|
+
/** Whether changes were found in the worktree. */
|
|
62
|
+
hasChanges: boolean;
|
|
63
|
+
/** Branch name if changes were committed. */
|
|
64
|
+
branch?: string;
|
|
65
|
+
/** Worktree path if it was kept. */
|
|
66
|
+
path?: string;
|
|
67
|
+
/** Cleanup failure; the worktree may contain uncommitted or unreachable work. */
|
|
68
|
+
error?: string;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Run git and return its trimmed stdout, throwing on failure so callers keep
|
|
73
|
+
* the try/catch control flow `execFileSync` gave them.
|
|
74
|
+
*
|
|
75
|
+
* `pi.exec` never rejects — it reports failure in the result — and a command
|
|
76
|
+
* killed by its timeout comes back as `killed` with an exit code of 0, so both
|
|
77
|
+
* have to be checked to reproduce `execFileSync`'s "throws on anything but a
|
|
78
|
+
* clean exit".
|
|
79
|
+
*/
|
|
80
|
+
async function git(pi: ExtensionAPI, cwd: string, args: string[], timeout: number): Promise<string> {
|
|
81
|
+
return (await gitRaw(pi, cwd, args, timeout)).trim();
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** `git`, with stdout untrimmed: a path may start or end with whitespace. */
|
|
85
|
+
async function gitRaw(pi: ExtensionAPI, cwd: string, args: string[], timeout: number): Promise<string> {
|
|
86
|
+
const result = await pi.exec("git", args, { cwd, timeout });
|
|
87
|
+
if (result.killed) throw new GitTimeoutError(`\`git ${args.join(" ")}\` timed out after ${timeout}ms in ${cwd}`);
|
|
88
|
+
if (result.code !== 0) {
|
|
89
|
+
throw new Error(result.stderr.trim() || `git ${args.join(" ")} failed (exit ${result.code})`);
|
|
90
|
+
}
|
|
91
|
+
return result.stdout;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** A git call killed by its timeout — reported as-is, never as a repo-state cause. */
|
|
95
|
+
class GitTimeoutError extends Error {}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Why `createWorktree` could not isolate an agent. The message names the
|
|
99
|
+
* directory it tried and the specific cause, so a caller can tell "wrong
|
|
100
|
+
* directory" from "repo with no commits" from a git failure.
|
|
101
|
+
*/
|
|
102
|
+
export class WorktreeError extends Error {}
|
|
103
|
+
|
|
104
|
+
/** Run one git step, rethrowing a failure as a `WorktreeError` whose message is `cause(detail)`. */
|
|
105
|
+
async function step(run: Promise<string>, cause: (detail: string) => string): Promise<string> {
|
|
106
|
+
try {
|
|
107
|
+
return await run;
|
|
108
|
+
} catch (err) {
|
|
109
|
+
const detail = errorMessage(err);
|
|
110
|
+
throw new WorktreeError(err instanceof GitTimeoutError ? detail : cause(detail));
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Create a temporary git worktree for an agent, from the repo containing `cwd`
|
|
116
|
+
* (which may be any directory inside it, not just the toplevel).
|
|
117
|
+
*
|
|
118
|
+
* @throws WorktreeError — `cwd` is not inside a git work tree, the repo has no
|
|
119
|
+
* commits yet, `git worktree add` failed, or a git call timed out.
|
|
120
|
+
*/
|
|
121
|
+
export async function createWorktree(pi: ExtensionAPI, cwd: string, agentId: string): Promise<WorktreeInfo> {
|
|
122
|
+
const inside = await step(
|
|
123
|
+
git(pi, cwd, ["rev-parse", "--is-inside-work-tree"], 5000),
|
|
124
|
+
detail => `${cwd} is not inside a git repository (${detail})`,
|
|
125
|
+
);
|
|
126
|
+
// "false" with a clean exit: inside a `.git` dir or a bare repo.
|
|
127
|
+
if (inside !== "true") throw new WorktreeError(`${cwd} is inside a git directory but not a work tree`);
|
|
128
|
+
// realpath both sides — git emits resolved paths while cwd may arrive
|
|
129
|
+
// through a symlink (macOS /tmp).
|
|
130
|
+
const repoRoot = realpathSync(await step(
|
|
131
|
+
git(pi, cwd, ["rev-parse", "--show-toplevel"], 5000),
|
|
132
|
+
detail => `${cwd} has no git work tree toplevel (${detail})`,
|
|
133
|
+
));
|
|
134
|
+
const baseSha = await step(
|
|
135
|
+
git(pi, cwd, ["rev-parse", "--verify", "HEAD"], 5000),
|
|
136
|
+
() => `${repoRoot} has no commits yet; commit at least once`,
|
|
137
|
+
);
|
|
138
|
+
// Where cwd sits inside the repo ("" at the root): the agent must work at
|
|
139
|
+
// the same subdirectory inside the copy, or a monorepo-package cwd would
|
|
140
|
+
// silently widen to the whole repo.
|
|
141
|
+
const subdir = relative(repoRoot, realpathSync(cwd));
|
|
142
|
+
|
|
143
|
+
const branch = `pi-agent-${agentId}`;
|
|
144
|
+
const suffix = randomUUID().slice(0, 8);
|
|
145
|
+
const worktreePath = join(tmpdir(), `pi-agent-${agentId}-${suffix}`);
|
|
146
|
+
|
|
147
|
+
// Create detached worktree at HEAD
|
|
148
|
+
await step(
|
|
149
|
+
git(pi, cwd, ["worktree", "add", "--detach", worktreePath, "HEAD"], 30000),
|
|
150
|
+
detail => `\`git worktree add\` failed in ${repoRoot}: ${detail}`,
|
|
151
|
+
);
|
|
152
|
+
return { path: worktreePath, branch, baseSha, repoRoot, workPath: subdir ? join(worktreePath, subdir) : worktreePath };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Clean up a worktree after agent completion.
|
|
157
|
+
* - If no changes: remove worktree entirely.
|
|
158
|
+
* - If changes exist: create a branch, commit changes, return branch info.
|
|
159
|
+
*/
|
|
160
|
+
export async function cleanupWorktree(
|
|
161
|
+
pi: ExtensionAPI,
|
|
162
|
+
cwd: string,
|
|
163
|
+
worktree: WorktreeInfo,
|
|
164
|
+
agentDescription: string,
|
|
165
|
+
): Promise<WorktreeCleanupResult> {
|
|
166
|
+
if (!existsSync(worktree.path)) {
|
|
167
|
+
return { hasChanges: false };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
let branch: string | undefined;
|
|
171
|
+
try {
|
|
172
|
+
// Submodule state lives in the worktree's private git dir and is deleted with it.
|
|
173
|
+
await assertSubmodulesDisposable(pi, worktree.path, worktree.baseSha);
|
|
174
|
+
// Check for uncommitted changes in the worktree
|
|
175
|
+
const status = await dirtyState(pi, worktree.path);
|
|
176
|
+
|
|
177
|
+
if (status) {
|
|
178
|
+
// Changes exist — stage, commit, and create a branch
|
|
179
|
+
const modes = await indexOnlyModes(pi, worktree.path);
|
|
180
|
+
if (modes.length > 0) {
|
|
181
|
+
throw new Error(`mode staged only in the index; worktree kept: ${modes.join(", ")}`);
|
|
182
|
+
}
|
|
183
|
+
await git(pi, worktree.path, ["add", "-A"], 10000);
|
|
184
|
+
// `add -A` skips a moved gitlink hidden by `submodule.<name>.ignore = all`; an empty
|
|
185
|
+
// commit would fail opaquely, so say why and keep the worktree.
|
|
186
|
+
if (!(await git(pi, worktree.path, ["diff", "--cached", "--name-only"], 10000))) {
|
|
187
|
+
throw new Error(`uncommitted changes could not be staged (a submodule \`ignore\` setting can hide a moved gitlink); worktree kept:\n${status}`);
|
|
188
|
+
}
|
|
189
|
+
// Truncate description for commit message (no shell sanitization needed — pi.exec uses argv)
|
|
190
|
+
const safeDesc = agentDescription.slice(0, 200);
|
|
191
|
+
const commitMsg = `pi-agent: ${safeDesc}`;
|
|
192
|
+
await git(pi, worktree.path, ["commit", "--no-verify", "-m", commitMsg], 10000);
|
|
193
|
+
} else {
|
|
194
|
+
const currentSha = await git(pi, worktree.path, ["rev-parse", "HEAD"], 5000);
|
|
195
|
+
|
|
196
|
+
if (currentSha === worktree.baseSha) {
|
|
197
|
+
// No changes — remove worktree
|
|
198
|
+
await removeWorktree(pi, cwd, worktree.path, worktree.baseSha);
|
|
199
|
+
return { hasChanges: false };
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Create a branch pointing to the worktree's HEAD.
|
|
204
|
+
// If the branch already exists, append a suffix to avoid overwriting previous work.
|
|
205
|
+
let branchName = worktree.branch;
|
|
206
|
+
try {
|
|
207
|
+
await git(pi, worktree.path, ["branch", branchName], 5000);
|
|
208
|
+
} catch {
|
|
209
|
+
// Branch already exists — use a unique suffix
|
|
210
|
+
branchName = `${worktree.branch}-${Date.now()}`;
|
|
211
|
+
await git(pi, worktree.path, ["branch", branchName], 5000);
|
|
212
|
+
}
|
|
213
|
+
// Update branch name in worktree info for the caller
|
|
214
|
+
worktree.branch = branchName;
|
|
215
|
+
branch = branchName;
|
|
216
|
+
|
|
217
|
+
// Remove the worktree (branch persists in main repo)
|
|
218
|
+
await removeWorktree(pi, cwd, worktree.path, worktree.baseSha);
|
|
219
|
+
|
|
220
|
+
return {
|
|
221
|
+
hasChanges: true,
|
|
222
|
+
branch: worktree.branch,
|
|
223
|
+
};
|
|
224
|
+
} catch (error) {
|
|
225
|
+
// A failed status means the contents are unknown, not clean. Never remove
|
|
226
|
+
// the tree after a failed preservation step: even committed HEAD may have
|
|
227
|
+
// no reachable branch yet.
|
|
228
|
+
return {
|
|
229
|
+
hasChanges: true,
|
|
230
|
+
...(branch ? { branch } : {}),
|
|
231
|
+
path: worktree.path,
|
|
232
|
+
error: (error instanceof Error ? error.message : String(error)) || "Unknown git failure",
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Lines of a path listing, read without `-z` and with `core.quotePath=true`: every
|
|
239
|
+
* non-ASCII path byte is escaped, so `pi.exec` decoding stdout chunk by chunk cannot
|
|
240
|
+
* corrupt a path (see `unquoteGitPath`). Each line's paths are decoded by the caller.
|
|
241
|
+
*/
|
|
242
|
+
async function gitLines(pi: ExtensionAPI, repo: string, args: string[], timeout: number): Promise<string[]> {
|
|
243
|
+
return (await gitRaw(pi, repo, [...QUOTE_PATH_ARGS, ...args], timeout)).split("\n").filter(Boolean);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const INDEX_BATCH = 200;
|
|
247
|
+
|
|
248
|
+
/** `git update-index <flag> -- <paths>` in argv-sized batches. */
|
|
249
|
+
async function updateIndex(pi: ExtensionAPI, repo: string, flag: string, paths: string[]): Promise<void> {
|
|
250
|
+
for (let i = 0; i < paths.length; i += INDEX_BATCH) {
|
|
251
|
+
await git(pi, repo, ["update-index", flag, "--", ...paths.slice(i, i + INDEX_BATCH)], 30000);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Clear the assume-unchanged and skip-worktree index bits of `repo`: with either
|
|
257
|
+
* set, `status` and git's own removal check ignore edits to the file, so an edited
|
|
258
|
+
* file would be deleted with the worktree. A skip-worktree file absent from disk
|
|
259
|
+
* (sparse checkout) is left alone: clearing it would report a deletion nobody made.
|
|
260
|
+
* The tree is disposable, so mutating its index is fine.
|
|
261
|
+
*/
|
|
262
|
+
async function clearHiddenIndexBits(pi: ExtensionAPI, repo: string): Promise<void> {
|
|
263
|
+
const assumed: string[] = [];
|
|
264
|
+
const skipped: string[] = [];
|
|
265
|
+
// `-v` tags: lowercase = assume-unchanged, `S` (or `s` with both) = skip-worktree.
|
|
266
|
+
for (const entry of await gitLines(pi, repo, ["ls-files", "-v"], 30000)) {
|
|
267
|
+
const tag = entry.charAt(0);
|
|
268
|
+
const hidden = tag !== tag.toUpperCase() || tag === "S";
|
|
269
|
+
if (!hidden) continue;
|
|
270
|
+
const path = unquoteGitPath(entry.slice(2));
|
|
271
|
+
if (tag !== tag.toUpperCase()) assumed.push(path);
|
|
272
|
+
if (tag.toLowerCase() === "s" && lstatOrUndefined(join(repo, path))) skipped.push(path);
|
|
273
|
+
}
|
|
274
|
+
await updateIndex(pi, repo, "--no-assume-unchanged", assumed);
|
|
275
|
+
await updateIndex(pi, repo, "--no-skip-worktree", skipped);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** `lstat` of `path`, or undefined when it is missing or unreachable (ENOENT, ENOTDIR). */
|
|
279
|
+
function lstatOrUndefined(path: string): BigIntStats | undefined {
|
|
280
|
+
try {
|
|
281
|
+
return lstatSync(path, { bigint: true });
|
|
282
|
+
} catch (error) {
|
|
283
|
+
// Anything else (EACCES, ...) is unknown, not absent: let cleanup fail and keep the tree.
|
|
284
|
+
const code = (error as NodeJS.ErrnoException).code;
|
|
285
|
+
if (code === "ENOENT" || code === "ENOTDIR") return undefined;
|
|
286
|
+
throw error;
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Regular files whose staged executable bit (index vs `HEAD`, a new file counting as a change)
|
|
292
|
+
* differs from the file on disk, decoded. `add -A` restages from disk and would silently drop
|
|
293
|
+
* such a mode, and an index-only mode cannot be told from a later on-disk change, so cleanup
|
|
294
|
+
* keeps the worktree instead of guessing. A plain unstaged `chmod` is not listed: the index
|
|
295
|
+
* still matches `HEAD`, so `add -A` commits the disk mode. Skipped when `core.fileMode=false`:
|
|
296
|
+
* git ignores the bit then, so a mismatch is not a change.
|
|
297
|
+
*/
|
|
298
|
+
async function indexOnlyModes(pi: ExtensionAPI, repo: string): Promise<string[]> {
|
|
299
|
+
if ((await git(pi, repo, ["config", "--type=bool", "--default", "true", "core.fileMode"], 5000)) === "false") return [];
|
|
300
|
+
const paths: string[] = [];
|
|
301
|
+
// `:<old mode> <new mode> <old sha> <new sha> <status>` TAB `<path>`
|
|
302
|
+
for (const line of await gitLines(pi, repo, ["diff", "--cached", "--raw", "--no-renames"], 30000)) {
|
|
303
|
+
const tab = line.indexOf("\t");
|
|
304
|
+
const [oldMode, newMode] = line.slice(1, tab).split(" ");
|
|
305
|
+
if (oldMode === newMode || (newMode !== "100644" && newMode !== "100755")) continue;
|
|
306
|
+
const path = unquoteGitPath(line.slice(tab + 1));
|
|
307
|
+
const disk = lstatOrUndefined(join(repo, path));
|
|
308
|
+
if (disk?.isFile() && ((disk.mode & 0o100n) !== 0n) !== (newMode === "100755")) paths.push(path);
|
|
309
|
+
}
|
|
310
|
+
return paths;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* Uncommitted state of `repo`, untracked files and submodule changes included.
|
|
315
|
+
* `status.showUntrackedFiles=no` in user or repo config would otherwise hide new
|
|
316
|
+
* files from `status`, and `submodule.<name>.ignore` / `diff.ignoreSubmodules`
|
|
317
|
+
* would hide a moved gitlink; a clean-looking tree gets removed with them.
|
|
318
|
+
* Index bits that hide edits are cleared first, see `clearHiddenIndexBits`.
|
|
319
|
+
*/
|
|
320
|
+
async function dirtyState(pi: ExtensionAPI, repo: string): Promise<string> {
|
|
321
|
+
await clearHiddenIndexBits(pi, repo);
|
|
322
|
+
return await git(pi, repo, ["status", "--porcelain", "--untracked-files=all", "--ignore-submodules=none"], 10000);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
const GITLINK_MODE = "160000";
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Gitlinks (mode 160000) of `repo` as path -> sha: the index when `rev` is
|
|
329
|
+
* omitted, else the tree of `rev`. Independent of `.gitmodules`: git refuses to
|
|
330
|
+
* remove a worktree on the gitlink, not on the config file.
|
|
331
|
+
*/
|
|
332
|
+
async function gitlinks(pi: ExtensionAPI, repo: string, rev?: string): Promise<Map<string, string>> {
|
|
333
|
+
const links = new Map<string, string>();
|
|
334
|
+
for (const entry of await gitLines(pi, repo, rev ? ["ls-tree", "-r", rev] : ["ls-files", "-s"], 30000)) {
|
|
335
|
+
// `gitLines` drops blanks, so every entry is `<fields>` TAB `<path>`.
|
|
336
|
+
const tab = entry.indexOf("\t");
|
|
337
|
+
// ls-files: `<mode> <sha> <stage>`; ls-tree: `<mode> <type> <sha>`.
|
|
338
|
+
const [mode, second, third] = entry.slice(0, tab).split(" ");
|
|
339
|
+
if (mode === GITLINK_MODE) links.set(unquoteGitPath(entry.slice(tab + 1)), rev ? (third as string) : (second as string));
|
|
340
|
+
}
|
|
341
|
+
return links;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/** What removing a repository would destroy: stashes and commits (branch, tag or HEAD) no remote-tracking ref (or `published`) contains. */
|
|
345
|
+
async function unpublished(pi: ExtensionAPI, gitArgs: string[], cwd: string, published: Iterable<string>): Promise<string> {
|
|
346
|
+
const stash = await git(pi, cwd, [...gitArgs, "stash", "list"], 10000);
|
|
347
|
+
// `--not` with an object this repository lacks would fail the whole listing.
|
|
348
|
+
const known: string[] = [];
|
|
349
|
+
for (const sha of published) {
|
|
350
|
+
const probe = await pi.exec("git", [...gitArgs, "cat-file", "-e", `${sha}^{commit}`], { cwd, timeout: 5000 });
|
|
351
|
+
if (!probe.killed && probe.code === 0) known.push(sha);
|
|
352
|
+
}
|
|
353
|
+
// Tags count: a local tag can be the only ref holding a commit. Offline there is
|
|
354
|
+
// no telling a fetched tag from a local one, so an unpublished tagged commit fails safe.
|
|
355
|
+
const commits = await git(pi, cwd, [...gitArgs, "rev-list", "--branches", "--tags", "HEAD", "--not", "--remotes", ...known], 30000);
|
|
356
|
+
return [stash, commits].filter(Boolean).join("\n");
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* The git dir of the repository whose work tree is `dir`, read from `.git` (a directory, or
|
|
361
|
+
* a `gitdir: <path>` file for a submodule) rather than from `git rev-parse` output, which
|
|
362
|
+
* `pi.exec` can mis-decode when the path is non-ASCII.
|
|
363
|
+
*/
|
|
364
|
+
function gitDirOf(dir: string): string {
|
|
365
|
+
const dotGit = join(dir, ".git");
|
|
366
|
+
if (statSync(dotGit).isDirectory()) return dotGit;
|
|
367
|
+
const target = /^gitdir: (.+)$/m.exec(readFileSync(dotGit, "utf8"))?.[1];
|
|
368
|
+
if (!target) throw new Error(`${dotGit} is neither a directory nor a gitdir file`);
|
|
369
|
+
return resolve(dir, target.trim());
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* Inspect the nested repository at `dir`, then the ones behind its own gitlinks.
|
|
374
|
+
* `recorded` is the sha a trusted baseline pins it at (when any).
|
|
375
|
+
*/
|
|
376
|
+
async function inspectRepo(
|
|
377
|
+
pi: ExtensionAPI,
|
|
378
|
+
dir: string,
|
|
379
|
+
label: string,
|
|
380
|
+
recorded: string | undefined,
|
|
381
|
+
trusted: Set<string>,
|
|
382
|
+
seen: Set<string>,
|
|
383
|
+
problems: string[],
|
|
384
|
+
): Promise<void> {
|
|
385
|
+
seen.add(realpathSync(gitDirOf(dir)));
|
|
386
|
+
const dirty = await dirtyState(pi, dir);
|
|
387
|
+
const lost = await unpublished(pi, [], dir, recorded ? [recorded] : []);
|
|
388
|
+
const detail = [dirty, lost].filter(Boolean).join("\n");
|
|
389
|
+
if (detail) problems.push(`${label}:\n${detail}`);
|
|
390
|
+
const head = await git(pi, dir, ["rev-parse", "HEAD"], 5000);
|
|
391
|
+
await inspectGitlinks(pi, dir, head === recorded ? "HEAD" : undefined, trusted, seen, problems);
|
|
392
|
+
await inspectIgnoredRepos(pi, dir, dir, trusted, seen, problems);
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* Inspect nested repositories under gitignored paths of `repo` not already
|
|
397
|
+
* `seen`. Labels are relative to `root`, the worktree.
|
|
398
|
+
*/
|
|
399
|
+
async function inspectIgnoredRepos(
|
|
400
|
+
pi: ExtensionAPI,
|
|
401
|
+
repo: string,
|
|
402
|
+
root: string,
|
|
403
|
+
trusted: Set<string>,
|
|
404
|
+
seen: Set<string>,
|
|
405
|
+
problems: string[],
|
|
406
|
+
): Promise<void> {
|
|
407
|
+
for (const dir of await ignoredRepoDirs(pi, repo)) {
|
|
408
|
+
if (!seen.has(realpathSync(gitDirOf(dir)))) {
|
|
409
|
+
await inspectRepo(pi, dir, relative(root, dir), undefined, trusted, seen, problems);
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/**
|
|
415
|
+
* Inspect the nested repository behind every gitlink of `repo`, recursively.
|
|
416
|
+
* `baseRev` is a revision whose gitlinks are already trusted as published (the
|
|
417
|
+
* commit the worktree was created from): a nested HEAD at exactly that recorded
|
|
418
|
+
* sha, e.g. fetched by sha during `submodule update`, is not at risk. A gitlink
|
|
419
|
+
* the agent introduced has no baseline, so only remote-tracking refs vouch for it.
|
|
420
|
+
* Every baseline sha seen is added to `trusted`, for repositories no gitlink reaches.
|
|
421
|
+
*/
|
|
422
|
+
async function inspectGitlinks(
|
|
423
|
+
pi: ExtensionAPI,
|
|
424
|
+
repo: string,
|
|
425
|
+
baseRev: string | undefined,
|
|
426
|
+
trusted: Set<string>,
|
|
427
|
+
seen: Set<string>,
|
|
428
|
+
problems: string[],
|
|
429
|
+
): Promise<void> {
|
|
430
|
+
const base = baseRev ? await gitlinks(pi, repo, baseRev) : new Map<string, string>();
|
|
431
|
+
for (const sha of base.values()) trusted.add(sha);
|
|
432
|
+
for (const path of (await gitlinks(pi, repo)).keys()) {
|
|
433
|
+
const dir = join(repo, path);
|
|
434
|
+
if (existsSync(join(dir, ".git"))) await inspectRepo(pi, dir, path, base.get(path), trusted, seen, problems);
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/**
|
|
439
|
+
* Nested repositories under gitignored paths: `add -A` skips them, so no gitlink
|
|
440
|
+
* records them, yet plain removal deletes their ignored content.
|
|
441
|
+
*/
|
|
442
|
+
async function ignoredRepoDirs(pi: ExtensionAPI, worktreePath: string): Promise<string[]> {
|
|
443
|
+
const entries = await gitLines(pi, worktreePath, ["status", "--porcelain", "--ignored", "--untracked-files=all", "--ignore-submodules=none"], 30000);
|
|
444
|
+
// With `--untracked-files=all` git lists an ignored nested repository as its own `!! dir/` entry.
|
|
445
|
+
// Only directory entries (`dir/`, or `"dir/"` when quoted) can be a clone: decode just those.
|
|
446
|
+
const dirs = entries.filter(entry => entry.startsWith("!! ") && /\/"?$/.test(entry)).map(entry => unquoteGitPath(entry.slice(3)));
|
|
447
|
+
return dirs.map(dir => join(worktreePath, dir)).filter(dir => existsSync(join(dir, ".git")));
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/** Git dirs under a `modules` directory, including those nested in other submodule git dirs. */
|
|
451
|
+
function moduleGitDirs(dir: string): string[] {
|
|
452
|
+
if (existsSync(join(dir, "HEAD")) && existsSync(join(dir, "objects"))) {
|
|
453
|
+
const nested = join(dir, "modules");
|
|
454
|
+
return [dir, ...(existsSync(nested) ? moduleGitDirs(nested) : [])];
|
|
455
|
+
}
|
|
456
|
+
return readdirSync(dir, { withFileTypes: true }).flatMap(entry => (entry.isDirectory() ? moduleGitDirs(join(dir, entry.name)) : []));
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/** Absolute `modules` directory of the worktree's private git dir, when present. */
|
|
460
|
+
function modulesDir(worktreePath: string): string | undefined {
|
|
461
|
+
const dir = join(gitDirOf(worktreePath), "modules");
|
|
462
|
+
return existsSync(dir) ? dir : undefined;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/**
|
|
466
|
+
* Throw unless every nested repository the removal would delete holds nothing
|
|
467
|
+
* but already-published state. Git deletes submodule git dirs with the worktree,
|
|
468
|
+
* so unpushed commits or uncommitted files there would be lost even after the
|
|
469
|
+
* outer repo is branched. Covers every gitlink in the index, registered in
|
|
470
|
+
* `.gitmodules` or not, plus any leftover repository in the worktree's `modules`
|
|
471
|
+
* dir (e.g. after the agent deleted `.gitmodules`).
|
|
472
|
+
*/
|
|
473
|
+
async function assertSubmodulesDisposable(pi: ExtensionAPI, worktreePath: string, baseSha: string): Promise<void> {
|
|
474
|
+
const seen = new Set<string>();
|
|
475
|
+
const trusted = new Set<string>();
|
|
476
|
+
const problems: string[] = [];
|
|
477
|
+
await inspectGitlinks(pi, worktreePath, baseSha, trusted, seen, problems);
|
|
478
|
+
await inspectIgnoredRepos(pi, worktreePath, worktreePath, trusted, seen, problems);
|
|
479
|
+
const modules = modulesDir(worktreePath);
|
|
480
|
+
for (const gitDir of modules ? moduleGitDirs(modules) : []) {
|
|
481
|
+
if (seen.has(realpathSync(gitDir))) continue;
|
|
482
|
+
const lost = await unpublished(pi, ["--git-dir", gitDir], worktreePath, trusted);
|
|
483
|
+
if (lost) problems.push(`${gitDir}:\n${lost}`);
|
|
484
|
+
}
|
|
485
|
+
if (problems.length) throw new Error(`submodule work would be lost on removal; worktree kept:\n${problems.join("\n")}`);
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/** Whether git would refuse plain removal for submodule state: any gitlink in the index or a `modules` dir. */
|
|
489
|
+
async function hasSubmoduleState(pi: ExtensionAPI, worktreePath: string): Promise<boolean> {
|
|
490
|
+
return (await gitlinks(pi, worktreePath)).size > 0 || modulesDir(worktreePath) !== undefined;
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
/**
|
|
494
|
+
* Remove only a clean worktree; git refuses newly appeared uncommitted work.
|
|
495
|
+
*
|
|
496
|
+
* Git also refuses any worktree with submodule state, clean or not. Whether that
|
|
497
|
+
* is why removal failed is decided structurally (gitlinks, `modules` dir), never
|
|
498
|
+
* from git's locale-dependent message. Only then is `--force` used, and only after
|
|
499
|
+
* the outer tree and every nested repository re-verify as holding nothing unpublished.
|
|
500
|
+
*/
|
|
501
|
+
async function removeWorktree(pi: ExtensionAPI, cwd: string, worktreePath: string, baseSha: string): Promise<void> {
|
|
502
|
+
try {
|
|
503
|
+
// Git's own clean check honors `status.showUntrackedFiles`; pin it so config cannot hide new files.
|
|
504
|
+
await git(pi, cwd, ["-c", "status.showUntrackedFiles=all", "worktree", "remove", worktreePath], 10000);
|
|
505
|
+
} catch (error) {
|
|
506
|
+
if (!(await hasSubmoduleState(pi, worktreePath))) throw error;
|
|
507
|
+
if (await dirtyState(pi, worktreePath)) throw error;
|
|
508
|
+
await assertSubmodulesDisposable(pi, worktreePath, baseSha);
|
|
509
|
+
await git(pi, cwd, ["worktree", "remove", "--force", worktreePath], 10000);
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
/**
|
|
514
|
+
* Prune any orphaned worktrees (crash recovery).
|
|
515
|
+
*/
|
|
516
|
+
export async function pruneWorktrees(pi: ExtensionAPI, cwd: string): Promise<void> {
|
|
517
|
+
try {
|
|
518
|
+
await git(pi, cwd, ["worktree", "prune"], 5000);
|
|
519
|
+
} catch { /* ignore */ }
|
|
520
|
+
}
|
package/src/xml.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* xml.ts — escaping for the `<task-notification>` payloads.
|
|
3
|
+
*
|
|
4
|
+
* A module of its own because both notification builders need it and they sit
|
|
5
|
+
* on opposite sides of a dependency edge: the agent one lives in `index.ts`,
|
|
6
|
+
* which imports `workflow/task.ts`, so the workflow one cannot reach back for
|
|
7
|
+
* it without a cycle.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/** Escape XML special characters to prevent injection in structured notifications. */
|
|
11
|
+
export function escapeXml(s: string): string {
|
|
12
|
+
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
13
|
+
}
|