simt-dev 0.7.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +191 -870
- package/README.md +188 -213
- package/bin/simt.mjs +19 -55
- package/docs/decisions/ADR-014-workspace-is-configuration.md +2 -2
- package/docs/decisions/ADR-025-a-baseline-is-a-region-in-a-file-the-repo-owns.md +1 -1
- package/docs/decisions/ADR-026-a-config-without-a-region-is-a-file-the-repo-owns.md +1 -1
- package/docs/decisions/ADR-027-the-one-gitattributes-line-that-is-neither-dangerous-nor-empty.md +1 -1
- package/docs/decisions/ADR-028-a-base-file-is-a-floor-so-the-disagreement-it-cannot-repair-becomes-a-check.md +1 -1
- package/docs/decisions/ADR-029-the-pins-a-repository-declares-are-its-own.md +1 -1
- package/docs/decisions/ADR-030-what-a-plan-does-not-ship-is-a-decision-with-a-record.md +1 -1
- package/docs/decisions/ADR-048-a-baseline-carries-only-keys-a-reader-honours.md +1 -1
- package/docs/decisions/ADR-082-the-tracker-override-is-a-manifest-key.md +1 -1
- package/docs/decisions/ADR-099-the-release-verifies-served-bytes-and-the-formula-mirrors-them.md +1 -1
- package/docs/decisions/ADR-117-the-darwin-release-builds-on-supported-macos-15-runners-and-a-re-run-is-idempotent.md +81 -0
- package/docs/decisions/ADR-118-the-bench-corpus-is-keyed-on-history-is-append-only-and-is-portable-as-a-sealed-document.md +91 -0
- package/docs/decisions/ADR-119-the-environment-variable-points-into-a-workspace-the-repo-declared.md +90 -0
- package/docs/decisions/ADR-120-a-pre-push-whose-tree-is-already-proven-green-skips-the-test-gate-on-that-evidence.md +96 -0
- package/docs/decisions/ADR-122-the-shims-move-into-the-state-directory-and-the-legacy-directory-removes-itself.md +94 -0
- package/docs/decisions/ADR-123-a-surplus-positional-is-not-refused-because-arity-is-not-in-the-source.md +57 -0
- package/docs/decisions/ADR-124-the-manifest-has-a-schema-in-the-tool-and-its-unknown-keys-stay-the-repositorys.md +64 -0
- package/docs/decisions/ADR-125-a-shared-context-belongs-to-a-read-and-a-writer-reads-directly.md +65 -0
- package/docs/decisions/ADR-126-the-mcp-surface-stays-a-reader-so-there-is-no-write-path-to-build.md +68 -0
- package/docs/decisions/ADR-127-the-mcp-tool-list-is-written-by-hand-and-roots-is-not-asked-for.md +65 -0
- package/docs/decisions/ADR-128-a-research-plan-is-a-plan-and-the-tracker-reads-it.md +42 -0
- package/ecosystems/bun.json +6 -0
- package/ecosystems/cargo.json +25 -0
- package/ecosystems/composer.json +6 -0
- package/ecosystems/dart.json +24 -0
- package/ecosystems/deno.json +21 -0
- package/ecosystems/devcontainers.json +6 -0
- package/ecosystems/docker-compose.json +6 -0
- package/ecosystems/docker.json +6 -0
- package/ecosystems/dotnet.json +25 -0
- package/ecosystems/elixir.json +23 -0
- package/ecosystems/gitsubmodule.json +6 -0
- package/ecosystems/go.json +25 -0
- package/ecosystems/gradle.json +13 -0
- package/ecosystems/maven.json +23 -0
- package/ecosystems/node.json +25 -0
- package/ecosystems/python.json +20 -0
- package/ecosystems/ruby.json +23 -0
- package/ecosystems/swift.json +25 -0
- package/ecosystems/terraform.json +6 -0
- package/ecosystems/uv.json +6 -0
- package/harnesses/antigravity.json +162 -0
- package/harnesses/claude.json +206 -0
- package/harnesses/codex.json +136 -0
- package/harnesses/copilot.json +88 -0
- package/harnesses/cursor.json +140 -0
- package/harnesses/deepseek.json +99 -0
- package/harnesses/gemini.json +122 -0
- package/harnesses/opencode.json +173 -0
- package/package.json +12 -2
- package/rules/core.md +7 -0
- package/rules/mdc/intake-questions.mdc +8 -0
- package/rules/mdc/slice-commit.mdc +57 -4
- package/src/advice/advice.mjs +3 -2
- package/src/agenthooks/prompt.mjs +5 -2
- package/src/agenthooks/render.mjs +86 -180
- package/src/agenthooks/shim.mjs +1 -1
- package/src/agents/render.mjs +15 -18
- package/src/api.mjs +31 -0
- package/src/argv.mjs +48 -3
- package/src/bench/ablate.mjs +3 -4
- package/src/bench/adapters.json +163 -105
- package/src/bench/adapters.mjs +466 -0
- package/src/bench/arms.mjs +333 -0
- package/src/bench/grade.mjs +33 -73
- package/src/bench/graders.mjs +42 -0
- package/src/bench/mine.mjs +121 -475
- package/src/bench/probe-ledger.mjs +149 -0
- package/src/bench/probes.catalog.mjs +918 -0
- package/src/bench/probes.mjs +79 -1064
- package/src/bench/render.mjs +111 -0
- package/src/bench/report.mjs +120 -962
- package/src/bench/run.mjs +185 -802
- package/src/bench/variants.mjs +4 -5
- package/src/bench/workspace.mjs +160 -0
- package/src/brief/assemble.mjs +48 -15
- package/src/ci/dependabot.mjs +25 -55
- package/src/ci/workflow.mjs +30 -7
- package/src/cli.mjs +45 -0
- package/src/commands/agent.mjs +4 -13
- package/src/commands/agenthook.mjs +70 -40
- package/src/commands/bench.mjs +191 -18
- package/src/commands/brief.mjs +5 -4
- package/src/commands/ci.mjs +13 -14
- package/src/commands/compare.mjs +42 -42
- package/src/commands/completions.mjs +2 -6
- package/src/commands/decide.mjs +4 -4
- package/src/commands/distill.mjs +4 -3
- package/src/commands/docs.mjs +88 -0
- package/src/commands/doctor.mjs +11 -7
- package/src/commands/evidence.mjs +6 -9
- package/src/commands/explain.mjs +0 -1
- package/src/commands/gate.mjs +26 -25
- package/src/commands/graph.mjs +2 -3
- package/src/commands/harness.mjs +6 -5
- package/src/commands/help.mjs +168 -19
- package/src/commands/hooks.mjs +119 -49
- package/src/commands/ignore.mjs +13 -8
- package/src/commands/impact.mjs +8 -15
- package/src/commands/index.mjs +318 -193
- package/src/commands/init-scan.mjs +90 -0
- package/src/commands/init.mjs +124 -105
- package/src/commands/lease.mjs +73 -25
- package/src/commands/lessons.mjs +10 -9
- package/src/commands/local.mjs +3 -3
- package/src/commands/map.mjs +24 -14
- package/src/commands/mcp.mjs +2 -18
- package/src/commands/pack.mjs +9 -6
- package/src/commands/plan.mjs +6 -5
- package/src/commands/plugin.mjs +2 -2
- package/src/commands/prbody.mjs +3 -3
- package/src/commands/repoindex.mjs +12 -11
- package/src/commands/review.mjs +2 -2
- package/src/commands/rules.mjs +37 -16
- package/src/commands/shared.mjs +14 -19
- package/src/commands/skills.mjs +33 -32
- package/src/commands/skillscompile.mjs +7 -8
- package/src/commands/skillsinstall.mjs +7 -6
- package/src/commands/slice.mjs +220 -74
- package/src/commands/spec.mjs +167 -0
- package/src/commands/status.mjs +27 -18
- package/src/commands/sync.mjs +27 -25
- package/src/commands/tests.mjs +18 -21
- package/src/commands/tokens.mjs +21 -26
- package/src/commands/undo.mjs +5 -15
- package/src/commands/upgrade.mjs +15 -7
- package/src/commands/why.mjs +15 -21
- package/src/compare/hooksstate.mjs +2 -6
- package/src/compare/learn.mjs +3 -1
- package/src/compare/row.mjs +0 -2
- package/src/context.mjs +64 -0
- package/src/decisions/index-doc.mjs +46 -0
- package/src/decisions/index.mjs +3 -2
- package/src/diff.mjs +1 -1
- package/src/distill/distill.mjs +14 -1
- package/src/distill/engine.mjs +5 -1
- package/src/docs/commands.mjs +65 -0
- package/src/ecosystem/records.mjs +219 -0
- package/src/explain.mjs +31 -0
- package/src/gates/index.mjs +385 -0
- package/src/graph/compile.mjs +8 -4
- package/src/graph/load.mjs +6 -2
- package/src/graph/spec.mjs +10 -8
- package/src/harness/records.mjs +143 -0
- package/src/hooks/capability.mjs +7 -5
- package/src/hooks/dispatcher.mjs +209 -60
- package/src/hooks/gates.mjs +10 -43
- package/src/hooks/resolve.mjs +4 -14
- package/src/hooks/shim.mjs +5 -2
- package/src/hooks/where.mjs +2 -7
- package/src/identity.mjs +31 -12
- package/src/index/coverage.mjs +58 -0
- package/src/index/extract.mjs +6 -16
- package/src/index/gate.mjs +1 -1
- package/src/index/govern.mjs +26 -9
- package/src/index/impact.mjs +4 -8
- package/src/index/map.mjs +14 -15
- package/src/index/open.mjs +173 -0
- package/src/index/persist.mjs +224 -57
- package/src/index/store.mjs +82 -39
- package/src/index/tests.mjs +2 -6
- package/src/journal.mjs +94 -20
- package/src/kernel/card.mjs +37 -63
- package/src/kernel/checks.mjs +4 -10
- package/src/lease.mjs +102 -7
- package/src/ledger/index.mjs +150 -14
- package/src/local/client.mjs +4 -6
- package/src/locate.mjs +33 -9
- package/src/manifest/schema.mjs +213 -0
- package/src/mcp/paginate.mjs +33 -0
- package/src/mcp/resources.mjs +7 -10
- package/src/mcp/schemas.mjs +512 -0
- package/src/mcp/server.mjs +119 -25
- package/src/mcp/tools-facts.mjs +326 -0
- package/src/mcp/tools-read.mjs +221 -0
- package/src/mcp/tools.mjs +4 -967
- package/src/measure/corpus.mjs +150 -0
- package/src/measure/metrics.mjs +236 -0
- package/src/measure/records.mjs +211 -0
- package/src/measure/session.mjs +3 -2
- package/src/measure/stats.mjs +538 -0
- package/src/measure/task-schema.mjs +125 -0
- package/src/measure/tasks.mjs +69 -0
- package/src/memory/decide.mjs +6 -15
- package/src/memory/lesson-text.mjs +61 -0
- package/src/memory/lessons.mjs +29 -82
- package/src/name.mjs +7 -0
- package/src/node-version.mjs +37 -0
- package/src/output/index.mjs +209 -10
- package/src/packs/manage.mjs +32 -12
- package/src/packs.mjs +20 -2
- package/src/plan/index.mjs +1 -1
- package/src/plugins.mjs +22 -16
- package/src/prbody.mjs +7 -11
- package/src/projection/loaders.mjs +7 -692
- package/src/projection/lock.mjs +11 -1
- package/src/projection/manifest.mjs +29 -468
- package/src/projection/mdc.mjs +7 -8
- package/src/projection/project.mjs +126 -221
- package/src/projection/region.mjs +561 -0
- package/src/projection/rules.mjs +20 -550
- package/src/projection/surfaces.mjs +43 -210
- package/src/projection/targets.mjs +5 -5
- package/src/repo-root.mjs +31 -0
- package/src/resolve/eval.mjs +2 -1
- package/src/resolve/fingerprint.mjs +19 -22
- package/src/resolve/resolve.mjs +1 -41
- package/src/review/critic.mjs +10 -13
- package/src/run.mjs +98 -0
- package/src/secrets.mjs +14 -22
- package/src/state/events.mjs +129 -0
- package/src/status-snapshot.mjs +233 -0
- package/src/status.mjs +2 -6
- package/src/sys/git.mjs +82 -0
- package/src/sys/proc.mjs +151 -0
- package/src/testing/parsers.mjs +265 -0
- package/src/testing/patches.mjs +23 -0
- package/src/testing/paths.mjs +49 -0
- package/src/testing/runners.mjs +186 -0
- package/src/testing/vault.mjs +68 -0
- package/src/text/frontmatter.mjs +49 -0
- package/src/text/tokens.mjs +52 -0
- package/src/toolwords.mjs +3 -0
- package/src/util/fs.mjs +20 -0
- package/src/util/sort.mjs +12 -0
- package/src/workspace.mjs +58 -11
- package/src/write/plan.mjs +82 -0
- package/templates/plan/tracker.json +1 -0
- package/docs/decisions/ADR-033-three-plan-rows-closed-by-decision-not-by-authoring.md +0 -82
- package/docs/decisions/ADR-102-the-two-queued-review-rows-are-closed-by-ruling.md +0 -49
- package/docs/decisions/README.md +0 -600
- package/src/bench/tasks.mjs +0 -36
package/src/hooks/dispatcher.mjs
CHANGED
|
@@ -18,22 +18,129 @@
|
|
|
18
18
|
// Report what did not run. The summary names every gate, its disposition and
|
|
19
19
|
// its reason, so "passed" can never be mistaken for "only some of it passed".
|
|
20
20
|
|
|
21
|
-
import {
|
|
21
|
+
import { spawn } from 'node:child_process';
|
|
22
22
|
import { mkdtemp, readFile, rm, stat, symlink } from 'node:fs/promises';
|
|
23
23
|
import { tmpdir } from 'node:os';
|
|
24
24
|
import { basename, join, matchesGlob, resolve, sep } from 'node:path';
|
|
25
|
-
import { promisify } from 'node:util';
|
|
26
25
|
|
|
27
|
-
import { GATES } from './gates.mjs';
|
|
26
|
+
import { GATES, getGate } from './gates.mjs';
|
|
28
27
|
import { resolveGate } from './resolve.mjs';
|
|
29
28
|
import { resolveNodeRunner } from '../ecosystem/node-runner.mjs';
|
|
30
29
|
import { CLI } from '../name.mjs';
|
|
31
30
|
import { MANIFEST_FILE } from '../name.mjs';
|
|
32
31
|
import { assertVersionPin } from '../package-version.mjs';
|
|
33
|
-
import { recordRun } from '../ledger/index.mjs';
|
|
32
|
+
import { hasTestEvidence, readLedger, recordRun } from '../ledger/index.mjs';
|
|
34
33
|
import { renderFindings, scanStaged } from '../secrets.mjs';
|
|
34
|
+
import { git, gitPaths, gitText } from '../sys/git.mjs';
|
|
35
35
|
|
|
36
|
-
|
|
36
|
+
/**
|
|
37
|
+
* The default bound on one gate, in seconds, and the grace a killed gate gets before
|
|
38
|
+
* SIGKILL. A hook that runs on every commit needs a ceiling: a test runner waiting on
|
|
39
|
+
* a socket that never closes blocks the commit with no output and no way to tell a
|
|
40
|
+
* hang from slow work. `gates.timeout` raises or removes it per repository.
|
|
41
|
+
*/
|
|
42
|
+
const DEFAULT_GATE_TIMEOUT_SECONDS = 600;
|
|
43
|
+
const KILL_GRACE_MS = 5000;
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* How much of a gate's output a report can carry. This is a bound on what is KEPT, not
|
|
47
|
+
* on what the process may write: a gate whose output exceeded execFile's buffer used to
|
|
48
|
+
* be reported `failed` although it exited 0.
|
|
49
|
+
*/
|
|
50
|
+
const GATE_OUTPUT_TAIL_BYTES = 64 * 1024;
|
|
51
|
+
|
|
52
|
+
function tailCollector() {
|
|
53
|
+
let kept = Buffer.alloc(0);
|
|
54
|
+
let dropped = 0;
|
|
55
|
+
return {
|
|
56
|
+
push(chunk) {
|
|
57
|
+
kept = Buffer.concat([kept, chunk]);
|
|
58
|
+
if (kept.length > GATE_OUTPUT_TAIL_BYTES) {
|
|
59
|
+
dropped += kept.length - GATE_OUTPUT_TAIL_BYTES;
|
|
60
|
+
kept = kept.subarray(kept.length - GATE_OUTPUT_TAIL_BYTES);
|
|
61
|
+
}
|
|
62
|
+
},
|
|
63
|
+
text: () => kept.toString('utf8'),
|
|
64
|
+
dropped: () => dropped,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Signal the gate's whole process group. A test runner spawns workers of its own, and
|
|
70
|
+
* signalling only the leader leaves them holding the pipes this function is waiting on.
|
|
71
|
+
* `detached` in the spawn below is what makes the group exist.
|
|
72
|
+
*/
|
|
73
|
+
function signalTree(child, signal) {
|
|
74
|
+
try {
|
|
75
|
+
process.kill(-child.pid, signal);
|
|
76
|
+
} catch {
|
|
77
|
+
try {
|
|
78
|
+
child.kill(signal);
|
|
79
|
+
} catch {
|
|
80
|
+
// Already gone: a second signal has nothing to reach.
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Run one gate to completion, keeping its exit code and the tail of what it printed.
|
|
87
|
+
*
|
|
88
|
+
* `timeoutMs` of 0 means no bound, which is what `gates.timeout: 0` asks for and what
|
|
89
|
+
* the manifest does not have to say. Output is collected in full while the process
|
|
90
|
+
* runs and only the tail is retained, so a noisy passing gate passes.
|
|
91
|
+
*/
|
|
92
|
+
async function runBounded(argv, { cwd, env, timeoutMs }) {
|
|
93
|
+
return await new Promise((settle) => {
|
|
94
|
+
let child;
|
|
95
|
+
try {
|
|
96
|
+
child = spawn(argv[0], argv.slice(1), { cwd, env, detached: true, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
97
|
+
} catch (error) {
|
|
98
|
+
settle({ code: 1, stdout: '', stderr: String(error.message ?? error), timedOut: false, dropped: 0 });
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
const out = tailCollector();
|
|
102
|
+
const err = tailCollector();
|
|
103
|
+
let timedOut = false;
|
|
104
|
+
let timer = null;
|
|
105
|
+
let killer = null;
|
|
106
|
+
let done = false;
|
|
107
|
+
const finish = (code) => {
|
|
108
|
+
if (done) return;
|
|
109
|
+
done = true;
|
|
110
|
+
clearTimeout(timer);
|
|
111
|
+
clearTimeout(killer);
|
|
112
|
+
settle({ code, stdout: out.text(), stderr: err.text(), timedOut, dropped: out.dropped() + err.dropped() });
|
|
113
|
+
};
|
|
114
|
+
child.stdout?.on('data', (chunk) => out.push(chunk));
|
|
115
|
+
child.stderr?.on('data', (chunk) => err.push(chunk));
|
|
116
|
+
// A missing binary emits `error` and no `close`; the message is the only evidence.
|
|
117
|
+
child.on('error', (error) => {
|
|
118
|
+
err.push(Buffer.from(String(error.message ?? error)));
|
|
119
|
+
finish(1);
|
|
120
|
+
});
|
|
121
|
+
child.on('close', (code, signal) => finish(typeof code === 'number' ? code : signal === null ? 0 : 1));
|
|
122
|
+
if (timeoutMs > 0) {
|
|
123
|
+
timer = setTimeout(() => {
|
|
124
|
+
timedOut = true;
|
|
125
|
+
signalTree(child, 'SIGTERM');
|
|
126
|
+
killer = setTimeout(() => signalTree(child, 'SIGKILL'), KILL_GRACE_MS);
|
|
127
|
+
}, timeoutMs);
|
|
128
|
+
}
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* The bound for one gate, in milliseconds. A number applies to every gate; a map applies
|
|
134
|
+
* per name, the shape `gates.cwd` already has. A value that is neither a positive whole
|
|
135
|
+
* number of seconds nor 0 falls back to the default rather than removing the bound: an
|
|
136
|
+
* unreadable declaration must not be the quiet way to disable the ceiling.
|
|
137
|
+
*/
|
|
138
|
+
function gateTimeoutMs(declared, name) {
|
|
139
|
+
const value = declared !== null && typeof declared === 'object' ? declared[name] : declared;
|
|
140
|
+
if (value === 0) return 0;
|
|
141
|
+
if (Number.isInteger(value) && value > 0) return value * 1000;
|
|
142
|
+
return DEFAULT_GATE_TIMEOUT_SECONDS * 1000;
|
|
143
|
+
}
|
|
37
144
|
|
|
38
145
|
/** Gates that are known but not required for the event to pass. */
|
|
39
146
|
const DEFAULT_EVENTS = { 'pre-commit': 'preCommit', 'pre-push': 'prePush' };
|
|
@@ -85,15 +192,14 @@ export function parsePushedRefs(text) {
|
|
|
85
192
|
async function tagCommitAccountedFor(root, tagSha, pushingTips) {
|
|
86
193
|
let commit;
|
|
87
194
|
try {
|
|
88
|
-
|
|
195
|
+
commit = await gitText(root, ['rev-parse', `${tagSha}^{commit}`]);
|
|
89
196
|
} catch (error) {
|
|
90
197
|
return { ok: false, reason: `the tag could not be resolved to a commit: ${String(error.stderr ?? error.message).trim()}` };
|
|
91
198
|
}
|
|
92
|
-
commit = commit.trim();
|
|
93
199
|
|
|
94
200
|
let remoteRefs;
|
|
95
201
|
try {
|
|
96
|
-
|
|
202
|
+
remoteRefs = await gitText(root, ['for-each-ref', 'refs/remotes/', '--format=%(refname)']);
|
|
97
203
|
} catch (error) {
|
|
98
204
|
return { ok: false, reason: `the remote refs could not be listed: ${String(error.stderr ?? error.message).trim()}` };
|
|
99
205
|
}
|
|
@@ -106,7 +212,7 @@ async function tagCommitAccountedFor(root, tagSha, pushingTips) {
|
|
|
106
212
|
}
|
|
107
213
|
for (const candidate of candidates) {
|
|
108
214
|
try {
|
|
109
|
-
await
|
|
215
|
+
await git(root, ['merge-base', '--is-ancestor', commit, candidate]);
|
|
110
216
|
return { ok: true, reason: null };
|
|
111
217
|
} catch {
|
|
112
218
|
// not reachable from this ref; try the next candidate
|
|
@@ -148,15 +254,17 @@ export async function classifyPush(root, refs, { maxCommits, paths }) {
|
|
|
148
254
|
}
|
|
149
255
|
let count, names;
|
|
150
256
|
try {
|
|
151
|
-
|
|
152
|
-
|
|
257
|
+
count = await gitText(root, ['rev-list', '--count', `${remoteSha}..${localSha}`]);
|
|
258
|
+
names = await gitPaths(root, ['diff', '--name-only', `${remoteSha}..${localSha}`]);
|
|
153
259
|
} catch (error) {
|
|
154
260
|
return { docsOnly: false, reason: `the push could not be diffed: ${String(error.stderr ?? error.message).trim()}` };
|
|
155
261
|
}
|
|
156
|
-
commits += Number(count
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
262
|
+
commits += Number(count);
|
|
263
|
+
// NUL-separated paths because a path can contain a newline, and git quotes such a
|
|
264
|
+
// name in the line-terminated form, so splitting on newlines compares a quoted path
|
|
265
|
+
// against an unquoted glob and never matches.
|
|
266
|
+
for (const name of names) {
|
|
267
|
+
files.add(name);
|
|
160
268
|
}
|
|
161
269
|
}
|
|
162
270
|
|
|
@@ -178,7 +286,11 @@ export async function classifyPush(root, refs, { maxCommits, paths }) {
|
|
|
178
286
|
if (commits > 0) parts.push(`${commits} commit(s) touching ${files.size} file(s), all inside the docs-only paths`);
|
|
179
287
|
if (tags.length > 0) parts.push(`${tags.length} tag(s) already delivered`);
|
|
180
288
|
if (deletions > 0) parts.push(`${deletions} ref deletion(s)`);
|
|
181
|
-
return { docsOnly: true, reason:
|
|
289
|
+
if (parts.length === 0) return { docsOnly: true, reason: 'no commits carried' };
|
|
290
|
+
// The report prints this as-is (ADR-113). The label names what is being pushed:
|
|
291
|
+
// a push that carries only tags is a tag push, not a docs-only push.
|
|
292
|
+
const kind = commits > 0 ? 'docs-only push' : tags.length > 0 ? 'tag push' : 'deletion push';
|
|
293
|
+
return { docsOnly: true, reason: `${kind}: ${parts.join('; ')}` };
|
|
182
294
|
}
|
|
183
295
|
|
|
184
296
|
/**
|
|
@@ -189,7 +301,7 @@ export async function classifyPush(root, refs, { maxCommits, paths }) {
|
|
|
189
301
|
* review. A misdeclaration (missing fields) also runs the suite - a skip the
|
|
190
302
|
* manifest cannot state precisely is a skip a reviewer cannot see.
|
|
191
303
|
*
|
|
192
|
-
* @returns {Promise<{reason: string} | null>} null runs the suite; a reason skips the `test` gate
|
|
304
|
+
* @returns {Promise<{reason: string, adr: string} | null>} null runs the suite; a reason skips the `test` gate
|
|
193
305
|
*/
|
|
194
306
|
async function resolveDocsOnlySkip(root, manifest, pushed) {
|
|
195
307
|
if (pushed === undefined) return null;
|
|
@@ -200,7 +312,43 @@ async function resolveDocsOnlySkip(root, manifest, pushed) {
|
|
|
200
312
|
return null; // the caller cannot be sure what is being pushed; the suite runs
|
|
201
313
|
}
|
|
202
314
|
const verdict = await classifyPush(root, refs, { maxCommits: config.maxCommits, paths: config.paths });
|
|
203
|
-
return verdict.docsOnly ? { reason: verdict.reason } : null;
|
|
315
|
+
return verdict.docsOnly ? { reason: verdict.reason, adr: 'ADR-113' } : null;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* The pre-push skip for a suite the author already ran on this exact tree (ADR-120).
|
|
320
|
+
*
|
|
321
|
+
* The dispatcher records every gate run against the tree it ran on (ADR-087). If a passing
|
|
322
|
+
* `test` run is recorded for the committed tree about to be pushed, and nothing has changed
|
|
323
|
+
* since, re-running the full suite at push time buys nothing: it is the same tree, already
|
|
324
|
+
* proven. This reads that evidence and earns the same reported skip the docs-only path does.
|
|
325
|
+
*
|
|
326
|
+
* It matches the COMMITTED tree (`HEAD^{tree}`), not the working tree: the pre-push gate runs
|
|
327
|
+
* at HEAD, so the evidence that matters is a pass on exactly that tree. A file edited after
|
|
328
|
+
* the green pass changes the tree id, so the match fails and the suite runs - the "no changes
|
|
329
|
+
* since" condition is enforced by the digest, not by trusting the author. Every uncertainty -
|
|
330
|
+
* an unborn HEAD, an unreadable ledger, no entry for this tree, a later failure - fails closed
|
|
331
|
+
* to the suite.
|
|
332
|
+
*
|
|
333
|
+
* @param {string} root the real repository root
|
|
334
|
+
* @returns {Promise<{reason: string} | null>} null runs the suite; a reason skips the `test` gate
|
|
335
|
+
*/
|
|
336
|
+
async function resolveAlreadyPassedSkip(root) {
|
|
337
|
+
let headTree;
|
|
338
|
+
try {
|
|
339
|
+
headTree = await gitText(root, ['rev-parse', 'HEAD^{tree}']);
|
|
340
|
+
} catch {
|
|
341
|
+
return null;
|
|
342
|
+
}
|
|
343
|
+
if (!/^[0-9a-f]{40}$/.test(headTree)) return null;
|
|
344
|
+
const { entries, errors } = await readLedger(root);
|
|
345
|
+
if (errors.length > 0) return null;
|
|
346
|
+
if (!hasTestEvidence(entries, headTree)) return null;
|
|
347
|
+
return {
|
|
348
|
+
reason:
|
|
349
|
+
'already passing on current state: the test gate ran green on this tree and nothing has changed since',
|
|
350
|
+
adr: 'ADR-120',
|
|
351
|
+
};
|
|
204
352
|
}
|
|
205
353
|
|
|
206
354
|
/**
|
|
@@ -263,10 +411,12 @@ export async function runGates(event, { root, manifest, pushed }) {
|
|
|
263
411
|
let headWorktree = null;
|
|
264
412
|
let skipTest = null;
|
|
265
413
|
if (event === 'pre-push') {
|
|
266
|
-
// Decided before the worktree exists: a push that carries only documentation
|
|
267
|
-
//
|
|
268
|
-
//
|
|
269
|
-
|
|
414
|
+
// Decided before the worktree exists: a push that carries only documentation skips the
|
|
415
|
+
// `test` gate (ADR-113), and a push whose tree the author already proved green skips it
|
|
416
|
+
// on that evidence (ADR-120). A push that earns either skip needs no tree for a gate
|
|
417
|
+
// that will not run.
|
|
418
|
+
skipTest = (await resolveDocsOnlySkip(root, manifest, pushed)) ??
|
|
419
|
+
(await resolveAlreadyPassedSkip(root));
|
|
270
420
|
if (skipTest === null) {
|
|
271
421
|
const materialized = await materializeHeadWorktree(root);
|
|
272
422
|
if (materialized.path === null) {
|
|
@@ -312,8 +462,9 @@ export async function runGates(event, { root, manifest, pushed }) {
|
|
|
312
462
|
* @param {object} ctx.manifest
|
|
313
463
|
* @param {string[]} ctx.declared the gate names for this event
|
|
314
464
|
* @param {string} ctx.key the manifest key for this event
|
|
315
|
-
* @param {{reason: string} | null} [ctx.skipTest] set when a
|
|
316
|
-
* `test` gate (ADR-113
|
|
465
|
+
* @param {{reason: string, adr: string} | null} [ctx.skipTest] set when a push skips the
|
|
466
|
+
* `test` gate (ADR-113 for docs-only, ADR-120 for an already-green tree); the reason is
|
|
467
|
+
* what the report prints in its place, and the adr is the record that earned it
|
|
317
468
|
* @returns {Promise<{event: string, gates: object[], failed: boolean}>}
|
|
318
469
|
*/
|
|
319
470
|
async function runGatesAt(event, { root, ledgerRoot, manifest, declared, key, skipTest = null }) {
|
|
@@ -345,13 +496,16 @@ async function runGatesAt(event, { root, ledgerRoot, manifest, declared, key, sk
|
|
|
345
496
|
// mean re-reading the command string to find out where anything runs, and a manifest
|
|
346
497
|
// written by an older release would have to be rewritten to gain the capability.
|
|
347
498
|
const cwds = manifest?.gates?.cwd ?? {};
|
|
499
|
+
// `gates.timeout` bounds one gate, in seconds: a number for every gate, or a map by
|
|
500
|
+
// name like `gates.cwd`. Absent means the default; 0 means no bound, declared.
|
|
501
|
+
const timeouts = manifest?.gates?.timeout;
|
|
348
502
|
const results = [];
|
|
349
503
|
// What the format gate rewrote, so restage touches those files and nothing else.
|
|
350
504
|
let formatted = [];
|
|
351
505
|
|
|
352
506
|
for (const name of declared) {
|
|
353
|
-
const gate =
|
|
354
|
-
if (gate ===
|
|
507
|
+
const gate = getGate(name, commands);
|
|
508
|
+
if (gate === null) {
|
|
355
509
|
results.push({
|
|
356
510
|
name,
|
|
357
511
|
status: 'failed',
|
|
@@ -362,11 +516,12 @@ async function runGatesAt(event, { root, ledgerRoot, manifest, declared, key, sk
|
|
|
362
516
|
continue;
|
|
363
517
|
}
|
|
364
518
|
|
|
365
|
-
// The one gate a
|
|
366
|
-
// dropped: a skip without a printed reason
|
|
367
|
-
// never ran", which is the exact confusion the report
|
|
519
|
+
// The one gate a push may not run (ADR-113 for a docs-only push, ADR-120 for a tree
|
|
520
|
+
// already proven green). It is reported, not dropped: a skip without a printed reason
|
|
521
|
+
// would let "passed" mean "the suite never ran", which is the exact confusion the report
|
|
522
|
+
// exists to prevent.
|
|
368
523
|
if (skipTest !== null && name === 'test') {
|
|
369
|
-
results.push({ name, status: 'skipped', detail:
|
|
524
|
+
results.push({ name, status: 'skipped', detail: skipTest.reason, adr: skipTest.adr, exitCode: null });
|
|
370
525
|
continue;
|
|
371
526
|
}
|
|
372
527
|
|
|
@@ -476,8 +631,9 @@ async function runGatesAt(event, { root, ledgerRoot, manifest, declared, key, sk
|
|
|
476
631
|
cwd = resolved;
|
|
477
632
|
}
|
|
478
633
|
|
|
634
|
+
const timeoutMs = gateTimeoutMs(timeouts, name);
|
|
479
635
|
const started = Date.now();
|
|
480
|
-
const outcome = await runGate(resolution, cwd);
|
|
636
|
+
const outcome = await runGate(resolution, cwd, timeoutMs);
|
|
481
637
|
// The ledger is not the gate. A tree that cannot be named leaves no line, and the
|
|
482
638
|
// claim check then finds nothing, which is the direction that cannot invent a run.
|
|
483
639
|
try {
|
|
@@ -498,11 +654,18 @@ async function runGatesAt(event, { root, ledgerRoot, manifest, declared, key, sk
|
|
|
498
654
|
} catch {
|
|
499
655
|
// Recorded by absence: the next evidence read for this tree has no line.
|
|
500
656
|
}
|
|
657
|
+
// A killed gate has no exit code of its own (`signal` instead), so the reason is
|
|
658
|
+
// stated rather than left to `exit 1`, and the remedy says how to change the bound.
|
|
501
659
|
results.push({
|
|
502
660
|
name,
|
|
503
661
|
status: outcome.code === 0 ? 'passed' : 'failed',
|
|
504
|
-
detail:
|
|
662
|
+
detail: outcome.timedOut
|
|
663
|
+
? `${resolution.detail}: timed out after ${Math.round(timeoutMs / 1000)} s`
|
|
664
|
+
: `${resolution.detail}${outcome.code === 0 ? '' : `: exit ${outcome.code}`}`,
|
|
505
665
|
output: outcome.code === 0 ? '' : tail(outcome.stderr || outcome.stdout),
|
|
666
|
+
...(outcome.timedOut
|
|
667
|
+
? { remedy: `fix the hang, raise gates.timeout to give it longer, or set gates.timeout to 0 to remove the bound` }
|
|
668
|
+
: {}),
|
|
506
669
|
adr: gate.adr,
|
|
507
670
|
exitCode: outcome.code,
|
|
508
671
|
});
|
|
@@ -560,7 +723,7 @@ async function runGatesAt(event, { root, ledgerRoot, manifest, declared, key, sk
|
|
|
560
723
|
async function materializeHeadWorktree(root) {
|
|
561
724
|
const path = await mkdtemp(join(tmpdir(), 'head-gate-'));
|
|
562
725
|
try {
|
|
563
|
-
await
|
|
726
|
+
await git(root, ['worktree', 'add', '--detach', path, 'HEAD']);
|
|
564
727
|
} catch (error) {
|
|
565
728
|
await rm(path, { recursive: true, force: true }).catch(() => {});
|
|
566
729
|
return { path: null, error: `git worktree add failed: ${String(error.stderr ?? error.message).trim()}` };
|
|
@@ -585,9 +748,9 @@ async function materializeHeadWorktree(root) {
|
|
|
585
748
|
* @param {string} path the worktree to remove
|
|
586
749
|
*/
|
|
587
750
|
async function removeHeadWorktree(root, path) {
|
|
588
|
-
await
|
|
751
|
+
await git(root, ['worktree', 'remove', '--force', path]).catch(async () => {
|
|
589
752
|
await rm(path, { recursive: true, force: true }).catch(() => {});
|
|
590
|
-
await
|
|
753
|
+
await git(root, ['worktree', 'prune']).catch(() => {});
|
|
591
754
|
});
|
|
592
755
|
}
|
|
593
756
|
|
|
@@ -616,13 +779,8 @@ export function renderGateReport(event, declared, result) {
|
|
|
616
779
|
return lines;
|
|
617
780
|
}
|
|
618
781
|
|
|
619
|
-
async function run(argv, cwd, env) {
|
|
620
|
-
|
|
621
|
-
const { stdout, stderr } = await exec(argv[0], argv.slice(1), { cwd, env, maxBuffer: 8 * 1024 * 1024 });
|
|
622
|
-
return { code: 0, stdout, stderr };
|
|
623
|
-
} catch (error) {
|
|
624
|
-
return { code: typeof error.code === 'number' ? error.code : 1, stdout: error.stdout ?? '', stderr: error.stderr ?? String(error.message ?? error) };
|
|
625
|
-
}
|
|
782
|
+
async function run(argv, cwd, env, timeoutMs) {
|
|
783
|
+
return await runBounded(argv, { cwd, env, timeoutMs });
|
|
626
784
|
}
|
|
627
785
|
|
|
628
786
|
/** The built-in gates this dispatcher can run, by name. */
|
|
@@ -693,24 +851,15 @@ async function runBuiltinGate(name, { root, staged, options }) {
|
|
|
693
851
|
* that the two sources stay distinguishable, which is why this is a separate branch
|
|
694
852
|
* rather than a flag on `run`.
|
|
695
853
|
*/
|
|
696
|
-
async function runGate(resolution, cwd) {
|
|
854
|
+
async function runGate(resolution, cwd, timeoutMs) {
|
|
697
855
|
// The push override is for the push itself, not for the gates the hook runs.
|
|
698
856
|
// A test that asserts a push is denied sees the override and fails.
|
|
699
857
|
const env = { ...process.env };
|
|
700
858
|
delete env.SIMT_ALLOW;
|
|
701
859
|
if (resolution.shell === true && typeof resolution.command === 'string') {
|
|
702
|
-
|
|
703
|
-
const { stdout, stderr } = await exec('sh', ['-c', resolution.command], { cwd, env, maxBuffer: 8 * 1024 * 1024 });
|
|
704
|
-
return { code: 0, stdout, stderr };
|
|
705
|
-
} catch (error) {
|
|
706
|
-
return {
|
|
707
|
-
code: typeof error.code === 'number' ? error.code : 1,
|
|
708
|
-
stdout: error.stdout ?? '',
|
|
709
|
-
stderr: error.stderr ?? String(error.message ?? error),
|
|
710
|
-
};
|
|
711
|
-
}
|
|
860
|
+
return await run(['sh', '-c', resolution.command], cwd, env, timeoutMs);
|
|
712
861
|
}
|
|
713
|
-
return run(resolution.argv, cwd, env);
|
|
862
|
+
return await run(resolution.argv, cwd, env, timeoutMs);
|
|
714
863
|
}
|
|
715
864
|
|
|
716
865
|
async function readPkg(root) {
|
|
@@ -724,8 +873,9 @@ async function readPkg(root) {
|
|
|
724
873
|
/** Staged paths, or [] when git cannot answer (an unborn branch, for instance). */
|
|
725
874
|
export async function stagedFiles(root) {
|
|
726
875
|
try {
|
|
727
|
-
|
|
728
|
-
|
|
876
|
+
// NUL-separated: git quotes a path containing a newline or a quote in the line form,
|
|
877
|
+
// and a quoted name is not the path the index holds, so it never matches a staged blob.
|
|
878
|
+
return await gitPaths(root, ['diff', '--cached', '--name-only', '--diff-filter=ACMR']);
|
|
729
879
|
} catch {
|
|
730
880
|
return [];
|
|
731
881
|
}
|
|
@@ -741,8 +891,7 @@ export async function stagedFiles(root) {
|
|
|
741
891
|
*/
|
|
742
892
|
async function unstagedFiles(root) {
|
|
743
893
|
try {
|
|
744
|
-
|
|
745
|
-
return new Set(stdout.split('\n').map((s) => s.trim()).filter(Boolean));
|
|
894
|
+
return new Set(await gitPaths(root, ['diff', '--name-only']));
|
|
746
895
|
} catch {
|
|
747
896
|
return new Set();
|
|
748
897
|
}
|
|
@@ -752,7 +901,7 @@ async function unstagedFiles(root) {
|
|
|
752
901
|
async function stagedBlobs(root, files) {
|
|
753
902
|
if (files.length === 0) return new Map();
|
|
754
903
|
try {
|
|
755
|
-
const
|
|
904
|
+
const stdout = await git(root, ['ls-files', '-s', '--', ...files]);
|
|
756
905
|
const blobs = new Map();
|
|
757
906
|
for (const line of stdout.split('\n')) {
|
|
758
907
|
const [meta, ...rest] = line.split('\t');
|
|
@@ -777,7 +926,7 @@ async function restage(root, files) {
|
|
|
777
926
|
if (files.length === 0) return { restaged: [], error: null };
|
|
778
927
|
const before = await stagedBlobs(root, files);
|
|
779
928
|
try {
|
|
780
|
-
await
|
|
929
|
+
await git(root, ['add', '--', ...files]);
|
|
781
930
|
} catch (error) {
|
|
782
931
|
return { restaged: [], error: String(error.stderr ?? error.message ?? error).trim() };
|
|
783
932
|
}
|
package/src/hooks/gates.mjs
CHANGED
|
@@ -101,49 +101,16 @@ export const GATES = {
|
|
|
101
101
|
},
|
|
102
102
|
};
|
|
103
103
|
|
|
104
|
-
export function getGate(name) {
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
* `--no-verify` - a repository removed its tests from pre-commit precisely
|
|
115
|
-
* because they were slow, and slow-plus-silent is what teaches everyone that
|
|
116
|
-
* hooks are advisory.
|
|
117
|
-
*/
|
|
118
|
-
export function unresolved(name, { hasScript, binary, command, stack }) {
|
|
119
|
-
const gate = getGate(name);
|
|
120
|
-
if (gate === null) {
|
|
121
|
-
return {
|
|
122
|
-
gate: name,
|
|
123
|
-
reason: `unknown gate; the shared dispatcher knows: ${Object.keys(GATES).sort().join(', ')}`,
|
|
124
|
-
remedy: `remove "${name}" from ${MANIFEST_FILE} gates`,
|
|
125
|
-
};
|
|
126
|
-
}
|
|
127
|
-
if (gate.kind === 'script' && !hasScript(gate.script)) {
|
|
128
|
-
return {
|
|
129
|
-
gate: name,
|
|
130
|
-
reason: `no "${gate.script}" script in package.json`,
|
|
131
|
-
remedy: `add the script, or remove "${name}" from ${MANIFEST_FILE} gates`,
|
|
132
|
-
};
|
|
133
|
-
}
|
|
134
|
-
if (gate.kind === 'command' && !command) {
|
|
135
|
-
return {
|
|
136
|
-
gate: name,
|
|
137
|
-
reason: `no command declared for this gate in ${MANIFEST_FILE}`,
|
|
138
|
-
remedy: `set gates.commands["${name}"] to the command that proves it, or remove "${name}" from ${MANIFEST_FILE} gates`,
|
|
139
|
-
};
|
|
140
|
-
}
|
|
141
|
-
if (gate.kind === 'staged' && !binary) {
|
|
142
|
-
return {
|
|
143
|
-
gate: name,
|
|
144
|
-
reason: `${gate.tools.join('/')} is not installed in node_modules/.bin`,
|
|
145
|
-
remedy: `run the repo install command, or remove "format" from ${MANIFEST_FILE} gates`,
|
|
146
|
-
};
|
|
104
|
+
export function getGate(name, commands = {}) {
|
|
105
|
+
const declared = GATES[name];
|
|
106
|
+
if (declared) return declared;
|
|
107
|
+
// Q70: a command the repository declared under gates.commands is a valid gate even when
|
|
108
|
+
// no central row knows it. The manifest is the source of truth for a repo's gates, and
|
|
109
|
+
// a name that resolves to a declared command must not read as unknown. Synthesized, not
|
|
110
|
+
// added to GATES: the central table stays the estate's gates, the record supplies the rest.
|
|
111
|
+
const cmd = commands?.[name];
|
|
112
|
+
if (typeof cmd === 'string' && cmd.trim() !== '') {
|
|
113
|
+
return { name, kind: 'command', adr: 'ADR-010', why: 'declared by the repository under gates.commands' };
|
|
147
114
|
}
|
|
148
115
|
return null;
|
|
149
116
|
}
|
package/src/hooks/resolve.mjs
CHANGED
|
@@ -12,16 +12,11 @@
|
|
|
12
12
|
|
|
13
13
|
import { access } from 'node:fs/promises';
|
|
14
14
|
import { constants } from 'node:fs';
|
|
15
|
-
import { join } from 'node:path';
|
|
15
|
+
import { extname, join } from 'node:path';
|
|
16
16
|
|
|
17
|
-
import { FORMATTABLE, GATES } from './gates.mjs';
|
|
17
|
+
import { FORMATTABLE, GATES, gateNames } from './gates.mjs';
|
|
18
18
|
import { MANIFEST_FILE } from '../name.mjs';
|
|
19
19
|
|
|
20
|
-
/** Gate names the shared dispatcher knows, sorted. */
|
|
21
|
-
export function knownGates() {
|
|
22
|
-
return Object.keys(GATES).sort();
|
|
23
|
-
}
|
|
24
|
-
|
|
25
20
|
/** Is this path an executable file? */
|
|
26
21
|
async function isExecutable(path) {
|
|
27
22
|
try {
|
|
@@ -144,7 +139,7 @@ export async function resolveGate(gate, { root, pkg, staged, commands, runner =
|
|
|
144
139
|
remedy: `run the repo install command, or remove "format" from ${MANIFEST_FILE} gates`,
|
|
145
140
|
};
|
|
146
141
|
}
|
|
147
|
-
const files = staged.filter((f) => FORMATTABLE.has(extname(f)));
|
|
142
|
+
const files = staged.filter((f) => FORMATTABLE.has(extname(f).toLowerCase()));
|
|
148
143
|
if (files.length === 0) {
|
|
149
144
|
return {
|
|
150
145
|
ok: true,
|
|
@@ -203,11 +198,6 @@ export async function resolveGate(gate, { root, pkg, staged, commands, runner =
|
|
|
203
198
|
gate: name,
|
|
204
199
|
kind: 'unknown',
|
|
205
200
|
reason: `unknown gate kind "${gate.kind}"`,
|
|
206
|
-
remedy: `this is a bug in simt-dev;
|
|
201
|
+
remedy: `this is a bug in simt-dev; )}', ')}`,
|
|
207
202
|
};
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
function extname(path) {
|
|
211
|
-
const dot = path.lastIndexOf('.');
|
|
212
|
-
return dot > 0 ? path.slice(dot).toLowerCase() : '';
|
|
213
203
|
}
|
package/src/hooks/shim.mjs
CHANGED
|
@@ -36,8 +36,11 @@ export const SHIM_MARKER = '# Generated by simt-dev. Do not edit.';
|
|
|
36
36
|
/** Events the shared dispatcher knows how to run. */
|
|
37
37
|
export const EVENTS = ['pre-commit', 'pre-push'];
|
|
38
38
|
|
|
39
|
-
/** Directory the shims are written to inside a repo. */
|
|
40
|
-
export const HOOKS_DIR = '.simt
|
|
39
|
+
/** Directory the shims are written to inside a repo, inside the state directory (ADR-122). */
|
|
40
|
+
export const HOOKS_DIR = '.simt/hooks';
|
|
41
|
+
|
|
42
|
+
/** The top-level shim directory that preceded the move into the state directory (ADR-122). */
|
|
43
|
+
export const HOOKS_DIR_LEGACY = '.simt-hooks';
|
|
41
44
|
|
|
42
45
|
/** Name of the git config key that points at the shim directory. */
|
|
43
46
|
export const HOOKS_PATH_KEY = 'core.hooksPath';
|
package/src/hooks/where.mjs
CHANGED
|
@@ -19,17 +19,12 @@
|
|
|
19
19
|
// ADR-012 dropped Windows, but nothing here is POSIX-specific; the worktree
|
|
20
20
|
// handling is the only subtlety and it is the same on every platform.
|
|
21
21
|
|
|
22
|
-
import { execFile } from 'node:child_process';
|
|
23
22
|
import { readdir, stat } from 'node:fs/promises';
|
|
24
23
|
import { isAbsolute, join, resolve } from 'node:path';
|
|
25
|
-
import { promisify } from 'node:util';
|
|
26
24
|
|
|
27
|
-
|
|
25
|
+
import { gitText } from '../sys/git.mjs';
|
|
28
26
|
|
|
29
|
-
|
|
30
|
-
const { stdout } = await exec('git', args, { cwd: root });
|
|
31
|
-
return stdout.trim();
|
|
32
|
-
}
|
|
27
|
+
const git = (root, args) => gitText(root, args);
|
|
33
28
|
|
|
34
29
|
/**
|
|
35
30
|
* The directory git runs hooks from, and which of the two rules produced it.
|
package/src/identity.mjs
CHANGED
|
@@ -24,14 +24,12 @@
|
|
|
24
24
|
// one; a repository that already tracks it is left that way, because removing a
|
|
25
25
|
// tracked file is not something an adopting command should do silently.
|
|
26
26
|
|
|
27
|
-
import { execFile } from 'node:child_process';
|
|
28
27
|
import { readFile, stat, writeFile } from 'node:fs/promises';
|
|
29
28
|
import { join } from 'node:path';
|
|
30
|
-
import { promisify } from 'node:util';
|
|
31
29
|
|
|
32
30
|
import { journaledGitConfig, journaledWriteFile } from './journal.mjs';
|
|
31
|
+
import { gitText } from './sys/git.mjs';
|
|
33
32
|
|
|
34
|
-
const exec = promisify(execFile);
|
|
35
33
|
|
|
36
34
|
/** The file, at the repository root. Named for what it is: a git config fragment. */
|
|
37
35
|
export const IDENTITY_FILE = 'git.config';
|
|
@@ -50,14 +48,7 @@ export const INCLUDE_KEY = 'include.path';
|
|
|
50
48
|
* @param {string[]} args
|
|
51
49
|
* @returns {Promise<string|null>}
|
|
52
50
|
*/
|
|
53
|
-
|
|
54
|
-
try {
|
|
55
|
-
const { stdout } = await exec('git', args, { cwd });
|
|
56
|
-
return stdout.trim();
|
|
57
|
-
} catch {
|
|
58
|
-
return null;
|
|
59
|
-
}
|
|
60
|
-
}
|
|
51
|
+
const git = (cwd, args) => gitText(cwd, args, { allowFailure: true });
|
|
61
52
|
|
|
62
53
|
/**
|
|
63
54
|
* Who last committed here.
|
|
@@ -257,4 +248,32 @@ export async function ignoreIdentity(root, journal = null) {
|
|
|
257
248
|
if (journal) await journaledWriteFile(root, journal, path, next);
|
|
258
249
|
else await writeFile(path, next);
|
|
259
250
|
return true;
|
|
260
|
-
}
|
|
251
|
+
}
|
|
252
|
+
/**
|
|
253
|
+
* The `user.*` keys the repository's own config sets, which is the one place an
|
|
254
|
+
* identity can be overridden without a trace in the tracked tree.
|
|
255
|
+
*
|
|
256
|
+
* A repo-local `[user]` block wins over both the `git.config` include and the
|
|
257
|
+
* machine's own identity, so every commit made here inherits it silently - including
|
|
258
|
+
* the commits this tool makes on the reader's behalf (`slice finish`, `plan done`).
|
|
259
|
+
* That is not a hypothetical: 34 commits in this repository were authored as
|
|
260
|
+
* `t <t@t.t>` because `.git/config` carried that block and nothing reported it. The
|
|
261
|
+
* helper exists so `test/repo-identity.test.mjs` can fail loudly instead.
|
|
262
|
+
*
|
|
263
|
+
* `--local` rather than `--get`: the question is what THIS config adds, not what the
|
|
264
|
+
* resolved identity happens to be.
|
|
265
|
+
*
|
|
266
|
+
* @param {string} root
|
|
267
|
+
* @returns {Promise<{key: string, value: string}[]>}
|
|
268
|
+
*/
|
|
269
|
+
export async function localIdentityOverrides(root) {
|
|
270
|
+
const out = await git(root, ['config', '--local', '--get-regexp', '^user\\.']);
|
|
271
|
+
if (!out) return [];
|
|
272
|
+
return out
|
|
273
|
+
.split('\n')
|
|
274
|
+
.filter(Boolean)
|
|
275
|
+
.map((line) => {
|
|
276
|
+
const space = line.indexOf(' ');
|
|
277
|
+
return { key: line.slice(0, space), value: line.slice(space + 1) };
|
|
278
|
+
});
|
|
279
|
+
}
|