@north-light/crouter 0.3.305 → 0.3.306
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.
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// Run with: npm run test:integration
|
|
2
|
+
//
|
|
3
|
+
// Regression: crouter's read-only git calls must not take `.git/index.lock`.
|
|
4
|
+
//
|
|
5
|
+
// `git status` refreshes the index stat cache and writes the index back, which
|
|
6
|
+
// means creating `.git/index.lock` and renaming it over `.git/index`. A process
|
|
7
|
+
// killed between those two steps orphans the lock, and it then blocks every
|
|
8
|
+
// later writer in that checkout with no owning process left to name. Agent
|
|
9
|
+
// processes are killed mid-command routinely — a closed node, a cancelled daemon
|
|
10
|
+
// tick, a timed-out shell — so the residue accumulates in a shared checkout
|
|
11
|
+
// until someone removes it by hand.
|
|
12
|
+
//
|
|
13
|
+
// `GIT_OPTIONAL_LOCKS=0` suppresses exactly that optional lock. These tests pin
|
|
14
|
+
// the two paths that reach git: the shared spawner in `core/git.ts`, and the
|
|
15
|
+
// bearings project-context block, which renders on every node boot and revive
|
|
16
|
+
// and is therefore the most frequent git caller in the runtime.
|
|
17
|
+
import { test } from 'node:test';
|
|
18
|
+
import assert from 'node:assert/strict';
|
|
19
|
+
import { mkdtempSync, rmSync, statSync, utimesSync, writeFileSync } from 'node:fs';
|
|
20
|
+
import { spawnSync } from 'node:child_process';
|
|
21
|
+
import { tmpdir } from 'node:os';
|
|
22
|
+
import { join } from 'node:path';
|
|
23
|
+
import { gitSync } from '../../git.js';
|
|
24
|
+
import { buildProjectContextBlock } from '../../runtime/bearings-render.js';
|
|
25
|
+
/** A repo with one committed file whose stat cache is deliberately stale, so a
|
|
26
|
+
* status that refreshes the index has a reason to write it back. Without the
|
|
27
|
+
* stale stat there is nothing to refresh and the assertion passes vacuously. */
|
|
28
|
+
function repoWithStaleStatCache() {
|
|
29
|
+
const dir = mkdtempSync(join(tmpdir(), 'crtr-git-locks-'));
|
|
30
|
+
const git = (args) => {
|
|
31
|
+
const result = spawnSync('git', args, { cwd: dir, encoding: 'utf8' });
|
|
32
|
+
assert.equal(result.status, 0, `git ${args.join(' ')}: ${result.stderr}`);
|
|
33
|
+
};
|
|
34
|
+
writeFileSync(join(dir, 'f.txt'), 'a\n', 'utf8');
|
|
35
|
+
git(['init', '-q', '.']);
|
|
36
|
+
git(['-c', 'user.email=t@t', '-c', 'user.name=t', 'add', 'f.txt']);
|
|
37
|
+
git(['-c', 'user.email=t@t', '-c', 'user.name=t', 'commit', '-qm', 'init']);
|
|
38
|
+
// Move the file's mtime forward so git's cached stat no longer matches.
|
|
39
|
+
const future = new Date(Date.now() + 10_000);
|
|
40
|
+
utimesSync(join(dir, 'f.txt'), future, future);
|
|
41
|
+
return dir;
|
|
42
|
+
}
|
|
43
|
+
function indexMtime(dir) {
|
|
44
|
+
return statSync(join(dir, '.git', 'index')).mtimeMs;
|
|
45
|
+
}
|
|
46
|
+
test('gitSync status does not rewrite the index', (t) => {
|
|
47
|
+
if (spawnSync('git', ['--version']).status !== 0) {
|
|
48
|
+
t.skip('git is unavailable');
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
const dir = repoWithStaleStatCache();
|
|
52
|
+
try {
|
|
53
|
+
const before = indexMtime(dir);
|
|
54
|
+
const result = gitSync(['status', '--porcelain'], dir);
|
|
55
|
+
assert.equal(result.status, 0, result.stderr);
|
|
56
|
+
// Suppressing the lock must not change what the command reports.
|
|
57
|
+
assert.equal(result.stdout, '');
|
|
58
|
+
assert.equal(indexMtime(dir), before, 'status took .git/index.lock and rewrote the index');
|
|
59
|
+
}
|
|
60
|
+
finally {
|
|
61
|
+
rmSync(dir, { recursive: true, force: true });
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
test('the bearings project-context block does not rewrite the index', (t) => {
|
|
65
|
+
if (spawnSync('git', ['--version']).status !== 0) {
|
|
66
|
+
t.skip('git is unavailable');
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
const dir = repoWithStaleStatCache();
|
|
70
|
+
try {
|
|
71
|
+
const before = indexMtime(dir);
|
|
72
|
+
const block = buildProjectContextBlock(dir);
|
|
73
|
+
// The snapshot still reports real git state; it just does not lock to do it.
|
|
74
|
+
assert.match(block, /Git: /);
|
|
75
|
+
assert.match(block, /clean/);
|
|
76
|
+
assert.equal(indexMtime(dir), before, 'the bearings snapshot took .git/index.lock');
|
|
77
|
+
}
|
|
78
|
+
finally {
|
|
79
|
+
rmSync(dir, { recursive: true, force: true });
|
|
80
|
+
}
|
|
81
|
+
});
|
package/dist/core/git.js
CHANGED
|
@@ -1,7 +1,21 @@
|
|
|
1
1
|
import { spawn, spawnSync } from 'node:child_process';
|
|
2
2
|
import { network } from './errors.js';
|
|
3
|
+
/** Git takes `.git/index.lock` to refresh the index stat cache even during
|
|
4
|
+
* otherwise read-only commands — `status` is the common one. Killed between
|
|
5
|
+
* creating that lock and renaming it over the index, the lock orphans and
|
|
6
|
+
* blocks every later writer in the checkout with no owning process left to
|
|
7
|
+
* name. Agent processes are killed mid-command routinely (a closed node, a
|
|
8
|
+
* cancelled daemon tick, a timed-out shell), so the residue accumulates.
|
|
9
|
+
*
|
|
10
|
+
* `GIT_OPTIONAL_LOCKS=0` suppresses exactly those optional locks and nothing
|
|
11
|
+
* else: a commit, rebase, or `add` still takes the lock it requires, and
|
|
12
|
+
* command output is byte-identical. Built per call rather than snapshotted at
|
|
13
|
+
* module load, so a later change to the ambient environment still reaches git. */
|
|
14
|
+
function gitEnv(base = process.env) {
|
|
15
|
+
return { ...base, GIT_OPTIONAL_LOCKS: '0' };
|
|
16
|
+
}
|
|
3
17
|
export function gitSync(args, cwd, input, options = {}) {
|
|
4
|
-
const res = spawnSync('git', args, { cwd, encoding: 'utf8', input, timeout: options.timeoutMs });
|
|
18
|
+
const res = spawnSync('git', args, { cwd, encoding: 'utf8', input, env: gitEnv(), timeout: options.timeoutMs });
|
|
5
19
|
const status = typeof res.status === 'number' ? res.status : 1;
|
|
6
20
|
const completed = typeof res.status === 'number';
|
|
7
21
|
const stdout = typeof res.stdout === 'string' ? res.stdout : '';
|
|
@@ -12,7 +26,7 @@ export function gitSync(args, cwd, input, options = {}) {
|
|
|
12
26
|
}
|
|
13
27
|
export async function gitAsync(args, cwd, input, options = {}) {
|
|
14
28
|
return new Promise((resolve) => {
|
|
15
|
-
const child = spawn('git', args, { cwd,
|
|
29
|
+
const child = spawn('git', args, { cwd, env: gitEnv(options.env) });
|
|
16
30
|
if (input === undefined)
|
|
17
31
|
child.stdin.end();
|
|
18
32
|
else
|
|
@@ -57,8 +57,8 @@ function directoryListingLines(cwd) {
|
|
|
57
57
|
return ['Directory listing: unavailable (cwd missing or unreadable).'];
|
|
58
58
|
}
|
|
59
59
|
}
|
|
60
|
-
function runCommand(cwd, cmd, args) {
|
|
61
|
-
const result = spawnSync(cmd, args, { cwd, encoding: 'utf8' });
|
|
60
|
+
function runCommand(cwd, cmd, args, env) {
|
|
61
|
+
const result = spawnSync(cmd, args, { cwd, encoding: 'utf8', ...(env === undefined ? {} : { env }) });
|
|
62
62
|
if (result.error !== undefined) {
|
|
63
63
|
return { ok: false, stdout: '', stderr: result.error.message };
|
|
64
64
|
}
|
|
@@ -68,8 +68,17 @@ function runCommand(cwd, cmd, args) {
|
|
|
68
68
|
stderr: (result.stderr ?? '').trim(),
|
|
69
69
|
};
|
|
70
70
|
}
|
|
71
|
+
/** This snapshot renders on every node boot and every revive, which makes it the
|
|
72
|
+
* most frequent git caller in the runtime — and boot is exactly when a process
|
|
73
|
+
* gets killed. `git status` would take `.git/index.lock` to refresh the stat
|
|
74
|
+
* cache; suppressing that optional lock keeps an interrupted render from
|
|
75
|
+
* orphaning a lock that then blocks every writer in the user's checkout.
|
|
76
|
+
* Only optional locks are affected, and the output is unchanged. */
|
|
77
|
+
function gitEnv() {
|
|
78
|
+
return { ...process.env, GIT_OPTIONAL_LOCKS: '0' };
|
|
79
|
+
}
|
|
71
80
|
function runGit(cwd, args) {
|
|
72
|
-
const result = runCommand(cwd, 'git', args);
|
|
81
|
+
const result = runCommand(cwd, 'git', args, gitEnv());
|
|
73
82
|
return result.ok ? result.stdout : '';
|
|
74
83
|
}
|
|
75
84
|
function parseWorktrees(lines) {
|
|
@@ -98,7 +107,7 @@ function parseWorktrees(lines) {
|
|
|
98
107
|
* benign state. Every other probe here is genuinely optional and already
|
|
99
108
|
* falls back to 'unknown'/'HEAD'/a zero count on failure, which stays as-is. */
|
|
100
109
|
function gitSnapshotLines(cwd) {
|
|
101
|
-
const probe = runCommand(cwd, 'git', ['rev-parse', '--is-inside-work-tree']);
|
|
110
|
+
const probe = runCommand(cwd, 'git', ['rev-parse', '--is-inside-work-tree'], gitEnv());
|
|
102
111
|
if (!probe.ok) {
|
|
103
112
|
if (/not a git repository/i.test(probe.stderr)) {
|
|
104
113
|
return ['Git: not a git repository.'];
|
package/package.json
CHANGED
package/runtime.lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@north-light/crouter",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.306",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@north-light/crouter",
|
|
9
|
-
"version": "0.3.
|
|
9
|
+
"version": "0.3.306",
|
|
10
10
|
"hasInstallScript": true,
|
|
11
11
|
"license": "MIT",
|
|
12
12
|
"workspaces": [
|
|
@@ -5206,17 +5206,17 @@
|
|
|
5206
5206
|
},
|
|
5207
5207
|
"packages/crouter-api": {
|
|
5208
5208
|
"name": "@north-light/crouter-api",
|
|
5209
|
-
"version": "0.3.
|
|
5209
|
+
"version": "0.3.306",
|
|
5210
5210
|
"license": "UNLICENSED"
|
|
5211
5211
|
},
|
|
5212
5212
|
"packages/crouter-env-docker": {
|
|
5213
5213
|
"name": "@north-light/crouter-env-docker",
|
|
5214
|
-
"version": "0.3.
|
|
5214
|
+
"version": "0.3.306",
|
|
5215
5215
|
"license": "UNLICENSED"
|
|
5216
5216
|
},
|
|
5217
5217
|
"packages/crouter-sdk": {
|
|
5218
5218
|
"name": "@north-light/crouter-sdk",
|
|
5219
|
-
"version": "0.3.
|
|
5219
|
+
"version": "0.3.306",
|
|
5220
5220
|
"license": "UNLICENSED",
|
|
5221
5221
|
"dependencies": {
|
|
5222
5222
|
"@north-light/crouter-api": "^0.3.295"
|