@rmyndharis/aimhooman 0.1.1 → 0.1.2
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/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/CHANGELOG.md +147 -1
- package/CONTRIBUTING.md +36 -3
- package/README.md +5 -3
- package/bin/aimhooman.mjs +43 -23
- package/package.json +1 -1
- package/rules/attribution.json +7 -7
- package/src/atomic-write.mjs +7 -0
- package/src/git-environment.mjs +10 -0
- package/src/githooks.mjs +113 -19
- package/src/gitx.mjs +48 -265
- package/src/history-scan.mjs +4 -1
- package/src/hook.mjs +28 -6
- package/src/scan-session.mjs +2 -1
- package/src/scan-target.mjs +19 -2
package/src/githooks.mjs
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { execFileSync } from 'node:child_process';
|
|
2
2
|
import { createHash } from 'node:crypto';
|
|
3
|
+
import { GIT_TIMEOUT_MS } from './git-environment.mjs';
|
|
3
4
|
import {
|
|
4
5
|
accessSync,
|
|
5
6
|
chmodSync,
|
|
@@ -38,13 +39,19 @@ function hooksDir(repo) {
|
|
|
38
39
|
|
|
39
40
|
const scope = configScope(repo, 'core.hooksPath');
|
|
40
41
|
const localScope = ['local', 'worktree'].includes(scope);
|
|
41
|
-
const
|
|
42
|
+
const inside = localScope && repositoryOwnsPath(repo, dir);
|
|
43
|
+
// Inside the worktree is not the same as ours. A hooks directory Git tracks
|
|
44
|
+
// (husky, the vanilla .githooks pattern) is repository content: a dispatcher
|
|
45
|
+
// written there stages this machine's absolute CLI, Node, and PATH for
|
|
46
|
+
// everyone who clones.
|
|
47
|
+
const tracked = inside && trackedPath(repo, dir);
|
|
48
|
+
const repositoryOwned = inside && !tracked;
|
|
42
49
|
const shared = !repositoryOwned || resolve(dir) === resolve(globalHooksDir());
|
|
43
50
|
if (shared) {
|
|
44
51
|
const where = scope ? `${scope} scope` : 'a non-local scope';
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
52
|
+
let reason = where;
|
|
53
|
+
if (tracked) reason = `${where}, tracked by this repository`;
|
|
54
|
+
else if (localScope && !inside) reason = `${where}, outside this repository`;
|
|
48
55
|
return {
|
|
49
56
|
dir,
|
|
50
57
|
shared: true,
|
|
@@ -60,16 +67,43 @@ function hooksDir(repo) {
|
|
|
60
67
|
};
|
|
61
68
|
}
|
|
62
69
|
|
|
70
|
+
function pathContains(root, candidate) {
|
|
71
|
+
const rel = relative(root, candidate);
|
|
72
|
+
return rel === '' || (!rel.startsWith(`..${sep}`) && rel !== '..' && !isAbsolute(rel));
|
|
73
|
+
}
|
|
74
|
+
|
|
63
75
|
function repositoryOwnsPath(repo, path) {
|
|
64
76
|
const candidate = canonicalPath(path);
|
|
65
77
|
return [repo.root, repo.commonDir, repo.gitDir]
|
|
66
78
|
.filter(Boolean)
|
|
67
79
|
.map(canonicalPath)
|
|
68
|
-
.some((root) =>
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
80
|
+
.some((root) => pathContains(root, candidate));
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// insideGitDir is the part of repositoryOwnsPath that cannot be shared. A .git
|
|
84
|
+
// belongs to exactly one repository, while a directory in the worktree can be
|
|
85
|
+
// the core.hooksPath of a second one.
|
|
86
|
+
function insideGitDir(repo, path) {
|
|
87
|
+
const candidate = canonicalPath(path);
|
|
88
|
+
return [repo.commonDir, repo.gitDir]
|
|
89
|
+
.filter(Boolean)
|
|
90
|
+
.map(canonicalPath)
|
|
91
|
+
.some((root) => pathContains(root, candidate));
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// trackedPath reports whether Git tracks anything under path. A Git that cannot
|
|
95
|
+
// answer counts as tracked: refusing to install costs a warning, while guessing
|
|
96
|
+
// wrong commits this machine's absolute paths into a shared repository.
|
|
97
|
+
function trackedPath(repo, path) {
|
|
98
|
+
try {
|
|
99
|
+
return execFileSync(
|
|
100
|
+
'git',
|
|
101
|
+
['--literal-pathspecs', 'ls-files', '-z', '--', String(path)],
|
|
102
|
+
{ cwd: repo.root, encoding: 'utf8', timeout: GIT_TIMEOUT_MS }
|
|
103
|
+
).length > 0;
|
|
104
|
+
} catch {
|
|
105
|
+
return true;
|
|
106
|
+
}
|
|
73
107
|
}
|
|
74
108
|
|
|
75
109
|
function canonicalPath(path) {
|
|
@@ -95,13 +129,14 @@ function effectiveHooksDir(repo) {
|
|
|
95
129
|
return execFileSync(
|
|
96
130
|
'git',
|
|
97
131
|
['rev-parse', '--path-format=absolute', '--git-path', 'hooks'],
|
|
98
|
-
{ cwd: repo.root, encoding: 'utf8' }
|
|
132
|
+
{ cwd: repo.root, encoding: 'utf8', timeout: GIT_TIMEOUT_MS }
|
|
99
133
|
).trim();
|
|
100
134
|
} catch {
|
|
101
135
|
try {
|
|
102
136
|
const path = execFileSync('git', ['rev-parse', '--git-path', 'hooks'], {
|
|
103
137
|
cwd: repo.root,
|
|
104
138
|
encoding: 'utf8',
|
|
139
|
+
timeout: GIT_TIMEOUT_MS,
|
|
105
140
|
}).trim();
|
|
106
141
|
return isAbsolute(path) ? path : resolve(repo.root, path);
|
|
107
142
|
} catch {
|
|
@@ -115,6 +150,7 @@ function configScope(repo, key) {
|
|
|
115
150
|
const line = execFileSync('git', ['config', '--show-scope', '--get', key], {
|
|
116
151
|
cwd: repo.root,
|
|
117
152
|
encoding: 'utf8',
|
|
153
|
+
timeout: GIT_TIMEOUT_MS,
|
|
118
154
|
}).trim();
|
|
119
155
|
return line.split(/\s/, 1)[0];
|
|
120
156
|
} catch {
|
|
@@ -125,6 +161,7 @@ function configScope(repo, key) {
|
|
|
125
161
|
execFileSync('git', ['config', '--local', '--get', key], {
|
|
126
162
|
cwd: repo.root,
|
|
127
163
|
stdio: ['ignore', 'ignore', 'ignore'],
|
|
164
|
+
timeout: GIT_TIMEOUT_MS,
|
|
128
165
|
});
|
|
129
166
|
return 'local';
|
|
130
167
|
} catch {
|
|
@@ -307,7 +344,7 @@ function installHooksLocked(repo, cliPath, options, dir, warnings) {
|
|
|
307
344
|
continue;
|
|
308
345
|
}
|
|
309
346
|
if (current && ownedHook(readFileSync(dest, 'utf8'), name)
|
|
310
|
-
&& !
|
|
347
|
+
&& !ownedByRepo(repo, dir, readFileSync(dest, 'utf8'), name, chainedPath)) {
|
|
311
348
|
warnings.push(`${name} is managed for another repository; refusing to overwrite it`);
|
|
312
349
|
unsafe = true;
|
|
313
350
|
}
|
|
@@ -381,13 +418,18 @@ function uninstallHooksLocked(repo, dir, warnings) {
|
|
|
381
418
|
const chained = join(chainedDir, name);
|
|
382
419
|
const content = readFileSync(dest, 'utf8');
|
|
383
420
|
if (!ownedHook(content, name)) continue;
|
|
384
|
-
if (!
|
|
385
|
-
|
|
421
|
+
if (!ownedByRepo(repo, dir, content, name, chained)) {
|
|
422
|
+
failures.push(`${name}: managed for another repository; left in place at ${dest}`);
|
|
386
423
|
continue;
|
|
387
424
|
}
|
|
388
425
|
const predecessor = entry(chained);
|
|
389
426
|
if (predecessor?.isSymbolicLink()) {
|
|
390
|
-
|
|
427
|
+
// Never read or copy through the symlink, but do let go of the
|
|
428
|
+
// dispatcher: holding the repository hostage over a backup we
|
|
429
|
+
// refuse to touch helps nobody.
|
|
430
|
+
warnings.push(`${name} chained backup is a symlink; ${chained} left in place and your original hook was not restored`);
|
|
431
|
+
unlinkSync(dest);
|
|
432
|
+
removed.push(name);
|
|
391
433
|
continue;
|
|
392
434
|
}
|
|
393
435
|
if (predecessor) {
|
|
@@ -407,6 +449,34 @@ function uninstallHooksLocked(repo, dir, warnings) {
|
|
|
407
449
|
return { removed: removed.sort(), restored: restored.sort(), warnings, failures };
|
|
408
450
|
}
|
|
409
451
|
|
|
452
|
+
// remainingDispatchers lists aimhooman dispatchers a local uninstall was meant
|
|
453
|
+
// to remove and did not. uninstall checks the directory rather than trusting its
|
|
454
|
+
// own report: a refusal that only produced a warning would otherwise be printed
|
|
455
|
+
// under a success headline, and "uninstalled" while four dispatchers still block
|
|
456
|
+
// every commit is the one lie a guard must never tell.
|
|
457
|
+
//
|
|
458
|
+
// A shared hooks directory is skipped because uninstallHooks does not touch one
|
|
459
|
+
// either — the global directory has `uninstall --global`, and a foreign or
|
|
460
|
+
// tracked one is reported by its own warning. This asks hooksDir rather than
|
|
461
|
+
// comparing paths: two spellings of one directory differ on Windows, and
|
|
462
|
+
// deciding ownership by string is the bug this change exists to remove.
|
|
463
|
+
export function remainingDispatchers(repo) {
|
|
464
|
+
const { dir, shared } = hooksDir(repo);
|
|
465
|
+
if (shared) return [];
|
|
466
|
+
return Object.keys(MANAGED).sort().flatMap((name) => {
|
|
467
|
+
const path = join(dir, name);
|
|
468
|
+
try {
|
|
469
|
+
const stat = entry(path);
|
|
470
|
+
if (!stat || stat.isSymbolicLink()) return [];
|
|
471
|
+
return ownedHook(readFileSync(path, 'utf8'), name) ? [path] : [];
|
|
472
|
+
} catch {
|
|
473
|
+
// Unreadable: cannot prove it is gone, so report it rather than
|
|
474
|
+
// claim a clean removal.
|
|
475
|
+
return [path];
|
|
476
|
+
}
|
|
477
|
+
});
|
|
478
|
+
}
|
|
479
|
+
|
|
410
480
|
// hookDiagnostics reports both dispatcher integrity and whether its configured
|
|
411
481
|
// command can be reached from the current environment.
|
|
412
482
|
export function hookDiagnostics(repo) {
|
|
@@ -414,8 +484,12 @@ export function hookDiagnostics(repo) {
|
|
|
414
484
|
// Global hooks (installGlobalHooks) keep chained backups in the hooks dir
|
|
415
485
|
// itself; local hooks keep them in repo state via chainDir. Diagnose the
|
|
416
486
|
// path the dispatcher actually embeds, not the repo-relative default.
|
|
487
|
+
// canonicalPath, not resolve: a HOME behind a symlink (Fedora Silverblue
|
|
488
|
+
// ships /home -> /var/home; NFS and autofs homes are everywhere) makes the
|
|
489
|
+
// two spellings differ, and every global dispatcher would then be diagnosed
|
|
490
|
+
// as belonging to another repository.
|
|
417
491
|
const globalDir = globalHooksDir();
|
|
418
|
-
const chained =
|
|
492
|
+
const chained = canonicalPath(dir) === canonicalPath(globalDir)
|
|
419
493
|
? join(globalDir, 'chained')
|
|
420
494
|
: chainDir(repo);
|
|
421
495
|
return Object.keys(MANAGED).sort().map((name) => {
|
|
@@ -458,7 +532,7 @@ export function hookDiagnostics(repo) {
|
|
|
458
532
|
reason: inspected.reason,
|
|
459
533
|
};
|
|
460
534
|
}
|
|
461
|
-
if (!
|
|
535
|
+
if (!ownedByRepo(repo, dir, content, name, base.chainedPath)) {
|
|
462
536
|
return {
|
|
463
537
|
...base,
|
|
464
538
|
managed: false,
|
|
@@ -580,8 +654,20 @@ AIMHOOMAN_CLI=${shq(resolvedCliPath)}
|
|
|
580
654
|
AIMHOOMAN_NODE=${shq(nodePath)}
|
|
581
655
|
AIMHOOMAN_PATH=${shq(shellPathValue)}
|
|
582
656
|
${captureTree}${captureTransaction}run_aimhooman() {
|
|
583
|
-
if [ ! -f "$AIMHOOMAN_CLI" ]
|
|
584
|
-
echo "aimhooman: guard unavailable (aimhooman CLI
|
|
657
|
+
if [ ! -f "$AIMHOOMAN_CLI" ]; then
|
|
658
|
+
echo "aimhooman: guard unavailable (aimhooman CLI is missing); allowing this operation without protection. Reinstall aimhooman or remove the managed hooks." >&2
|
|
659
|
+
return 0
|
|
660
|
+
fi
|
|
661
|
+
if [ ! -f "$AIMHOOMAN_NODE" ]; then
|
|
662
|
+
# Both remedies below are Node programs, so stopping is only fair when a Node
|
|
663
|
+
# exists to run them with. Without one the CLI file is inert: 'init' and
|
|
664
|
+
# 'uninstall' cannot run, and refusing would leave the repository unusable
|
|
665
|
+
# with no way to remove these hooks except deleting them by hand.
|
|
666
|
+
if command -v node >/dev/null 2>&1; then
|
|
667
|
+
echo "aimhooman: the pinned Node interpreter is missing (\$AIMHOOMAN_NODE); the operation was stopped. Run 'aimhooman init' to re-pin it, or 'aimhooman uninstall' to remove the guard." >&2
|
|
668
|
+
return 20
|
|
669
|
+
fi
|
|
670
|
+
echo "aimhooman: guard unavailable (no Node interpreter found); allowing this operation without protection. Install Node and run 'aimhooman init' to restore the guard." >&2
|
|
585
671
|
return 0
|
|
586
672
|
fi
|
|
587
673
|
(
|
|
@@ -698,8 +784,16 @@ function ownedHook(content, name) {
|
|
|
698
784
|
return inspected.valid || legacyHook(content, name);
|
|
699
785
|
}
|
|
700
786
|
|
|
701
|
-
|
|
787
|
+
// ownedByRepo decides whether this repository may rewrite or remove a dispatcher.
|
|
788
|
+
// Inside our own .git the fingerprint settles it: we wrote the file, and no
|
|
789
|
+
// second repository can own anything there. The baked chained path only earns a
|
|
790
|
+
// vote where the hooks directory can genuinely be shared — two repositories may
|
|
791
|
+
// point core.hooksPath at one directory, and then it is the only way to tell the
|
|
792
|
+
// installs apart. Comparing paths everywhere means a renamed repository stops
|
|
793
|
+
// recognising the guard it installed itself, with no way to re-pin or remove it.
|
|
794
|
+
function ownedByRepo(repo, dir, content, name, chainedPath) {
|
|
702
795
|
if (!ownedHook(content, name)) return false;
|
|
796
|
+
if (insideGitDir(repo, dir)) return true;
|
|
703
797
|
return assignmentValue(content, 'CHAINED') === String(chainedPath);
|
|
704
798
|
}
|
|
705
799
|
|
package/src/gitx.mjs
CHANGED
|
@@ -12,21 +12,8 @@ import {
|
|
|
12
12
|
rmSync,
|
|
13
13
|
symlinkSync,
|
|
14
14
|
} from 'node:fs';
|
|
15
|
-
import { createHash, randomUUID } from 'node:crypto';
|
|
16
15
|
import { isAbsolute, join } from 'node:path';
|
|
17
|
-
import { gitEnvironment } from './git-environment.mjs';
|
|
18
|
-
|
|
19
|
-
export class StateMigrationError extends Error {
|
|
20
|
-
constructor(detail, { stateDir, legacyStateDirs = [], diagnostics = [], cause } = {}) {
|
|
21
|
-
super(`repository state: ${detail}`);
|
|
22
|
-
this.name = 'StateMigrationError';
|
|
23
|
-
this.stateDir = stateDir;
|
|
24
|
-
this.legacyStateDirs = legacyStateDirs;
|
|
25
|
-
this.stateDiagnostics = diagnostics;
|
|
26
|
-
this.diagnostics = diagnostics;
|
|
27
|
-
if (cause) this.cause = cause;
|
|
28
|
-
}
|
|
29
|
-
}
|
|
16
|
+
import { gitEnvironment, GIT_TIMEOUT_MS } from './git-environment.mjs';
|
|
30
17
|
|
|
31
18
|
export class GitRevisionError extends Error {
|
|
32
19
|
constructor(revision, detail, cause) {
|
|
@@ -53,6 +40,7 @@ function gitBuf(args, cwd, input) {
|
|
|
53
40
|
env: gitEnvironment(),
|
|
54
41
|
encoding: 'buffer',
|
|
55
42
|
maxBuffer: 64 * 1024 * 1024,
|
|
43
|
+
timeout: GIT_TIMEOUT_MS,
|
|
56
44
|
// Capture stderr into the thrown error (pipe) instead of letting git's
|
|
57
45
|
// raw stderr leak to the user's terminal alongside aimhooman's own
|
|
58
46
|
// diagnostics; stdin is ignored unless input is supplied.
|
|
@@ -162,257 +150,17 @@ export function openRepo(cwd = process.cwd()) {
|
|
|
162
150
|
const gitDir = gitStr(['rev-parse', '--absolute-git-dir'], root);
|
|
163
151
|
let commonDir = gitStr(['rev-parse', '--git-common-dir'], root);
|
|
164
152
|
if (!isAbsolute(commonDir)) commonDir = join(root, commonDir);
|
|
165
|
-
const state = prepareSharedState(commonDir, gitDir);
|
|
166
153
|
return {
|
|
167
154
|
root,
|
|
168
155
|
gitDir,
|
|
169
156
|
commonDir,
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
157
|
+
// Shared by linked worktrees, which is why it hangs off commonDir rather
|
|
158
|
+
// than gitDir. Every released version has written here.
|
|
159
|
+
stateDir: join(commonDir, 'aimhooman'),
|
|
173
160
|
excludeFile: join(commonDir, 'info', 'exclude'),
|
|
174
161
|
};
|
|
175
162
|
}
|
|
176
163
|
|
|
177
|
-
function prepareSharedState(commonDir, gitDir) {
|
|
178
|
-
const stateDir = join(commonDir, 'aimhooman');
|
|
179
|
-
const legacyStateDirs = findLegacyStateDirs(commonDir, gitDir, stateDir);
|
|
180
|
-
const diagnostics = [];
|
|
181
|
-
if (!legacyStateDirs.length) return { stateDir, legacyStateDirs, diagnostics };
|
|
182
|
-
|
|
183
|
-
const currentExists = pathExists(stateDir);
|
|
184
|
-
let fingerprints;
|
|
185
|
-
try {
|
|
186
|
-
fingerprints = legacyStateDirs.map((path) => ({ path, fingerprint: stateFingerprint(path) }));
|
|
187
|
-
if (currentExists) {
|
|
188
|
-
const currentFingerprint = stateFingerprint(stateDir);
|
|
189
|
-
const conflicts = fingerprints.filter((entry) => entry.fingerprint !== currentFingerprint);
|
|
190
|
-
if (conflicts.length) {
|
|
191
|
-
throw stateConflict(stateDir, legacyStateDirs, conflicts.map((entry) => entry.path));
|
|
192
|
-
}
|
|
193
|
-
archiveLegacyStates(fingerprints, stateDir, diagnostics);
|
|
194
|
-
return { stateDir, legacyStateDirs, diagnostics };
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
const first = fingerprints[0];
|
|
198
|
-
const conflicts = fingerprints.filter((entry) => entry.fingerprint !== first.fingerprint);
|
|
199
|
-
if (conflicts.length) {
|
|
200
|
-
throw stateConflict(stateDir, legacyStateDirs, conflicts.map((entry) => entry.path));
|
|
201
|
-
}
|
|
202
|
-
try {
|
|
203
|
-
copyStateAtomically(first.path, stateDir, first.fingerprint);
|
|
204
|
-
} catch (error) {
|
|
205
|
-
if (!pathExists(stateDir)) throw error;
|
|
206
|
-
const currentFingerprint = stateFingerprint(stateDir);
|
|
207
|
-
const conflicts = fingerprints.filter((entry) => entry.fingerprint !== currentFingerprint);
|
|
208
|
-
if (conflicts.length) {
|
|
209
|
-
throw stateConflict(stateDir, legacyStateDirs, conflicts.map((entry) => entry.path));
|
|
210
|
-
}
|
|
211
|
-
diagnostics.push({
|
|
212
|
-
level: 'info',
|
|
213
|
-
code: 'legacy-state-migrated-concurrently',
|
|
214
|
-
path: first.path,
|
|
215
|
-
stateDir,
|
|
216
|
-
message: `shared state at "${stateDir}" was created by another process`,
|
|
217
|
-
});
|
|
218
|
-
archiveLegacyStates(fingerprints, stateDir, diagnostics);
|
|
219
|
-
return { stateDir, legacyStateDirs, diagnostics };
|
|
220
|
-
}
|
|
221
|
-
diagnostics.push({
|
|
222
|
-
level: 'info',
|
|
223
|
-
code: 'legacy-state-migrated',
|
|
224
|
-
path: first.path,
|
|
225
|
-
stateDir,
|
|
226
|
-
message: `copied legacy state from "${first.path}" to shared state "${stateDir}"`,
|
|
227
|
-
});
|
|
228
|
-
archiveLegacyStates(fingerprints, stateDir, diagnostics);
|
|
229
|
-
return { stateDir, legacyStateDirs, diagnostics };
|
|
230
|
-
} catch (error) {
|
|
231
|
-
if (error instanceof StateMigrationError) throw error;
|
|
232
|
-
throw new StateMigrationError(`cannot inspect or migrate legacy state: ${error.message}`, {
|
|
233
|
-
stateDir,
|
|
234
|
-
legacyStateDirs,
|
|
235
|
-
diagnostics,
|
|
236
|
-
cause: error,
|
|
237
|
-
});
|
|
238
|
-
}
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
function findLegacyStateDirs(commonDir, gitDir, stateDir) {
|
|
242
|
-
const found = new Set();
|
|
243
|
-
const adminDir = join(commonDir, 'worktrees');
|
|
244
|
-
let entries = [];
|
|
245
|
-
try {
|
|
246
|
-
entries = readdirSync(adminDir, { withFileTypes: true });
|
|
247
|
-
} catch (error) {
|
|
248
|
-
if (error?.code !== 'ENOENT') {
|
|
249
|
-
throw new StateMigrationError(`cannot list linked worktrees: ${error.message}`, {
|
|
250
|
-
stateDir,
|
|
251
|
-
cause: error,
|
|
252
|
-
});
|
|
253
|
-
}
|
|
254
|
-
}
|
|
255
|
-
for (const entry of entries) {
|
|
256
|
-
if (!entry.isDirectory()) continue;
|
|
257
|
-
const candidate = join(adminDir, entry.name, 'aimhooman');
|
|
258
|
-
if (pathExists(candidate) && !isCompatibilityLink(candidate, stateDir)) found.add(candidate);
|
|
259
|
-
}
|
|
260
|
-
const currentLegacy = join(gitDir, 'aimhooman');
|
|
261
|
-
if (currentLegacy !== stateDir
|
|
262
|
-
&& pathExists(currentLegacy)
|
|
263
|
-
&& !isCompatibilityLink(currentLegacy, stateDir)) {
|
|
264
|
-
found.add(currentLegacy);
|
|
265
|
-
}
|
|
266
|
-
return [...found].sort();
|
|
267
|
-
}
|
|
268
|
-
|
|
269
|
-
function isCompatibilityLink(path, stateDir) {
|
|
270
|
-
try {
|
|
271
|
-
if (!lstatSync(path).isSymbolicLink()) return false;
|
|
272
|
-
return realpathSync(path) === realpathSync(stateDir);
|
|
273
|
-
} catch (error) {
|
|
274
|
-
if (error?.code === 'ENOENT') return false;
|
|
275
|
-
throw error;
|
|
276
|
-
}
|
|
277
|
-
}
|
|
278
|
-
|
|
279
|
-
function pathExists(path) {
|
|
280
|
-
try {
|
|
281
|
-
lstatSync(path);
|
|
282
|
-
return true;
|
|
283
|
-
} catch (error) {
|
|
284
|
-
if (error?.code === 'ENOENT') return false;
|
|
285
|
-
throw error;
|
|
286
|
-
}
|
|
287
|
-
}
|
|
288
|
-
|
|
289
|
-
function stateFingerprint(root) {
|
|
290
|
-
const hash = createHash('sha256');
|
|
291
|
-
function visit(path, relative) {
|
|
292
|
-
const stat = lstatSync(path);
|
|
293
|
-
hash.update(relative);
|
|
294
|
-
hash.update('\0');
|
|
295
|
-
hash.update(String(stat.mode & 0o777));
|
|
296
|
-
hash.update('\0');
|
|
297
|
-
if (stat.isDirectory()) {
|
|
298
|
-
hash.update('directory\0');
|
|
299
|
-
for (const name of readdirSync(path).sort()) {
|
|
300
|
-
visit(join(path, name), relative ? join(relative, name) : name);
|
|
301
|
-
}
|
|
302
|
-
return;
|
|
303
|
-
}
|
|
304
|
-
if (stat.isFile()) {
|
|
305
|
-
hash.update('file\0');
|
|
306
|
-
hash.update(readFileSync(path));
|
|
307
|
-
hash.update('\0');
|
|
308
|
-
return;
|
|
309
|
-
}
|
|
310
|
-
if (stat.isSymbolicLink()) {
|
|
311
|
-
hash.update('symlink\0');
|
|
312
|
-
hash.update(readlinkSync(path));
|
|
313
|
-
hash.update('\0');
|
|
314
|
-
return;
|
|
315
|
-
}
|
|
316
|
-
throw new Error(`unsupported state entry "${path}"`);
|
|
317
|
-
}
|
|
318
|
-
visit(root, '');
|
|
319
|
-
return hash.digest('hex');
|
|
320
|
-
}
|
|
321
|
-
|
|
322
|
-
function copyStateAtomically(source, destination, expectedFingerprint) {
|
|
323
|
-
const temporary = `${destination}.migrate-${process.pid}-${randomUUID()}`;
|
|
324
|
-
try {
|
|
325
|
-
cpSync(source, temporary, {
|
|
326
|
-
recursive: true,
|
|
327
|
-
dereference: false,
|
|
328
|
-
errorOnExist: true,
|
|
329
|
-
force: false,
|
|
330
|
-
preserveTimestamps: true,
|
|
331
|
-
});
|
|
332
|
-
if (stateFingerprint(temporary) !== expectedFingerprint) {
|
|
333
|
-
throw new Error(`legacy state changed while it was copied from "${source}"`);
|
|
334
|
-
}
|
|
335
|
-
renameSync(temporary, destination);
|
|
336
|
-
} catch (error) {
|
|
337
|
-
try {
|
|
338
|
-
rmSync(temporary, { recursive: true, force: true });
|
|
339
|
-
} catch {
|
|
340
|
-
// Keep the migration error when cleanup is unavailable.
|
|
341
|
-
}
|
|
342
|
-
throw error;
|
|
343
|
-
}
|
|
344
|
-
}
|
|
345
|
-
|
|
346
|
-
export function archiveLegacyStates(entries, stateDir, diagnostics) {
|
|
347
|
-
for (const entry of entries) {
|
|
348
|
-
// A concurrent process may have already archived this legacy state and
|
|
349
|
-
// replaced entry.path with a compatibility symlink to stateDir. Renaming
|
|
350
|
-
// that symlink would then fingerprint stateDir (mismatching the legacy
|
|
351
|
-
// fingerprint taken before the race) and spuriously fail migration. Treat
|
|
352
|
-
// an already-symlinked entry as archived-concurrently and move on.
|
|
353
|
-
if (isCompatibilityLink(entry.path, stateDir)) {
|
|
354
|
-
diagnostics.push({
|
|
355
|
-
level: 'info',
|
|
356
|
-
code: 'legacy-state-archived-concurrently',
|
|
357
|
-
path: entry.path,
|
|
358
|
-
stateDir,
|
|
359
|
-
message: `legacy state at "${entry.path}" was archived by another process`,
|
|
360
|
-
});
|
|
361
|
-
continue;
|
|
362
|
-
}
|
|
363
|
-
const archivedPath = `${entry.path}.migrated-${randomUUID()}`;
|
|
364
|
-
let renamed = false;
|
|
365
|
-
try {
|
|
366
|
-
renameSync(entry.path, archivedPath);
|
|
367
|
-
renamed = true;
|
|
368
|
-
if (stateFingerprint(archivedPath) !== entry.fingerprint) {
|
|
369
|
-
throw new Error(`legacy state changed while it was archived from "${entry.path}"`);
|
|
370
|
-
}
|
|
371
|
-
symlinkSync(stateDir, entry.path, process.platform === 'win32' ? 'junction' : 'dir');
|
|
372
|
-
} catch (error) {
|
|
373
|
-
if (!renamed && error?.code === 'ENOENT' && !pathExists(entry.path)) {
|
|
374
|
-
diagnostics.push({
|
|
375
|
-
level: 'info',
|
|
376
|
-
code: 'legacy-state-archived-concurrently',
|
|
377
|
-
path: entry.path,
|
|
378
|
-
stateDir,
|
|
379
|
-
message: `legacy state at "${entry.path}" was archived by another process`,
|
|
380
|
-
});
|
|
381
|
-
continue;
|
|
382
|
-
}
|
|
383
|
-
try {
|
|
384
|
-
if (isCompatibilityLink(entry.path, stateDir)) rmSync(entry.path, { force: true });
|
|
385
|
-
if (!pathExists(entry.path) && pathExists(archivedPath)) renameSync(archivedPath, entry.path);
|
|
386
|
-
} catch {
|
|
387
|
-
// Keep the migration error; both locations are named in it.
|
|
388
|
-
}
|
|
389
|
-
throw error;
|
|
390
|
-
}
|
|
391
|
-
diagnostics.push({
|
|
392
|
-
level: 'info',
|
|
393
|
-
code: 'legacy-state-archived',
|
|
394
|
-
path: entry.path,
|
|
395
|
-
archivedPath,
|
|
396
|
-
stateDir,
|
|
397
|
-
message: `archived legacy state at "${archivedPath}" and linked its old path to "${stateDir}"`,
|
|
398
|
-
});
|
|
399
|
-
}
|
|
400
|
-
}
|
|
401
|
-
|
|
402
|
-
function stateConflict(stateDir, legacyStateDirs, conflictingPaths) {
|
|
403
|
-
const diagnostics = conflictingPaths.map((path) => ({
|
|
404
|
-
level: 'error',
|
|
405
|
-
code: 'legacy-state-conflict',
|
|
406
|
-
path,
|
|
407
|
-
stateDir,
|
|
408
|
-
message: `legacy state at "${path}" conflicts with another per-clone state`,
|
|
409
|
-
}));
|
|
410
|
-
return new StateMigrationError(
|
|
411
|
-
`conflicting legacy state found; select which state to keep at "${stateDir}"`,
|
|
412
|
-
{ stateDir, legacyStateDirs, diagnostics },
|
|
413
|
-
);
|
|
414
|
-
}
|
|
415
|
-
|
|
416
164
|
// stagedEntries snapshots index changes, including deletion metadata. Rename
|
|
417
165
|
// and copy entries use the destination path; object IDs keep blob reads stable.
|
|
418
166
|
export function stagedEntries(repo) {
|
|
@@ -636,21 +384,40 @@ export function withIndexFromTree(repo, treeOid, fn) {
|
|
|
636
384
|
}
|
|
637
385
|
}
|
|
638
386
|
|
|
639
|
-
//
|
|
640
|
-
//
|
|
641
|
-
//
|
|
642
|
-
//
|
|
643
|
-
|
|
387
|
+
// gatedTips lists the local branch tips this guard has already cleared. Only
|
|
388
|
+
// refs/heads/* qualifies, because it is the only namespace cmdRefcheck gates;
|
|
389
|
+
// the refs under review are excluded, since Git already publishes them at the
|
|
390
|
+
// `committed` phase and a ref must never serve as its own proof.
|
|
391
|
+
function gatedTips(repo, reviewing) {
|
|
392
|
+
return gitBuf(['for-each-ref', '--format=%(refname) %(objectname)', 'refs/heads/'], repo.root)
|
|
393
|
+
.toString('utf8')
|
|
394
|
+
.split('\n')
|
|
395
|
+
.filter(Boolean)
|
|
396
|
+
.map((line) => line.split(' '))
|
|
397
|
+
.filter(([name, oid]) => name && oid && !reviewing.has(name))
|
|
398
|
+
.map(([, oid]) => oid);
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// introducedCommits returns each commit added by a proposed branch update.
|
|
402
|
+
// Reachability through refs/heads/* is trusted because those commits passed this
|
|
403
|
+
// same guard when their branch was written; every other namespace is not. Using
|
|
404
|
+
// `--not --all` would let an ignored tag or remote ref pre-poison reachability
|
|
405
|
+
// and suppress the scan, so tips come from refs/heads/* alone. A newly created
|
|
406
|
+
// branch is measured against those tips instead of its full ancestry, which
|
|
407
|
+
// would otherwise rescan the entire repository on every `git checkout -b`;
|
|
408
|
+
// ordinary updates scan exactly old..new.
|
|
644
409
|
export function introducedCommits(repo, updates) {
|
|
645
410
|
const commits = [];
|
|
646
411
|
const seen = new Set();
|
|
412
|
+
const reviewing = new Set(updates.map((update) => update?.ref).filter(Boolean));
|
|
413
|
+
const gated = gatedTips(repo, reviewing);
|
|
647
414
|
for (const update of updates) {
|
|
648
415
|
const oldObjectId = update?.oldObjectId;
|
|
649
416
|
const newObjectId = update?.newObjectId;
|
|
650
417
|
assertOid(oldObjectId);
|
|
651
418
|
assertOid(newObjectId);
|
|
652
419
|
const revisions = /^0+$/.test(oldObjectId)
|
|
653
|
-
? [newObjectId]
|
|
420
|
+
? [newObjectId, ...(gated.length ? ['--not', ...gated] : [])]
|
|
654
421
|
: [newObjectId, `^${oldObjectId}`];
|
|
655
422
|
const resolved = gitBuf(['rev-list', '--reverse', ...revisions], repo.root)
|
|
656
423
|
.toString('utf8')
|
|
@@ -686,12 +453,28 @@ export function unstagePaths(repo, paths) {
|
|
|
686
453
|
cwd: repo.root,
|
|
687
454
|
env: gitEnvironment(),
|
|
688
455
|
input: pathspecs,
|
|
456
|
+
timeout: GIT_TIMEOUT_MS,
|
|
689
457
|
stdio: ['pipe', 'ignore', 'pipe'],
|
|
690
458
|
};
|
|
691
459
|
let hasHead = true;
|
|
692
460
|
try {
|
|
693
|
-
|
|
694
|
-
|
|
461
|
+
// Deliberately not `opts`: rev-parse never reads stdin, so feeding it the
|
|
462
|
+
// pathspec makes git exit before draining the pipe, and a pathspec over the
|
|
463
|
+
// ~64 KiB pipe buffer fails the write with EPIPE. That read as "no HEAD" and
|
|
464
|
+
// sent a repository that has one down the `rm --cached` branch, staging the
|
|
465
|
+
// deletion of every tracked path this was asked to restore.
|
|
466
|
+
execFileSync('git', ['rev-parse', '--verify', 'HEAD'], {
|
|
467
|
+
cwd: repo.root,
|
|
468
|
+
env: gitEnvironment(),
|
|
469
|
+
timeout: GIT_TIMEOUT_MS,
|
|
470
|
+
stdio: ['ignore', 'ignore', 'pipe'],
|
|
471
|
+
});
|
|
472
|
+
} catch (error) {
|
|
473
|
+
// Only git's own verdict may select the branch. An unborn HEAD exits 128;
|
|
474
|
+
// a timeout or a missing git carries no exit status, and guessing "no HEAD"
|
|
475
|
+
// there would delete instead of restore. Fail closed and let the caller stop
|
|
476
|
+
// the commit rather than silently rewrite the index.
|
|
477
|
+
if (typeof error?.status !== 'number') throw error;
|
|
695
478
|
hasHead = false;
|
|
696
479
|
}
|
|
697
480
|
if (hasHead) {
|
package/src/history-scan.mjs
CHANGED
|
@@ -2,7 +2,7 @@ import { execFileSync } from 'node:child_process';
|
|
|
2
2
|
import { readFileSync } from 'node:fs';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
4
|
import { GitRevisionError } from './gitx.mjs';
|
|
5
|
-
import { gitEnvironment } from './git-environment.mjs';
|
|
5
|
+
import { gitEnvironment, GIT_TIMEOUT_MS } from './git-environment.mjs';
|
|
6
6
|
|
|
7
7
|
export const EMPTY_HISTORY_OID = '0'.repeat(40);
|
|
8
8
|
|
|
@@ -12,6 +12,7 @@ function gitBuffer(repo, args, input) {
|
|
|
12
12
|
env: gitEnvironment(),
|
|
13
13
|
encoding: 'buffer',
|
|
14
14
|
maxBuffer: 128 * 1024 * 1024,
|
|
15
|
+
timeout: GIT_TIMEOUT_MS,
|
|
15
16
|
...(input === undefined ? {} : { input }),
|
|
16
17
|
});
|
|
17
18
|
}
|
|
@@ -26,6 +27,7 @@ function gitStringQuiet(repo, args) {
|
|
|
26
27
|
env: gitEnvironment(),
|
|
27
28
|
encoding: 'utf8',
|
|
28
29
|
maxBuffer: 128 * 1024 * 1024,
|
|
30
|
+
timeout: GIT_TIMEOUT_MS,
|
|
29
31
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
30
32
|
}).trim();
|
|
31
33
|
}
|
|
@@ -223,6 +225,7 @@ function isAncestor(repo, ancestor, descendant) {
|
|
|
223
225
|
cwd: repo.root,
|
|
224
226
|
env: gitEnvironment(),
|
|
225
227
|
stdio: ['ignore', 'ignore', 'pipe'],
|
|
228
|
+
timeout: GIT_TIMEOUT_MS,
|
|
226
229
|
});
|
|
227
230
|
return true;
|
|
228
231
|
} catch (error) {
|