@sigrun/sigrun 0.6.1 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +92 -0
- package/README.md +105 -18
- package/dist/blocks/build-stamp.json +2 -2
- package/dist/blocks/schema.js +5 -0
- package/dist/cli.js +85 -22
- package/dist/curation/host.js +46 -21
- package/dist/curation/migrate.js +96 -0
- package/dist/curation/overlays.js +52 -16
- package/dist/curation/paths.js +22 -0
- package/dist/curation/runs.js +34 -17
- package/dist/curation/skill-tool.js +34 -9
- package/dist/curation/store.js +382 -254
- package/dist/curation/targets.js +21 -0
- package/dist/curation/wrap.js +1 -6
- package/dist/extension.d.ts +12 -3
- package/dist/harness/boot.js +143 -6
- package/dist/harness/event-bus.js +19 -0
- package/dist/harness/loader.js +22 -0
- package/dist/harness/reserved-sections.js +2 -0
- package/dist/harness/seam-host.js +71 -16
- package/dist/harness/workspace.js +34 -1
- package/dist/history/git.js +707 -0
- package/dist/history/trailers.js +145 -0
- package/dist/history/workspace-history.js +47 -0
- package/dist/jobs/index.js +5 -3
- package/dist/jobs/reconcile.js +2 -3
- package/dist/jobs/state-dir.js +9 -0
- package/dist/jobs/transfer.js +6 -6
- package/dist/sandbox/grants.js +11 -0
- package/dist/sandbox/launch.js +19 -9
- package/dist/sandbox/policy.js +6 -1
- package/dist/serve.js +3 -0
- package/dist/server/auth/proxy-only.js +5 -4
- package/dist/server/core-views/index.js +26 -0
- package/dist/server/core-views/jobs.js +661 -0
- package/dist/server/core-views/skills-tree.js +628 -0
- package/dist/server/core-views/skills.js +1090 -0
- package/dist/server/index.js +88 -0
- package/dist/server/jobs-access.js +62 -0
- package/dist/server/notifications.js +24 -12
- package/dist/server/routes/extensions.js +9 -1
- package/dist/server/routes/jobs.js +19 -52
- package/dist/server/routes/meta.js +9 -0
- package/dist/server/routes/onboarding.js +13 -1
- package/dist/server/surfaces/service.js +38 -0
- package/dist/skills/index.js +1 -1
- package/dist/skills/roots.js +301 -0
- package/node_modules/@sigrun/shared/CHANGELOG.md +8 -0
- package/node_modules/@sigrun/shared/dist/auth.d.ts +22 -0
- package/node_modules/@sigrun/shared/dist/auth.js +26 -0
- package/node_modules/@sigrun/shared/dist/blocks/index.d.ts +1 -1
- package/node_modules/@sigrun/shared/dist/blocks/types.d.ts +15 -0
- package/node_modules/@sigrun/shared/dist/model-label.d.ts +29 -0
- package/node_modules/@sigrun/shared/dist/model-label.js +55 -0
- package/node_modules/@sigrun/shared/dist/views.d.ts +28 -0
- package/node_modules/@sigrun/shared/package.json +9 -1
- package/package.json +7 -7
- package/webui/dist/assets/index-BI5k-Oci.js +55 -0
- package/webui/dist/assets/index-EGluyRXk.css +1 -0
- package/webui/dist/favicon.svg +7 -5
- package/webui/dist/index.html +3 -3
- package/webui/dist/assets/index-BR0di0OG.css +0 -1
- package/webui/dist/assets/index-BzrA1T4g.js +0 -55
|
@@ -0,0 +1,707 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GitHistory — isolated local git for sigrun-owned history stores
|
|
3
|
+
* (tabs+skills plan, Task 7). The history repos are tamper-resistant only
|
|
4
|
+
* under active sandbox isolation; the isolation argv contract below is the
|
|
5
|
+
* whole point of the module:
|
|
6
|
+
*
|
|
7
|
+
* - execFile, never a shell; every argv is produced by gitArgs();
|
|
8
|
+
* - --git-dir and --work-tree are explicit on every invocation (the work
|
|
9
|
+
* tree is a user's live directory, never the git dir's parent);
|
|
10
|
+
* - env GIT_CONFIG_GLOBAL=/dev/null, GIT_CONFIG_NOSYSTEM=1, HOME and
|
|
11
|
+
* XDG_CONFIG_HOME = opts.gitHome (created empty), GIT_TERMINAL_PROMPT=0;
|
|
12
|
+
* - -c commit.gpgsign=false -c tag.gpgsign=false -c core.hooksPath=/dev/null
|
|
13
|
+
* -c core.attributesFile=/dev/null -c core.excludesFile=/dev/null
|
|
14
|
+
* -c core.fsmonitor=false -c safe.directory=<workTree>. safe.directory
|
|
15
|
+
* must ride the -c flags: git honours it only from protected config,
|
|
16
|
+
* never from the repo's own config.
|
|
17
|
+
*
|
|
18
|
+
* Failure semantics are fail-soft: the caller's mutation always runs; git
|
|
19
|
+
* failures are logged and degrade to "no commit" (write returns undefined).
|
|
20
|
+
* A corrupt git dir is renamed to <gitDir>.broken-<UTC stamp> and
|
|
21
|
+
* re-initialized with a snapshot commit; a stale index.lock (> 10 minutes)
|
|
22
|
+
* is removed with a log line while a fresh one fails only the commit.
|
|
23
|
+
*/
|
|
24
|
+
import { execFile } from "node:child_process";
|
|
25
|
+
import { access, mkdir, readdir, rename, rm, stat, writeFile, } from "node:fs/promises";
|
|
26
|
+
import { constants } from "node:fs";
|
|
27
|
+
import { relative, sep } from "node:path";
|
|
28
|
+
import { promisify } from "node:util";
|
|
29
|
+
import { actorEmail, actorName, decodeActor, encodeTrailers, sanitizeMeta, } from "./trailers.js";
|
|
30
|
+
const execFileAsync = promisify(execFile);
|
|
31
|
+
/** Files larger than this (or binary) are not inlined into changes(). */
|
|
32
|
+
const MAX_INLINE_BYTES = 64 * 1024;
|
|
33
|
+
export const OVERSIZE_MARKER = "(binary or larger than 64 KiB)";
|
|
34
|
+
/** A fresh index.lock fails the commit; one older than this is removed. */
|
|
35
|
+
const STALE_LOCK_MS = 10 * 60 * 1000;
|
|
36
|
+
const EMPTY_TREE = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
|
|
37
|
+
const RS = "\x1e";
|
|
38
|
+
const FS = "\x1f";
|
|
39
|
+
/** One in-process async mutex per gitDir (module-level, keyed by gitDir). */
|
|
40
|
+
const locks = new Map();
|
|
41
|
+
function utcStamp(now) {
|
|
42
|
+
return now.toISOString().replace(/[:.]/g, "-");
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* The ONE argv builder: every git invocation goes through this so the
|
|
46
|
+
* isolation flags can never be dropped at a call site.
|
|
47
|
+
*/
|
|
48
|
+
export function gitArgs(spec, ...args) {
|
|
49
|
+
return [
|
|
50
|
+
`--git-dir=${spec.gitDir}`,
|
|
51
|
+
`--work-tree=${spec.workTree}`,
|
|
52
|
+
"-c",
|
|
53
|
+
"commit.gpgsign=false",
|
|
54
|
+
"-c",
|
|
55
|
+
"tag.gpgsign=false",
|
|
56
|
+
"-c",
|
|
57
|
+
"core.hooksPath=/dev/null",
|
|
58
|
+
"-c",
|
|
59
|
+
"core.attributesFile=/dev/null",
|
|
60
|
+
"-c",
|
|
61
|
+
"core.excludesFile=/dev/null",
|
|
62
|
+
"-c",
|
|
63
|
+
"core.fsmonitor=false",
|
|
64
|
+
"-c",
|
|
65
|
+
`safe.directory=${spec.workTree}`,
|
|
66
|
+
...args,
|
|
67
|
+
];
|
|
68
|
+
}
|
|
69
|
+
export class GitHistory {
|
|
70
|
+
spec;
|
|
71
|
+
git;
|
|
72
|
+
gitHome;
|
|
73
|
+
now;
|
|
74
|
+
logLine;
|
|
75
|
+
/** catchUp's prepare failure logs once per instance (never per settle). */
|
|
76
|
+
prepareFailureLogged = false;
|
|
77
|
+
constructor(spec, opts) {
|
|
78
|
+
this.spec = spec;
|
|
79
|
+
this.git = opts.gitBinary ?? "git";
|
|
80
|
+
this.gitHome = opts.gitHome;
|
|
81
|
+
this.now = opts.now ?? (() => new Date());
|
|
82
|
+
this.logLine = opts.log ?? (() => { });
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Make the repo usable WITHOUT committing (boot's eager-init
|
|
86
|
+
* shape): `prepare()` under the per-repo lock. A catch-up would
|
|
87
|
+
* commit whatever the work tree holds under the catch-up summary
|
|
88
|
+
* — an eager init racing the user's first edit would absorb that
|
|
89
|
+
* edit under an init summary (the 2026-11-03 core/skills failure).
|
|
90
|
+
* Settling only INITS the repo; the next catch-up/write records
|
|
91
|
+
* the content under its own actor and summary.
|
|
92
|
+
*/
|
|
93
|
+
settle() {
|
|
94
|
+
return this.withLock(async () => {
|
|
95
|
+
const state = await this.prepare();
|
|
96
|
+
if (!state.ok)
|
|
97
|
+
this.logLine(`history settle failed: ${state.reason}`);
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Whether the repo is usable, under the per-repo lock: a bare
|
|
102
|
+
* `rev-parse` outside the lock can interleave with another
|
|
103
|
+
* instance's init (the 2026-11-03 core/skills race — an
|
|
104
|
+
* availability read between a racing init's `mkdir` and its
|
|
105
|
+
* completion answered "not a git repository", and the caller
|
|
106
|
+
* rendered history-unavailable while a catch-up committed fine).
|
|
107
|
+
* `prepare()` self-heals the half-initialized state (its mkdir +
|
|
108
|
+
* init are idempotent), so an availability read NEVER blocks a
|
|
109
|
+
* recoverable repo behind a false negative.
|
|
110
|
+
*/
|
|
111
|
+
async availability() {
|
|
112
|
+
try {
|
|
113
|
+
await access(this.spec.workTree, constants.R_OK | constants.W_OK);
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
return {
|
|
117
|
+
ok: false,
|
|
118
|
+
reason: `work tree ${this.spec.workTree} is not readable and writable by the sigrun process`,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
try {
|
|
122
|
+
const state = await this.withLock(() => this.prepare());
|
|
123
|
+
if (state.ok)
|
|
124
|
+
return { ok: true };
|
|
125
|
+
return { ok: false, reason: state.reason };
|
|
126
|
+
}
|
|
127
|
+
catch (error) {
|
|
128
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
129
|
+
return {
|
|
130
|
+
ok: false,
|
|
131
|
+
reason: `git unavailable for ${this.spec.gitDir} (${this.git}): ${detail}`,
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Lock → catch-up (actor catchUpAs) → mutate → commit(meta). The
|
|
137
|
+
* mutation runs even when git is unavailable or failing; the failure is
|
|
138
|
+
* logged and the result is undefined (no commit).
|
|
139
|
+
*/
|
|
140
|
+
write(meta, mutate, catchUpAs) {
|
|
141
|
+
return this.withLock(async () => {
|
|
142
|
+
const state = await this.prepare();
|
|
143
|
+
if (!state.ok) {
|
|
144
|
+
await mutate();
|
|
145
|
+
this.logLine(`history write landed without a commit: ${state.reason}`);
|
|
146
|
+
return undefined;
|
|
147
|
+
}
|
|
148
|
+
if (catchUpAs !== undefined)
|
|
149
|
+
await this.commitDirty(catchUpAs);
|
|
150
|
+
await mutate();
|
|
151
|
+
return this.commit(meta);
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* Lock → commit uncommitted changes as meta (a catch-up). A prepare
|
|
156
|
+
* failure is logged ONCE per instance (the settle-time catch-up fires
|
|
157
|
+
* per run — the legitimate "agent never wrote skills" state must not
|
|
158
|
+
* warn every settle) and degrades to no commit.
|
|
159
|
+
*/
|
|
160
|
+
catchUp(meta) {
|
|
161
|
+
return this.withLock(async () => {
|
|
162
|
+
const state = await this.prepare();
|
|
163
|
+
if (!state.ok) {
|
|
164
|
+
if (!this.prepareFailureLogged) {
|
|
165
|
+
this.prepareFailureLogged = true;
|
|
166
|
+
this.logLine(`history catch-up skipped: ${state.reason}`);
|
|
167
|
+
}
|
|
168
|
+
return undefined;
|
|
169
|
+
}
|
|
170
|
+
return this.commit(meta);
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* Lock → DISCARD any existing repo → init fresh → one commit of the
|
|
175
|
+
* current pathspec content as meta (the curation migration import,
|
|
176
|
+
* Task 9). Atomic under the per-repo lock: a concurrent write either
|
|
177
|
+
* lands before the discard (its content is re-imported from disk —
|
|
178
|
+
* nothing lost) or after the import commit. Returns the import
|
|
179
|
+
* commit's sha, or undefined when nothing could be staged.
|
|
180
|
+
*/
|
|
181
|
+
freshImport(meta) {
|
|
182
|
+
return this.withLock(async () => {
|
|
183
|
+
await rm(this.spec.gitDir, { recursive: true, force: true });
|
|
184
|
+
const state = await this.prepare();
|
|
185
|
+
if (!state.ok) {
|
|
186
|
+
this.logLine(`history import landed without a commit: ${state.reason}`);
|
|
187
|
+
return undefined;
|
|
188
|
+
}
|
|
189
|
+
return this.commit(meta);
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
async log(opts) {
|
|
193
|
+
if (await this.headMissing())
|
|
194
|
+
return [];
|
|
195
|
+
const head = await this.output(["rev-parse", "--verify", "HEAD"]);
|
|
196
|
+
// `before` pages strictly older commits: start from the cursor's
|
|
197
|
+
// parent so the cursor commit itself is excluded.
|
|
198
|
+
const start = opts.before === undefined ? head.trim() : `${opts.before}^`;
|
|
199
|
+
// Paging from the root commit's sha (`<root>^` does not exist) is an
|
|
200
|
+
// empty page, not an error: Task 12's paginator reaches it normally.
|
|
201
|
+
if (opts.before !== undefined && (await this.parentOf(opts.before)) === undefined) {
|
|
202
|
+
return [];
|
|
203
|
+
}
|
|
204
|
+
const format = [
|
|
205
|
+
"--format=%x1e%H%x1f%aI%x1f%s%x1f%an",
|
|
206
|
+
"%x1f%(trailers:key=Sigrun-Actor,valueonly)",
|
|
207
|
+
"%x1f%(trailers:key=Sigrun-Action,valueonly)",
|
|
208
|
+
"%x1f%(trailers:key=Sigrun-Skill,valueonly)",
|
|
209
|
+
"%x1f%(trailers:key=Sigrun-Review,valueonly)",
|
|
210
|
+
"%x1f%(trailers:key=Sigrun-Conversation,valueonly)",
|
|
211
|
+
"%x1f%(trailers:key=Sigrun-Reason,valueonly)",
|
|
212
|
+
"%x1f%(trailers:key=Sigrun-Undoes,valueonly)",
|
|
213
|
+
].join("");
|
|
214
|
+
const args = [
|
|
215
|
+
"log",
|
|
216
|
+
`--max-count=${opts.limit}`,
|
|
217
|
+
format,
|
|
218
|
+
"--name-only",
|
|
219
|
+
"-z",
|
|
220
|
+
start,
|
|
221
|
+
];
|
|
222
|
+
if (opts.path !== undefined)
|
|
223
|
+
args.push("--", opts.path);
|
|
224
|
+
const stdout = await this.output(args);
|
|
225
|
+
const commits = [];
|
|
226
|
+
for (const record of stdout.split(RS)) {
|
|
227
|
+
// --name-only with -z appends a NUL and the NUL-separated path list
|
|
228
|
+
// after the %x1f fields; it belongs to this record.
|
|
229
|
+
const nulAt = record.indexOf("\0");
|
|
230
|
+
const head = (nulAt === -1 ? record : record.slice(0, nulAt)).trim();
|
|
231
|
+
if (head === "")
|
|
232
|
+
continue;
|
|
233
|
+
const paths = nulAt === -1
|
|
234
|
+
? []
|
|
235
|
+
: record
|
|
236
|
+
.slice(nulAt + 1)
|
|
237
|
+
.split("\0")
|
|
238
|
+
.map((line) => line.trim())
|
|
239
|
+
.filter((line) => line !== "");
|
|
240
|
+
// Single-line %x1f fields can carry a trailing newline: first-line.
|
|
241
|
+
const parts = head.split(FS).map((part) => {
|
|
242
|
+
const newlineAt = part.indexOf("\n");
|
|
243
|
+
return newlineAt === -1 ? part.trim() : part.slice(0, newlineAt).trim();
|
|
244
|
+
});
|
|
245
|
+
const sha = parts[0] ?? "";
|
|
246
|
+
const at = parts[1] ?? "";
|
|
247
|
+
const summary = parts[2] ?? "";
|
|
248
|
+
const authorName = parts[3] ?? "";
|
|
249
|
+
const trailers = parts.slice(4, 11).map((value) => orUndefined(value));
|
|
250
|
+
const actorRaw = trailers[0];
|
|
251
|
+
const conversationRaw = trailers[4];
|
|
252
|
+
commits.push({
|
|
253
|
+
sha,
|
|
254
|
+
at: at !== "" ? new Date(at).toISOString() : "",
|
|
255
|
+
summary: summary.trim(),
|
|
256
|
+
actor: decodeActor(actorRaw === undefined ? authorName.trim() : actorRaw),
|
|
257
|
+
action: trailers[1],
|
|
258
|
+
skill: trailers[2],
|
|
259
|
+
reviewId: trailers[3],
|
|
260
|
+
conversationId: conversationRaw === undefined ? undefined : Number(conversationRaw),
|
|
261
|
+
reason: trailers[5],
|
|
262
|
+
undoes: trailers[6],
|
|
263
|
+
paths,
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
return commits;
|
|
267
|
+
}
|
|
268
|
+
/**
|
|
269
|
+
* Per-file before/after text for one commit, restricted to path. Binary
|
|
270
|
+
* or oversize (> 64 KiB) blobs return OVERSIZE_MARKER on the affected
|
|
271
|
+
* side; the side with no blob (add / delete) is the empty string.
|
|
272
|
+
*/
|
|
273
|
+
async changes(sha, path) {
|
|
274
|
+
const parent = await this.parentOf(sha);
|
|
275
|
+
const beforeTree = parent === undefined ? EMPTY_TREE : parent;
|
|
276
|
+
const args = ["diff-tree", "-r", "--format=", beforeTree, sha];
|
|
277
|
+
if (path !== undefined)
|
|
278
|
+
args.push("--", path);
|
|
279
|
+
const raw = await this.output(args);
|
|
280
|
+
const pairs = [];
|
|
281
|
+
for (const line of raw.split("\n")) {
|
|
282
|
+
if (!line.startsWith(":"))
|
|
283
|
+
continue;
|
|
284
|
+
const match = /^:\d+ \d+ ([0-9a-f]{40}) ([0-9a-f]{40}) [A-Z]\t(.+)$/.exec(line);
|
|
285
|
+
if (match === null)
|
|
286
|
+
continue;
|
|
287
|
+
const [, beforeSha, afterSha, filePath] = match;
|
|
288
|
+
if (beforeSha === undefined || afterSha === undefined || filePath === undefined) {
|
|
289
|
+
continue;
|
|
290
|
+
}
|
|
291
|
+
const ZERO = "0000000000000000000000000000000000000000";
|
|
292
|
+
pairs.push({
|
|
293
|
+
path: filePath,
|
|
294
|
+
before: beforeSha === ZERO ? null : beforeSha,
|
|
295
|
+
after: afterSha === ZERO ? null : afterSha,
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
const result = [];
|
|
299
|
+
for (const pair of pairs) {
|
|
300
|
+
result.push({
|
|
301
|
+
path: pair.path,
|
|
302
|
+
before: pair.before === null ? "" : await this.blob(pair.before),
|
|
303
|
+
after: pair.after === null ? "" : await this.blob(pair.after),
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
return result;
|
|
307
|
+
}
|
|
308
|
+
/**
|
|
309
|
+
* Lock → make paths exactly as they were at sha → commit(meta).
|
|
310
|
+
* sha === undefined means "before history began": the paths are removed.
|
|
311
|
+
*/
|
|
312
|
+
restore(sha, paths, meta) {
|
|
313
|
+
return this.withLock(async () => {
|
|
314
|
+
const state = await this.prepare();
|
|
315
|
+
if (!state.ok)
|
|
316
|
+
return undefined;
|
|
317
|
+
const head = await this.output(["rev-parse", "--verify", "HEAD"]);
|
|
318
|
+
const headSha = head.trim();
|
|
319
|
+
const headTree = await this.treeListing(headSha, paths);
|
|
320
|
+
const atSha = sha === undefined ? new Set() : await this.treeListing(sha, paths);
|
|
321
|
+
const remove = [...headTree].filter((path) => !atSha.has(path));
|
|
322
|
+
if (remove.length > 0) {
|
|
323
|
+
await this.run(["rm", "-q", "-r", "--ignore-unmatch", "--", ...remove]);
|
|
324
|
+
}
|
|
325
|
+
if (sha !== undefined && atSha.size > 0) {
|
|
326
|
+
const present = paths.filter((path) => this.existsAt(sha, atSha, path));
|
|
327
|
+
const restoreArgs = ["checkout", sha, "--", ...present];
|
|
328
|
+
await this.run(restoreArgs);
|
|
329
|
+
}
|
|
330
|
+
return this.commit(meta);
|
|
331
|
+
});
|
|
332
|
+
}
|
|
333
|
+
/** sha's first parent, or undefined for a root commit. */
|
|
334
|
+
async parentOf(sha) {
|
|
335
|
+
const stdout = await this.output([
|
|
336
|
+
"rev-list",
|
|
337
|
+
"--max-parents=0",
|
|
338
|
+
"HEAD",
|
|
339
|
+
"--",
|
|
340
|
+
]);
|
|
341
|
+
const roots = new Set(stdout.split("\n").map((line) => line.trim()));
|
|
342
|
+
if (roots.has(sha))
|
|
343
|
+
return undefined;
|
|
344
|
+
const parent = await this.output(["rev-parse", "--verify", `${sha}^`]);
|
|
345
|
+
const trimmed = parent.trim();
|
|
346
|
+
return trimmed === "" ? undefined : trimmed;
|
|
347
|
+
}
|
|
348
|
+
// ---- internals ------------------------------------------------------
|
|
349
|
+
async blob(sha) {
|
|
350
|
+
const info = await this.output(["cat-file", "-s", sha]);
|
|
351
|
+
if (Number(info.trim()) > MAX_INLINE_BYTES)
|
|
352
|
+
return OVERSIZE_MARKER;
|
|
353
|
+
const content = Buffer.from(await this.outputBuffer(["cat-file", "blob", sha]));
|
|
354
|
+
if (content.indexOf(0) !== -1)
|
|
355
|
+
return OVERSIZE_MARKER;
|
|
356
|
+
return content.toString("utf8");
|
|
357
|
+
}
|
|
358
|
+
/** Files in `tree` under any pathspec entry (path-restricted ls-tree). */
|
|
359
|
+
async treeListing(tree, paths) {
|
|
360
|
+
const result = new Set();
|
|
361
|
+
for (const path of paths) {
|
|
362
|
+
const stdout = await this.output([
|
|
363
|
+
"ls-tree",
|
|
364
|
+
"-r",
|
|
365
|
+
"--name-only",
|
|
366
|
+
tree,
|
|
367
|
+
"--",
|
|
368
|
+
path,
|
|
369
|
+
]);
|
|
370
|
+
for (const line of stdout.split("\n")) {
|
|
371
|
+
const trimmed = line.trim();
|
|
372
|
+
if (trimmed !== "")
|
|
373
|
+
result.add(trimmed);
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
return result;
|
|
377
|
+
}
|
|
378
|
+
/**
|
|
379
|
+
* Whether a pathspec entry has any files at sha. ls-tree reports dirs as
|
|
380
|
+
* a single "skills" line without -r, so compare against the recursive
|
|
381
|
+
* listing plus the entry itself.
|
|
382
|
+
*/
|
|
383
|
+
existsAt(sha, listing, entry) {
|
|
384
|
+
void sha;
|
|
385
|
+
if (listing.has(entry))
|
|
386
|
+
return true;
|
|
387
|
+
const prefix = entry.endsWith("/") ? entry : `${entry}/`;
|
|
388
|
+
for (const file of listing) {
|
|
389
|
+
if (file.startsWith(prefix))
|
|
390
|
+
return true;
|
|
391
|
+
}
|
|
392
|
+
return false;
|
|
393
|
+
}
|
|
394
|
+
/** No HEAD yet → the log reads as empty (never a git error surface). */
|
|
395
|
+
async headMissing() {
|
|
396
|
+
try {
|
|
397
|
+
await this.run(["rev-parse", "--verify", "HEAD"]);
|
|
398
|
+
return false;
|
|
399
|
+
}
|
|
400
|
+
catch {
|
|
401
|
+
return true;
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
withLock(operation) {
|
|
405
|
+
const key = this.spec.gitDir;
|
|
406
|
+
const previous = locks.get(key) ?? Promise.resolve();
|
|
407
|
+
const gate = new Promise((resolve) => {
|
|
408
|
+
previous.then(resolve, resolve);
|
|
409
|
+
});
|
|
410
|
+
const run = gate.then(operation);
|
|
411
|
+
const tail = run.then(() => undefined, () => undefined);
|
|
412
|
+
locks.set(key, tail);
|
|
413
|
+
return run;
|
|
414
|
+
}
|
|
415
|
+
/**
|
|
416
|
+
* Inside the lock: make the repo usable, or explain why not. The work
|
|
417
|
+
* tree itself is never created or modified here.
|
|
418
|
+
*/
|
|
419
|
+
async prepare() {
|
|
420
|
+
try {
|
|
421
|
+
await access(this.spec.workTree, constants.R_OK | constants.W_OK);
|
|
422
|
+
}
|
|
423
|
+
catch {
|
|
424
|
+
return {
|
|
425
|
+
ok: false,
|
|
426
|
+
reason: `work tree ${this.spec.workTree} is not readable and writable`,
|
|
427
|
+
};
|
|
428
|
+
}
|
|
429
|
+
try {
|
|
430
|
+
await mkdir(this.gitHome, { recursive: true });
|
|
431
|
+
}
|
|
432
|
+
catch {
|
|
433
|
+
// The env still points at gitHome; git tolerates it missing.
|
|
434
|
+
}
|
|
435
|
+
try {
|
|
436
|
+
await this.run(["rev-parse", "--git-dir"]);
|
|
437
|
+
}
|
|
438
|
+
catch (error) {
|
|
439
|
+
if (this.isMissingBinary(error)) {
|
|
440
|
+
return {
|
|
441
|
+
ok: false,
|
|
442
|
+
reason: `git binary not usable: ${this.git}`,
|
|
443
|
+
};
|
|
444
|
+
}
|
|
445
|
+
const gitDirInfo = await stat(this.spec.gitDir).catch(() => undefined);
|
|
446
|
+
if (gitDirInfo === undefined) {
|
|
447
|
+
try {
|
|
448
|
+
// The git dir's PARENT must exist: `git init` does not create
|
|
449
|
+
// it, and this module owns the git dir (never the work tree).
|
|
450
|
+
await mkdir(this.spec.gitDir, { recursive: true });
|
|
451
|
+
await this.run(["init", "--initial-branch=main"]);
|
|
452
|
+
await this.writeInfoExclude();
|
|
453
|
+
}
|
|
454
|
+
catch (initError) {
|
|
455
|
+
if (this.isMissingBinary(initError)) {
|
|
456
|
+
return {
|
|
457
|
+
ok: false,
|
|
458
|
+
reason: `git binary not usable: ${this.git}`,
|
|
459
|
+
};
|
|
460
|
+
}
|
|
461
|
+
return {
|
|
462
|
+
ok: false,
|
|
463
|
+
reason: `git init failed for ${this.spec.gitDir}: ${String(initError)}`,
|
|
464
|
+
};
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
else {
|
|
468
|
+
// Corrupt recovery is fail-soft like everything else: any failure
|
|
469
|
+
// here (rename blocked, init failing) degrades to no-commit; the
|
|
470
|
+
// caller's mutation still runs.
|
|
471
|
+
try {
|
|
472
|
+
await this.recoverCorrupt();
|
|
473
|
+
}
|
|
474
|
+
catch (recoverError) {
|
|
475
|
+
this.logLine(`corrupt git dir recovery failed for ${this.spec.gitDir}: ${String(recoverError)}`);
|
|
476
|
+
return {
|
|
477
|
+
ok: false,
|
|
478
|
+
reason: `corrupt git dir at ${this.spec.gitDir} could not be recovered: ${String(recoverError)}`,
|
|
479
|
+
};
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
return { ok: true };
|
|
484
|
+
}
|
|
485
|
+
isMissingBinary(error) {
|
|
486
|
+
return (typeof error === "object" &&
|
|
487
|
+
error !== null &&
|
|
488
|
+
"code" in error &&
|
|
489
|
+
error.code === "ENOENT");
|
|
490
|
+
}
|
|
491
|
+
/** Corrupt git dir → move aside, re-init, snapshot commit (loud log). */
|
|
492
|
+
async recoverCorrupt() {
|
|
493
|
+
const brokenName = `${this.spec.gitDir.split(sep).pop() ?? "repo"}.broken-${utcStamp(this.now())}`;
|
|
494
|
+
const brokenPath = `${this.spec.gitDir}.broken-${utcStamp(this.now())}`;
|
|
495
|
+
await rename(this.spec.gitDir, brokenPath);
|
|
496
|
+
this.logLine(`corrupt git dir moved aside: ${this.spec.gitDir} → ${brokenPath}`);
|
|
497
|
+
await this.run(["init", "--initial-branch=main"]);
|
|
498
|
+
await this.writeInfoExclude();
|
|
499
|
+
const sha = await this.commit({
|
|
500
|
+
actor: { kind: "outside" },
|
|
501
|
+
summary: `Re-initialized after a corrupt history (old repo moved to ${brokenName})`,
|
|
502
|
+
});
|
|
503
|
+
void sha;
|
|
504
|
+
}
|
|
505
|
+
/** The git dir must never be read as work-tree content. */
|
|
506
|
+
async writeInfoExclude() {
|
|
507
|
+
const relativeGitDir = relative(this.spec.workTree, this.spec.gitDir);
|
|
508
|
+
if (relativeGitDir === "" || relativeGitDir.startsWith(".."))
|
|
509
|
+
return;
|
|
510
|
+
const infoDir = `${this.spec.gitDir}/info`;
|
|
511
|
+
await mkdir(infoDir, { recursive: true });
|
|
512
|
+
await writeFile(`${infoDir}/exclude`, `/${relativeGitDir}\n`);
|
|
513
|
+
}
|
|
514
|
+
/**
|
|
515
|
+
* Staging: `add --all --force -- <entries>` filtered to entries that
|
|
516
|
+
* exist (git fatals on a pathspec matching nothing), minus any nested
|
|
517
|
+
* .git dirs, which are logged and excluded via a computed :(exclude)
|
|
518
|
+
* pathspec. Returns false when nothing could be staged.
|
|
519
|
+
*/
|
|
520
|
+
async stage() {
|
|
521
|
+
const entries = [];
|
|
522
|
+
const excluded = [];
|
|
523
|
+
for (const entry of this.spec.pathspec) {
|
|
524
|
+
const absolute = `${this.spec.workTree}/${entry}`;
|
|
525
|
+
const info = await stat(absolute).catch(() => undefined);
|
|
526
|
+
if (info === undefined) {
|
|
527
|
+
// An absent entry still stages when git tracks files under it:
|
|
528
|
+
// that's how a deleted pathspec root records its deletions.
|
|
529
|
+
// `git add --all` fatals only when the pathspec matches nothing
|
|
530
|
+
// in the index OR the work tree.
|
|
531
|
+
if (!(await this.tracksAny(entry)))
|
|
532
|
+
continue;
|
|
533
|
+
entries.push(entry);
|
|
534
|
+
continue;
|
|
535
|
+
}
|
|
536
|
+
if (info.isDirectory()) {
|
|
537
|
+
for (const nested of await this.nestedGitDirs(absolute, entry)) {
|
|
538
|
+
excluded.push(nested);
|
|
539
|
+
this.logLine(`skipping nested .git dir: ${this.spec.workTree}/${nested}`);
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
entries.push(entry);
|
|
543
|
+
}
|
|
544
|
+
if (entries.length === 0)
|
|
545
|
+
return false;
|
|
546
|
+
await this.run([
|
|
547
|
+
"add",
|
|
548
|
+
"--all",
|
|
549
|
+
"--force",
|
|
550
|
+
"--",
|
|
551
|
+
...entries,
|
|
552
|
+
...excluded.map((path) => `:(exclude)${path}`),
|
|
553
|
+
]);
|
|
554
|
+
return true;
|
|
555
|
+
}
|
|
556
|
+
/** Whether the index tracks any file under the pathspec entry. */
|
|
557
|
+
async tracksAny(entry) {
|
|
558
|
+
try {
|
|
559
|
+
const stdout = await this.output(["ls-files", "--", entry]);
|
|
560
|
+
return stdout.trim() !== "";
|
|
561
|
+
}
|
|
562
|
+
catch {
|
|
563
|
+
return false;
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
async nestedGitDirs(absolute, relativeRoot) {
|
|
567
|
+
const found = [];
|
|
568
|
+
const walk = async (dir, prefix) => {
|
|
569
|
+
const children = await readdir(dir, { withFileTypes: true }).catch(() => []);
|
|
570
|
+
for (const child of children) {
|
|
571
|
+
if (!child.isDirectory())
|
|
572
|
+
continue;
|
|
573
|
+
const childPath = `${dir}/${child.name}`;
|
|
574
|
+
const childRelative = `${prefix}${child.name}`;
|
|
575
|
+
if (child.name === ".git") {
|
|
576
|
+
// Exclude the whole nested repo root, not just its .git dir.
|
|
577
|
+
found.push(prefix.slice(0, -1));
|
|
578
|
+
continue;
|
|
579
|
+
}
|
|
580
|
+
await walk(childPath, `${childRelative}/`);
|
|
581
|
+
}
|
|
582
|
+
};
|
|
583
|
+
const root = relativeRoot.endsWith("/") ? relativeRoot : `${relativeRoot}/`;
|
|
584
|
+
await walk(absolute, root);
|
|
585
|
+
return found;
|
|
586
|
+
}
|
|
587
|
+
/** A fresh index.lock fails the commit; a stale one is removed. */
|
|
588
|
+
async checkIndexLock() {
|
|
589
|
+
const lockPath = `${this.spec.gitDir}/index.lock`;
|
|
590
|
+
const info = await stat(lockPath).catch(() => undefined);
|
|
591
|
+
if (info === undefined)
|
|
592
|
+
return { ok: true };
|
|
593
|
+
const ageMs = this.now().getTime() - info.mtimeMs;
|
|
594
|
+
if (ageMs > STALE_LOCK_MS) {
|
|
595
|
+
await rm(lockPath, { force: true });
|
|
596
|
+
this.logLine(`removed stale index.lock (age ${Math.round(ageMs / 60000)} min) at ${lockPath}`);
|
|
597
|
+
return { ok: true };
|
|
598
|
+
}
|
|
599
|
+
return {
|
|
600
|
+
ok: false,
|
|
601
|
+
reason: `index.lock present at ${lockPath} (age ${Math.round(ageMs / 1000)}s)`,
|
|
602
|
+
};
|
|
603
|
+
}
|
|
604
|
+
/**
|
|
605
|
+
* Stage + commit if anything changed. Returns the new commit's sha, or
|
|
606
|
+
* undefined when there was no change or git failed (logged).
|
|
607
|
+
*/
|
|
608
|
+
async commit(meta) {
|
|
609
|
+
try {
|
|
610
|
+
// Trailer/content injection guard: every string that flows into
|
|
611
|
+
// the message is sanitized first (trailers.ts).
|
|
612
|
+
const cleanMeta = sanitizeMeta(meta);
|
|
613
|
+
const lockState = await this.checkIndexLock();
|
|
614
|
+
if (!lockState.ok) {
|
|
615
|
+
this.logLine(`history commit skipped: ${lockState.reason}`);
|
|
616
|
+
return undefined;
|
|
617
|
+
}
|
|
618
|
+
if (!(await this.stage()))
|
|
619
|
+
return undefined;
|
|
620
|
+
const status = await this.output([
|
|
621
|
+
"status",
|
|
622
|
+
"--porcelain",
|
|
623
|
+
"--",
|
|
624
|
+
...this.spec.pathspec,
|
|
625
|
+
]);
|
|
626
|
+
if (status.trim() === "")
|
|
627
|
+
return undefined;
|
|
628
|
+
const trailers = encodeTrailers(cleanMeta);
|
|
629
|
+
const message = trailers === "" ? cleanMeta.summary : `${cleanMeta.summary}\n\n${trailers}`;
|
|
630
|
+
const sha = await this.runCommit(message, cleanMeta);
|
|
631
|
+
await this.gc();
|
|
632
|
+
return sha;
|
|
633
|
+
}
|
|
634
|
+
catch (error) {
|
|
635
|
+
this.logLine(`history commit failed: ${String(error)}`);
|
|
636
|
+
return undefined;
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
/** commitDirty = the catch-up half of write(): commit what is there. */
|
|
640
|
+
async commitDirty(actor) {
|
|
641
|
+
return this.commit({ actor, summary: "outside changes" });
|
|
642
|
+
}
|
|
643
|
+
async runCommit(message, meta) {
|
|
644
|
+
const env = {
|
|
645
|
+
...this.env(),
|
|
646
|
+
GIT_AUTHOR_NAME: actorName(meta.actor),
|
|
647
|
+
GIT_AUTHOR_EMAIL: actorEmail(meta.actor),
|
|
648
|
+
GIT_COMMITTER_NAME: "sigrun",
|
|
649
|
+
GIT_COMMITTER_EMAIL: "sigrun@sigrun.invalid",
|
|
650
|
+
};
|
|
651
|
+
await execFileAsync(this.git, gitArgs(this.spec, "commit", "-m", message), {
|
|
652
|
+
env,
|
|
653
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
654
|
+
});
|
|
655
|
+
const sha = await this.output(["rev-parse", "--verify", "HEAD"]);
|
|
656
|
+
return sha.trim();
|
|
657
|
+
}
|
|
658
|
+
async gc() {
|
|
659
|
+
try {
|
|
660
|
+
await this.run(["gc", "--auto", "--quiet"]);
|
|
661
|
+
}
|
|
662
|
+
catch (error) {
|
|
663
|
+
this.logLine(`git gc --auto failed (ignored): ${String(error)}`);
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
env() {
|
|
667
|
+
// Ambient GIT_* variables (GIT_INDEX_FILE, GIT_OBJECT_DIRECTORY,
|
|
668
|
+
// GIT_COMMON_DIR, ...) survive an argv-level -c and would let the
|
|
669
|
+
// caller's environment redirect or corrupt the repo. Strip them all;
|
|
670
|
+
// the module sets exactly the five it needs.
|
|
671
|
+
const env = {};
|
|
672
|
+
for (const [key, value] of Object.entries(process.env)) {
|
|
673
|
+
if (!key.startsWith("GIT_"))
|
|
674
|
+
env[key] = value;
|
|
675
|
+
}
|
|
676
|
+
return {
|
|
677
|
+
...env,
|
|
678
|
+
GIT_CONFIG_GLOBAL: "/dev/null",
|
|
679
|
+
GIT_CONFIG_NOSYSTEM: "1",
|
|
680
|
+
HOME: this.gitHome,
|
|
681
|
+
XDG_CONFIG_HOME: this.gitHome,
|
|
682
|
+
GIT_TERMINAL_PROMPT: "0",
|
|
683
|
+
};
|
|
684
|
+
}
|
|
685
|
+
async run(args) {
|
|
686
|
+
await execFileAsync(this.git, gitArgs(this.spec, ...args), {
|
|
687
|
+
env: this.env(),
|
|
688
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
689
|
+
});
|
|
690
|
+
}
|
|
691
|
+
async output(args) {
|
|
692
|
+
const { stdout } = await execFileAsync(this.git, gitArgs(this.spec, ...args), { env: this.env(), maxBuffer: 64 * 1024 * 1024 });
|
|
693
|
+
return stdout;
|
|
694
|
+
}
|
|
695
|
+
async outputBuffer(args) {
|
|
696
|
+
const { stdout } = await execFileAsync(this.git, gitArgs(this.spec, ...args), {
|
|
697
|
+
env: this.env(),
|
|
698
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
699
|
+
encoding: "buffer",
|
|
700
|
+
});
|
|
701
|
+
return stdout;
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
function orUndefined(value) {
|
|
705
|
+
const trimmed = value?.trim();
|
|
706
|
+
return trimmed === undefined || trimmed === "" ? undefined : trimmed;
|
|
707
|
+
}
|