@hyperdrive.bot/fleet-server 0.3.157 → 0.3.158
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/dist/server/utils/git-command-cache.d.ts +67 -0
- package/dist/server/utils/git-command-cache.js +436 -0
- package/dist/server/utils/run-git-command.js +14 -4
- package/dist/server/utils/spawn-broker-child.mjs +156 -0
- package/dist/server/utils/spawn-broker.d.ts +120 -0
- package/dist/server/utils/spawn-broker.js +299 -0
- package/dist/server/utils/spawn.d.ts +21 -0
- package/dist/server/utils/spawn.js +144 -10
- package/dist/server/web-ui/_expo/static/js/web/{index-7ca8a9a256b79485a77556fab01588ca.js → index-461552a3716a48e99f87634c93c7b220.js} +4 -4
- package/dist/server/web-ui/_expo/static/js/web/index-461552a3716a48e99f87634c93c7b220.js.br +0 -0
- package/dist/server/web-ui/_expo/static/js/web/index-461552a3716a48e99f87634c93c7b220.js.gz +0 -0
- package/dist/server/web-ui/_expo/static/js/web/{index-7ca8a9a256b79485a77556fab01588ca.js.map.br → index-461552a3716a48e99f87634c93c7b220.js.map.br} +0 -0
- package/dist/server/web-ui/_expo/static/js/web/{index-7ca8a9a256b79485a77556fab01588ca.js.map.gz → index-461552a3716a48e99f87634c93c7b220.js.map.gz} +0 -0
- package/dist/server/web-ui/index.html +1 -1
- package/dist/server/web-ui/index.html.br +0 -0
- package/dist/server/web-ui/index.html.gz +0 -0
- package/dist/src/utils/spawn-broker-child.mjs +156 -0
- package/dist/src/utils/spawn-broker.js +299 -0
- package/dist/src/utils/spawn.js +144 -10
- package/package.json +8 -8
- package/dist/server/web-ui/_expo/static/js/web/index-7ca8a9a256b79485a77556fab01588ca.js.br +0 -0
- package/dist/server/web-ui/_expo/static/js/web/index-7ca8a9a256b79485a77556fab01588ca.js.gz +0 -0
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-repo cache for read-only git commands the daemon repeats many times a second
|
|
3
|
+
* (config --get, rev-parse, symbolic-ref, show-ref, ...). Every git spawn from the
|
|
4
|
+
* daemon used to cost about 100ms of blocked event loop, and the same questions
|
|
5
|
+
* were asked over and over.
|
|
6
|
+
*
|
|
7
|
+
* Correctness first:
|
|
8
|
+
* - Repo location answers are cached until the `.git` entry changes.
|
|
9
|
+
* - Config and ref answers are keyed by a fingerprint of cheap stat() calls (HEAD,
|
|
10
|
+
* the ref files named in the command, packed-refs, FETCH_HEAD, repo/worktree/
|
|
11
|
+
* global config) AND expire after a short TTL (PASEO_GIT_CACHE_TTL_MS, 5s). The
|
|
12
|
+
* TTL is the backstop for what the fingerprint cannot see: include/includeIf
|
|
13
|
+
* targets, vendor system configs, and updates to nested ref files
|
|
14
|
+
* (refs/heads/feature/x) that do not touch any watched directory.
|
|
15
|
+
* - Working tree commands (status, ls-files --others, diff --shortstat) are never
|
|
16
|
+
* cached: identical concurrent calls share one spawn, nothing more.
|
|
17
|
+
* - Anything with `@{...}` (upstream/push/reflog) is not cached at all.
|
|
18
|
+
* - Reftable repos are not cached.
|
|
19
|
+
* Any git command not on the read-only list invalidates the repo's entries.
|
|
20
|
+
*/
|
|
21
|
+
type CommandClass =
|
|
22
|
+
/** Answer depends only on where the repository is. */
|
|
23
|
+
"location"
|
|
24
|
+
/** Depends on config files. */
|
|
25
|
+
| "config"
|
|
26
|
+
/** Depends on HEAD and refs. */
|
|
27
|
+
| "refs"
|
|
28
|
+
/** Depends on the working tree: single-flight only, never kept after settling. */
|
|
29
|
+
| "worktree";
|
|
30
|
+
export interface GitRepoDirs {
|
|
31
|
+
/** The `.git` entry (dir or gitfile) found walking up from cwd. */
|
|
32
|
+
dotGit: string;
|
|
33
|
+
/** Worktree-specific git dir (holds HEAD and index). */
|
|
34
|
+
gitDir: string;
|
|
35
|
+
/** Shared git dir (holds config, refs, packed-refs). */
|
|
36
|
+
commonDir: string;
|
|
37
|
+
}
|
|
38
|
+
export interface GitCacheStats {
|
|
39
|
+
hits: number;
|
|
40
|
+
misses: number;
|
|
41
|
+
coalesced: number;
|
|
42
|
+
invalidations: number;
|
|
43
|
+
}
|
|
44
|
+
export declare function isGitCacheEnabled(): boolean;
|
|
45
|
+
export declare function isReadOnlyCommand(args: readonly string[]): boolean;
|
|
46
|
+
/** Classify a command, or return null when it must not be cached. */
|
|
47
|
+
export declare function classifyGitCommand(args: readonly string[]): CommandClass | null;
|
|
48
|
+
export declare class GitCommandCache {
|
|
49
|
+
private readonly entries;
|
|
50
|
+
private readonly dirsByCwd;
|
|
51
|
+
private readonly generations;
|
|
52
|
+
readonly stats: GitCacheStats;
|
|
53
|
+
clear(): void;
|
|
54
|
+
/**
|
|
55
|
+
* Walk up from cwd to the `.git` entry and resolve worktree gitfiles and
|
|
56
|
+
* `commondir`. Pure filesystem work, no git spawn; cached per cwd briefly and
|
|
57
|
+
* re-validated by the `.git` entry still existing.
|
|
58
|
+
*/
|
|
59
|
+
resolveRepoDirs(cwd: string): GitRepoDirs | null;
|
|
60
|
+
run<T>(args: readonly string[], cwd: string, env: Record<string, string | undefined> | undefined, extraKey: string, execute: () => Promise<T>): Promise<T>;
|
|
61
|
+
invalidate(commonDir: string): void;
|
|
62
|
+
private evictIfNeeded;
|
|
63
|
+
}
|
|
64
|
+
export declare function findRepoDirs(cwd: string): GitRepoDirs | null;
|
|
65
|
+
export declare function getSharedGitCommandCache(): GitCommandCache;
|
|
66
|
+
export {};
|
|
67
|
+
//# sourceMappingURL=git-command-cache.d.ts.map
|
|
@@ -0,0 +1,436 @@
|
|
|
1
|
+
import { readFileSync, statSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { dirname, isAbsolute, join, resolve } from "node:path";
|
|
4
|
+
const MAX_ENTRIES = 5000;
|
|
5
|
+
const DIRS_CACHE_TTL_MS = 5000;
|
|
6
|
+
const ENV_KEYS_THAT_BYPASS = [
|
|
7
|
+
"GIT_DIR",
|
|
8
|
+
"GIT_WORK_TREE",
|
|
9
|
+
"GIT_INDEX_FILE",
|
|
10
|
+
"GIT_COMMON_DIR",
|
|
11
|
+
"GIT_CONFIG",
|
|
12
|
+
"GIT_CONFIG_GLOBAL",
|
|
13
|
+
"GIT_CONFIG_SYSTEM",
|
|
14
|
+
"GIT_CONFIG_COUNT",
|
|
15
|
+
"GIT_CONFIG_PARAMETERS",
|
|
16
|
+
"GIT_CONFIG_NOSYSTEM",
|
|
17
|
+
"GIT_NAMESPACE",
|
|
18
|
+
];
|
|
19
|
+
function cacheTtlMs() {
|
|
20
|
+
const raw = process.env.PASEO_GIT_CACHE_TTL_MS;
|
|
21
|
+
const parsed = raw === undefined ? NaN : Number(raw);
|
|
22
|
+
return Number.isFinite(parsed) && parsed >= 0 ? parsed : 5000;
|
|
23
|
+
}
|
|
24
|
+
function ttlFor(commandClass) {
|
|
25
|
+
if (commandClass === "location")
|
|
26
|
+
return Number.POSITIVE_INFINITY;
|
|
27
|
+
if (commandClass === "worktree")
|
|
28
|
+
return 0;
|
|
29
|
+
return cacheTtlMs();
|
|
30
|
+
}
|
|
31
|
+
export function isGitCacheEnabled() {
|
|
32
|
+
const flag = process.env.PASEO_GIT_CACHE;
|
|
33
|
+
return flag !== "0" && flag !== "false";
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Subcommands that never change refs, config or HEAD. Running one of them does not
|
|
37
|
+
* invalidate the repo's cached answers; running anything else does.
|
|
38
|
+
*/
|
|
39
|
+
const READ_ONLY_SUBCOMMANDS = new Set([
|
|
40
|
+
"status",
|
|
41
|
+
"log",
|
|
42
|
+
"show",
|
|
43
|
+
"diff",
|
|
44
|
+
"ls-files",
|
|
45
|
+
"ls-tree",
|
|
46
|
+
"ls-remote",
|
|
47
|
+
"cat-file",
|
|
48
|
+
"rev-list",
|
|
49
|
+
"rev-parse",
|
|
50
|
+
"for-each-ref",
|
|
51
|
+
"show-ref",
|
|
52
|
+
"merge-base",
|
|
53
|
+
"merge-tree",
|
|
54
|
+
"blame",
|
|
55
|
+
"grep",
|
|
56
|
+
"describe",
|
|
57
|
+
"name-rev",
|
|
58
|
+
"check-ignore",
|
|
59
|
+
"check-ref-format",
|
|
60
|
+
"var",
|
|
61
|
+
"version",
|
|
62
|
+
"--version",
|
|
63
|
+
]);
|
|
64
|
+
const BRANCH_WRITE_FLAGS = new Set([
|
|
65
|
+
"-d",
|
|
66
|
+
"-D",
|
|
67
|
+
"--delete",
|
|
68
|
+
"-m",
|
|
69
|
+
"-M",
|
|
70
|
+
"--move",
|
|
71
|
+
"-c",
|
|
72
|
+
"-C",
|
|
73
|
+
"--copy",
|
|
74
|
+
"-u",
|
|
75
|
+
"--set-upstream-to",
|
|
76
|
+
"--unset-upstream",
|
|
77
|
+
"-f",
|
|
78
|
+
"--force",
|
|
79
|
+
"--edit-description",
|
|
80
|
+
]);
|
|
81
|
+
const BRANCH_LIST_FLAGS = new Set([
|
|
82
|
+
"--list",
|
|
83
|
+
"-l",
|
|
84
|
+
"--format",
|
|
85
|
+
"--show-current",
|
|
86
|
+
"-a",
|
|
87
|
+
"--all",
|
|
88
|
+
"-r",
|
|
89
|
+
"--remotes",
|
|
90
|
+
"-v",
|
|
91
|
+
"-vv",
|
|
92
|
+
"--contains",
|
|
93
|
+
"--merged",
|
|
94
|
+
"--no-merged",
|
|
95
|
+
"--points-at",
|
|
96
|
+
]);
|
|
97
|
+
const REMOTE_READ_FORMS = new Set(["-v", "get-url", "show"]);
|
|
98
|
+
const REV_PARSE_LOCATION_FLAGS = new Set([
|
|
99
|
+
"--git-common-dir",
|
|
100
|
+
"--absolute-git-dir",
|
|
101
|
+
"--git-dir",
|
|
102
|
+
"--show-toplevel",
|
|
103
|
+
"--show-cdup",
|
|
104
|
+
"--show-prefix",
|
|
105
|
+
"--is-inside-work-tree",
|
|
106
|
+
"--is-bare-repository",
|
|
107
|
+
]);
|
|
108
|
+
const REV_PARSE_REF_FLAGS = new Set([
|
|
109
|
+
"--abbrev-ref",
|
|
110
|
+
"--verify",
|
|
111
|
+
"--quiet",
|
|
112
|
+
"-q",
|
|
113
|
+
"--symbolic-full-name",
|
|
114
|
+
"--short",
|
|
115
|
+
]);
|
|
116
|
+
const CONFIG_READ_FLAGS = new Set([
|
|
117
|
+
"--get",
|
|
118
|
+
"--get-all",
|
|
119
|
+
"--get-regexp",
|
|
120
|
+
"--bool",
|
|
121
|
+
"--int",
|
|
122
|
+
"--null",
|
|
123
|
+
"-z",
|
|
124
|
+
]);
|
|
125
|
+
const isFlag = (arg) => arg.startsWith("-");
|
|
126
|
+
function isBranchListing(rest) {
|
|
127
|
+
// Listing forms only: `branch`, `branch --format=...`, `branch --show-current`, `branch -a`.
|
|
128
|
+
const names = rest.map((arg) => arg.split("=")[0] ?? arg);
|
|
129
|
+
if (names.some((name) => BRANCH_WRITE_FLAGS.has(name)))
|
|
130
|
+
return false;
|
|
131
|
+
return names.some((name) => BRANCH_LIST_FLAGS.has(name)) || rest.every(isFlag);
|
|
132
|
+
}
|
|
133
|
+
export function isReadOnlyCommand(args) {
|
|
134
|
+
const [sub, next] = args;
|
|
135
|
+
if (!sub)
|
|
136
|
+
return false;
|
|
137
|
+
if (READ_ONLY_SUBCOMMANDS.has(sub))
|
|
138
|
+
return true;
|
|
139
|
+
if (sub === "worktree")
|
|
140
|
+
return next === "list";
|
|
141
|
+
if (sub === "branch")
|
|
142
|
+
return isBranchListing(args.slice(1));
|
|
143
|
+
if (sub === "remote")
|
|
144
|
+
return next === undefined || REMOTE_READ_FORMS.has(next);
|
|
145
|
+
return false;
|
|
146
|
+
}
|
|
147
|
+
function classifyRevParse(rest) {
|
|
148
|
+
if (rest.length > 0 && rest.every((arg) => REV_PARSE_LOCATION_FLAGS.has(arg))) {
|
|
149
|
+
return "location";
|
|
150
|
+
}
|
|
151
|
+
const hasPositional = rest.some((arg) => !isFlag(arg));
|
|
152
|
+
const flagsOk = rest
|
|
153
|
+
.filter(isFlag)
|
|
154
|
+
.every((arg) => REV_PARSE_REF_FLAGS.has(arg) || /^--short=\d+$/.test(arg));
|
|
155
|
+
return hasPositional && flagsOk ? "refs" : null;
|
|
156
|
+
}
|
|
157
|
+
function classifyConfig(rest) {
|
|
158
|
+
// Only pure reads.
|
|
159
|
+
const flags = rest.filter(isFlag);
|
|
160
|
+
const isRead = flags.some((flag) => flag === "--get" || flag === "--get-all" || flag === "--get-regexp");
|
|
161
|
+
return isRead && flags.every((flag) => CONFIG_READ_FLAGS.has(flag)) ? "config" : null;
|
|
162
|
+
}
|
|
163
|
+
function classifySymbolicRef(rest) {
|
|
164
|
+
// Reads only: with a second positional, or -d, it would write.
|
|
165
|
+
const positionals = rest.filter((arg) => !isFlag(arg));
|
|
166
|
+
const deletes = rest.includes("--delete") || rest.includes("-d");
|
|
167
|
+
return positionals.length <= 1 && !deletes ? "refs" : null;
|
|
168
|
+
}
|
|
169
|
+
function classifyRevList(rest) {
|
|
170
|
+
// Ahead/behind counts: `rev-list --left-right --count A...B`.
|
|
171
|
+
const flags = rest.filter(isFlag);
|
|
172
|
+
const countsOnly = flags.every((flag) => flag === "--left-right" || flag === "--count");
|
|
173
|
+
return flags.length > 0 && countsOnly ? "refs" : null;
|
|
174
|
+
}
|
|
175
|
+
const CLASSIFIERS = {
|
|
176
|
+
"rev-parse": classifyRevParse,
|
|
177
|
+
config: classifyConfig,
|
|
178
|
+
"symbolic-ref": classifySymbolicRef,
|
|
179
|
+
"show-ref": (rest) => (rest.includes("--verify") || rest.includes("--exists") ? "refs" : null),
|
|
180
|
+
"merge-base": (rest) => rest.every((arg) => !isFlag(arg) || arg === "--is-ancestor") ? "refs" : null,
|
|
181
|
+
// Listing forms read refs (and upstream config for -vv).
|
|
182
|
+
branch: (rest) => (isBranchListing(rest) ? "refs" : null),
|
|
183
|
+
"rev-list": classifyRevList,
|
|
184
|
+
// Working tree readers: coalesced while in flight, never served after settling.
|
|
185
|
+
"ls-files": (rest) => (rest.includes("--others") || rest.includes("-o") ? "worktree" : null),
|
|
186
|
+
diff: (rest) => (rest.includes("--shortstat") ? "worktree" : null),
|
|
187
|
+
status: () => "worktree",
|
|
188
|
+
};
|
|
189
|
+
/** Classify a command, or return null when it must not be cached. */
|
|
190
|
+
export function classifyGitCommand(args) {
|
|
191
|
+
const [sub, ...rest] = args;
|
|
192
|
+
// Upstream/push/reflog selectors resolve through config and remote-tracking refs
|
|
193
|
+
// we do not fingerprint precisely.
|
|
194
|
+
if (rest.some((arg) => arg.includes("@{")))
|
|
195
|
+
return null;
|
|
196
|
+
const classify = sub ? CLASSIFIERS[sub] : undefined;
|
|
197
|
+
return classify ? classify(rest) : null;
|
|
198
|
+
}
|
|
199
|
+
function statKey(path) {
|
|
200
|
+
try {
|
|
201
|
+
const stat = statSync(path, { bigint: true });
|
|
202
|
+
return `${stat.ino}:${stat.size}:${stat.mtimeNs}`;
|
|
203
|
+
}
|
|
204
|
+
catch {
|
|
205
|
+
return "-";
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
function readTrimmed(path) {
|
|
209
|
+
try {
|
|
210
|
+
return readFileSync(path, "utf8").trim();
|
|
211
|
+
}
|
|
212
|
+
catch {
|
|
213
|
+
return null;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
export class GitCommandCache {
|
|
217
|
+
constructor() {
|
|
218
|
+
this.entries = new Map();
|
|
219
|
+
this.dirsByCwd = new Map();
|
|
220
|
+
this.generations = new Map();
|
|
221
|
+
this.stats = { hits: 0, misses: 0, coalesced: 0, invalidations: 0 };
|
|
222
|
+
}
|
|
223
|
+
clear() {
|
|
224
|
+
this.entries.clear();
|
|
225
|
+
this.dirsByCwd.clear();
|
|
226
|
+
this.generations.clear();
|
|
227
|
+
}
|
|
228
|
+
/**
|
|
229
|
+
* Walk up from cwd to the `.git` entry and resolve worktree gitfiles and
|
|
230
|
+
* `commondir`. Pure filesystem work, no git spawn; cached per cwd briefly and
|
|
231
|
+
* re-validated by the `.git` entry still existing.
|
|
232
|
+
*/
|
|
233
|
+
resolveRepoDirs(cwd) {
|
|
234
|
+
const now = Date.now();
|
|
235
|
+
const cached = this.dirsByCwd.get(cwd);
|
|
236
|
+
if (cached && now - cached.checkedAt < DIRS_CACHE_TTL_MS) {
|
|
237
|
+
return cached.dirs;
|
|
238
|
+
}
|
|
239
|
+
const dirs = findRepoDirs(cwd);
|
|
240
|
+
this.dirsByCwd.set(cwd, { dirs, checkedAt: now });
|
|
241
|
+
return dirs;
|
|
242
|
+
}
|
|
243
|
+
run(args, cwd, env, extraKey, execute) {
|
|
244
|
+
if (!isGitCacheEnabled() || bypassesForEnv(env)) {
|
|
245
|
+
return execute();
|
|
246
|
+
}
|
|
247
|
+
const dirs = this.resolveRepoDirs(cwd);
|
|
248
|
+
if (!dirs) {
|
|
249
|
+
return execute();
|
|
250
|
+
}
|
|
251
|
+
const commandClass = classifyGitCommand(args);
|
|
252
|
+
if (!commandClass) {
|
|
253
|
+
if (isReadOnlyCommand(args)) {
|
|
254
|
+
return execute();
|
|
255
|
+
}
|
|
256
|
+
// Anything we do not know to be read-only may change the repo.
|
|
257
|
+
this.invalidate(dirs.commonDir);
|
|
258
|
+
return execute().finally(() => this.invalidate(dirs.commonDir));
|
|
259
|
+
}
|
|
260
|
+
const key = JSON.stringify([cwd, args, extraKey, env ?? null]);
|
|
261
|
+
const fingerprint = computeFingerprint(commandClass, args, dirs);
|
|
262
|
+
const generation = this.generations.get(dirs.commonDir) ?? 0;
|
|
263
|
+
const now = Date.now();
|
|
264
|
+
const existing = this.entries.get(key);
|
|
265
|
+
if (existing &&
|
|
266
|
+
existing.fingerprint === fingerprint &&
|
|
267
|
+
existing.generation === generation &&
|
|
268
|
+
existing.expiresAt > now) {
|
|
269
|
+
if (existing.expiresAt === Number.POSITIVE_INFINITY) {
|
|
270
|
+
this.stats.coalesced += 1;
|
|
271
|
+
}
|
|
272
|
+
else {
|
|
273
|
+
this.stats.hits += 1;
|
|
274
|
+
}
|
|
275
|
+
// Refresh LRU position.
|
|
276
|
+
this.entries.delete(key);
|
|
277
|
+
this.entries.set(key, existing);
|
|
278
|
+
return existing.promise;
|
|
279
|
+
}
|
|
280
|
+
this.stats.misses += 1;
|
|
281
|
+
const ttl = ttlFor(commandClass);
|
|
282
|
+
const promise = execute();
|
|
283
|
+
const entry = {
|
|
284
|
+
fingerprint,
|
|
285
|
+
generation,
|
|
286
|
+
// Single-flight while pending; after settling, keep for the TTL.
|
|
287
|
+
expiresAt: Number.POSITIVE_INFINITY,
|
|
288
|
+
promise,
|
|
289
|
+
};
|
|
290
|
+
this.entries.set(key, entry);
|
|
291
|
+
this.evictIfNeeded();
|
|
292
|
+
promise.then(() => {
|
|
293
|
+
entry.expiresAt = Date.now() + ttl;
|
|
294
|
+
return undefined;
|
|
295
|
+
}, (error) => {
|
|
296
|
+
// Cache definite answers (a nonzero git exit), never transient failures.
|
|
297
|
+
if (typeof error?.gitExitCode === "number") {
|
|
298
|
+
entry.expiresAt = Date.now() + ttl;
|
|
299
|
+
}
|
|
300
|
+
else if (this.entries.get(key) === entry) {
|
|
301
|
+
this.entries.delete(key);
|
|
302
|
+
}
|
|
303
|
+
});
|
|
304
|
+
return promise;
|
|
305
|
+
}
|
|
306
|
+
invalidate(commonDir) {
|
|
307
|
+
this.stats.invalidations += 1;
|
|
308
|
+
this.generations.set(commonDir, (this.generations.get(commonDir) ?? 0) + 1);
|
|
309
|
+
}
|
|
310
|
+
evictIfNeeded() {
|
|
311
|
+
while (this.entries.size > MAX_ENTRIES) {
|
|
312
|
+
const oldest = this.entries.keys().next().value;
|
|
313
|
+
if (oldest === undefined)
|
|
314
|
+
break;
|
|
315
|
+
this.entries.delete(oldest);
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
function bypassesForEnv(env) {
|
|
320
|
+
for (const key of ENV_KEYS_THAT_BYPASS) {
|
|
321
|
+
if (process.env[key] !== undefined || env?.[key] !== undefined) {
|
|
322
|
+
return true;
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
return false;
|
|
326
|
+
}
|
|
327
|
+
export function findRepoDirs(cwd) {
|
|
328
|
+
let current = resolve(cwd);
|
|
329
|
+
for (;;) {
|
|
330
|
+
const dotGit = join(current, ".git");
|
|
331
|
+
let isDir = false;
|
|
332
|
+
let exists = false;
|
|
333
|
+
try {
|
|
334
|
+
const stat = statSync(dotGit);
|
|
335
|
+
exists = true;
|
|
336
|
+
isDir = stat.isDirectory();
|
|
337
|
+
}
|
|
338
|
+
catch {
|
|
339
|
+
exists = false;
|
|
340
|
+
}
|
|
341
|
+
if (exists) {
|
|
342
|
+
let gitDir = dotGit;
|
|
343
|
+
if (!isDir) {
|
|
344
|
+
const content = readTrimmed(dotGit);
|
|
345
|
+
const match = content?.match(/^gitdir:\s*(.+)$/m);
|
|
346
|
+
if (!match)
|
|
347
|
+
return null;
|
|
348
|
+
gitDir = isAbsolute(match[1]) ? match[1] : resolve(current, match[1]);
|
|
349
|
+
}
|
|
350
|
+
const commonRel = readTrimmed(join(gitDir, "commondir"));
|
|
351
|
+
let commonDir = gitDir;
|
|
352
|
+
if (commonRel) {
|
|
353
|
+
commonDir = isAbsolute(commonRel) ? commonRel : resolve(gitDir, commonRel);
|
|
354
|
+
}
|
|
355
|
+
if (usesReftable(commonDir))
|
|
356
|
+
return null;
|
|
357
|
+
return { dotGit, gitDir, commonDir };
|
|
358
|
+
}
|
|
359
|
+
const parent = dirname(current);
|
|
360
|
+
if (parent === current)
|
|
361
|
+
return null;
|
|
362
|
+
current = parent;
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
function usesReftable(commonDir) {
|
|
366
|
+
// Reftable keeps refs in reftable/*.ref; none of the files we stat change on a
|
|
367
|
+
// commit there, so such repos are simply not cached.
|
|
368
|
+
try {
|
|
369
|
+
statSync(join(commonDir, "reftable"));
|
|
370
|
+
return true;
|
|
371
|
+
}
|
|
372
|
+
catch {
|
|
373
|
+
// no reftable dir
|
|
374
|
+
}
|
|
375
|
+
const config = readTrimmed(join(commonDir, "config")) ?? "";
|
|
376
|
+
return /refstorage\s*=\s*reftable/i.test(config);
|
|
377
|
+
}
|
|
378
|
+
function globalConfigPaths() {
|
|
379
|
+
const home = process.env.HOME ?? homedir();
|
|
380
|
+
const xdg = process.env.XDG_CONFIG_HOME ?? join(home, ".config");
|
|
381
|
+
return [join(home, ".gitconfig"), join(xdg, "git", "config"), "/etc/gitconfig"];
|
|
382
|
+
}
|
|
383
|
+
function refFilesFor(args, dirs) {
|
|
384
|
+
const files = [];
|
|
385
|
+
const headContent = readTrimmed(join(dirs.gitDir, "HEAD"));
|
|
386
|
+
const headRef = headContent?.startsWith("ref:") ? headContent.slice(4).trim() : null;
|
|
387
|
+
if (headRef)
|
|
388
|
+
files.push(join(dirs.commonDir, headRef));
|
|
389
|
+
const names = args
|
|
390
|
+
.slice(1)
|
|
391
|
+
.filter((arg) => !arg.startsWith("-"))
|
|
392
|
+
.flatMap((arg) => arg.split(/\.{2,3}/));
|
|
393
|
+
for (const raw of names) {
|
|
394
|
+
const name = raw.replace(/[\^~].*$/, "");
|
|
395
|
+
if (!name || name === "HEAD")
|
|
396
|
+
continue;
|
|
397
|
+
if (name.startsWith("refs/")) {
|
|
398
|
+
files.push(join(dirs.commonDir, name));
|
|
399
|
+
}
|
|
400
|
+
else {
|
|
401
|
+
files.push(join(dirs.commonDir, "refs", "heads", name), join(dirs.commonDir, "refs", "remotes", name), join(dirs.commonDir, "refs", "tags", name));
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
return files;
|
|
405
|
+
}
|
|
406
|
+
function computeFingerprint(commandClass, args, dirs) {
|
|
407
|
+
const paths = [dirs.dotGit];
|
|
408
|
+
const configPaths = [
|
|
409
|
+
join(dirs.commonDir, "config"),
|
|
410
|
+
join(dirs.gitDir, "config.worktree"),
|
|
411
|
+
...globalConfigPaths(),
|
|
412
|
+
];
|
|
413
|
+
switch (commandClass) {
|
|
414
|
+
case "location":
|
|
415
|
+
break;
|
|
416
|
+
case "config":
|
|
417
|
+
paths.push(...configPaths);
|
|
418
|
+
break;
|
|
419
|
+
case "worktree":
|
|
420
|
+
// Never served after settling; the fingerprint only scopes coalescing.
|
|
421
|
+
paths.push(join(dirs.gitDir, "HEAD"), join(dirs.gitDir, "index"));
|
|
422
|
+
break;
|
|
423
|
+
case "refs":
|
|
424
|
+
paths.push(join(dirs.gitDir, "HEAD"), join(dirs.commonDir, "packed-refs"), join(dirs.commonDir, "FETCH_HEAD"), join(dirs.commonDir, "refs", "heads"), join(dirs.commonDir, "refs", "remotes"), join(dirs.commonDir, "refs", "tags"), ...refFilesFor(args, dirs),
|
|
425
|
+
// Upstream tracking (branch -vv) lives in config.
|
|
426
|
+
...configPaths);
|
|
427
|
+
break;
|
|
428
|
+
}
|
|
429
|
+
return paths.map(statKey).join("|");
|
|
430
|
+
}
|
|
431
|
+
let sharedCache = null;
|
|
432
|
+
export function getSharedGitCommandCache() {
|
|
433
|
+
sharedCache ?? (sharedCache = new GitCommandCache());
|
|
434
|
+
return sharedCache;
|
|
435
|
+
}
|
|
436
|
+
//# sourceMappingURL=git-command-cache.js.map
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { existsSync } from "node:fs";
|
|
2
2
|
import pLimit from "p-limit";
|
|
3
|
-
import {
|
|
3
|
+
import { getSharedGitCommandCache, isReadOnlyCommand } from "./git-command-cache.js";
|
|
4
|
+
import { spawnNonInteractive } from "./spawn.js";
|
|
4
5
|
const DEFAULT_TIMEOUT_MS = 30000;
|
|
5
6
|
const DEFAULT_MAX_OUTPUT_BYTES = 20 * 1024 * 1024; // 20MB
|
|
6
7
|
const DEFAULT_STDERR_LIMIT = 2048;
|
|
@@ -61,6 +62,11 @@ function getEnvOverlayKeys(envOverlay) {
|
|
|
61
62
|
return Object.keys(envOverlay ?? {}).sort();
|
|
62
63
|
}
|
|
63
64
|
export function runGitCommand(args, options) {
|
|
65
|
+
// Read-only commands are answered from a per-repo cache validated by .git stat
|
|
66
|
+
// fingerprints, and identical concurrent commands share one spawn.
|
|
67
|
+
return getSharedGitCommandCache().run(args, options.cwd, mergeEnvOverlays(options.env, options.envOverlay), JSON.stringify([options.acceptExitCodes ?? [0], options.maxOutputBytes ?? null]), () => runGitCommandUncached(args, options));
|
|
68
|
+
}
|
|
69
|
+
function runGitCommandUncached(args, options) {
|
|
64
70
|
return gitLimit(() => new Promise((resolve, reject) => {
|
|
65
71
|
const timeout = options.timeout ?? DEFAULT_TIMEOUT_MS;
|
|
66
72
|
const maxOutputBytes = options.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES;
|
|
@@ -87,11 +93,14 @@ export function runGitCommand(args, options) {
|
|
|
87
93
|
}
|
|
88
94
|
// `core.quotepath=false` makes git emit raw UTF-8 paths instead of
|
|
89
95
|
// octal-escaping non-ASCII bytes (e.g. `测试文件.txt` vs `"\346\265\213..."`).
|
|
90
|
-
const child =
|
|
96
|
+
const child = spawnNonInteractive("git", ["-c", "core.quotepath=false", ...args], {
|
|
91
97
|
cwd: options.cwd,
|
|
92
98
|
envOverlay,
|
|
93
99
|
shell: false,
|
|
94
|
-
|
|
100
|
+
// One byte over the cap so the truncation below still triggers.
|
|
101
|
+
maxStdoutBytes: maxOutputBytes + 1,
|
|
102
|
+
maxStderrBytes: DEFAULT_STDERR_LIMIT,
|
|
103
|
+
retryOnDirect: isReadOnlyCommand(args),
|
|
95
104
|
});
|
|
96
105
|
let settled = false;
|
|
97
106
|
let metricFinished = false;
|
|
@@ -210,7 +219,8 @@ export function runGitCommand(args, options) {
|
|
|
210
219
|
});
|
|
211
220
|
const stderrPreview = result.stderr.trim() || "(no stderr)";
|
|
212
221
|
const truncationNote = result.truncated ? " (stdout truncated)" : "";
|
|
213
|
-
|
|
222
|
+
const error = Object.assign(new Error(`Git command failed: ${command}${truncationNote} (exit code: ${String(exitCode)}, signal: ${signal ?? "none"})\n${stderrPreview}`), { gitExitCode: exitCode });
|
|
223
|
+
settle(() => reject(error));
|
|
214
224
|
return;
|
|
215
225
|
}
|
|
216
226
|
finishMetricOnce({
|