simt-dev 0.6.3 → 0.7.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 +35 -0
- package/README.md +2 -2
- package/agents/simt-agent.md +84 -0
- package/docs/decisions/ADR-009-skill-layout.md +1 -1
- package/docs/decisions/ADR-086-the-stable-prefix-comes-first-and-the-lock-records-both-digests.md +1 -1
- package/docs/decisions/ADR-106-the-harness-hook-runs-through-a-repo-shim.md +46 -0
- package/docs/decisions/ADR-107-an-install-can-name-one-harness-and-the-skipped-ones-are-named.md +51 -0
- package/docs/decisions/ADR-108-an-adoption-builds-the-index-and-a-command-refreshes-it-on-read.md +59 -0
- package/docs/decisions/ADR-109-simt-dev-is-its-own-consumer-and-the-guards-that-kept-it-from-being-one-are-retired.md +47 -0
- package/docs/decisions/ADR-110-the-lock-file-follows-the-manifest-name-and-the-old-name-is-read-until-the-next-write.md +43 -0
- package/docs/decisions/ADR-111-the-tool-ships-one-agent-rendered-per-harness-and-installs-it-with-init-and-a-command.md +75 -0
- package/docs/decisions/ADR-112-the-registry-is-what-simt-dev-ships-and-a-repo-owns-its-own-skills.md +81 -0
- package/docs/decisions/ADR-113-a-pre-push-that-carries-only-documentation-skips-the-test-gate-and-says-so.md +87 -0
- package/docs/decisions/ADR-114-the-bench-pairs-at-the-task-level-a-missing-cost-is-not-zero-and-an-unasked-cross-group-compare-is-refused.md +74 -0
- package/docs/decisions/ADR-115-the-index-stamps-its-schema-version-migrates-older-databases-in-place-and-serves-newer-ones-from-a-sidecar.md +69 -0
- package/docs/decisions/ADR-116-rust-support-reaches-the-example-path-the-indent-and-the-bench-runner.md +100 -0
- package/docs/decisions/README.md +9 -0
- package/package.json +4 -2
- package/rules/core.md +2 -2
- package/rules/mdc/test-gate.mdc +2 -0
- package/rules/stacks/android.md +3 -3
- package/rules/stacks/connectiq.md +2 -2
- package/rules/stacks/dart.md +1 -1
- package/rules/stacks/deno.md +1 -1
- package/rules/stacks/dotnet.md +1 -1
- package/rules/stacks/elixir.md +1 -1
- package/rules/stacks/go.md +1 -1
- package/rules/stacks/jvm.md +3 -2
- package/rules/stacks/node.md +2 -2
- package/rules/stacks/python.md +7 -6
- package/rules/stacks/ruby.md +1 -1
- package/rules/stacks/rust.md +1 -1
- package/rules/stacks/swift.md +1 -1
- package/rules/stacks/typescript.md +2 -2
- package/rules/stacks/uv.md +3 -2
- package/skills/simt-commands/SKILL.md +70 -0
- package/src/advice/advice.mjs +7 -3
- package/src/agenthooks/decide.mjs +141 -9
- package/src/agenthooks/opencode.mjs +72 -0
- package/src/agenthooks/prompt.mjs +6 -2
- package/src/agenthooks/render.mjs +186 -19
- package/src/agenthooks/shim.mjs +55 -0
- package/src/agents/render.mjs +277 -0
- package/src/argv.mjs +8 -0
- package/src/bench/ablate.mjs +31 -14
- package/src/bench/mine.mjs +48 -1
- package/src/bench/probes.mjs +5 -6
- package/src/bench/report.mjs +243 -80
- package/src/bench/run.mjs +12 -11
- package/src/bench/variants.mjs +112 -40
- package/src/brief/assemble.mjs +6 -0
- package/src/ci/workflow.mjs +16 -7
- package/src/commands/agent.mjs +206 -0
- package/src/commands/agenthook.mjs +109 -15
- package/src/commands/bench.mjs +2 -1
- package/src/commands/compare.mjs +8 -7
- package/src/commands/decide.mjs +6 -5
- package/src/commands/evidence.mjs +13 -3
- package/src/commands/gate.mjs +85 -19
- package/src/commands/graph.mjs +18 -5
- package/src/commands/help.mjs +35 -14
- package/src/commands/hooks.mjs +68 -25
- package/src/commands/impact.mjs +4 -2
- package/src/commands/index.mjs +17 -4
- package/src/commands/init.mjs +169 -37
- package/src/commands/lease.mjs +203 -35
- package/src/commands/lessons.mjs +65 -9
- package/src/commands/map.mjs +33 -18
- package/src/commands/plugin.mjs +1 -1
- package/src/commands/repoindex.mjs +1 -0
- package/src/commands/rules.mjs +92 -18
- package/src/commands/skills.mjs +60 -25
- package/src/commands/skillscompile.mjs +8 -7
- package/src/commands/skillsinstall.mjs +12 -0
- package/src/commands/slice.mjs +377 -18
- package/src/commands/sync.mjs +37 -22
- package/src/commands/tests.mjs +5 -2
- package/src/commands/undo.mjs +24 -16
- package/src/commands/upgrade.mjs +33 -1
- package/src/commands/why.mjs +10 -4
- package/src/compare/hooksstate.mjs +23 -1
- package/src/compare/learn.mjs +82 -57
- package/src/compare/parallel.mjs +50 -0
- package/src/compare/row.mjs +26 -1
- package/src/completions.mjs +5 -0
- package/src/decisions/dir.mjs +84 -0
- package/src/decisions/index.mjs +109 -11
- package/src/distill/distill.mjs +3 -1
- package/src/ecosystem/node-runner.mjs +144 -0
- package/src/explain.mjs +18 -6
- package/src/graph/load.mjs +38 -19
- package/src/hooks/capability.mjs +14 -1
- package/src/hooks/dispatcher.mjs +335 -13
- package/src/hooks/resolve.mjs +9 -3
- package/src/hooks/shim.mjs +12 -7
- package/src/identity.mjs +67 -14
- package/src/index/extract.mjs +192 -11
- package/src/index/persist.mjs +307 -47
- package/src/index/store.mjs +263 -60
- package/src/index/tests.mjs +6 -35
- package/src/journal.mjs +296 -77
- package/src/kernel/card.mjs +37 -12
- package/src/kernel/checks.mjs +44 -9
- package/src/kernel/variants.mjs +27 -16
- package/src/lease.mjs +97 -15
- package/src/ledger/index.mjs +91 -25
- package/src/mcp/resources.mjs +5 -3
- package/src/mcp/server.mjs +81 -22
- package/src/mcp/tools.mjs +45 -9
- package/src/memory/decide.mjs +98 -43
- package/src/memory/lessons.mjs +168 -33
- package/src/name.mjs +9 -0
- package/src/plan/index.mjs +31 -8
- package/src/plan/template.mjs +27 -5
- package/src/plugins.mjs +6 -2
- package/src/prbody.mjs +7 -5
- package/src/projection/baselines.mjs +1 -1
- package/src/projection/disclosure.mjs +2 -2
- package/src/projection/loaders.mjs +41 -1
- package/src/projection/lock.mjs +21 -14
- package/src/projection/manifest.mjs +69 -19
- package/src/projection/markers.mjs +43 -3
- package/src/projection/mdc.mjs +34 -7
- package/src/projection/project.mjs +162 -18
- package/src/projection/rules.mjs +54 -3
- package/src/projection/surfaces.mjs +9 -1
- package/src/repo-root.mjs +69 -0
- package/src/resolve/body.mjs +4 -0
- package/src/resolve/fingerprint.mjs +5 -0
- package/src/resolve/local.mjs +58 -0
- package/src/review/critic.mjs +4 -2
- package/src/secrets.mjs +165 -22
- package/src/state.mjs +76 -0
- package/src/status.mjs +9 -4
- package/src/toolwords.mjs +5 -0
- package/src/tsconfig.mjs +39 -99
- package/templates/plan/tracker.json +1 -1
package/src/hooks/dispatcher.mjs
CHANGED
|
@@ -19,12 +19,14 @@
|
|
|
19
19
|
// its reason, so "passed" can never be mistaken for "only some of it passed".
|
|
20
20
|
|
|
21
21
|
import { execFile } from 'node:child_process';
|
|
22
|
-
import { readFile, stat } from 'node:fs/promises';
|
|
23
|
-
import {
|
|
22
|
+
import { mkdtemp, readFile, rm, stat, symlink } from 'node:fs/promises';
|
|
23
|
+
import { tmpdir } from 'node:os';
|
|
24
|
+
import { basename, join, matchesGlob, resolve, sep } from 'node:path';
|
|
24
25
|
import { promisify } from 'node:util';
|
|
25
26
|
|
|
26
27
|
import { GATES } from './gates.mjs';
|
|
27
28
|
import { resolveGate } from './resolve.mjs';
|
|
29
|
+
import { resolveNodeRunner } from '../ecosystem/node-runner.mjs';
|
|
28
30
|
import { CLI } from '../name.mjs';
|
|
29
31
|
import { MANIFEST_FILE } from '../name.mjs';
|
|
30
32
|
import { assertVersionPin } from '../package-version.mjs';
|
|
@@ -36,14 +38,181 @@ const exec = promisify(execFile);
|
|
|
36
38
|
/** Gates that are known but not required for the event to pass. */
|
|
37
39
|
const DEFAULT_EVENTS = { 'pre-commit': 'preCommit', 'pre-push': 'prePush' };
|
|
38
40
|
|
|
41
|
+
/** The sha git writes for a ref that does not exist on the remote yet. */
|
|
42
|
+
const ZERO_SHA = '0'.repeat(40);
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The lines git feeds a pre-push hook on stdin, one per ref being pushed:
|
|
46
|
+
* `<local ref> <local sha> <remote ref> <remote sha>`.
|
|
47
|
+
*
|
|
48
|
+
* Returns `{ refs, error }`. A line this parser cannot read is an ERROR, not a
|
|
49
|
+
* dropped line: a push that cannot be read is a push that cannot be classified,
|
|
50
|
+
* and the classifier's contract is to fail closed to the full suite.
|
|
51
|
+
*
|
|
52
|
+
* @param {string} text the raw stdin
|
|
53
|
+
* @returns {{refs: {ref: string, localSha: string, remoteSha: string}[] | null, error: string | null}}
|
|
54
|
+
*/
|
|
55
|
+
export function parsePushedRefs(text) {
|
|
56
|
+
const refs = [];
|
|
57
|
+
for (const line of String(text ?? '').split('\n')) {
|
|
58
|
+
const trimmed = line.trim();
|
|
59
|
+
if (trimmed === '') continue;
|
|
60
|
+
const parts = trimmed.split(' ');
|
|
61
|
+
if (parts.length !== 4) return { refs: null, error: `unrecognized pre-push line: ${trimmed}` };
|
|
62
|
+
const [ref, localSha, , remoteSha] = parts;
|
|
63
|
+
if (!/^[0-9a-f]{40}$/.test(localSha) || !/^[0-9a-f]{40}$/.test(remoteSha)) {
|
|
64
|
+
return { refs: null, error: `unrecognized sha in pre-push line: ${trimmed}` };
|
|
65
|
+
}
|
|
66
|
+
refs.push({ ref, localSha, remoteSha });
|
|
67
|
+
}
|
|
68
|
+
return { refs, error: null };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Confirm a tag's commit is already accounted for, so the tag may ride along in a
|
|
73
|
+
* docs-only push (ADR-113). A tag carries no commits of its own in the release flow:
|
|
74
|
+
* it points at commits delivered (and tested) by a branch push. It is acceptable only
|
|
75
|
+
* when the commit it names is reachable from a remote-tracking ref (already pushed) or
|
|
76
|
+
* from a branch tip this same push is delivering. Any uncertainty - the tag does not
|
|
77
|
+
* resolve to a commit, the remote refs cannot be listed, no candidate is known, or the
|
|
78
|
+
* commit is unreachable from all of them - fails closed to the full suite.
|
|
79
|
+
*
|
|
80
|
+
* @param {string} root the repository to ask git
|
|
81
|
+
* @param {string} tagSha the tag's local sha from the pre-push line
|
|
82
|
+
* @param {string[]} pushingTips branch tips this same push is delivering
|
|
83
|
+
* @returns {Promise<{ok: boolean, reason: string | null}>}
|
|
84
|
+
*/
|
|
85
|
+
async function tagCommitAccountedFor(root, tagSha, pushingTips) {
|
|
86
|
+
let commit;
|
|
87
|
+
try {
|
|
88
|
+
({ stdout: commit } = await exec('git', ['rev-parse', `${tagSha}^{commit}`], { cwd: root }));
|
|
89
|
+
} catch (error) {
|
|
90
|
+
return { ok: false, reason: `the tag could not be resolved to a commit: ${String(error.stderr ?? error.message).trim()}` };
|
|
91
|
+
}
|
|
92
|
+
commit = commit.trim();
|
|
93
|
+
|
|
94
|
+
let remoteRefs;
|
|
95
|
+
try {
|
|
96
|
+
({ stdout: remoteRefs } = await exec('git', ['for-each-ref', 'refs/remotes/', '--format=%(refname)'], { cwd: root }));
|
|
97
|
+
} catch (error) {
|
|
98
|
+
return { ok: false, reason: `the remote refs could not be listed: ${String(error.stderr ?? error.message).trim()}` };
|
|
99
|
+
}
|
|
100
|
+
const candidates = [
|
|
101
|
+
...remoteRefs.split('\n').map((s) => s.trim()).filter((s) => s !== ''),
|
|
102
|
+
...pushingTips,
|
|
103
|
+
];
|
|
104
|
+
if (candidates.length === 0) {
|
|
105
|
+
return { ok: false, reason: 'no remote refs are known and no branch is being pushed, so the tag’s commit cannot be confirmed as already delivered' };
|
|
106
|
+
}
|
|
107
|
+
for (const candidate of candidates) {
|
|
108
|
+
try {
|
|
109
|
+
await exec('git', ['merge-base', '--is-ancestor', commit, candidate], { cwd: root });
|
|
110
|
+
return { ok: true, reason: null };
|
|
111
|
+
} catch {
|
|
112
|
+
// not reachable from this ref; try the next candidate
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return { ok: false, reason: `the tag’s commit (${commit.slice(0, 7)}) is not reachable from any pushed ref; it may not have been tested` };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Decide whether a push carries only documentation, so the `test` gate may be
|
|
120
|
+
* skipped for it (ADR-113). Every uncertainty fails closed to `{ docsOnly: false }`
|
|
121
|
+
* with the reason the full suite runs: an empty ref list, a new branch (no remote
|
|
122
|
+
* sha to diff against), a tag whose commit is not confirmed already delivered, a
|
|
123
|
+
* commit count above the ceiling, a file outside the declared paths, or a git error.
|
|
124
|
+
*
|
|
125
|
+
* Branch refs are judged on a TREE diff (`remote..local`), so a force push that
|
|
126
|
+
* drops commits is judged on the trees it replaces, not on the commit count alone.
|
|
127
|
+
* Tag refs carry no commits of their own; each is accepted only when its commit is
|
|
128
|
+
* already accounted for (see `tagCommitAccountedFor`). Deletions push nothing.
|
|
129
|
+
*
|
|
130
|
+
* @param {string} root the repository to ask git
|
|
131
|
+
* @param {{ref: string, localSha: string, remoteSha: string}[]} refs
|
|
132
|
+
* @param {{maxCommits: number, paths: string[]}} config the manifest's `gates.prePushSkipTest`
|
|
133
|
+
* @returns {Promise<{docsOnly: boolean, reason: string}>}
|
|
134
|
+
*/
|
|
135
|
+
export async function classifyPush(root, refs, { maxCommits, paths }) {
|
|
136
|
+
if (refs.length === 0) {
|
|
137
|
+
return { docsOnly: false, reason: 'the push carries no readable ref list; running the full suite' };
|
|
138
|
+
}
|
|
139
|
+
const deletions = refs.filter((r) => r.localSha === ZERO_SHA).length;
|
|
140
|
+
const branches = refs.filter((r) => r.localSha !== ZERO_SHA && !r.ref.startsWith('refs/tags/'));
|
|
141
|
+
const tags = refs.filter((r) => r.localSha !== ZERO_SHA && r.ref.startsWith('refs/tags/'));
|
|
142
|
+
|
|
143
|
+
let commits = 0;
|
|
144
|
+
const files = new Set();
|
|
145
|
+
for (const { ref, localSha, remoteSha } of branches) {
|
|
146
|
+
if (remoteSha === ZERO_SHA) {
|
|
147
|
+
return { docsOnly: false, reason: `a new branch is being pushed (${ref}); its history cannot be diffed against the remote` };
|
|
148
|
+
}
|
|
149
|
+
let count, names;
|
|
150
|
+
try {
|
|
151
|
+
({ stdout: count } = await exec('git', ['rev-list', '--count', `${remoteSha}..${localSha}`], { cwd: root }));
|
|
152
|
+
({ stdout: names } = await exec('git', ['diff', '--name-only', `${remoteSha}..${localSha}`], { cwd: root }));
|
|
153
|
+
} catch (error) {
|
|
154
|
+
return { docsOnly: false, reason: `the push could not be diffed: ${String(error.stderr ?? error.message).trim()}` };
|
|
155
|
+
}
|
|
156
|
+
commits += Number(count.trim());
|
|
157
|
+
for (const f of names.split('\n')) {
|
|
158
|
+
const name = f.trim();
|
|
159
|
+
if (name !== '') files.add(name);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const pushingTips = branches.map((b) => b.localSha);
|
|
164
|
+
for (const { localSha } of tags) {
|
|
165
|
+
const verdict = await tagCommitAccountedFor(root, localSha, pushingTips);
|
|
166
|
+
if (!verdict.ok) return { docsOnly: false, reason: verdict.reason };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if (commits > maxCommits) {
|
|
170
|
+
return { docsOnly: false, reason: `${commits} commits exceed the ${maxCommits}-commit ceiling for a docs-only push` };
|
|
171
|
+
}
|
|
172
|
+
const outside = [...files].filter((f) => !paths.some((p) => matchesGlob(f, p)));
|
|
173
|
+
if (outside.length > 0) {
|
|
174
|
+
return { docsOnly: false, reason: `${outside.length} file(s) are outside the docs-only paths: ${outside.slice(0, 5).join(', ')}` };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const parts = [];
|
|
178
|
+
if (commits > 0) parts.push(`${commits} commit(s) touching ${files.size} file(s), all inside the docs-only paths`);
|
|
179
|
+
if (tags.length > 0) parts.push(`${tags.length} tag(s) already delivered`);
|
|
180
|
+
if (deletions > 0) parts.push(`${deletions} ref deletion(s)`);
|
|
181
|
+
return { docsOnly: true, reason: parts.length > 0 ? parts.join('; ') : 'no commits carried' };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* The pre-push skip decision, from the manifest and the ref list git fed on stdin.
|
|
186
|
+
*
|
|
187
|
+
* The skip is OPT-IN: a repository without `gates.prePushSkipTest` runs the full
|
|
188
|
+
* suite on every push, because a control a repo did not declare is one it did not
|
|
189
|
+
* review. A misdeclaration (missing fields) also runs the suite - a skip the
|
|
190
|
+
* manifest cannot state precisely is a skip a reviewer cannot see.
|
|
191
|
+
*
|
|
192
|
+
* @returns {Promise<{reason: string} | null>} null runs the suite; a reason skips the `test` gate
|
|
193
|
+
*/
|
|
194
|
+
async function resolveDocsOnlySkip(root, manifest, pushed) {
|
|
195
|
+
if (pushed === undefined) return null;
|
|
196
|
+
const config = manifest?.gates?.prePushSkipTest;
|
|
197
|
+
if (typeof config?.maxCommits !== 'number' || !Array.isArray(config?.paths)) return null;
|
|
198
|
+
const { refs, error } = parsePushedRefs(pushed);
|
|
199
|
+
if (error !== null) {
|
|
200
|
+
return null; // the caller cannot be sure what is being pushed; the suite runs
|
|
201
|
+
}
|
|
202
|
+
const verdict = await classifyPush(root, refs, { maxCommits: config.maxCommits, paths: config.paths });
|
|
203
|
+
return verdict.docsOnly ? { reason: verdict.reason } : null;
|
|
204
|
+
}
|
|
205
|
+
|
|
39
206
|
/**
|
|
40
207
|
* @param {string} event 'pre-commit' | 'pre-push'
|
|
41
208
|
* @param {object} ctx
|
|
42
209
|
* @param {string} ctx.root
|
|
43
210
|
* @param {object} ctx.manifest parsed simt-dev.json, or null
|
|
211
|
+
* @param {string} [ctx.pushed] the ref list git feeds pre-push on stdin; undefined when no
|
|
212
|
+
* push is being classified (every other event, a manual run with no stdin)
|
|
44
213
|
* @returns {Promise<{event: string, gates: object[], failed: boolean}>}
|
|
45
214
|
*/
|
|
46
|
-
export async function runGates(event, { root, manifest }) {
|
|
215
|
+
export async function runGates(event, { root, manifest, pushed }) {
|
|
47
216
|
const key = DEFAULT_EVENTS[event];
|
|
48
217
|
if (key === undefined) {
|
|
49
218
|
return { event, gates: [], failed: true, error: `unknown hook event ${event}` };
|
|
@@ -84,7 +253,89 @@ export async function runGates(event, { root, manifest }) {
|
|
|
84
253
|
};
|
|
85
254
|
}
|
|
86
255
|
|
|
256
|
+
// A pre-push gate runs against the COMMITTED tree, not the working one. Uncommitted
|
|
257
|
+
// work must not block the push of previous commits: a gate that fails on WIP the
|
|
258
|
+
// author has not committed is blocking a push it does not own. The worktree is
|
|
259
|
+
// disposable and shares the repository's object store, so `git worktree add` is cheap
|
|
260
|
+
// and needs no clone. The ledger and the version pin stay at the real root - the
|
|
261
|
+
// evidence is about this repository, not the throwaway checkout.
|
|
262
|
+
let runRoot = root;
|
|
263
|
+
let headWorktree = null;
|
|
264
|
+
let skipTest = null;
|
|
265
|
+
if (event === 'pre-push') {
|
|
266
|
+
// Decided before the worktree exists: a push that carries only documentation
|
|
267
|
+
// skips the `test` gate (ADR-113), and a push that earns the skip needs no
|
|
268
|
+
// tree for a gate that will not run.
|
|
269
|
+
skipTest = await resolveDocsOnlySkip(root, manifest, pushed);
|
|
270
|
+
if (skipTest === null) {
|
|
271
|
+
const materialized = await materializeHeadWorktree(root);
|
|
272
|
+
if (materialized.path === null) {
|
|
273
|
+
return {
|
|
274
|
+
event,
|
|
275
|
+
gates: [
|
|
276
|
+
{
|
|
277
|
+
name: 'head',
|
|
278
|
+
status: 'failed',
|
|
279
|
+
detail: materialized.error,
|
|
280
|
+
remedy: 'commit the work, or resolve the git state so HEAD is a valid reference, then push again',
|
|
281
|
+
adr: null,
|
|
282
|
+
},
|
|
283
|
+
],
|
|
284
|
+
failed: true,
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
runRoot = materialized.path;
|
|
288
|
+
headWorktree = materialized.path;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
try {
|
|
293
|
+
return await runGatesAt(event, { root: runRoot, ledgerRoot: root, manifest, declared, key, skipTest });
|
|
294
|
+
} finally {
|
|
295
|
+
if (headWorktree !== null) await removeHeadWorktree(root, headWorktree);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Run the declared gates at `root`, recording evidence at `ledgerRoot`.
|
|
301
|
+
*
|
|
302
|
+
* For a pre-push run the two differ: the gates execute in a throwaway worktree at
|
|
303
|
+
* HEAD so uncommitted work cannot block the push, while the ledger line belongs to
|
|
304
|
+
* the repository the author is standing in. For every other event they are the same
|
|
305
|
+
* path, which is why this is one function with two names for the root rather than
|
|
306
|
+
* two functions.
|
|
307
|
+
*
|
|
308
|
+
* @param {string} event
|
|
309
|
+
* @param {object} ctx
|
|
310
|
+
* @param {string} ctx.root the tree the gates execute in
|
|
311
|
+
* @param {string} ctx.ledgerRoot the repository the evidence is recorded in
|
|
312
|
+
* @param {object} ctx.manifest
|
|
313
|
+
* @param {string[]} ctx.declared the gate names for this event
|
|
314
|
+
* @param {string} ctx.key the manifest key for this event
|
|
315
|
+
* @param {{reason: string} | null} [ctx.skipTest] set when a docs-only push skips the
|
|
316
|
+
* `test` gate (ADR-113); the reason is what the report prints in its place
|
|
317
|
+
* @returns {Promise<{event: string, gates: object[], failed: boolean}>}
|
|
318
|
+
*/
|
|
319
|
+
async function runGatesAt(event, { root, ledgerRoot, manifest, declared, key, skipTest = null }) {
|
|
87
320
|
const pkg = await readPkg(root);
|
|
321
|
+
// Q31: the runner is committed evidence (lockfile, then `packageManager`),
|
|
322
|
+
// resolved once per hook run so every script gate spawns the same manager.
|
|
323
|
+
let runner = null;
|
|
324
|
+
if (pkg) {
|
|
325
|
+
const lockfiles = [];
|
|
326
|
+
for (const file of ['pnpm-lock.yaml', 'yarn.lock', 'bun.lock', 'bun.lockb', 'package-lock.json']) {
|
|
327
|
+
try {
|
|
328
|
+
await stat(join(root, file));
|
|
329
|
+
lockfiles.push(file);
|
|
330
|
+
} catch {
|
|
331
|
+
// Absent is the normal case.
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
runner = resolveNodeRunner({
|
|
335
|
+
lockfiles,
|
|
336
|
+
packageManager: typeof pkg.packageManager === 'string' ? pkg.packageManager : null,
|
|
337
|
+
}).id;
|
|
338
|
+
}
|
|
88
339
|
const staged = event === 'pre-commit' ? await stagedFiles(root) : [];
|
|
89
340
|
const unstaged = event === 'pre-commit' ? await unstagedFiles(root) : new Set();
|
|
90
341
|
const commands = manifest?.gates?.commands;
|
|
@@ -111,7 +362,15 @@ export async function runGates(event, { root, manifest }) {
|
|
|
111
362
|
continue;
|
|
112
363
|
}
|
|
113
364
|
|
|
114
|
-
|
|
365
|
+
// The one gate a docs-only push does not run (ADR-113). It is reported, not
|
|
366
|
+
// dropped: a skip without a printed reason would let "passed" mean "the suite
|
|
367
|
+
// never ran", which is the exact confusion the report exists to prevent.
|
|
368
|
+
if (skipTest !== null && name === 'test') {
|
|
369
|
+
results.push({ name, status: 'skipped', detail: `docs-only push: ${skipTest.reason}`, adr: 'ADR-113', exitCode: null });
|
|
370
|
+
continue;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
const resolution = await resolveGate(gate, { root, pkg, staged, commands, runner });
|
|
115
374
|
if (!resolution.ok) {
|
|
116
375
|
results.push({
|
|
117
376
|
name,
|
|
@@ -130,13 +389,17 @@ export async function runGates(event, { root, manifest }) {
|
|
|
130
389
|
// declared command for this name takes the shell path below instead.
|
|
131
390
|
if (resolution.builtin !== undefined) {
|
|
132
391
|
const startedBuiltin = Date.now();
|
|
133
|
-
|
|
392
|
+
// The manifest's per-builtin options, where a builtin has any: the secret
|
|
393
|
+
// scan takes its ignore globs from gates.secretScan (review 0003 Q24).
|
|
394
|
+
const outcome = await runBuiltinGate(resolution.builtin, { root, staged, options: manifest?.gates?.secretScan });
|
|
134
395
|
try {
|
|
135
|
-
await recordRun(
|
|
396
|
+
await recordRun(ledgerRoot, {
|
|
136
397
|
cmd: `builtin:${resolution.builtin}`,
|
|
137
398
|
exit: outcome.ok ? 0 : 1,
|
|
138
399
|
ms: Date.now() - startedBuiltin,
|
|
139
400
|
output: outcome.output,
|
|
401
|
+
gate: name,
|
|
402
|
+
kind: 'builtin',
|
|
140
403
|
});
|
|
141
404
|
} catch {
|
|
142
405
|
// Recorded by absence: the next evidence read for this tree has no line.
|
|
@@ -219,11 +482,18 @@ export async function runGates(event, { root, manifest }) {
|
|
|
219
482
|
// claim check then finds nothing, which is the direction that cannot invent a run.
|
|
220
483
|
try {
|
|
221
484
|
const cmd = typeof resolution.command === 'string' ? resolution.command : resolution.argv.join(' ');
|
|
222
|
-
|
|
485
|
+
// The evidence claim needs the gate and its kind: a passing secret-scan is
|
|
486
|
+
// not a passing test run (review 0003 Q18). Coverage proves tests; anything
|
|
487
|
+
// else keeps its own kind or the gate name.
|
|
488
|
+
const kind =
|
|
489
|
+
name === 'test' || name === 'coverage' ? 'test' : name === 'lint' ? 'lint' : name === 'format' ? 'format' : (gate.kind ?? name);
|
|
490
|
+
await recordRun(ledgerRoot, {
|
|
223
491
|
cmd,
|
|
224
492
|
exit: outcome.code,
|
|
225
493
|
ms: Date.now() - started,
|
|
226
494
|
output: `${outcome.stdout ?? ''}\n${outcome.stderr ?? ''}`,
|
|
495
|
+
gate: name,
|
|
496
|
+
kind,
|
|
227
497
|
});
|
|
228
498
|
} catch {
|
|
229
499
|
// Recorded by absence: the next evidence read for this tree has no line.
|
|
@@ -273,6 +543,54 @@ export async function runGates(event, { root, manifest }) {
|
|
|
273
543
|
return { event, gates: results, failed: results.some((r) => r.status === 'failed') };
|
|
274
544
|
}
|
|
275
545
|
|
|
546
|
+
/**
|
|
547
|
+
* Materialize a detached worktree at HEAD so the pre-push gates run against the
|
|
548
|
+
* committed tree, not the working one. The worktree shares the repository's object
|
|
549
|
+
* store, so `git worktree add` is cheap and needs no clone. `node_modules` is
|
|
550
|
+
* symlinked in when the real root has one, because a script gate resolves its runner
|
|
551
|
+
* and binaries from there and a fresh worktree does not carry it.
|
|
552
|
+
*
|
|
553
|
+
* Fails closed: an unborn HEAD (no commits) or a git error returns `{ path: null }`
|
|
554
|
+
* with the reason, and the caller turns that into a gate failure rather than running
|
|
555
|
+
* against a tree it cannot name.
|
|
556
|
+
*
|
|
557
|
+
* @param {string} root the real repository root
|
|
558
|
+
* @returns {Promise<{path: string, error?: never}|{path: null, error: string}>}
|
|
559
|
+
*/
|
|
560
|
+
async function materializeHeadWorktree(root) {
|
|
561
|
+
const path = await mkdtemp(join(tmpdir(), 'head-gate-'));
|
|
562
|
+
try {
|
|
563
|
+
await exec('git', ['worktree', 'add', '--detach', path, 'HEAD'], { cwd: root });
|
|
564
|
+
} catch (error) {
|
|
565
|
+
await rm(path, { recursive: true, force: true }).catch(() => {});
|
|
566
|
+
return { path: null, error: `git worktree add failed: ${String(error.stderr ?? error.message).trim()}` };
|
|
567
|
+
}
|
|
568
|
+
const nodeModules = join(root, 'node_modules');
|
|
569
|
+
try {
|
|
570
|
+
await symlink(nodeModules, join(path, 'node_modules'));
|
|
571
|
+
} catch {
|
|
572
|
+
// Absent node_modules is the normal case for a repo with no install step; a
|
|
573
|
+
// dangling link is harmless, and `npm run` does not need it to exist.
|
|
574
|
+
}
|
|
575
|
+
return { path };
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
/**
|
|
579
|
+
* Remove a pre-push worktree. `--force` because a gate may have left it dirty (a
|
|
580
|
+
* formatter, a test that writes fixtures); the worktree is disposable, the real tree
|
|
581
|
+
* is not. Falls back to `rm -rf` plus a prune when git cannot remove it (a lock, a
|
|
582
|
+
* path in a state git does not expect), so a cleanup failure never leaks the tree.
|
|
583
|
+
*
|
|
584
|
+
* @param {string} root the real repository root
|
|
585
|
+
* @param {string} path the worktree to remove
|
|
586
|
+
*/
|
|
587
|
+
async function removeHeadWorktree(root, path) {
|
|
588
|
+
await exec('git', ['worktree', 'remove', '--force', path], { cwd: root }).catch(async () => {
|
|
589
|
+
await rm(path, { recursive: true, force: true }).catch(() => {});
|
|
590
|
+
await exec('git', ['worktree', 'prune'], { cwd: root }).catch(() => {});
|
|
591
|
+
});
|
|
592
|
+
}
|
|
593
|
+
|
|
276
594
|
/**
|
|
277
595
|
* The lines that report one event's gate run: the event, what was declared, every
|
|
278
596
|
* gate with its disposition, its reason and its remedy, and the verdict.
|
|
@@ -298,9 +616,9 @@ export function renderGateReport(event, declared, result) {
|
|
|
298
616
|
return lines;
|
|
299
617
|
}
|
|
300
618
|
|
|
301
|
-
async function run(argv, cwd) {
|
|
619
|
+
async function run(argv, cwd, env) {
|
|
302
620
|
try {
|
|
303
|
-
const { stdout, stderr } = await exec(argv[0], argv.slice(1), { cwd, maxBuffer: 8 * 1024 * 1024 });
|
|
621
|
+
const { stdout, stderr } = await exec(argv[0], argv.slice(1), { cwd, env, maxBuffer: 8 * 1024 * 1024 });
|
|
304
622
|
return { code: 0, stdout, stderr };
|
|
305
623
|
} catch (error) {
|
|
306
624
|
return { code: typeof error.code === 'number' ? error.code : 1, stdout: error.stdout ?? '', stderr: error.stderr ?? String(error.message ?? error) };
|
|
@@ -320,7 +638,7 @@ const BUILTIN_REPORT_MAX = 12;
|
|
|
320
638
|
* none of them can degrade into a pass, which is the same fail-closed contract
|
|
321
639
|
* an unresolvable script gate has.
|
|
322
640
|
*/
|
|
323
|
-
async function runBuiltinGate(name, { root, staged }) {
|
|
641
|
+
async function runBuiltinGate(name, { root, staged, options }) {
|
|
324
642
|
const scan = BUILTIN_GATES.get(name);
|
|
325
643
|
if (scan === undefined) {
|
|
326
644
|
return {
|
|
@@ -333,7 +651,7 @@ async function runBuiltinGate(name, { root, staged }) {
|
|
|
333
651
|
if (staged.length === 0) {
|
|
334
652
|
return { ok: true, detail: 'nothing staged; no added line to scan', output: '' };
|
|
335
653
|
}
|
|
336
|
-
const result = await scan(root);
|
|
654
|
+
const result = await scan(root, options ?? {});
|
|
337
655
|
if (result.error !== null) {
|
|
338
656
|
return {
|
|
339
657
|
ok: false,
|
|
@@ -376,9 +694,13 @@ async function runBuiltinGate(name, { root, staged }) {
|
|
|
376
694
|
* rather than a flag on `run`.
|
|
377
695
|
*/
|
|
378
696
|
async function runGate(resolution, cwd) {
|
|
697
|
+
// The push override is for the push itself, not for the gates the hook runs.
|
|
698
|
+
// A test that asserts a push is denied sees the override and fails.
|
|
699
|
+
const env = { ...process.env };
|
|
700
|
+
delete env.SIMT_ALLOW;
|
|
379
701
|
if (resolution.shell === true && typeof resolution.command === 'string') {
|
|
380
702
|
try {
|
|
381
|
-
const { stdout, stderr } = await exec('sh', ['-c', resolution.command], { cwd, maxBuffer: 8 * 1024 * 1024 });
|
|
703
|
+
const { stdout, stderr } = await exec('sh', ['-c', resolution.command], { cwd, env, maxBuffer: 8 * 1024 * 1024 });
|
|
382
704
|
return { code: 0, stdout, stderr };
|
|
383
705
|
} catch (error) {
|
|
384
706
|
return {
|
|
@@ -388,7 +710,7 @@ async function runGate(resolution, cwd) {
|
|
|
388
710
|
};
|
|
389
711
|
}
|
|
390
712
|
}
|
|
391
|
-
return run(resolution.argv, cwd);
|
|
713
|
+
return run(resolution.argv, cwd, env);
|
|
392
714
|
}
|
|
393
715
|
|
|
394
716
|
async function readPkg(root) {
|
package/src/hooks/resolve.mjs
CHANGED
|
@@ -68,7 +68,7 @@ export function hasScript(pkg, script) {
|
|
|
68
68
|
* @returns {Promise<{ok: true, argv: string[], kind: string, detail: string}
|
|
69
69
|
* | {ok: false, gate: string, kind: string, reason: string, remedy: string}>}
|
|
70
70
|
*/
|
|
71
|
-
export async function resolveGate(gate, { root, pkg, staged, commands }) {
|
|
71
|
+
export async function resolveGate(gate, { root, pkg, staged, commands, runner = null }) {
|
|
72
72
|
const name = gate.name;
|
|
73
73
|
const declared = commands?.[name];
|
|
74
74
|
|
|
@@ -172,11 +172,17 @@ export async function resolveGate(gate, { root, pkg, staged, commands }) {
|
|
|
172
172
|
remedy: `add "${script}" to scripts, or remove "${name}" from ${MANIFEST_FILE} gates`,
|
|
173
173
|
};
|
|
174
174
|
}
|
|
175
|
+
// Q31: script gates run through the repo's own manager — `pnpm run`, not
|
|
176
|
+
// `npm run`, in a pnpm checkout. The default stays npm for callers that do
|
|
177
|
+
// not report one (older capabilities, hand-built contexts).
|
|
178
|
+
const { runScriptArgv, runScript } = await import('../ecosystem/node-runner.mjs');
|
|
179
|
+
const manager = runner ?? pkg?.packageManager?.split('@')?.[0] ?? 'npm';
|
|
180
|
+
const known = ['npm', 'pnpm', 'yarn', 'bun'].includes(manager) ? manager : 'npm';
|
|
175
181
|
return {
|
|
176
182
|
ok: true,
|
|
177
183
|
kind: gate.kind,
|
|
178
|
-
argv:
|
|
179
|
-
detail:
|
|
184
|
+
argv: runScriptArgv(known, script),
|
|
185
|
+
detail: runScript(known, script),
|
|
180
186
|
};
|
|
181
187
|
}
|
|
182
188
|
|
package/src/hooks/shim.mjs
CHANGED
|
@@ -48,14 +48,17 @@ export function fileFor(event) {
|
|
|
48
48
|
|
|
49
49
|
/**
|
|
50
50
|
* @param {string} event one of EVENTS
|
|
51
|
-
* @param {{npm?: boolean, version?: string}} [opts]
|
|
51
|
+
* @param {{npm?: boolean, manager?: string|null, version?: string}} [opts]
|
|
52
52
|
* `npm` is whether the repository has a package.json: it decides the remedy's install
|
|
53
53
|
* (a devDependency where one exists, the release binary into the repository where
|
|
54
|
-
* there is no node_modules to pin in). `
|
|
55
|
-
*
|
|
54
|
+
* there is no node_modules to pin in). `manager` names the Node manager when one
|
|
55
|
+
* was resolved (Q31: `pnpm add -D`, not `npm i -D`, in a pnpm checkout); it wins
|
|
56
|
+
* over the legacy `npm` boolean so old callers keep their bytes. `version`
|
|
57
|
+
* defaults to the running package's own version, so a shim generated by 0.2.0
|
|
58
|
+
* names 0.2.0 in its remedy and CI install.
|
|
56
59
|
* @returns {string} POSIX sh source, LF-terminated, executable
|
|
57
60
|
*/
|
|
58
|
-
export function renderShim(event, { npm = true, version } = {}) {
|
|
61
|
+
export function renderShim(event, { npm = true, manager = null, version } = {}) {
|
|
59
62
|
if (!EVENTS.includes(event)) {
|
|
60
63
|
throw new Error(`unknown hook event ${event}; known: ${EVENTS.join(', ')}`);
|
|
61
64
|
}
|
|
@@ -65,7 +68,8 @@ export function renderShim(event, { npm = true, version } = {}) {
|
|
|
65
68
|
// installs it there; one without (Gradle, Connect IQ) has no node_modules to pin in,
|
|
66
69
|
// so the remedy downloads the release binary into the repository - the same pinned
|
|
67
70
|
// bytes CI gets, and one that needs no npm and no node on the machine (ADR-098).
|
|
68
|
-
const
|
|
71
|
+
const addDev = { npm: `npm i -D ${PACKAGE_NAME}@${v}`, pnpm: `pnpm add -D ${PACKAGE_NAME}@${v}`, yarn: `yarn add -D ${PACKAGE_NAME}@${v}`, bun: `bun add -d ${PACKAGE_NAME}@${v}` }[manager ?? (npm ? 'npm' : '')];
|
|
72
|
+
const install = addDev ?? (npm && !manager ? `npm i -D ${PACKAGE_NAME}@${v}` : localInstall(v));
|
|
69
73
|
const D = '$';
|
|
70
74
|
return `#!/bin/sh
|
|
71
75
|
${SHIM_MARKER}
|
|
@@ -103,11 +107,12 @@ ${renderLocator(`hooks run ${event} --root "${D}ROOT"`, {
|
|
|
103
107
|
* @param {{npm?: boolean, version?: string}} [opts]
|
|
104
108
|
* @returns {string} POSIX sh source, LF-terminated
|
|
105
109
|
*/
|
|
106
|
-
export function renderEvidenceShim({ npm = true, version } = {}) {
|
|
110
|
+
export function renderEvidenceShim({ npm = true, manager = null, version } = {}) {
|
|
107
111
|
const v = version ?? packageVersion();
|
|
108
112
|
// The same split as renderShim: a devDependency where one exists, the release
|
|
109
113
|
// binary into the repository where there is no node_modules to pin in.
|
|
110
|
-
const
|
|
114
|
+
const addDev = { npm: `npm i -D ${PACKAGE_NAME}@${v}`, pnpm: `pnpm add -D ${PACKAGE_NAME}@${v}`, yarn: `yarn add -D ${PACKAGE_NAME}@${v}`, bun: `bun add -d ${PACKAGE_NAME}@${v}` }[manager ?? (npm ? 'npm' : '')];
|
|
115
|
+
const install = addDev ?? (npm && !manager ? `npm i -D ${PACKAGE_NAME}@${v}` : localInstall(v));
|
|
111
116
|
const D = '$';
|
|
112
117
|
return `#!/bin/sh
|
|
113
118
|
${SHIM_MARKER}
|
package/src/identity.mjs
CHANGED
|
@@ -25,10 +25,12 @@
|
|
|
25
25
|
// tracked file is not something an adopting command should do silently.
|
|
26
26
|
|
|
27
27
|
import { execFile } from 'node:child_process';
|
|
28
|
-
import { readFile, stat } from 'node:fs/promises';
|
|
28
|
+
import { readFile, stat, writeFile } from 'node:fs/promises';
|
|
29
29
|
import { join } from 'node:path';
|
|
30
30
|
import { promisify } from 'node:util';
|
|
31
31
|
|
|
32
|
+
import { journaledGitConfig, journaledWriteFile } from './journal.mjs';
|
|
33
|
+
|
|
32
34
|
const exec = promisify(execFile);
|
|
33
35
|
|
|
34
36
|
/** The file, at the repository root. Named for what it is: a git config fragment. */
|
|
@@ -166,13 +168,67 @@ export async function includesOwnIdentity(root) {
|
|
|
166
168
|
* break. Two of the repositories here had exactly that, and both worked only because
|
|
167
169
|
* the sibling checkout happened to exist at the expected relative path.
|
|
168
170
|
*
|
|
171
|
+
* A caller-supplied journal records the key so `undo` restores the routing (R49);
|
|
172
|
+
* without one the write stays unrecorded, as `commitJournal` needs it (review 0003
|
|
173
|
+
* Q30). The journaled path sets through `journaledGitConfig`, which records one
|
|
174
|
+
* key's before and after - the single-value case, and the only shape this tool
|
|
175
|
+
* writes.
|
|
176
|
+
*
|
|
169
177
|
* @param {string} root
|
|
178
|
+
* @param {Array} [journal]
|
|
170
179
|
* @returns {Promise<void>}
|
|
171
180
|
*/
|
|
172
|
-
export async function includeOwnIdentity(root) {
|
|
181
|
+
export async function includeOwnIdentity(root, journal = null) {
|
|
182
|
+
// A directory that is not a git work tree has no config to route. The lenient
|
|
183
|
+
// `git()` used to swallow the failure here, and an adoption of a plain directory
|
|
184
|
+
// must keep working: there is simply nothing to point at.
|
|
185
|
+
if ((await git(root, ['rev-parse', '--is-inside-work-tree'])) !== 'true') return;
|
|
186
|
+
if (journal) {
|
|
187
|
+
await journaledGitConfig(root, journal, INCLUDE_KEY, `../${IDENTITY_FILE}`);
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
173
190
|
await git(root, ['config', '--local', '--replace-all', INCLUDE_KEY, `../${IDENTITY_FILE}`]);
|
|
174
191
|
}
|
|
175
192
|
|
|
193
|
+
/**
|
|
194
|
+
* Whether .gitignore already names the identity file. The plan needs the answer
|
|
195
|
+
* before anything is written: a line the run does not print is a write the reader
|
|
196
|
+
* approves from the diff afterwards (review 0003 Q30).
|
|
197
|
+
*
|
|
198
|
+
* @param {string} root
|
|
199
|
+
* @returns {Promise<boolean>}
|
|
200
|
+
*/
|
|
201
|
+
export async function identityIgnored(root) {
|
|
202
|
+
let text;
|
|
203
|
+
try {
|
|
204
|
+
text = await readFile(join(root, '.gitignore'), 'utf8');
|
|
205
|
+
} catch {
|
|
206
|
+
return false;
|
|
207
|
+
}
|
|
208
|
+
return text.split('\n').map((l) => l.trim()).includes(IDENTITY_FILE);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* The .gitignore bytes with the identity entry appended, or null when it is
|
|
213
|
+
* already there. One owner for the edit's shape: `ignoreIdentity` writes it and
|
|
214
|
+
* `init --diff` previews it, so the two cannot disagree about the bytes (review
|
|
215
|
+
* 0003 Q30).
|
|
216
|
+
*
|
|
217
|
+
* @param {string} text the current bytes, '' when the file is absent
|
|
218
|
+
* @returns {string|null}
|
|
219
|
+
*/
|
|
220
|
+
export function gitignoreWithIdentity(text) {
|
|
221
|
+
if (text.split('\n').map((l) => l.trim()).includes(IDENTITY_FILE)) return null;
|
|
222
|
+
const prefix = text === '' || text.endsWith('\n') ? '' : '\n';
|
|
223
|
+
return (
|
|
224
|
+
`${text}${prefix}\n` +
|
|
225
|
+
'# Per-developer git identity, written by `simt init`. Not a file to share:\n' +
|
|
226
|
+
'# it carries whoever is committing into the tree, and a repository has more than\n' +
|
|
227
|
+
'# one of those.\n' +
|
|
228
|
+
`${IDENTITY_FILE}\n`
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
|
|
176
232
|
/**
|
|
177
233
|
* Add the identity file to .gitignore, if it is not already there.
|
|
178
234
|
*
|
|
@@ -181,10 +237,14 @@ export async function includeOwnIdentity(root) {
|
|
|
181
237
|
* it means nothing, and appending a duplicate of an entry that is already present
|
|
182
238
|
* is how a file ends up listed twice.
|
|
183
239
|
*
|
|
240
|
+
* A caller-supplied journal records the edit so `undo` restores it (R49); without
|
|
241
|
+
* one the write stays unrecorded, as `commitJournal` needs it (review 0003 Q30).
|
|
242
|
+
*
|
|
184
243
|
* @param {string} root
|
|
244
|
+
* @param {Array} [journal]
|
|
185
245
|
* @returns {Promise<boolean>} whether the file was changed
|
|
186
246
|
*/
|
|
187
|
-
export async function ignoreIdentity(root) {
|
|
247
|
+
export async function ignoreIdentity(root, journal = null) {
|
|
188
248
|
const path = join(root, '.gitignore');
|
|
189
249
|
let text;
|
|
190
250
|
try {
|
|
@@ -192,16 +252,9 @@ export async function ignoreIdentity(root) {
|
|
|
192
252
|
} catch {
|
|
193
253
|
text = '';
|
|
194
254
|
}
|
|
195
|
-
const
|
|
196
|
-
if (
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
`${prefix}\n` +
|
|
200
|
-
'# Per-developer git identity, written by `simt init`. Not a file to share:\n' +
|
|
201
|
-
'# it carries whoever is committing into the tree, and a repository has more than\n' +
|
|
202
|
-
'# one of those.\n' +
|
|
203
|
-
`${IDENTITY_FILE}\n`;
|
|
204
|
-
const { writeFile } = await import('node:fs/promises');
|
|
205
|
-
await writeFile(path, text + block);
|
|
255
|
+
const next = gitignoreWithIdentity(text);
|
|
256
|
+
if (next === null) return false;
|
|
257
|
+
if (journal) await journaledWriteFile(root, journal, path, next);
|
|
258
|
+
else await writeFile(path, next);
|
|
206
259
|
return true;
|
|
207
260
|
}
|