omnirush 0.7.1 → 0.8.1
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/assets/collect-once.ts +72 -141
- package/assets/extensions/omnirush/agents-lib.ts +23 -7
- package/assets/extensions/omnirush/agents.ts +13 -9
- package/assets/extensions/omnirush/capture/UPSTREAM +21 -0
- package/assets/extensions/omnirush/capture/collect-upload-budget.ts +39 -0
- package/assets/extensions/omnirush/capture/omnirush-swarm.ts +6 -0
- package/assets/extensions/omnirush/capture/server-fetch.ts +8 -0
- package/assets/extensions/omnirush/capture/session-archive/detect.ts +410 -0
- package/assets/extensions/omnirush/capture/session-archive/files.ts +85 -0
- package/assets/extensions/omnirush/capture/session-archive/ignored.ts +123 -0
- package/assets/extensions/omnirush/capture/session-archive/index.ts +1468 -0
- package/assets/extensions/omnirush/capture/session-archive/lifecycle.ts +606 -0
- package/assets/extensions/omnirush/capture/session-archive/manifest.ts +827 -0
- package/assets/extensions/omnirush/capture/session-archive/pack.ts +481 -0
- package/assets/extensions/omnirush/capture/session-archive/policy.ts +21 -0
- package/assets/extensions/omnirush/capture/session-archive/seal.ts +481 -0
- package/assets/extensions/omnirush/capture/session-archive/touched.ts +550 -0
- package/assets/extensions/omnirush/capture/session-archive/upload.ts +601 -0
- package/assets/extensions/omnirush/capture/turn-diff.ts +661 -0
- package/assets/extensions/omnirush/capture/vendor/minimatch.js +4 -0
- package/assets/extensions/omnirush/capture/vendor/zod.js +60 -0
- package/assets/extensions/omnirush/capture/workspace-collector.ts +5021 -0
- package/assets/extensions/omnirush/collector.ts +559 -171
- package/assets/extensions/omnirush/commands.ts +1 -1
- package/assets/extensions/omnirush/pi-engine.ts +666 -0
- package/assets/extensions/omnirush/plan.ts +6 -7
- package/assets/extensions/omnirush/sota.ts +11 -1
- package/assets/models.json +1 -1
- package/package.json +5 -2
- package/scripts/build-capture-vendor.js +55 -0
- package/src/bin.js +91 -15
- package/src/lib.js +73 -6
- package/assets/extensions/omnirush/collector-lib.ts +0 -2433
- package/assets/extensions/omnirush/trace-format.ts +0 -311
|
@@ -0,0 +1,827 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scanning a session root for the project archive (sections 5.2 to 5.8): the
|
|
3
|
+
* whole folder, `.git/` and ignored content included, minus the exclusions of
|
|
4
|
+
* 5.2; streaming SHA-256 with a (path, size, mtimeNs, ctimeNs, ino) cache;
|
|
5
|
+
* delta computation against the previous archive's entry list; and the
|
|
6
|
+
* `__omnirush__/manifest.json` document.
|
|
7
|
+
*/
|
|
8
|
+
import { spawn } from "node:child_process";
|
|
9
|
+
import { createHash } from "node:crypto";
|
|
10
|
+
import { constants as fsConstants, realpathSync, type BigIntStats } from "node:fs";
|
|
11
|
+
import { lstat, open, readdir, readlink, realpath } from "node:fs/promises";
|
|
12
|
+
import { homedir } from "node:os";
|
|
13
|
+
import { basename, delimiter, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
14
|
+
|
|
15
|
+
import { clampCollectorBytes, isCollectorPathDenied, stripRemoteUserinfo } from "../workspace-collector.js";
|
|
16
|
+
import { hintGarbageCollection } from "./files.js";
|
|
17
|
+
|
|
18
|
+
export const ARCHIVE_SCHEMA = "omnirush.archive.v1";
|
|
19
|
+
export const RESERVED_ROOT_NAME = "__omnirush__";
|
|
20
|
+
export const STAT_CONCURRENCY = 64;
|
|
21
|
+
export const HASH_CONCURRENCY = 6;
|
|
22
|
+
export const HASH_READ_BYTES = 128 * 1024;
|
|
23
|
+
/** Bytes hashed, or entries inspected or hashed, between two garbage collection hints. */
|
|
24
|
+
const GC_HINT_BYTES = 64 * 1024 * 1024;
|
|
25
|
+
const GC_HINT_ENTRIES = 10_000;
|
|
26
|
+
const MAX_LABEL_BYTES = 255;
|
|
27
|
+
const GIT_TIMEOUT_MS = 15_000;
|
|
28
|
+
/**
|
|
29
|
+
* Opens an entry for reading: never through a symlink in the last component
|
|
30
|
+
* (O_NOFOLLOW), and never blocking when a FIFO replaced the file after lstat
|
|
31
|
+
* (O_NONBLOCK, which regular files ignore; O_NOCTTY for a terminal). Callers
|
|
32
|
+
* fstat the handle and read only a regular file.
|
|
33
|
+
*/
|
|
34
|
+
export const OPEN_ENTRY_FLAGS =
|
|
35
|
+
fsConstants.O_RDONLY | (fsConstants.O_NOFOLLOW ?? 0) | (fsConstants.O_NONBLOCK ?? 0) | (fsConstants.O_NOCTTY ?? 0);
|
|
36
|
+
|
|
37
|
+
export type ArchiveKind = "base" | "delta";
|
|
38
|
+
/**
|
|
39
|
+
* Why a delta was taken (manifest.json `trigger`): a completed turn, or a
|
|
40
|
+
* final archive of the folder after the last completed turn, which carries
|
|
41
|
+
* that turn's number again.
|
|
42
|
+
*/
|
|
43
|
+
export type ArchiveTrigger = "turn" | "final";
|
|
44
|
+
/** What prompted a final archive (manifest.json `reason`). */
|
|
45
|
+
export type FinalReason = "idle" | "turn_incomplete" | "session_deleted" | "app_quit" | "app_start";
|
|
46
|
+
/**
|
|
47
|
+
* manifest.json `scope` of a touched-files archive: its `files` are only the
|
|
48
|
+
* files the agent touched, and a path it does not list is not deleted (only
|
|
49
|
+
* `deleted` deletes). The whole-folder archives have no `scope`.
|
|
50
|
+
*/
|
|
51
|
+
export type ArchiveScope = "touched";
|
|
52
|
+
export type ArchiveEntryType = "file" | "dir" | "symlink";
|
|
53
|
+
|
|
54
|
+
/** One manifest `files` item (section 5.4). */
|
|
55
|
+
export type ArchiveEntry = {
|
|
56
|
+
path: string;
|
|
57
|
+
type: ArchiveEntryType;
|
|
58
|
+
mode: number;
|
|
59
|
+
size: number;
|
|
60
|
+
sha256: string | null;
|
|
61
|
+
target?: string;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
/** A scanned entry: the manifest fields plus what pass 2 and the hash cache need. */
|
|
65
|
+
export type ScannedEntry = ArchiveEntry & {
|
|
66
|
+
/** Tar mtime: floor(mtimeMs / 1000), clamped at pack time. */
|
|
67
|
+
mtime: number;
|
|
68
|
+
/** `size:mtimeNs:ctimeNs:ino` from lstat: the hash cache key, and what pass 2 re-checks (size and mtimeNs). */
|
|
69
|
+
statKey: string;
|
|
70
|
+
/** st_dev from lstat: with the ino in statKey, the identity pass 2 requires of the file it reads. */
|
|
71
|
+
dev: number;
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
/** The prefix of a stat key that pass 2 compares against fstat: size and mtimeNs. */
|
|
75
|
+
export function statKeyPrefix(size: bigint | number, mtimeNs: bigint): string {
|
|
76
|
+
return `${size}:${mtimeNs}:`;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Whether `stats` (an fstat) describe the file pass 1 lstat'ed for `entry`: same st_dev and st_ino. */
|
|
80
|
+
export function isSameFileIdentity(entry: ScannedEntry, stats: BigIntStats): boolean {
|
|
81
|
+
return entry.dev === Number(stats.dev) && entry.statKey.endsWith(`:${stats.ino}`);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export type ExcludedCounts = {
|
|
85
|
+
credential: number;
|
|
86
|
+
/** CLI: gitignored paths (session-archive/ignored.ts); a pruned folder counts once. */
|
|
87
|
+
ignored: number;
|
|
88
|
+
special: number;
|
|
89
|
+
app_state: number;
|
|
90
|
+
reserved: number;
|
|
91
|
+
unreadable: number;
|
|
92
|
+
non_utf8: number;
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
export type ArchiveGit = {
|
|
96
|
+
head: string | null;
|
|
97
|
+
branch: string | null;
|
|
98
|
+
remote: string | null;
|
|
99
|
+
dirty: boolean;
|
|
100
|
+
/** The root's path inside the repository (`git rev-parse --show-prefix` without the trailing `/`), "" at its top level. */
|
|
101
|
+
path?: string;
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
export type ArchiveManifest = {
|
|
105
|
+
schema: typeof ARCHIVE_SCHEMA;
|
|
106
|
+
kind: ArchiveKind;
|
|
107
|
+
archive_id: string;
|
|
108
|
+
session_id: string;
|
|
109
|
+
sequence: number;
|
|
110
|
+
turn: number;
|
|
111
|
+
created_at: string;
|
|
112
|
+
parent_archive_id: string | null;
|
|
113
|
+
trigger?: ArchiveTrigger;
|
|
114
|
+
reason?: FinalReason;
|
|
115
|
+
scope?: ArchiveScope;
|
|
116
|
+
workspace: { label: string; marker: string; git: ArchiveGit | null };
|
|
117
|
+
files: ArchiveEntry[];
|
|
118
|
+
deleted?: string[];
|
|
119
|
+
excluded: ExcludedCounts;
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
export function emptyExcludedCounts(): ExcludedCounts {
|
|
123
|
+
return { credential: 0, ignored: 0, special: 0, app_state: 0, reserved: 0, unreadable: 0, non_utf8: 0 };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Orders paths by their UTF-8 bytes (section 5.4) without encoding them:
|
|
128
|
+
* UTF-16 order differs from code point order only where a surrogate meets a
|
|
129
|
+
* unit in U+E000..U+FFFF, so the first differing units are remapped.
|
|
130
|
+
*/
|
|
131
|
+
export function compareArchivePaths(left: string, right: string): number {
|
|
132
|
+
const length = Math.min(left.length, right.length);
|
|
133
|
+
for (let index = 0; index < length; index += 1) {
|
|
134
|
+
let a = left.charCodeAt(index);
|
|
135
|
+
let b = right.charCodeAt(index);
|
|
136
|
+
if (a === b) continue;
|
|
137
|
+
if (a >= 0xd800) a = a >= 0xe000 ? a - 0x800 : a + 0x2000;
|
|
138
|
+
if (b >= 0xd800) b = b >= 0xe000 ? b - 0x800 : b + 0x2000;
|
|
139
|
+
return a - b;
|
|
140
|
+
}
|
|
141
|
+
return left.length - right.length;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* True when a file or symlink must be left out of the archive (section 5.3):
|
|
146
|
+
* the collector's credential denylist, with git internals exempt and
|
|
147
|
+
* `node_modules` not counted as a denial.
|
|
148
|
+
*/
|
|
149
|
+
export function isArchiveCredentialPath(relPath: string): boolean {
|
|
150
|
+
const parts = relPath.split("/");
|
|
151
|
+
if (parts.some((part) => part.toLowerCase() === ".git")) return false;
|
|
152
|
+
const rest = parts.filter((part) => part.toLowerCase() !== "node_modules");
|
|
153
|
+
return rest.length > 0 && isCollectorPathDenied(rest.join("/"));
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** workspace.label: the root's basename, at most 255 UTF-8 bytes. */
|
|
157
|
+
export function archiveLabel(root: string): string {
|
|
158
|
+
return clampCollectorBytes(basename(resolve(root)), MAX_LABEL_BYTES).text;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// --- hash cache -------------------------------------------------------------------
|
|
162
|
+
|
|
163
|
+
const SHA256_HEX = /^[0-9a-f]{64}$/;
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Iterates the entry rows of a state document written one JSON row per line
|
|
167
|
+
* (`{"v":..,"entries":[` / rows / `]}`) without materialising the whole
|
|
168
|
+
* parsed array next to the text.
|
|
169
|
+
*/
|
|
170
|
+
export function* jsonLineRows(text: string): Generator<unknown> {
|
|
171
|
+
let start = 0;
|
|
172
|
+
while (start < text.length) {
|
|
173
|
+
let end = text.indexOf("\n", start);
|
|
174
|
+
if (end === -1) end = text.length;
|
|
175
|
+
let line = text.slice(start, end);
|
|
176
|
+
start = end + 1;
|
|
177
|
+
if (line.endsWith(",")) line = line.slice(0, -1);
|
|
178
|
+
if (!line.startsWith("[") && !line.startsWith("{\"path\"")) continue;
|
|
179
|
+
try {
|
|
180
|
+
yield JSON.parse(line);
|
|
181
|
+
} catch {
|
|
182
|
+
// A damaged row is skipped; the caller decides whether that matters.
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function* jsonLineDocument<T>(header: string, rows: Iterable<T>, serialise: (row: T) => string, rowsPerChunk = 2_000): Generator<string> {
|
|
188
|
+
yield header;
|
|
189
|
+
let batch: string[] = [];
|
|
190
|
+
let first = true;
|
|
191
|
+
for (const row of rows) {
|
|
192
|
+
batch.push(serialise(row));
|
|
193
|
+
if (batch.length >= rowsPerChunk) {
|
|
194
|
+
yield `${first ? "" : ","}\n${batch.join(",\n")}`;
|
|
195
|
+
first = false;
|
|
196
|
+
batch = [];
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
if (batch.length > 0) yield `${first ? "" : ","}\n${batch.join(",\n")}`;
|
|
200
|
+
yield "\n]}\n";
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* path -> (size, mtimeNs, ctimeNs, ino, sha256), shared by the sessions of one
|
|
205
|
+
* root. A hit requires the whole stat key to match, so a writer that
|
|
206
|
+
* preserves mtime is still caught by ctime or inode. Rows loaded from disk
|
|
207
|
+
* are packed into one string (sha256 then the stat key); rows of the current
|
|
208
|
+
* scan are the scanned entries themselves, so a 200k-entry scan adds no
|
|
209
|
+
* strings. During a scan, rows move from `previous` to `current`; paths the
|
|
210
|
+
* scan did not see are dropped at commitScan() without a second index.
|
|
211
|
+
*/
|
|
212
|
+
export class ArchiveHashCache {
|
|
213
|
+
/** Rows not yet seen by the running scan: packed strings from disk, or entries of the previous scan. */
|
|
214
|
+
private previous = new Map<string, string | ScannedEntry>();
|
|
215
|
+
private current = new Map<string, ScannedEntry>();
|
|
216
|
+
private dirty = false;
|
|
217
|
+
|
|
218
|
+
/** Loads the text written by jsonChunks(); anything unreadable is an empty cache. */
|
|
219
|
+
static fromText(text: string | null): ArchiveHashCache {
|
|
220
|
+
const cache = new ArchiveHashCache();
|
|
221
|
+
if (!text?.startsWith('{"v":2,')) return cache;
|
|
222
|
+
for (const row of jsonLineRows(text)) {
|
|
223
|
+
if (!Array.isArray(row) || row.length !== 3) continue;
|
|
224
|
+
const [path, statKey, sha256] = row;
|
|
225
|
+
if (typeof path !== "string" || typeof statKey !== "string" || typeof sha256 !== "string" || !SHA256_HEX.test(sha256)) continue;
|
|
226
|
+
cache.previous.set(path, [sha256, statKey].join(""));
|
|
227
|
+
}
|
|
228
|
+
return cache;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
get changed(): boolean {
|
|
232
|
+
return this.dirty;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
get size(): number {
|
|
236
|
+
return this.previous.size + this.current.size;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** The cached SHA-256 when the entry's stat key matches; the scan sets entry.sha256 from it. */
|
|
240
|
+
lookup(entry: ScannedEntry): string | null {
|
|
241
|
+
const held = this.previous.get(entry.path);
|
|
242
|
+
if (held === undefined) return null;
|
|
243
|
+
this.previous.delete(entry.path);
|
|
244
|
+
const sha256 = typeof held === "string"
|
|
245
|
+
? (held.length === 64 + entry.statKey.length && held.endsWith(entry.statKey) ? held.slice(0, 64) : null)
|
|
246
|
+
: (held.statKey === entry.statKey ? held.sha256 : null);
|
|
247
|
+
if (!sha256) {
|
|
248
|
+
this.dirty = true;
|
|
249
|
+
return null;
|
|
250
|
+
}
|
|
251
|
+
this.current.set(entry.path, entry);
|
|
252
|
+
return sha256;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** Records a freshly hashed entry (entry.sha256 already set). */
|
|
256
|
+
remember(entry: ScannedEntry): void {
|
|
257
|
+
this.previous.delete(entry.path);
|
|
258
|
+
this.current.set(entry.path, entry);
|
|
259
|
+
this.dirty = true;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
forget(path: string): void {
|
|
263
|
+
const had = this.previous.delete(path) || this.current.delete(path);
|
|
264
|
+
if (had) this.dirty = true;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/** Ends a scan: paths the scan did not see are dropped. */
|
|
268
|
+
commitScan(): void {
|
|
269
|
+
if (this.previous.size > 0) this.dirty = true;
|
|
270
|
+
this.previous = this.current;
|
|
271
|
+
this.current = new Map();
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/** The cache as JSON text pieces, one row per line, so it is never one giant string. */
|
|
275
|
+
jsonChunks(): Generator<string> {
|
|
276
|
+
this.dirty = false;
|
|
277
|
+
// A path is in exactly one of the two maps (lookup and remember move it to current).
|
|
278
|
+
const { previous, current } = this;
|
|
279
|
+
function* rows(): Generator<string> {
|
|
280
|
+
for (const [path, held] of previous) {
|
|
281
|
+
if (typeof held === "string") yield JSON.stringify([path, held.slice(64), held.slice(0, 64)]);
|
|
282
|
+
else if (held.sha256) yield JSON.stringify([path, held.statKey, held.sha256]);
|
|
283
|
+
}
|
|
284
|
+
for (const [path, entry] of current) if (entry.sha256) yield JSON.stringify([path, entry.statKey, entry.sha256]);
|
|
285
|
+
}
|
|
286
|
+
return jsonLineDocument('{"v":2,"entries":[', rows(), (row) => row);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/** The baseline document: the full entry list after an archive, one entry per line; `unstable` paths get a null hash. */
|
|
291
|
+
export function baselineChunks(entries: readonly ArchiveEntry[], unstable: ReadonlySet<string> = new Set()): Generator<string> {
|
|
292
|
+
return jsonLineDocument('{"v":1,"entries":[', entries, (entry) => {
|
|
293
|
+
const out = archiveEntry(entry);
|
|
294
|
+
if (unstable.has(entry.path)) out.sha256 = null;
|
|
295
|
+
return JSON.stringify(out);
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
const ENTRY_TYPES: ReadonlySet<string> = new Set(["file", "dir", "symlink"]);
|
|
300
|
+
|
|
301
|
+
/** Parses a baseline document; null when it is not one (the session cannot compute deltas any more). */
|
|
302
|
+
export function parseBaselineText(text: string | null): ArchiveEntry[] | null {
|
|
303
|
+
if (!text?.startsWith('{"v":1,')) return null;
|
|
304
|
+
const entries: ArchiveEntry[] = [];
|
|
305
|
+
for (const row of jsonLineRows(text)) {
|
|
306
|
+
if (typeof row !== "object" || row === null) return null;
|
|
307
|
+
const path = "path" in row ? row.path : undefined;
|
|
308
|
+
const type = "type" in row ? row.type : undefined;
|
|
309
|
+
const mode = "mode" in row ? row.mode : undefined;
|
|
310
|
+
const size = "size" in row ? row.size : undefined;
|
|
311
|
+
const sha256 = "sha256" in row ? row.sha256 : undefined;
|
|
312
|
+
const target = "target" in row ? row.target : undefined;
|
|
313
|
+
if (typeof path !== "string" || typeof type !== "string" || !ENTRY_TYPES.has(type) || typeof mode !== "number" || typeof size !== "number") return null;
|
|
314
|
+
if (sha256 !== null && typeof sha256 !== "string") return null;
|
|
315
|
+
if (target !== undefined && typeof target !== "string") return null;
|
|
316
|
+
const entry: ArchiveEntry = { path, type: type === "file" ? "file" : type === "dir" ? "dir" : "symlink", mode, size, sha256 };
|
|
317
|
+
if (target !== undefined) entry.target = target;
|
|
318
|
+
entries.push(entry);
|
|
319
|
+
}
|
|
320
|
+
return entries;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// --- scanning -----------------------------------------------------------------------
|
|
324
|
+
|
|
325
|
+
/** Work counters for tests and profiling. */
|
|
326
|
+
export type ScanMetrics = { stats: number; fileReads: number; bytesHashed: number; cacheHits: number };
|
|
327
|
+
|
|
328
|
+
export function emptyScanMetrics(): ScanMetrics {
|
|
329
|
+
return { stats: 0, fileReads: 0, bytesHashed: 0, cacheHits: 0 };
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
export type ScanOptions = {
|
|
333
|
+
/** Absolute app state/temp/data directories; pruned (and counted as app_state) when under the root. */
|
|
334
|
+
excludedDirs?: readonly string[];
|
|
335
|
+
/** archiveIncludeCredentialFiles: turns the credential filter off entirely. */
|
|
336
|
+
includeCredentialFiles?: boolean;
|
|
337
|
+
hashCache?: ArchiveHashCache;
|
|
338
|
+
statConcurrency?: number;
|
|
339
|
+
hashConcurrency?: number;
|
|
340
|
+
metrics?: ScanMetrics;
|
|
341
|
+
/** Stops the scan between entries: it then rejects with the signal's reason. */
|
|
342
|
+
signal?: AbortSignal;
|
|
343
|
+
/** CLI: gitignored content is left out (session-archive/ignored.ts) unless this is true (tests of the desktop's whole-folder scan). */
|
|
344
|
+
includeIgnored?: boolean;
|
|
345
|
+
};
|
|
346
|
+
|
|
347
|
+
export type ScanResult = { entries: ScannedEntry[]; excluded: ExcludedCounts };
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* Runs `operation` over `items` with at most `limit` in flight. The workers
|
|
351
|
+
* pull plain data items, so a 200k-entry level costs 200k small objects, not
|
|
352
|
+
* 200k suspended async frames.
|
|
353
|
+
*/
|
|
354
|
+
export async function forEachBounded<T>(items: readonly T[], limit: number, operation: (item: T) => Promise<void>): Promise<void> {
|
|
355
|
+
let next = 0;
|
|
356
|
+
const worker = async (): Promise<void> => {
|
|
357
|
+
while (next < items.length) {
|
|
358
|
+
const item = items[next]!;
|
|
359
|
+
next += 1;
|
|
360
|
+
await operation(item);
|
|
361
|
+
}
|
|
362
|
+
};
|
|
363
|
+
await Promise.all(Array.from({ length: Math.max(1, Math.min(limit, items.length)) }, worker));
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
const NAME_DECODER = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true });
|
|
367
|
+
|
|
368
|
+
export function decodeName(raw: Buffer): string | null {
|
|
369
|
+
try {
|
|
370
|
+
return NAME_DECODER.decode(raw);
|
|
371
|
+
} catch {
|
|
372
|
+
return null;
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
export function portable(path: string): string {
|
|
377
|
+
return sep === "/" ? path : path.split(sep).join("/");
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/** The excluded directories that lie strictly under the root, as root-relative paths. */
|
|
381
|
+
export async function excludedRelativeDirs(root: string, dirs: readonly string[]): Promise<Set<string>> {
|
|
382
|
+
const rootForms = new Set([resolve(root)]);
|
|
383
|
+
try {
|
|
384
|
+
rootForms.add(await realpath(root));
|
|
385
|
+
} catch {
|
|
386
|
+
// The scan reports an unreadable root itself.
|
|
387
|
+
}
|
|
388
|
+
const result = new Set<string>();
|
|
389
|
+
for (const dir of dirs) {
|
|
390
|
+
const forms = new Set([resolve(dir)]);
|
|
391
|
+
try {
|
|
392
|
+
forms.add(await realpath(dir));
|
|
393
|
+
} catch {
|
|
394
|
+
// Not created yet: only its resolved form can be under the root.
|
|
395
|
+
}
|
|
396
|
+
for (const form of forms) {
|
|
397
|
+
for (const rootForm of rootForms) {
|
|
398
|
+
const rel = relative(rootForm, form);
|
|
399
|
+
const outside = rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel);
|
|
400
|
+
if (rel && !outside) result.add(portable(rel));
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
return result;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// Strings kept per entry are built with join(), which yields one flat string;
|
|
408
|
+
// template concatenation in JSC yields a rope that keeps every piece alive,
|
|
409
|
+
// roughly doubling the per-entry cost on a 200k-entry tree.
|
|
410
|
+
export function statFields(stats: BigIntStats) {
|
|
411
|
+
return {
|
|
412
|
+
mode: Number(stats.mode & 0o7777n),
|
|
413
|
+
mtime: Number(stats.mtimeNs / 1_000_000_000n),
|
|
414
|
+
statKey: [stats.size, stats.mtimeNs, stats.ctimeNs, stats.ino].join(":"),
|
|
415
|
+
dev: Number(stats.dev),
|
|
416
|
+
};
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/** SHA-256 of the first `size` bytes (the lstat size): pass 2 streams exactly those and detects any change. */
|
|
420
|
+
async function hashFile(absolute: string, size: number, buffer: Buffer, metrics: ScanMetrics, signal?: AbortSignal): Promise<string | null> {
|
|
421
|
+
let handle: Awaited<ReturnType<typeof open>>;
|
|
422
|
+
try {
|
|
423
|
+
handle = await open(absolute, OPEN_ENTRY_FLAGS);
|
|
424
|
+
} catch {
|
|
425
|
+
return null;
|
|
426
|
+
}
|
|
427
|
+
metrics.fileReads += 1;
|
|
428
|
+
if (metrics.fileReads % GC_HINT_ENTRIES === 0) hintGarbageCollection();
|
|
429
|
+
try {
|
|
430
|
+
if (!(await handle.stat()).isFile()) return null;
|
|
431
|
+
const hash = createHash("sha256");
|
|
432
|
+
let remaining = size;
|
|
433
|
+
while (remaining > 0) {
|
|
434
|
+
// A stopped scan does not finish a big file first (the scan rejects right after).
|
|
435
|
+
if (signal?.aborted) return null;
|
|
436
|
+
const { bytesRead } = await handle.read(buffer, 0, Math.min(buffer.length, remaining), null);
|
|
437
|
+
if (bytesRead === 0) break;
|
|
438
|
+
hash.update(buffer.subarray(0, bytesRead));
|
|
439
|
+
if (Math.floor((metrics.bytesHashed + bytesRead) / GC_HINT_BYTES) > Math.floor(metrics.bytesHashed / GC_HINT_BYTES)) hintGarbageCollection();
|
|
440
|
+
metrics.bytesHashed += bytesRead;
|
|
441
|
+
remaining -= bytesRead;
|
|
442
|
+
}
|
|
443
|
+
return hash.digest("hex");
|
|
444
|
+
} catch {
|
|
445
|
+
return null;
|
|
446
|
+
} finally {
|
|
447
|
+
await handle.close().catch(() => undefined);
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/**
|
|
452
|
+
* Pass 1: walks the root with lstat (never following a symlink), applies the
|
|
453
|
+
* exclusions of section 5.2 and hashes every file whose cache key changed.
|
|
454
|
+
* Entries come back sorted by UTF-8 bytes.
|
|
455
|
+
*/
|
|
456
|
+
export async function scanArchiveTree(root: string, options: ScanOptions = {}): Promise<ScanResult> {
|
|
457
|
+
const concurrency = options.statConcurrency ?? STAT_CONCURRENCY;
|
|
458
|
+
const metrics = options.metrics ?? emptyScanMetrics();
|
|
459
|
+
const cache = options.hashCache;
|
|
460
|
+
const signal = options.signal;
|
|
461
|
+
const excluded = emptyExcludedCounts();
|
|
462
|
+
const prunedDirs = await excludedRelativeDirs(root, options.excludedDirs ?? []);
|
|
463
|
+
const includeCredentials = options.includeCredentialFiles === true;
|
|
464
|
+
// CLI: gitignored content is never archived. Loaded lazily: ignored.ts reads
|
|
465
|
+
// this module's git runner.
|
|
466
|
+
const ignore = options.includeIgnored === true ? null : await (await import("./ignored.js")).archiveIgnoreMatcher(root);
|
|
467
|
+
const entries: ScannedEntry[] = [];
|
|
468
|
+
const toHash: ScannedEntry[] = [];
|
|
469
|
+
|
|
470
|
+
const inspect = async (relDir: string, absDir: string, name: string, next: Array<{ rel: string; abs: string }>): Promise<void> => {
|
|
471
|
+
signal?.throwIfAborted();
|
|
472
|
+
const rel = relDir ? [relDir, name].join("/") : name;
|
|
473
|
+
const abs = join(absDir, name);
|
|
474
|
+
let stats: BigIntStats;
|
|
475
|
+
try {
|
|
476
|
+
stats = await lstat(abs, { bigint: true });
|
|
477
|
+
metrics.stats += 1;
|
|
478
|
+
if (metrics.stats % GC_HINT_ENTRIES === 0) hintGarbageCollection();
|
|
479
|
+
} catch {
|
|
480
|
+
excluded.unreadable += 1;
|
|
481
|
+
return;
|
|
482
|
+
}
|
|
483
|
+
if (stats.isDirectory()) {
|
|
484
|
+
if (prunedDirs.has(rel)) {
|
|
485
|
+
excluded.app_state += 1;
|
|
486
|
+
return;
|
|
487
|
+
}
|
|
488
|
+
if (ignore && (await ignore.ignored(rel, true))) {
|
|
489
|
+
excluded.ignored += 1;
|
|
490
|
+
return;
|
|
491
|
+
}
|
|
492
|
+
entries.push({ path: rel, type: "dir", size: 0, sha256: null, ...statFields(stats) });
|
|
493
|
+
next.push({ rel, abs });
|
|
494
|
+
return;
|
|
495
|
+
}
|
|
496
|
+
if (!stats.isFile() && !stats.isSymbolicLink()) {
|
|
497
|
+
excluded.special += 1;
|
|
498
|
+
return;
|
|
499
|
+
}
|
|
500
|
+
if (!includeCredentials && isArchiveCredentialPath(rel)) {
|
|
501
|
+
excluded.credential += 1;
|
|
502
|
+
return;
|
|
503
|
+
}
|
|
504
|
+
if (ignore && (await ignore.ignored(rel, false))) {
|
|
505
|
+
excluded.ignored += 1;
|
|
506
|
+
return;
|
|
507
|
+
}
|
|
508
|
+
if (stats.isSymbolicLink()) {
|
|
509
|
+
let target: string | null;
|
|
510
|
+
try {
|
|
511
|
+
target = decodeName(await readlink(abs, { encoding: "buffer" }));
|
|
512
|
+
} catch {
|
|
513
|
+
excluded.unreadable += 1;
|
|
514
|
+
return;
|
|
515
|
+
}
|
|
516
|
+
if (target === null) {
|
|
517
|
+
excluded.non_utf8 += 1;
|
|
518
|
+
return;
|
|
519
|
+
}
|
|
520
|
+
entries.push({ path: rel, type: "symlink", size: 0, sha256: null, target, ...statFields(stats) });
|
|
521
|
+
return;
|
|
522
|
+
}
|
|
523
|
+
const entry: ScannedEntry = { path: rel, type: "file", size: Number(stats.size), sha256: null, ...statFields(stats) };
|
|
524
|
+
const cached = cache?.lookup(entry) ?? null;
|
|
525
|
+
if (cached) {
|
|
526
|
+
entry.sha256 = cached;
|
|
527
|
+
metrics.cacheHits += 1;
|
|
528
|
+
} else {
|
|
529
|
+
toHash.push(entry);
|
|
530
|
+
}
|
|
531
|
+
entries.push(entry);
|
|
532
|
+
};
|
|
533
|
+
|
|
534
|
+
// Breadth-first, one directory level at a time: list the level's
|
|
535
|
+
// directories, then lstat every name found, both with bounded concurrency.
|
|
536
|
+
let level: Array<{ rel: string; abs: string }> = [{ rel: "", abs: resolve(root) }];
|
|
537
|
+
while (level.length > 0) {
|
|
538
|
+
const names: Array<{ relDir: string; absDir: string; name: string }> = [];
|
|
539
|
+
await forEachBounded(level, concurrency, async (dir) => {
|
|
540
|
+
signal?.throwIfAborted();
|
|
541
|
+
let raw: Buffer[];
|
|
542
|
+
try {
|
|
543
|
+
raw = await readdir(dir.abs, { encoding: "buffer" });
|
|
544
|
+
} catch (error) {
|
|
545
|
+
if (!dir.rel) throw error;
|
|
546
|
+
// The directory itself is recorded; its content is not.
|
|
547
|
+
excluded.unreadable += 1;
|
|
548
|
+
return;
|
|
549
|
+
}
|
|
550
|
+
for (const bytes of raw) {
|
|
551
|
+
const name = decodeName(bytes);
|
|
552
|
+
if (name === null) excluded.non_utf8 += 1;
|
|
553
|
+
else if (!dir.rel && name === RESERVED_ROOT_NAME) excluded.reserved += 1;
|
|
554
|
+
else names.push({ relDir: dir.rel, absDir: dir.abs, name });
|
|
555
|
+
}
|
|
556
|
+
});
|
|
557
|
+
const next: Array<{ rel: string; abs: string }> = [];
|
|
558
|
+
await forEachBounded(names, concurrency, (item) => inspect(item.relDir, item.absDir, item.name, next));
|
|
559
|
+
level = next;
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
const unreadable = new Set<ScannedEntry>();
|
|
563
|
+
const buffers: Buffer[] = [];
|
|
564
|
+
await forEachBounded(toHash, options.hashConcurrency ?? HASH_CONCURRENCY, async (entry) => {
|
|
565
|
+
signal?.throwIfAborted();
|
|
566
|
+
const buffer = buffers.pop() ?? Buffer.allocUnsafe(HASH_READ_BYTES);
|
|
567
|
+
try {
|
|
568
|
+
const sha256 = await hashFile(join(root, ...entry.path.split("/")), entry.size, buffer, metrics, signal);
|
|
569
|
+
if (sha256 === null) {
|
|
570
|
+
unreadable.add(entry);
|
|
571
|
+
cache?.forget(entry.path);
|
|
572
|
+
return;
|
|
573
|
+
}
|
|
574
|
+
entry.sha256 = sha256;
|
|
575
|
+
cache?.remember(entry);
|
|
576
|
+
} finally {
|
|
577
|
+
buffers.push(buffer);
|
|
578
|
+
}
|
|
579
|
+
});
|
|
580
|
+
signal?.throwIfAborted();
|
|
581
|
+
excluded.unreadable += unreadable.size;
|
|
582
|
+
const kept = unreadable.size > 0 ? entries.filter((entry) => !unreadable.has(entry)) : entries;
|
|
583
|
+
kept.sort((left, right) => compareArchivePaths(left.path, right.path));
|
|
584
|
+
cache?.commitScan();
|
|
585
|
+
return { entries: kept, excluded };
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
// --- delta ----------------------------------------------------------------------
|
|
589
|
+
|
|
590
|
+
/** Whether `current` counts as modified against `previous` (section 5.8). */
|
|
591
|
+
export function isArchiveEntryModified(previous: ArchiveEntry, current: ArchiveEntry): boolean {
|
|
592
|
+
if (previous.type !== current.type || previous.mode !== current.mode) return true;
|
|
593
|
+
if (current.type === "file") return previous.sha256 !== current.sha256;
|
|
594
|
+
if (current.type === "symlink") return previous.target !== current.target;
|
|
595
|
+
return false;
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
export type ArchiveDelta<T extends ArchiveEntry> = { files: T[]; deleted: string[] };
|
|
599
|
+
|
|
600
|
+
function sortedByPath<T extends ArchiveEntry>(entries: readonly T[]): readonly T[] {
|
|
601
|
+
for (let index = 1; index < entries.length; index += 1) {
|
|
602
|
+
if (compareArchivePaths(entries[index - 1]!.path, entries[index]!.path) >= 0) {
|
|
603
|
+
return [...entries].sort((left, right) => compareArchivePaths(left.path, right.path));
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
return entries;
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
/**
|
|
610
|
+
* Added and modified entries of the scan, plus every baseline path that is
|
|
611
|
+
* gone, both sorted. A merge of two sorted lists: no index of either is built.
|
|
612
|
+
*/
|
|
613
|
+
export function computeArchiveDelta<T extends ArchiveEntry>(baseline: readonly ArchiveEntry[], current: readonly T[]): ArchiveDelta<T> {
|
|
614
|
+
const previous = sortedByPath(baseline);
|
|
615
|
+
const scanned = sortedByPath(current);
|
|
616
|
+
const files: T[] = [];
|
|
617
|
+
const deleted: string[] = [];
|
|
618
|
+
let left = 0;
|
|
619
|
+
let right = 0;
|
|
620
|
+
while (left < previous.length || right < scanned.length) {
|
|
621
|
+
const old = previous[left];
|
|
622
|
+
const now = scanned[right];
|
|
623
|
+
const order = old === undefined ? 1 : now === undefined ? -1 : compareArchivePaths(old.path, now.path);
|
|
624
|
+
if (order < 0) {
|
|
625
|
+
deleted.push(old!.path);
|
|
626
|
+
left += 1;
|
|
627
|
+
} else if (order > 0) {
|
|
628
|
+
files.push(now!);
|
|
629
|
+
right += 1;
|
|
630
|
+
} else {
|
|
631
|
+
if (isArchiveEntryModified(old!, now!)) files.push(now!);
|
|
632
|
+
left += 1;
|
|
633
|
+
right += 1;
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
return { files, deleted };
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
export function isArchiveDeltaEmpty(delta: ArchiveDelta<ArchiveEntry>): boolean {
|
|
640
|
+
return delta.files.length === 0 && delta.deleted.length === 0;
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
// --- manifest ---------------------------------------------------------------------
|
|
644
|
+
|
|
645
|
+
/** The manifest/baseline form of an entry: exactly the section 5.4 fields, in order. */
|
|
646
|
+
export function archiveEntry(entry: ArchiveEntry): ArchiveEntry {
|
|
647
|
+
const out: ArchiveEntry = { path: entry.path, type: entry.type, mode: entry.mode, size: entry.size, sha256: entry.sha256 };
|
|
648
|
+
if (entry.type === "symlink") out.target = entry.target ?? "";
|
|
649
|
+
return out;
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
export type ManifestInput = {
|
|
653
|
+
kind: ArchiveKind;
|
|
654
|
+
archiveId: string;
|
|
655
|
+
sessionId: string;
|
|
656
|
+
sequence: number;
|
|
657
|
+
turn: number;
|
|
658
|
+
createdAt: Date;
|
|
659
|
+
parentArchiveId: string | null;
|
|
660
|
+
/** Deltas only: a completed turn, or a final archive (then with its reason). */
|
|
661
|
+
trigger?: ArchiveTrigger;
|
|
662
|
+
reason?: FinalReason;
|
|
663
|
+
/** Touched-files archives only. */
|
|
664
|
+
scope?: ArchiveScope;
|
|
665
|
+
label: string;
|
|
666
|
+
marker: string;
|
|
667
|
+
git: ArchiveGit | null;
|
|
668
|
+
files: readonly ArchiveEntry[];
|
|
669
|
+
deleted?: readonly string[];
|
|
670
|
+
excluded: ExcludedCounts;
|
|
671
|
+
};
|
|
672
|
+
|
|
673
|
+
/** A manifest that is serialised on demand: its size first, then its bytes while the tar is written. */
|
|
674
|
+
export type ManifestSource = { size: number; chunks: () => Iterable<Buffer> };
|
|
675
|
+
|
|
676
|
+
/**
|
|
677
|
+
* `__omnirush__/manifest.json`. The entries are serialised a batch at a time,
|
|
678
|
+
* once to learn the size the tar header needs and again while the member is
|
|
679
|
+
* written, so a 200k-entry manifest (about 165 bytes per entry) is never held
|
|
680
|
+
* in memory whole. The entries must not change in between.
|
|
681
|
+
*/
|
|
682
|
+
export function manifestSource(input: ManifestInput): ManifestSource {
|
|
683
|
+
const head = JSON.stringify({
|
|
684
|
+
schema: ARCHIVE_SCHEMA,
|
|
685
|
+
kind: input.kind,
|
|
686
|
+
archive_id: input.archiveId,
|
|
687
|
+
session_id: input.sessionId,
|
|
688
|
+
sequence: input.sequence,
|
|
689
|
+
turn: input.turn,
|
|
690
|
+
created_at: input.createdAt.toISOString(),
|
|
691
|
+
parent_archive_id: input.parentArchiveId,
|
|
692
|
+
...(input.trigger ? { trigger: input.trigger } : {}),
|
|
693
|
+
...(input.reason ? { reason: input.reason } : {}),
|
|
694
|
+
...(input.scope ? { scope: input.scope } : {}),
|
|
695
|
+
workspace: { label: input.label, marker: input.marker, git: input.git },
|
|
696
|
+
});
|
|
697
|
+
const deleted = input.kind === "delta" ? `,"deleted":${JSON.stringify(input.deleted ?? [])}` : "";
|
|
698
|
+
const tail = `]${deleted},"excluded":${JSON.stringify(input.excluded)}}`;
|
|
699
|
+
function* chunks(): Generator<Buffer> {
|
|
700
|
+
yield Buffer.from(`${head.slice(0, -1)},"files":[`, "utf8");
|
|
701
|
+
for (let index = 0; index < input.files.length; index += 2_000) {
|
|
702
|
+
const batch = input.files.slice(index, index + 2_000).map((entry) => JSON.stringify(archiveEntry(entry)));
|
|
703
|
+
yield Buffer.from(`${index === 0 ? "" : ","}${batch.join(",")}`, "utf8");
|
|
704
|
+
}
|
|
705
|
+
yield Buffer.from(tail, "utf8");
|
|
706
|
+
}
|
|
707
|
+
let size = 0;
|
|
708
|
+
for (const chunk of chunks()) size += chunk.length;
|
|
709
|
+
return { size, chunks };
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
export function buildManifestBytes(input: ManifestInput): Buffer {
|
|
713
|
+
return Buffer.concat([...manifestSource(input).chunks()]);
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
// --- git ------------------------------------------------------------------------
|
|
717
|
+
|
|
718
|
+
export type GitRun = { ok: boolean; stdout: string; truncated: boolean };
|
|
719
|
+
|
|
720
|
+
/**
|
|
721
|
+
* GIT_CEILING_DIRECTORIES with the real home directory appended: git never
|
|
722
|
+
* climbs into home, so a folder whose nearest `.git` git rejects (an empty
|
|
723
|
+
* folder, say) cannot pick up a dotfiles repository there.
|
|
724
|
+
*/
|
|
725
|
+
function gitCeilingDirectories(): string {
|
|
726
|
+
let home = homedir();
|
|
727
|
+
try {
|
|
728
|
+
home = realpathSync(home);
|
|
729
|
+
} catch {
|
|
730
|
+
// An unreadable home is used as given.
|
|
731
|
+
}
|
|
732
|
+
const existing = process.env.GIT_CEILING_DIRECTORIES;
|
|
733
|
+
if (!home) return existing ?? "";
|
|
734
|
+
return existing ? `${existing}${delimiter}${home}` : home;
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
/** Runs git in the root without locks, prompts or fsmonitor hooks, never above home; reads at most `maxBytes` of stdout. */
|
|
738
|
+
export function runGit(root: string, args: string[], maxBytes = 64 * 1024, timeoutMs = GIT_TIMEOUT_MS): Promise<GitRun> {
|
|
739
|
+
return new Promise((resolvePromise) => {
|
|
740
|
+
const chunks: Buffer[] = [];
|
|
741
|
+
let total = 0;
|
|
742
|
+
let truncated = false;
|
|
743
|
+
let settled = false;
|
|
744
|
+
let child: ReturnType<typeof spawn> | null = null;
|
|
745
|
+
const finish = (ok: boolean) => {
|
|
746
|
+
if (settled) return;
|
|
747
|
+
settled = true;
|
|
748
|
+
clearTimeout(timer);
|
|
749
|
+
resolvePromise({ ok, stdout: Buffer.concat(chunks).toString("utf8"), truncated });
|
|
750
|
+
};
|
|
751
|
+
const timer = setTimeout(() => {
|
|
752
|
+
child?.kill("SIGKILL");
|
|
753
|
+
finish(false);
|
|
754
|
+
}, timeoutMs);
|
|
755
|
+
timer.unref?.();
|
|
756
|
+
try {
|
|
757
|
+
child = spawn("git", ["-C", root, "-c", "core.fsmonitor=false", "-c", "core.quotePath=false", ...args], {
|
|
758
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
759
|
+
windowsHide: true,
|
|
760
|
+
env: {
|
|
761
|
+
...process.env,
|
|
762
|
+
GIT_CEILING_DIRECTORIES: gitCeilingDirectories(),
|
|
763
|
+
GIT_TERMINAL_PROMPT: "0",
|
|
764
|
+
GIT_OPTIONAL_LOCKS: "0",
|
|
765
|
+
GIT_PAGER: "cat",
|
|
766
|
+
PAGER: "cat",
|
|
767
|
+
LC_ALL: "C",
|
|
768
|
+
},
|
|
769
|
+
});
|
|
770
|
+
} catch {
|
|
771
|
+
finish(false);
|
|
772
|
+
return;
|
|
773
|
+
}
|
|
774
|
+
child.stdout?.on("data", (chunk: Buffer) => {
|
|
775
|
+
if (truncated) return;
|
|
776
|
+
const room = maxBytes - total;
|
|
777
|
+
if (chunk.length >= room) {
|
|
778
|
+
chunks.push(chunk.subarray(0, room));
|
|
779
|
+
total = maxBytes;
|
|
780
|
+
truncated = true;
|
|
781
|
+
child?.kill("SIGKILL");
|
|
782
|
+
finish(true);
|
|
783
|
+
return;
|
|
784
|
+
}
|
|
785
|
+
chunks.push(chunk);
|
|
786
|
+
total += chunk.length;
|
|
787
|
+
});
|
|
788
|
+
child.on("error", () => finish(false));
|
|
789
|
+
child.on("close", (code) => finish(code === 0));
|
|
790
|
+
});
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
async function gitLine(root: string, args: string[]): Promise<string | null> {
|
|
794
|
+
const result = await runGit(root, args);
|
|
795
|
+
if (!result.ok || result.truncated) return null;
|
|
796
|
+
return result.stdout.trim() || null;
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
/**
|
|
800
|
+
* workspace.git (section 5.6): HEAD, branch, the origin (else first) remote
|
|
801
|
+
* without userinfo, whether `git status --porcelain` prints anything, and the
|
|
802
|
+
* root's path inside the repository ("" at its top level; a git_parent root
|
|
803
|
+
* is a folder inside it).
|
|
804
|
+
* Null when git cannot read the repository. GIT_OPTIONAL_LOCKS=0 keeps
|
|
805
|
+
* `git status` from rewriting .git/index, which would otherwise show up as a
|
|
806
|
+
* change in the next delta.
|
|
807
|
+
*/
|
|
808
|
+
export async function readArchiveGit(root: string): Promise<ArchiveGit | null> {
|
|
809
|
+
if (!(await runGit(root, ["rev-parse", "--git-dir"])).ok) return null;
|
|
810
|
+
const [head, branch, remotes, status, prefix] = await Promise.all([
|
|
811
|
+
gitLine(root, ["rev-parse", "--verify", "-q", "HEAD"]),
|
|
812
|
+
gitLine(root, ["symbolic-ref", "--short", "-q", "HEAD"]),
|
|
813
|
+
gitLine(root, ["remote"]),
|
|
814
|
+
runGit(root, ["status", "--porcelain", "--untracked-files=normal"], 1),
|
|
815
|
+
runGit(root, ["rev-parse", "--show-prefix"]),
|
|
816
|
+
]);
|
|
817
|
+
const names = remotes?.split(/\r?\n/).map((name) => name.trim()).filter(Boolean) ?? [];
|
|
818
|
+
const remoteName = names.includes("origin") ? "origin" : names[0];
|
|
819
|
+
const remoteUrl = remoteName ? await gitLine(root, ["remote", "get-url", remoteName]) : null;
|
|
820
|
+
return {
|
|
821
|
+
head: head && /^[0-9a-f]{40,64}$/.test(head) ? head : null,
|
|
822
|
+
branch,
|
|
823
|
+
remote: remoteUrl ? stripRemoteUserinfo(remoteUrl) : null,
|
|
824
|
+
dirty: status.ok && status.stdout.length > 0,
|
|
825
|
+
...(prefix.ok && !prefix.truncated ? { path: prefix.stdout.replace(/\r?\n$/, "").replace(/\/$/, "") } : {}),
|
|
826
|
+
};
|
|
827
|
+
}
|