@descent-vtt/spec-harness 0.18.0 → 0.19.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/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +160 -0
- package/README.md +57 -5
- package/dist/cli.d.ts +1 -1
- package/dist/cli.js +274 -51
- package/dist/cli.js.map +1 -1
- package/dist/config.d.ts +2 -0
- package/dist/config.js +2 -0
- package/dist/config.js.map +1 -1
- package/dist/configure.d.ts +88 -32
- package/dist/configure.js +187 -68
- package/dist/configure.js.map +1 -1
- package/dist/fs.d.ts +73 -3
- package/dist/fs.js +176 -13
- package/dist/fs.js.map +1 -1
- package/dist/git.d.ts +35 -7
- package/dist/git.js +42 -8
- package/dist/git.js.map +1 -1
- package/dist/hooks.d.ts +66 -0
- package/dist/hooks.js +112 -15
- package/dist/hooks.js.map +1 -1
- package/dist/round.js +11 -3
- package/dist/round.js.map +1 -1
- package/dist/setup.d.ts +6 -3
- package/dist/setup.js +39 -18
- package/dist/setup.js.map +1 -1
- package/dist/siblings.d.ts +18 -4
- package/dist/siblings.js +30 -17
- package/dist/siblings.js.map +1 -1
- package/dist/types.d.ts +5 -0
- package/dist/types.js.map +1 -1
- package/dist/usage.d.ts +25 -6
- package/dist/usage.js +73 -19
- package/dist/usage.js.map +1 -1
- package/dist/workspace.d.ts +38 -1
- package/dist/workspace.js +68 -8
- package/dist/workspace.js.map +1 -1
- package/hooks/hooks.json +16 -0
- package/package.json +2 -2
package/dist/fs.d.ts
CHANGED
|
@@ -3,11 +3,25 @@
|
|
|
3
3
|
* directory outside the work tree, and paths as the filesystem spells them.
|
|
4
4
|
*/
|
|
5
5
|
import { type HarnessConfig } from './config.js';
|
|
6
|
-
/**
|
|
6
|
+
/**
|
|
7
|
+
* Reads `.spec-harness.json` at the root, or the defaults when there is none.
|
|
8
|
+
*
|
|
9
|
+
* Only a file is read. A named pipe under the file's name was read without
|
|
10
|
+
* end, by every command and by the hook before every write; and Node's words
|
|
11
|
+
* for a directory, EISDIR, are not the harness's.
|
|
12
|
+
*/
|
|
7
13
|
export declare function loadConfig(root: string): Promise<{
|
|
8
14
|
config: HarnessConfig;
|
|
9
15
|
file: string | null;
|
|
10
16
|
}>;
|
|
17
|
+
/**
|
|
18
|
+
* A path where the harness keeps its state that holds something else: said
|
|
19
|
+
* in a line, exit 2, where Node's ENOTDIR and a stack sent a person looking
|
|
20
|
+
* for a defect (spec-core ADR-0005).
|
|
21
|
+
*/
|
|
22
|
+
export declare class StateError extends Error {
|
|
23
|
+
constructor(message: string);
|
|
24
|
+
}
|
|
11
25
|
/**
|
|
12
26
|
* The real spelling of a path: links resolved and, on a filesystem that
|
|
13
27
|
* ignores case, the case the directory entries have. A path that does not
|
|
@@ -25,6 +39,15 @@ export declare function realSpelling(path: string): string;
|
|
|
25
39
|
* for what it would say from the other.
|
|
26
40
|
*/
|
|
27
41
|
export declare function isTop(directory: string, top: string): boolean;
|
|
42
|
+
/**
|
|
43
|
+
* The repository's main work tree, where git keeps the repository as that
|
|
44
|
+
* work tree's `.git`, as `git init` and `git clone` do and every linked
|
|
45
|
+
* worktree's common directory then is: `null` for a common directory of any
|
|
46
|
+
* other name, a bare repository's, a submodule's under its superproject's
|
|
47
|
+
* `modules`, or one kept apart with `--separate-git-dir`. Read from the
|
|
48
|
+
* name, with no process: it says only where an install is looked for.
|
|
49
|
+
*/
|
|
50
|
+
export declare function mainWorkTree(commonDir: string): string | null;
|
|
28
51
|
/** Whether a `.git` stands in `directory`, a repository's or the file that names one, as at the top of a work tree. */
|
|
29
52
|
export declare function holdsGit(directory: string): boolean;
|
|
30
53
|
/**
|
|
@@ -32,11 +55,58 @@ export declare function holdsGit(directory: string): boolean;
|
|
|
32
55
|
* real spelling - or `null` when it lies outside the root.
|
|
33
56
|
*/
|
|
34
57
|
export declare function repositoryPath(given: string, root: string, cwd: string): string | null;
|
|
35
|
-
/**
|
|
58
|
+
/**
|
|
59
|
+
* The harness's own directory under the git common directory, created on
|
|
60
|
+
* demand (ADR-0003). Where something that is no directory stands at its path,
|
|
61
|
+
* or at one between it and git's, Node refuses to make it, with ENOTDIR or
|
|
62
|
+
* EEXIST, and the harness says which path holds what.
|
|
63
|
+
*/
|
|
36
64
|
export declare function stateDirectory(commonDir: string, ...parts: string[]): Promise<string>;
|
|
65
|
+
/**
|
|
66
|
+
* A work tree and its git directories as the files git keeps name them, read
|
|
67
|
+
* with no process: the nearest directory at or above `root` that holds a
|
|
68
|
+
* `.git`; the git directory, that `.git` or the one a `.git` file names
|
|
69
|
+
* (`gitdir: <path>`, relative to the file's directory); and the common
|
|
70
|
+
* directory, the one that git directory's `commondir` file names, relative
|
|
71
|
+
* to it, or the git directory itself (gitrepository-layout(5)). `exact` is
|
|
72
|
+
* whether git, asked, would name the same work tree: not where
|
|
73
|
+
* `GIT_DIR` or `GIT_WORK_TREE` sends it elsewhere, and not where the
|
|
74
|
+
* repository's configuration names a `worktree`, which `core.worktree`
|
|
75
|
+
* moves it to (git-config(1)), or keeps one of its own for a worktree.
|
|
76
|
+
* `null` where no directory up to the top of the filesystem holds one, or
|
|
77
|
+
* a `.git` file names none.
|
|
78
|
+
*/
|
|
79
|
+
export declare function gitDirectories(root: string, env: Readonly<Record<string, string | undefined>>): {
|
|
80
|
+
readonly top: string;
|
|
81
|
+
readonly common: string;
|
|
82
|
+
readonly exact: boolean;
|
|
83
|
+
} | null;
|
|
84
|
+
/**
|
|
85
|
+
* How long a call's decisions are kept for a run after the write that does
|
|
86
|
+
* not come: a call refused by the person, one that failed, one stopped
|
|
87
|
+
* (ADR-0002). A day, since a call waits on a person's prompt for as long as
|
|
88
|
+
* the person is away, and an entry removed too soon costs only that call's
|
|
89
|
+
* answer being decided again.
|
|
90
|
+
*/
|
|
91
|
+
export declare const CALL_LIFE_MS: number;
|
|
92
|
+
/**
|
|
93
|
+
* Keeps what a run before a write decided, under the common directory, for
|
|
94
|
+
* the run after it, and removes what no run after a write read in a day.
|
|
95
|
+
* Nothing here may change the answer before the write: whatever fails is
|
|
96
|
+
* passed over, and the run after the write then decides as it did.
|
|
97
|
+
*/
|
|
98
|
+
export declare function keepCall(commonDir: string, name: string, text: string, now?: number): Promise<void>;
|
|
99
|
+
/** What was kept for a call, taken away as it is read; `null` where nothing was. */
|
|
100
|
+
export declare function takeCall(commonDir: string, name: string): Promise<string | null>;
|
|
37
101
|
/** Writes a file whole or not at all: a temporary file renamed over the target. */
|
|
38
102
|
export declare function writeAtomic(file: string, content: string): Promise<void>;
|
|
39
|
-
/**
|
|
103
|
+
/**
|
|
104
|
+
* A JSON object from a file: `null` when there is no file, `'unreadable'`
|
|
105
|
+
* when it holds anything else. Only a file is read: a named pipe where a
|
|
106
|
+
* settings file is expected was read without end, and neither doctor nor
|
|
107
|
+
* init came back. What is neither a file nor a directory holds no JSON
|
|
108
|
+
* object, as a directory holds none.
|
|
109
|
+
*/
|
|
40
110
|
export declare function readJsonObject(file: string): Promise<Record<string, unknown> | null | 'unreadable'>;
|
|
41
111
|
/** Whether a path names a directory that is there; one that cannot be read names none. */
|
|
42
112
|
export declare function isDirectory(path: string): boolean;
|
package/dist/fs.js
CHANGED
|
@@ -2,29 +2,54 @@
|
|
|
2
2
|
* The edge where the harness meets the disk: the configuration, the state
|
|
3
3
|
* directory outside the work tree, and paths as the filesystem spells them.
|
|
4
4
|
*/
|
|
5
|
-
import { existsSync, realpathSync, statSync } from 'node:fs';
|
|
6
|
-
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
|
|
7
|
-
import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
|
|
5
|
+
import { existsSync, readFileSync, realpathSync, statSync } from 'node:fs';
|
|
6
|
+
import { mkdir, readdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises';
|
|
7
|
+
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
|
|
8
8
|
import { ConfigError, CONFIG_FILE, DEFAULT_CONFIG, parseConfig } from './config.js';
|
|
9
|
-
/**
|
|
9
|
+
/** What stands at a path, links followed, or `null` where nothing does: a link to nothing is nothing. */
|
|
10
|
+
function standing(path) {
|
|
11
|
+
try {
|
|
12
|
+
return statSync(path);
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Reads `.spec-harness.json` at the root, or the defaults when there is none.
|
|
20
|
+
*
|
|
21
|
+
* Only a file is read. A named pipe under the file's name was read without
|
|
22
|
+
* end, by every command and by the hook before every write; and Node's words
|
|
23
|
+
* for a directory, EISDIR, are not the harness's.
|
|
24
|
+
*/
|
|
10
25
|
export async function loadConfig(root) {
|
|
11
26
|
const file = join(root, CONFIG_FILE);
|
|
12
|
-
|
|
27
|
+
const there = standing(file);
|
|
28
|
+
if (there === null)
|
|
13
29
|
return { config: DEFAULT_CONFIG, file: null };
|
|
30
|
+
if (!there.isFile())
|
|
31
|
+
throw new ConfigError(`${CONFIG_FILE} is ${there.isDirectory() ? 'a directory' : 'neither a file nor a directory'}, not a configuration file`);
|
|
14
32
|
let raw;
|
|
15
33
|
try {
|
|
16
34
|
// A Buffer parses as its text, so the encoding is equivalent to its mutant.
|
|
17
35
|
raw = JSON.parse(await readFile(file, 'utf8'));
|
|
18
36
|
}
|
|
19
37
|
catch (error) {
|
|
20
|
-
// A directory under the file's name is not JSON that went wrong, and
|
|
21
|
-
// Node's words for it, EISDIR, are not the harness's.
|
|
22
|
-
if (error.code === 'EISDIR')
|
|
23
|
-
throw new ConfigError(`${CONFIG_FILE} is a directory, not a configuration file`);
|
|
24
38
|
throw new ConfigError(`${CONFIG_FILE} is not valid JSON: ${error.message}`);
|
|
25
39
|
}
|
|
26
40
|
return { config: parseConfig(raw), file };
|
|
27
41
|
}
|
|
42
|
+
/**
|
|
43
|
+
* A path where the harness keeps its state that holds something else: said
|
|
44
|
+
* in a line, exit 2, where Node's ENOTDIR and a stack sent a person looking
|
|
45
|
+
* for a defect (spec-core ADR-0005).
|
|
46
|
+
*/
|
|
47
|
+
export class StateError extends Error {
|
|
48
|
+
constructor(message) {
|
|
49
|
+
super(message);
|
|
50
|
+
this.name = 'StateError';
|
|
51
|
+
}
|
|
52
|
+
}
|
|
28
53
|
/**
|
|
29
54
|
* The real spelling of a path: links resolved and, on a filesystem that
|
|
30
55
|
* ignores case, the case the directory entries have. A path that does not
|
|
@@ -63,6 +88,17 @@ export function realSpelling(path) {
|
|
|
63
88
|
export function isTop(directory, top) {
|
|
64
89
|
return realSpelling(directory).split(sep).join('/') === top;
|
|
65
90
|
}
|
|
91
|
+
/**
|
|
92
|
+
* The repository's main work tree, where git keeps the repository as that
|
|
93
|
+
* work tree's `.git`, as `git init` and `git clone` do and every linked
|
|
94
|
+
* worktree's common directory then is: `null` for a common directory of any
|
|
95
|
+
* other name, a bare repository's, a submodule's under its superproject's
|
|
96
|
+
* `modules`, or one kept apart with `--separate-git-dir`. Read from the
|
|
97
|
+
* name, with no process: it says only where an install is looked for.
|
|
98
|
+
*/
|
|
99
|
+
export function mainWorkTree(commonDir) {
|
|
100
|
+
return basename(commonDir) === '.git' ? dirname(commonDir) : null;
|
|
101
|
+
}
|
|
66
102
|
/** Whether a `.git` stands in `directory`, a repository's or the file that names one, as at the top of a work tree. */
|
|
67
103
|
export function holdsGit(directory) {
|
|
68
104
|
return existsSync(join(directory, '.git'));
|
|
@@ -81,12 +117,130 @@ export function repositoryPath(given, root, cwd) {
|
|
|
81
117
|
return null;
|
|
82
118
|
return rel.split(sep).join('/');
|
|
83
119
|
}
|
|
84
|
-
/**
|
|
120
|
+
/**
|
|
121
|
+
* The harness's own directory under the git common directory, created on
|
|
122
|
+
* demand (ADR-0003). Where something that is no directory stands at its path,
|
|
123
|
+
* or at one between it and git's, Node refuses to make it, with ENOTDIR or
|
|
124
|
+
* EEXIST, and the harness says which path holds what.
|
|
125
|
+
*/
|
|
85
126
|
export async function stateDirectory(commonDir, ...parts) {
|
|
86
127
|
const directory = join(commonDir, 'spec-harness', ...parts);
|
|
87
|
-
|
|
128
|
+
try {
|
|
129
|
+
await mkdir(directory, { recursive: true });
|
|
130
|
+
}
|
|
131
|
+
catch (error) {
|
|
132
|
+
const { code } = error;
|
|
133
|
+
if (code !== 'ENOTDIR' && code !== 'EEXIST')
|
|
134
|
+
throw error;
|
|
135
|
+
// mkdir refuses for this alone, so one of the steps is there and is no
|
|
136
|
+
// directory, and every step before it is one.
|
|
137
|
+
const steps = ['spec-harness', ...parts].map((_, at, all) => join(commonDir, ...all.slice(0, at + 1)));
|
|
138
|
+
const there = steps.find((step) => !standing(step).isDirectory());
|
|
139
|
+
throw new StateError(`${there} is not a directory, where spec-harness keeps its state in one: move it away, and run the command again`);
|
|
140
|
+
}
|
|
88
141
|
return directory;
|
|
89
142
|
}
|
|
143
|
+
/**
|
|
144
|
+
* A work tree and its git directories as the files git keeps name them, read
|
|
145
|
+
* with no process: the nearest directory at or above `root` that holds a
|
|
146
|
+
* `.git`; the git directory, that `.git` or the one a `.git` file names
|
|
147
|
+
* (`gitdir: <path>`, relative to the file's directory); and the common
|
|
148
|
+
* directory, the one that git directory's `commondir` file names, relative
|
|
149
|
+
* to it, or the git directory itself (gitrepository-layout(5)). `exact` is
|
|
150
|
+
* whether git, asked, would name the same work tree: not where
|
|
151
|
+
* `GIT_DIR` or `GIT_WORK_TREE` sends it elsewhere, and not where the
|
|
152
|
+
* repository's configuration names a `worktree`, which `core.worktree`
|
|
153
|
+
* moves it to (git-config(1)), or keeps one of its own for a worktree.
|
|
154
|
+
* `null` where no directory up to the top of the filesystem holds one, or
|
|
155
|
+
* a `.git` file names none.
|
|
156
|
+
*/
|
|
157
|
+
export function gitDirectories(root, env) {
|
|
158
|
+
let top = realSpelling(root);
|
|
159
|
+
let found = null;
|
|
160
|
+
for (;;) {
|
|
161
|
+
found = standing(join(top, '.git'));
|
|
162
|
+
if (found !== null)
|
|
163
|
+
break;
|
|
164
|
+
const parent = dirname(top);
|
|
165
|
+
if (parent === top)
|
|
166
|
+
return null;
|
|
167
|
+
top = parent;
|
|
168
|
+
}
|
|
169
|
+
const dotGit = join(top, '.git');
|
|
170
|
+
let gitDir = dotGit;
|
|
171
|
+
if (found.isFile()) {
|
|
172
|
+
// The path is the rest of the line: `.` stops at a line's end, a
|
|
173
|
+
// carriage return among them, so the mutant that drops `$` is
|
|
174
|
+
// equivalent; and a Buffer is read as its text, so is the encoding's.
|
|
175
|
+
const named = /^gitdir: (.+)$/m.exec(readFileSync(dotGit, 'utf8'))?.[1];
|
|
176
|
+
if (named === undefined)
|
|
177
|
+
return null;
|
|
178
|
+
gitDir = resolve(top, named);
|
|
179
|
+
}
|
|
180
|
+
const commondir = textOf(join(gitDir, 'commondir'));
|
|
181
|
+
const common = commondir === null ? gitDir : resolve(gitDir, commondir.trim());
|
|
182
|
+
const moved = env['GIT_DIR'] !== undefined || env['GIT_WORK_TREE'] !== undefined;
|
|
183
|
+
// No configuration holds no key, as a text without one does: the mutant
|
|
184
|
+
// of the empty text is equivalent.
|
|
185
|
+
const elsewhere = /^\s*worktree\s*=/im.test(textOf(join(common, 'config')) ?? '') || existsSync(join(gitDir, 'config.worktree'));
|
|
186
|
+
return { top, common, exact: !moved && !elsewhere };
|
|
187
|
+
}
|
|
188
|
+
/** A file's text, or `null` where it cannot be read. */
|
|
189
|
+
function textOf(file) {
|
|
190
|
+
try {
|
|
191
|
+
return readFileSync(file, 'utf8');
|
|
192
|
+
}
|
|
193
|
+
catch {
|
|
194
|
+
return null;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* How long a call's decisions are kept for a run after the write that does
|
|
199
|
+
* not come: a call refused by the person, one that failed, one stopped
|
|
200
|
+
* (ADR-0002). A day, since a call waits on a person's prompt for as long as
|
|
201
|
+
* the person is away, and an entry removed too soon costs only that call's
|
|
202
|
+
* answer being decided again.
|
|
203
|
+
*/
|
|
204
|
+
export const CALL_LIFE_MS = 24 * 60 * 60 * 1000;
|
|
205
|
+
/**
|
|
206
|
+
* Keeps what a run before a write decided, under the common directory, for
|
|
207
|
+
* the run after it, and removes what no run after a write read in a day.
|
|
208
|
+
* Nothing here may change the answer before the write: whatever fails is
|
|
209
|
+
* passed over, and the run after the write then decides as it did.
|
|
210
|
+
*/
|
|
211
|
+
export async function keepCall(commonDir, name, text, now = Date.now()) {
|
|
212
|
+
try {
|
|
213
|
+
const directory = await stateDirectory(commonDir, 'calls');
|
|
214
|
+
await writeAtomic(join(directory, name), text);
|
|
215
|
+
// A file another run takes away before it is removed here ends the
|
|
216
|
+
// pruning in the catch below, where `force` would pass over it, and what
|
|
217
|
+
// is left is pruned by a later run: the mutants of `force` are
|
|
218
|
+
// equivalent.
|
|
219
|
+
for (const entry of await readdir(directory)) {
|
|
220
|
+
const file = join(directory, entry);
|
|
221
|
+
if (now - (await stat(file)).mtimeMs > CALL_LIFE_MS)
|
|
222
|
+
await rm(file, { force: true });
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
catch {
|
|
226
|
+
// A full disk, a directory another run removed: the call is decided again after the write.
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
/** What was kept for a call, taken away as it is read; `null` where nothing was. */
|
|
230
|
+
export async function takeCall(commonDir, name) {
|
|
231
|
+
const file = join(commonDir, 'spec-harness', 'calls', name);
|
|
232
|
+
try {
|
|
233
|
+
const text = await readFile(file, 'utf8');
|
|
234
|
+
// Taken away by another run since it was read, the mutants of `force`
|
|
235
|
+
// decide the call again, which answers as the text read does: they are
|
|
236
|
+
// equivalent.
|
|
237
|
+
await rm(file, { force: true });
|
|
238
|
+
return text;
|
|
239
|
+
}
|
|
240
|
+
catch {
|
|
241
|
+
return null;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
90
244
|
/** Writes a file whole or not at all: a temporary file renamed over the target. */
|
|
91
245
|
export async function writeAtomic(file, content) {
|
|
92
246
|
await mkdir(dirname(file), { recursive: true });
|
|
@@ -95,10 +249,19 @@ export async function writeAtomic(file, content) {
|
|
|
95
249
|
await writeFile(temporary, content, 'utf8');
|
|
96
250
|
await rename(temporary, file);
|
|
97
251
|
}
|
|
98
|
-
/**
|
|
252
|
+
/**
|
|
253
|
+
* A JSON object from a file: `null` when there is no file, `'unreadable'`
|
|
254
|
+
* when it holds anything else. Only a file is read: a named pipe where a
|
|
255
|
+
* settings file is expected was read without end, and neither doctor nor
|
|
256
|
+
* init came back. What is neither a file nor a directory holds no JSON
|
|
257
|
+
* object, as a directory holds none.
|
|
258
|
+
*/
|
|
99
259
|
export async function readJsonObject(file) {
|
|
100
|
-
|
|
260
|
+
const there = standing(file);
|
|
261
|
+
if (there === null)
|
|
101
262
|
return null;
|
|
263
|
+
if (!there.isFile())
|
|
264
|
+
return 'unreadable';
|
|
102
265
|
try {
|
|
103
266
|
// A Buffer parses as its text, so the encoding is equivalent to its mutant.
|
|
104
267
|
const value = JSON.parse(await readFile(file, 'utf8'));
|
package/dist/fs.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"fs.js","sourceRoot":"","sources":["../src/fs.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * The edge where the harness meets the disk: the configuration, the state\n * directory outside the work tree, and paths as the filesystem spells them.\n */\n\nimport { existsSync, realpathSync, statSync } from 'node:fs';\nimport { mkdir, readFile, rename, writeFile } from 'node:fs/promises';\nimport { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';\n\nimport { ConfigError, CONFIG_FILE, DEFAULT_CONFIG, parseConfig, type HarnessConfig } from './config.js';\n\n/** Reads `.spec-harness.json` at the root, or the defaults when there is none. */\nexport async function loadConfig(root: string): Promise<{ config: HarnessConfig; file: string | null }> {\n const file = join(root, CONFIG_FILE);\n if (!existsSync(file)) return { config: DEFAULT_CONFIG, file: null };\n let raw: unknown;\n try {\n // A Buffer parses as its text, so the encoding is equivalent to its mutant.\n raw = JSON.parse(await readFile(file, 'utf8'));\n } catch (error) {\n // A directory under the file's name is not JSON that went wrong, and\n // Node's words for it, EISDIR, are not the harness's.\n if ((error as NodeJS.ErrnoException).code === 'EISDIR') throw new ConfigError(`${CONFIG_FILE} is a directory, not a configuration file`);\n throw new ConfigError(`${CONFIG_FILE} is not valid JSON: ${(error as Error).message}`);\n }\n return { config: parseConfig(raw), file };\n}\n\n/**\n * The real spelling of a path: links resolved and, on a filesystem that\n * ignores case, the case the directory entries have. A path that does not\n * exist yet is resolved through its nearest existing ancestor.\n *\n * This is what stops `SRC/db/schema.ts` from walking past a protection on\n * `src/db/schema.ts` on Windows or macOS, where both name one file.\n */\nexport function realSpelling(path: string): string {\n const missing: string[] = [];\n let current = resolve(path);\n for (;;) {\n try {\n // join leaves a real path as it is, and reads a segment's leading\n // separator as the one between it and the last.\n return join(realpathSync.native(current), ...missing.reverse());\n } catch {\n const parent = dirname(current);\n // Only a root that does not exist fails at the top, a drive Windows\n // does not have; everywhere else the loop ends in the try.\n if (parent === current) return resolve(path);\n missing.push(current.slice(parent.length));\n current = parent;\n }\n }\n}\n\n/**\n * Whether `directory` is the one git names as the top of a work tree: the\n * two spelled alike once the filesystem has spelled the directory, links\n * and short names resolved, as git spells a top. A spelling that differs is\n * read as another directory: what git said from the one is then not taken\n * for what it would say from the other.\n */\nexport function isTop(directory: string, top: string): boolean {\n return realSpelling(directory).split(sep).join('/') === top;\n}\n\n/** Whether a `.git` stands in `directory`, a repository's or the file that names one, as at the top of a work tree. */\nexport function holdsGit(directory: string): boolean {\n return existsSync(join(directory, '.git'));\n}\n\n/**\n * A path as the repository names it - relative to the root, POSIX-separated,\n * real spelling - or `null` when it lies outside the root.\n */\nexport function repositoryPath(given: string, root: string, cwd: string): string | null {\n const absolute = isAbsolute(given) ? given : resolve(cwd, given);\n const real = realSpelling(absolute);\n const realRoot = realSpelling(root);\n const rel = relative(realRoot, real);\n // `..env` at the root is inside it; only a `..` segment climbs out.\n if (rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel)) return null;\n return rel.split(sep).join('/');\n}\n\n/** The harness's own directory under the git common directory, created on demand (ADR-0003). */\nexport async function stateDirectory(commonDir: string, ...parts: string[]): Promise<string> {\n const directory = join(commonDir, 'spec-harness', ...parts);\n await mkdir(directory, { recursive: true });\n return directory;\n}\n\n/** Writes a file whole or not at all: a temporary file renamed over the target. */\nexport async function writeAtomic(file: string, content: string): Promise<void> {\n await mkdir(dirname(file), { recursive: true });\n const temporary = `${file}.${process.pid}.tmp`;\n // utf8 is writeFile's own default, so naming it is equivalent to its mutant.\n await writeFile(temporary, content, 'utf8');\n await rename(temporary, file);\n}\n\n/** A JSON object from a file: `null` when there is no file, `'unreadable'` when it holds anything else. */\nexport async function readJsonObject(file: string): Promise<Record<string, unknown> | null | 'unreadable'> {\n if (!existsSync(file)) return null;\n try {\n // A Buffer parses as its text, so the encoding is equivalent to its mutant.\n const value: unknown = JSON.parse(await readFile(file, 'utf8'));\n return typeof value === 'object' && value !== null && !Array.isArray(value) ? (value as Record<string, unknown>) : 'unreadable';\n } catch {\n return 'unreadable';\n }\n}\n\n/** Whether a path names a directory that is there; one that cannot be read names none. */\nexport function isDirectory(path: string): boolean {\n try {\n return statSync(path).isDirectory();\n } catch {\n // Each caller reads the answer with `!`, so the mutant that answers\n // nothing here is equivalent.\n return false;\n }\n}\n\nexport async function readText(file: string): Promise<string | null> {\n try {\n return await readFile(file, 'utf8');\n } catch {\n return null;\n }\n}\n"]}
|
|
1
|
+
{"version":3,"file":"fs.js","sourceRoot":"","sources":["../src/fs.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * The edge where the harness meets the disk: the configuration, the state\n * directory outside the work tree, and paths as the filesystem spells them.\n */\n\nimport { existsSync, readFileSync, realpathSync, statSync, type Stats } from 'node:fs';\nimport { mkdir, readdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises';\nimport { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';\n\nimport { ConfigError, CONFIG_FILE, DEFAULT_CONFIG, parseConfig, type HarnessConfig } from './config.js';\n\n/** What stands at a path, links followed, or `null` where nothing does: a link to nothing is nothing. */\nfunction standing(path: string): Stats | null {\n try {\n return statSync(path);\n } catch {\n return null;\n }\n}\n\n/**\n * Reads `.spec-harness.json` at the root, or the defaults when there is none.\n *\n * Only a file is read. A named pipe under the file's name was read without\n * end, by every command and by the hook before every write; and Node's words\n * for a directory, EISDIR, are not the harness's.\n */\nexport async function loadConfig(root: string): Promise<{ config: HarnessConfig; file: string | null }> {\n const file = join(root, CONFIG_FILE);\n const there = standing(file);\n if (there === null) return { config: DEFAULT_CONFIG, file: null };\n if (!there.isFile()) throw new ConfigError(`${CONFIG_FILE} is ${there.isDirectory() ? 'a directory' : 'neither a file nor a directory'}, not a configuration file`);\n let raw: unknown;\n try {\n // A Buffer parses as its text, so the encoding is equivalent to its mutant.\n raw = JSON.parse(await readFile(file, 'utf8'));\n } catch (error) {\n throw new ConfigError(`${CONFIG_FILE} is not valid JSON: ${(error as Error).message}`);\n }\n return { config: parseConfig(raw), file };\n}\n\n/**\n * A path where the harness keeps its state that holds something else: said\n * in a line, exit 2, where Node's ENOTDIR and a stack sent a person looking\n * for a defect (spec-core ADR-0005).\n */\nexport class StateError extends Error {\n constructor(message: string) {\n super(message);\n this.name = 'StateError';\n }\n}\n\n/**\n * The real spelling of a path: links resolved and, on a filesystem that\n * ignores case, the case the directory entries have. A path that does not\n * exist yet is resolved through its nearest existing ancestor.\n *\n * This is what stops `SRC/db/schema.ts` from walking past a protection on\n * `src/db/schema.ts` on Windows or macOS, where both name one file.\n */\nexport function realSpelling(path: string): string {\n const missing: string[] = [];\n let current = resolve(path);\n for (;;) {\n try {\n // join leaves a real path as it is, and reads a segment's leading\n // separator as the one between it and the last.\n return join(realpathSync.native(current), ...missing.reverse());\n } catch {\n const parent = dirname(current);\n // Only a root that does not exist fails at the top, a drive Windows\n // does not have; everywhere else the loop ends in the try.\n if (parent === current) return resolve(path);\n missing.push(current.slice(parent.length));\n current = parent;\n }\n }\n}\n\n/**\n * Whether `directory` is the one git names as the top of a work tree: the\n * two spelled alike once the filesystem has spelled the directory, links\n * and short names resolved, as git spells a top. A spelling that differs is\n * read as another directory: what git said from the one is then not taken\n * for what it would say from the other.\n */\nexport function isTop(directory: string, top: string): boolean {\n return realSpelling(directory).split(sep).join('/') === top;\n}\n\n/**\n * The repository's main work tree, where git keeps the repository as that\n * work tree's `.git`, as `git init` and `git clone` do and every linked\n * worktree's common directory then is: `null` for a common directory of any\n * other name, a bare repository's, a submodule's under its superproject's\n * `modules`, or one kept apart with `--separate-git-dir`. Read from the\n * name, with no process: it says only where an install is looked for.\n */\nexport function mainWorkTree(commonDir: string): string | null {\n return basename(commonDir) === '.git' ? dirname(commonDir) : null;\n}\n\n/** Whether a `.git` stands in `directory`, a repository's or the file that names one, as at the top of a work tree. */\nexport function holdsGit(directory: string): boolean {\n return existsSync(join(directory, '.git'));\n}\n\n/**\n * A path as the repository names it - relative to the root, POSIX-separated,\n * real spelling - or `null` when it lies outside the root.\n */\nexport function repositoryPath(given: string, root: string, cwd: string): string | null {\n const absolute = isAbsolute(given) ? given : resolve(cwd, given);\n const real = realSpelling(absolute);\n const realRoot = realSpelling(root);\n const rel = relative(realRoot, real);\n // `..env` at the root is inside it; only a `..` segment climbs out.\n if (rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel)) return null;\n return rel.split(sep).join('/');\n}\n\n/**\n * The harness's own directory under the git common directory, created on\n * demand (ADR-0003). Where something that is no directory stands at its path,\n * or at one between it and git's, Node refuses to make it, with ENOTDIR or\n * EEXIST, and the harness says which path holds what.\n */\nexport async function stateDirectory(commonDir: string, ...parts: string[]): Promise<string> {\n const directory = join(commonDir, 'spec-harness', ...parts);\n try {\n await mkdir(directory, { recursive: true });\n } catch (error) {\n const { code } = error as NodeJS.ErrnoException;\n if (code !== 'ENOTDIR' && code !== 'EEXIST') throw error;\n // mkdir refuses for this alone, so one of the steps is there and is no\n // directory, and every step before it is one.\n const steps = ['spec-harness', ...parts].map((_, at, all) => join(commonDir, ...all.slice(0, at + 1)));\n const there = steps.find((step) => !standing(step)!.isDirectory())!;\n throw new StateError(`${there} is not a directory, where spec-harness keeps its state in one: move it away, and run the command again`);\n }\n return directory;\n}\n\n/**\n * A work tree and its git directories as the files git keeps name them, read\n * with no process: the nearest directory at or above `root` that holds a\n * `.git`; the git directory, that `.git` or the one a `.git` file names\n * (`gitdir: <path>`, relative to the file's directory); and the common\n * directory, the one that git directory's `commondir` file names, relative\n * to it, or the git directory itself (gitrepository-layout(5)). `exact` is\n * whether git, asked, would name the same work tree: not where\n * `GIT_DIR` or `GIT_WORK_TREE` sends it elsewhere, and not where the\n * repository's configuration names a `worktree`, which `core.worktree`\n * moves it to (git-config(1)), or keeps one of its own for a worktree.\n * `null` where no directory up to the top of the filesystem holds one, or\n * a `.git` file names none.\n */\nexport function gitDirectories(root: string, env: Readonly<Record<string, string | undefined>>): { readonly top: string; readonly common: string; readonly exact: boolean } | null {\n let top = realSpelling(root);\n let found: Stats | null = null;\n for (;;) {\n found = standing(join(top, '.git'));\n if (found !== null) break;\n const parent = dirname(top);\n if (parent === top) return null;\n top = parent;\n }\n const dotGit = join(top, '.git');\n let gitDir = dotGit;\n if (found.isFile()) {\n // The path is the rest of the line: `.` stops at a line's end, a\n // carriage return among them, so the mutant that drops `$` is\n // equivalent; and a Buffer is read as its text, so is the encoding's.\n const named = /^gitdir: (.+)$/m.exec(readFileSync(dotGit, 'utf8'))?.[1];\n if (named === undefined) return null;\n gitDir = resolve(top, named);\n }\n const commondir = textOf(join(gitDir, 'commondir'));\n const common = commondir === null ? gitDir : resolve(gitDir, commondir.trim());\n const moved = env['GIT_DIR'] !== undefined || env['GIT_WORK_TREE'] !== undefined;\n // No configuration holds no key, as a text without one does: the mutant\n // of the empty text is equivalent.\n const elsewhere = /^\\s*worktree\\s*=/im.test(textOf(join(common, 'config')) ?? '') || existsSync(join(gitDir, 'config.worktree'));\n return { top, common, exact: !moved && !elsewhere };\n}\n\n/** A file's text, or `null` where it cannot be read. */\nfunction textOf(file: string): string | null {\n try {\n return readFileSync(file, 'utf8');\n } catch {\n return null;\n }\n}\n\n/**\n * How long a call's decisions are kept for a run after the write that does\n * not come: a call refused by the person, one that failed, one stopped\n * (ADR-0002). A day, since a call waits on a person's prompt for as long as\n * the person is away, and an entry removed too soon costs only that call's\n * answer being decided again.\n */\nexport const CALL_LIFE_MS = 24 * 60 * 60 * 1000;\n\n/**\n * Keeps what a run before a write decided, under the common directory, for\n * the run after it, and removes what no run after a write read in a day.\n * Nothing here may change the answer before the write: whatever fails is\n * passed over, and the run after the write then decides as it did.\n */\nexport async function keepCall(commonDir: string, name: string, text: string, now: number = Date.now()): Promise<void> {\n try {\n const directory = await stateDirectory(commonDir, 'calls');\n await writeAtomic(join(directory, name), text);\n // A file another run takes away before it is removed here ends the\n // pruning in the catch below, where `force` would pass over it, and what\n // is left is pruned by a later run: the mutants of `force` are\n // equivalent.\n for (const entry of await readdir(directory)) {\n const file = join(directory, entry);\n if (now - (await stat(file)).mtimeMs > CALL_LIFE_MS) await rm(file, { force: true });\n }\n } catch {\n // A full disk, a directory another run removed: the call is decided again after the write.\n }\n}\n\n/** What was kept for a call, taken away as it is read; `null` where nothing was. */\nexport async function takeCall(commonDir: string, name: string): Promise<string | null> {\n const file = join(commonDir, 'spec-harness', 'calls', name);\n try {\n const text = await readFile(file, 'utf8');\n // Taken away by another run since it was read, the mutants of `force`\n // decide the call again, which answers as the text read does: they are\n // equivalent.\n await rm(file, { force: true });\n return text;\n } catch {\n return null;\n }\n}\n\n/** Writes a file whole or not at all: a temporary file renamed over the target. */\nexport async function writeAtomic(file: string, content: string): Promise<void> {\n await mkdir(dirname(file), { recursive: true });\n const temporary = `${file}.${process.pid}.tmp`;\n // utf8 is writeFile's own default, so naming it is equivalent to its mutant.\n await writeFile(temporary, content, 'utf8');\n await rename(temporary, file);\n}\n\n/**\n * A JSON object from a file: `null` when there is no file, `'unreadable'`\n * when it holds anything else. Only a file is read: a named pipe where a\n * settings file is expected was read without end, and neither doctor nor\n * init came back. What is neither a file nor a directory holds no JSON\n * object, as a directory holds none.\n */\nexport async function readJsonObject(file: string): Promise<Record<string, unknown> | null | 'unreadable'> {\n const there = standing(file);\n if (there === null) return null;\n if (!there.isFile()) return 'unreadable';\n try {\n // A Buffer parses as its text, so the encoding is equivalent to its mutant.\n const value: unknown = JSON.parse(await readFile(file, 'utf8'));\n return typeof value === 'object' && value !== null && !Array.isArray(value) ? (value as Record<string, unknown>) : 'unreadable';\n } catch {\n return 'unreadable';\n }\n}\n\n/** Whether a path names a directory that is there; one that cannot be read names none. */\nexport function isDirectory(path: string): boolean {\n try {\n return statSync(path).isDirectory();\n } catch {\n // Each caller reads the answer with `!`, so the mutant that answers\n // nothing here is equivalent.\n return false;\n }\n}\n\nexport async function readText(file: string): Promise<string | null> {\n try {\n return await readFile(file, 'utf8');\n } catch {\n return null;\n }\n}\n"]}
|
package/dist/git.d.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* call is an argument vector, never a shell string, so a path or a ref cannot
|
|
7
7
|
* become a command.
|
|
8
8
|
*/
|
|
9
|
-
import type { FileChange } from './types.js';
|
|
9
|
+
import type { FileChange, LocalBranch } from './types.js';
|
|
10
10
|
export interface Run {
|
|
11
11
|
readonly code: number;
|
|
12
12
|
readonly stdout: string;
|
|
@@ -55,15 +55,43 @@ export declare function workTree(cwd: string): Promise<WorkTree | 'unread' | nul
|
|
|
55
55
|
/**
|
|
56
56
|
* Where git runs the hook `name` from, absolute: `core.hooksPath` read as git
|
|
57
57
|
* reads it - relative to the work tree, absolute, or under `~` - and a linked
|
|
58
|
-
* worktree's shared hooks.
|
|
58
|
+
* worktree's shared hooks. Where git cannot say, the first line of what it
|
|
59
|
+
* said instead: under a `core.hooksPath` that names a file it refuses the
|
|
60
|
+
* path as one that is not a directory (measured with git 2.47 and 2.55).
|
|
61
|
+
*/
|
|
62
|
+
export declare function hookPath(name: string, cwd: string): Promise<{
|
|
63
|
+
readonly path: string;
|
|
64
|
+
} | {
|
|
65
|
+
readonly refused: string;
|
|
66
|
+
}>;
|
|
67
|
+
/**
|
|
68
|
+
* The checked-out branch's name, or `null` on a detached head: what HEAD
|
|
69
|
+
* points at, with `refs/heads/` taken off and nothing else. `--short` gives
|
|
70
|
+
* the shortest name git reads as that ref and no other, which is another
|
|
71
|
+
* name where a tag, or a ref under `refs/`, has the branch's: measured with
|
|
72
|
+
* git 2.55, on `claude/brief-042-x` beside the tag `claude/brief-042-x` it
|
|
73
|
+
* answers `heads/claude/brief-042-x`, which no template reads, and the
|
|
74
|
+
* brief went unnamed (ADR-0005). A HEAD that points outside `refs/heads/`,
|
|
75
|
+
* as `git symbolic-ref HEAD refs/tags/x` leaves it, is named as git status
|
|
76
|
+
* names it, the ref whole.
|
|
59
77
|
*/
|
|
60
|
-
export declare function hookPath(name: string, cwd: string): Promise<string | null>;
|
|
61
|
-
/** The checked-out branch's short name, or `null` on a detached head. */
|
|
62
78
|
export declare function currentBranch(cwd: string): Promise<string | null>;
|
|
63
|
-
/**
|
|
79
|
+
/**
|
|
80
|
+
* The remote's default branch as a local ref, such as `origin/main`, or
|
|
81
|
+
* `null`. This is a ref the harness hands git and spec-brief as the base, so
|
|
82
|
+
* it is the short name git reads as that ref and no other: beside a local
|
|
83
|
+
* branch or a tag named `origin/main`, measured with git 2.55, it is
|
|
84
|
+
* `remotes/origin/main`, where `origin/main` would be read as the branch or
|
|
85
|
+
* the tag (gitrevisions(7), the order a ref name is read in).
|
|
86
|
+
*/
|
|
64
87
|
export declare function remoteDefault(cwd: string): Promise<string | null>;
|
|
65
|
-
/**
|
|
66
|
-
|
|
88
|
+
/**
|
|
89
|
+
* Every local branch. The two are one name but where a tag, or a ref under
|
|
90
|
+
* `refs/`, has the branch's: `main` beside the tag `main` is the ref
|
|
91
|
+
* `heads/main`. A ref name holds no control character
|
|
92
|
+
* (git-check-ref-format(1)), so a tab parts the two.
|
|
93
|
+
*/
|
|
94
|
+
export declare function localBranches(cwd: string): Promise<LocalBranch[]>;
|
|
67
95
|
export declare function revision(ref: string, cwd: string): Promise<string | null>;
|
|
68
96
|
export declare function mergeBase(a: string, b: string, cwd: string): Promise<string | null>;
|
|
69
97
|
/** Parses `git diff --name-status -z` output. */
|
package/dist/git.js
CHANGED
|
@@ -79,25 +79,59 @@ export async function workTree(cwd) {
|
|
|
79
79
|
/**
|
|
80
80
|
* Where git runs the hook `name` from, absolute: `core.hooksPath` read as git
|
|
81
81
|
* reads it - relative to the work tree, absolute, or under `~` - and a linked
|
|
82
|
-
* worktree's shared hooks.
|
|
82
|
+
* worktree's shared hooks. Where git cannot say, the first line of what it
|
|
83
|
+
* said instead: under a `core.hooksPath` that names a file it refuses the
|
|
84
|
+
* path as one that is not a directory (measured with git 2.47 and 2.55).
|
|
83
85
|
*/
|
|
84
86
|
export async function hookPath(name, cwd) {
|
|
85
|
-
|
|
87
|
+
const run = await git(['rev-parse', '--path-format=absolute', '--git-path', `hooks/${name}`], cwd);
|
|
88
|
+
if (run.code === 0)
|
|
89
|
+
return { path: run.stdout.trim() };
|
|
90
|
+
return { refused: run.stderr.split(/\r?\n/)[0] };
|
|
86
91
|
}
|
|
87
|
-
|
|
92
|
+
const HEADS = 'refs/heads/';
|
|
93
|
+
/**
|
|
94
|
+
* The checked-out branch's name, or `null` on a detached head: what HEAD
|
|
95
|
+
* points at, with `refs/heads/` taken off and nothing else. `--short` gives
|
|
96
|
+
* the shortest name git reads as that ref and no other, which is another
|
|
97
|
+
* name where a tag, or a ref under `refs/`, has the branch's: measured with
|
|
98
|
+
* git 2.55, on `claude/brief-042-x` beside the tag `claude/brief-042-x` it
|
|
99
|
+
* answers `heads/claude/brief-042-x`, which no template reads, and the
|
|
100
|
+
* brief went unnamed (ADR-0005). A HEAD that points outside `refs/heads/`,
|
|
101
|
+
* as `git symbolic-ref HEAD refs/tags/x` leaves it, is named as git status
|
|
102
|
+
* names it, the ref whole.
|
|
103
|
+
*/
|
|
88
104
|
export async function currentBranch(cwd) {
|
|
89
|
-
|
|
105
|
+
const ref = await value(['symbolic-ref', '--quiet', 'HEAD'], cwd);
|
|
106
|
+
return ref !== null && ref.startsWith(HEADS) ? ref.slice(HEADS.length) : ref;
|
|
90
107
|
}
|
|
91
|
-
/**
|
|
108
|
+
/**
|
|
109
|
+
* The remote's default branch as a local ref, such as `origin/main`, or
|
|
110
|
+
* `null`. This is a ref the harness hands git and spec-brief as the base, so
|
|
111
|
+
* it is the short name git reads as that ref and no other: beside a local
|
|
112
|
+
* branch or a tag named `origin/main`, measured with git 2.55, it is
|
|
113
|
+
* `remotes/origin/main`, where `origin/main` would be read as the branch or
|
|
114
|
+
* the tag (gitrevisions(7), the order a ref name is read in).
|
|
115
|
+
*/
|
|
92
116
|
export async function remoteDefault(cwd) {
|
|
93
117
|
return value(['symbolic-ref', '--quiet', '--short', 'refs/remotes/origin/HEAD'], cwd);
|
|
94
118
|
}
|
|
95
|
-
/**
|
|
119
|
+
/**
|
|
120
|
+
* Every local branch. The two are one name but where a tag, or a ref under
|
|
121
|
+
* `refs/`, has the branch's: `main` beside the tag `main` is the ref
|
|
122
|
+
* `heads/main`. A ref name holds no control character
|
|
123
|
+
* (git-check-ref-format(1)), so a tab parts the two.
|
|
124
|
+
*/
|
|
96
125
|
export async function localBranches(cwd) {
|
|
97
|
-
const listed = await value(['for-each-ref', '--format=%(refname:short)',
|
|
126
|
+
const listed = await value(['for-each-ref', '--format=%(refname:lstrip=2)%09%(refname:short)', HEADS], cwd);
|
|
98
127
|
// for-each-ref fails only where git itself does, outside a repository,
|
|
99
128
|
// where init never runs: the null check is there for the type.
|
|
100
|
-
|
|
129
|
+
if (listed === null || listed === '')
|
|
130
|
+
return [];
|
|
131
|
+
return listed.split('\n').map((line) => {
|
|
132
|
+
const parts = line.split('\t');
|
|
133
|
+
return { name: parts[0], ref: parts[1] };
|
|
134
|
+
});
|
|
101
135
|
}
|
|
102
136
|
export async function revision(ref, cwd) {
|
|
103
137
|
return value(['rev-parse', '--verify', '--quiet', `${ref}^{commit}`], cwd);
|
package/dist/git.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"git.js","sourceRoot":"","sources":["../src/git.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * The edge where the harness meets git.\n *\n * Reads, except for two things ADR-0003 allows and bounds: a temporary\n * worktree, added and always removed by the sandbox, and nothing else. Every\n * call is an argument vector, never a shell string, so a path or a ref cannot\n * become a command.\n */\n\nimport { execFile } from 'node:child_process';\n\nimport type { FileChange } from './types.js';\n\nexport interface Run {\n readonly code: number;\n readonly stdout: string;\n readonly stderr: string;\n}\n\n/** Runs git and answers whatever it answered; a missing git is a code of -1. */\nexport function git(args: readonly string[], cwd: string): Promise<Run> {\n return new Promise((resolve) => {\n // windowsHide only keeps a console window from flashing up on Windows,\n // and the sweep runs on Linux, where its mutant is equivalent.\n execFile('git', [...args], { cwd, maxBuffer: 256 * 1024 * 1024, encoding: 'utf8', windowsHide: true }, (error, stdout, stderr) => {\n // Every caller tells 0 from any other code, never one failure from\n // another, so which code a failure gets is equivalent to its mutants.\n const code = error === null ? 0 : typeof (error as { code?: unknown }).code === 'number' ? (error as { code: number }).code : -1;\n resolve({ code, stdout, stderr });\n });\n });\n}\n\nasync function value(args: readonly string[], cwd: string): Promise<string | null> {\n const run = await git(args, cwd);\n return run.code === 0 ? run.stdout.trim() : null;\n}\n\nexport class GitError extends Error {\n constructor(message: string) {\n super(message);\n this.name = 'GitError';\n }\n}\n\n/** The top of the work tree holding `cwd`, POSIX-separated, or `null` outside one. */\nexport async function workTreeRoot(cwd: string): Promise<string | null> {\n return value(['rev-parse', '--show-toplevel'], cwd);\n}\n\n/** The git directory every worktree of this repository shares. */\nexport async function commonDirectory(cwd: string): Promise<string | null> {\n return value(['rev-parse', '--path-format=absolute', '--git-common-dir'], cwd);\n}\n\n/** A work tree as one start of git names it: its top, and the git directory its worktrees share. */\nexport interface WorkTree {\n readonly root: string;\n readonly commonDir: string;\n}\n\n/**\n * The top of the work tree holding `cwd` and the common directory, asked of\n * git in one start where {@link workTreeRoot} and {@link commonDirectory}\n * are two: `null` outside a work tree, and `'unread'` where the one answer\n * cannot be taken for the two, which are then to be asked for as they were.\n *\n * Measured with git 2.43 and 2.55, rev-parse answers the two in the order\n * they stand on its line, a line each, and prints nothing where the first\n * fails. Its manual says of that only that `--show-toplevel` reports an\n * error where there is no working tree (git-rev-parse(1), Options for\n * Files, as git 2.55.0 has it, read 2026-10-14), so nothing is read on\n * trust. Two lines that are each what `trim` would leave, from a git that\n * did not fail, are the two answers. Nothing printed, by a git that\n * failed, is the first question failing, the one {@link workTreeRoot}\n * asks: `cwd` is in no work tree. Anything else is not read: a path that\n * holds a line break is more lines than two, and so is what a git before\n * 2.31 would print, to which `--path-format=absolute` is a flag to hand\n * back (the same manual, Options for Filtering; the option is new in the\n * release notes of 2.31.0). No such git was measured.\n *\n * `--path-format` reaches only what follows it on the line (Options for\n * Files), so the top is printed as it is where it is asked for alone. The\n * common directory is asked for from `cwd` here, and from the top by\n * {@link commonDirectory}: the same directory only where `cwd` is the top.\n */\nexport async function workTree(cwd: string): Promise<WorkTree | 'unread' | null> {\n const run = await git(['rev-parse', '--show-toplevel', '--path-format=absolute', '--git-common-dir'], cwd);\n if (run.stdout === '') return run.code === 0 ? 'unread' : null;\n const lines = run.stdout.split('\\n');\n if (run.code !== 0 || lines.length !== 3 || lines[2] !== '') return 'unread';\n const root = lines[0]!;\n const commonDir = lines[1]!;\n if (root === '' || commonDir === '' || root.trim() !== root || commonDir.trim() !== commonDir) return 'unread';\n return { root, commonDir };\n}\n\n/**\n * Where git runs the hook `name` from, absolute: `core.hooksPath` read as git\n * reads it - relative to the work tree, absolute, or under `~` - and a linked\n * worktree's shared hooks. `null` when git cannot say, before 2.31.\n */\nexport async function hookPath(name: string, cwd: string): Promise<string | null> {\n return value(['rev-parse', '--path-format=absolute', '--git-path', `hooks/${name}`], cwd);\n}\n\n/** The checked-out branch's short name, or `null` on a detached head. */\nexport async function currentBranch(cwd: string): Promise<string | null> {\n return value(['symbolic-ref', '--quiet', '--short', 'HEAD'], cwd);\n}\n\n/** The remote's default branch as a local ref, such as `origin/main`, or `null`. */\nexport async function remoteDefault(cwd: string): Promise<string | null> {\n return value(['symbolic-ref', '--quiet', '--short', 'refs/remotes/origin/HEAD'], cwd);\n}\n\n/** Every local branch, by its short name. */\nexport async function localBranches(cwd: string): Promise<string[]> {\n const listed = await value(['for-each-ref', '--format=%(refname:short)', 'refs/heads/'], cwd);\n // for-each-ref fails only where git itself does, outside a repository,\n // where init never runs: the null check is there for the type.\n return listed === null || listed === '' ? [] : listed.split('\\n');\n}\n\nexport async function revision(ref: string, cwd: string): Promise<string | null> {\n return value(['rev-parse', '--verify', '--quiet', `${ref}^{commit}`], cwd);\n}\n\nexport async function mergeBase(a: string, b: string, cwd: string): Promise<string | null> {\n return value(['merge-base', a, b], cwd);\n}\n\n/** Parses `git diff --name-status -z` output. */\nexport function parseNameStatus(output: string): FileChange[] {\n const fields = output.split('\\0');\n const out: FileChange[] = [];\n for (let i = 0; i < fields.length; ) {\n const code = fields[i] as string;\n if (code === '') {\n i += 1;\n continue;\n }\n const letter = code.charAt(0);\n const status = letter as FileChange['status'];\n if (status === 'R' || status === 'C') {\n out.push({ status, from: fields[i + 1] ?? '', path: fields[i + 2] ?? '' });\n i += 3;\n } else {\n out.push({ status, path: fields[i + 1] ?? '' });\n i += 2;\n }\n }\n return out;\n}\n\n/** The files changed from one revision to another, renames found. */\nexport async function changes(from: string, to: string, cwd: string): Promise<FileChange[]> {\n const run = await git(['diff', '--name-status', '-z', '--find-renames', '--no-color', from, to, '--'], cwd);\n if (run.code !== 0) throw new GitError(`git diff ${from} ${to} failed: ${run.stderr.trim()}`);\n return parseNameStatus(run.stdout);\n}\n\n/** The files a commit would change: what is staged. */\nexport async function stagedChanges(cwd: string): Promise<FileChange[]> {\n const run = await git(['diff', '--cached', '--name-status', '-z', '--find-renames', '--no-color', '--'], cwd);\n if (run.code !== 0) throw new GitError(`git diff --cached failed: ${run.stderr.trim()}`);\n return parseNameStatus(run.stdout);\n}\n\n/** A file's content at a revision, or `null` when it does not exist there. */\nexport async function show(ref: string, path: string, cwd: string): Promise<string | null> {\n const run = await git(['show', `${ref}:${path}`], cwd);\n return run.code === 0 ? run.stdout : null;\n}\n\n/** Whether a commit id from {@link blameLine} is the all-zero one of a line nobody committed. */\nexport function isUncommitted(sha: string): boolean {\n return /^0+$/.test(sha);\n}\n\n/**\n * The commit that last changed one line of a file, as of a revision, or of\n * the working tree when `ref` is `null` - where a line nobody committed yet\n * belongs to the all-zero commit.\n */\nexport async function blameLine(ref: string | null, path: string, line: number, cwd: string): Promise<string | null> {\n const run = await git(['blame', '--porcelain', '-L', `${line},${line}`, ...(ref === null ? [] : [ref]), '--', path], cwd);\n // A blame that fails prints nothing on stdout, which names no commit. Its\n // output opens with the commit, so the anchor is equivalent to its mutant.\n const sha = /^([0-9a-f]{40,64}) /.exec(run.stdout)?.[1];\n return sha ?? null;\n}\n\n/** A good signature names its allowed signer's principal; any other names none. */\nexport type Signature =\n | { readonly good: true; readonly principal: string; readonly detail: string }\n | { readonly good: false; readonly principal: null; readonly detail: string };\n\n/**\n * Whether a commit carries a good SSH signature by a principal in the given\n * allowed-signers file. The file is passed in, never read from the commit's\n * own tree: a round cannot vouch for itself (ADR-0006).\n */\nexport async function verifyCommit(sha: string, allowedSignersFile: string, cwd: string): Promise<Signature> {\n const run = await git(\n ['-c', `gpg.ssh.allowedSignersFile=${allowedSignersFile}`, '-c', 'gpg.format=ssh', 'verify-commit', '--raw', sha],\n cwd,\n );\n const text = `${run.stderr}\\n${run.stdout}`;\n const principal = /Good \"git\" signature for (\\S+)/.exec(text)?.[1];\n // git writes the check to stderr, so the trim changes the first line only\n // for output on stdout alone, which verify-commit does not print; and split\n // always answers a first part.\n const detail = text.trim().split('\\n')[0]!;\n // git exits 0 only for a signature by a listed principal, which it names:\n // either condition alone is equivalent to both, and both trust neither.\n return run.code === 0 && principal !== undefined ? { good: true, principal, detail } : { good: false, principal: null, detail };\n}\n\nexport async function addWorktree(path: string, rev: string, cwd: string): Promise<void> {\n const run = await git(['worktree', 'add', '--detach', '--force', path, rev], cwd);\n if (run.code !== 0) throw new GitError(`git worktree add failed: ${run.stderr.trim()}`);\n}\n\n/**\n * Has git remove the worktree at `path`, and no other, with its record of it,\n * and answers whether git did. Git deletes what it can of the worktree's\n * directory and forgets the worktree even when it cannot delete it all. It\n * refuses a worktree whose directory has lost its `.git` file, and removes\n * its record of one whose directory is gone, found by the path it was added\n * at: `git worktree prune` would also forget every other worktree git has\n * lost track of, the person's among them.\n */\nexport async function removeWorktree(path: string, cwd: string): Promise<boolean> {\n // The sandbox asks again when this answers false, and git, asked again\n // about a worktree it has forgotten, changes nothing: the mutant that\n // always answers false is equivalent.\n return (await git(['worktree', 'remove', '--force', path], cwd)).code === 0;\n}\n"]}
|
|
1
|
+
{"version":3,"file":"git.js","sourceRoot":"","sources":["../src/git.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * The edge where the harness meets git.\n *\n * Reads, except for two things ADR-0003 allows and bounds: a temporary\n * worktree, added and always removed by the sandbox, and nothing else. Every\n * call is an argument vector, never a shell string, so a path or a ref cannot\n * become a command.\n */\n\nimport { execFile } from 'node:child_process';\n\nimport type { FileChange, LocalBranch } from './types.js';\n\nexport interface Run {\n readonly code: number;\n readonly stdout: string;\n readonly stderr: string;\n}\n\n/** Runs git and answers whatever it answered; a missing git is a code of -1. */\nexport function git(args: readonly string[], cwd: string): Promise<Run> {\n return new Promise((resolve) => {\n // windowsHide only keeps a console window from flashing up on Windows,\n // and the sweep runs on Linux, where its mutant is equivalent.\n execFile('git', [...args], { cwd, maxBuffer: 256 * 1024 * 1024, encoding: 'utf8', windowsHide: true }, (error, stdout, stderr) => {\n // Every caller tells 0 from any other code, never one failure from\n // another, so which code a failure gets is equivalent to its mutants.\n const code = error === null ? 0 : typeof (error as { code?: unknown }).code === 'number' ? (error as { code: number }).code : -1;\n resolve({ code, stdout, stderr });\n });\n });\n}\n\nasync function value(args: readonly string[], cwd: string): Promise<string | null> {\n const run = await git(args, cwd);\n return run.code === 0 ? run.stdout.trim() : null;\n}\n\nexport class GitError extends Error {\n constructor(message: string) {\n super(message);\n this.name = 'GitError';\n }\n}\n\n/** The top of the work tree holding `cwd`, POSIX-separated, or `null` outside one. */\nexport async function workTreeRoot(cwd: string): Promise<string | null> {\n return value(['rev-parse', '--show-toplevel'], cwd);\n}\n\n/** The git directory every worktree of this repository shares. */\nexport async function commonDirectory(cwd: string): Promise<string | null> {\n return value(['rev-parse', '--path-format=absolute', '--git-common-dir'], cwd);\n}\n\n/** A work tree as one start of git names it: its top, and the git directory its worktrees share. */\nexport interface WorkTree {\n readonly root: string;\n readonly commonDir: string;\n}\n\n/**\n * The top of the work tree holding `cwd` and the common directory, asked of\n * git in one start where {@link workTreeRoot} and {@link commonDirectory}\n * are two: `null` outside a work tree, and `'unread'` where the one answer\n * cannot be taken for the two, which are then to be asked for as they were.\n *\n * Measured with git 2.43 and 2.55, rev-parse answers the two in the order\n * they stand on its line, a line each, and prints nothing where the first\n * fails. Its manual says of that only that `--show-toplevel` reports an\n * error where there is no working tree (git-rev-parse(1), Options for\n * Files, as git 2.55.0 has it, read 2026-10-14), so nothing is read on\n * trust. Two lines that are each what `trim` would leave, from a git that\n * did not fail, are the two answers. Nothing printed, by a git that\n * failed, is the first question failing, the one {@link workTreeRoot}\n * asks: `cwd` is in no work tree. Anything else is not read: a path that\n * holds a line break is more lines than two, and so is what a git before\n * 2.31 would print, to which `--path-format=absolute` is a flag to hand\n * back (the same manual, Options for Filtering; the option is new in the\n * release notes of 2.31.0). No such git was measured.\n *\n * `--path-format` reaches only what follows it on the line (Options for\n * Files), so the top is printed as it is where it is asked for alone. The\n * common directory is asked for from `cwd` here, and from the top by\n * {@link commonDirectory}: the same directory only where `cwd` is the top.\n */\nexport async function workTree(cwd: string): Promise<WorkTree | 'unread' | null> {\n const run = await git(['rev-parse', '--show-toplevel', '--path-format=absolute', '--git-common-dir'], cwd);\n if (run.stdout === '') return run.code === 0 ? 'unread' : null;\n const lines = run.stdout.split('\\n');\n if (run.code !== 0 || lines.length !== 3 || lines[2] !== '') return 'unread';\n const root = lines[0]!;\n const commonDir = lines[1]!;\n if (root === '' || commonDir === '' || root.trim() !== root || commonDir.trim() !== commonDir) return 'unread';\n return { root, commonDir };\n}\n\n/**\n * Where git runs the hook `name` from, absolute: `core.hooksPath` read as git\n * reads it - relative to the work tree, absolute, or under `~` - and a linked\n * worktree's shared hooks. Where git cannot say, the first line of what it\n * said instead: under a `core.hooksPath` that names a file it refuses the\n * path as one that is not a directory (measured with git 2.47 and 2.55).\n */\nexport async function hookPath(name: string, cwd: string): Promise<{ readonly path: string } | { readonly refused: string }> {\n const run = await git(['rev-parse', '--path-format=absolute', '--git-path', `hooks/${name}`], cwd);\n if (run.code === 0) return { path: run.stdout.trim() };\n return { refused: run.stderr.split(/\\r?\\n/)[0]! };\n}\n\nconst HEADS = 'refs/heads/';\n\n/**\n * The checked-out branch's name, or `null` on a detached head: what HEAD\n * points at, with `refs/heads/` taken off and nothing else. `--short` gives\n * the shortest name git reads as that ref and no other, which is another\n * name where a tag, or a ref under `refs/`, has the branch's: measured with\n * git 2.55, on `claude/brief-042-x` beside the tag `claude/brief-042-x` it\n * answers `heads/claude/brief-042-x`, which no template reads, and the\n * brief went unnamed (ADR-0005). A HEAD that points outside `refs/heads/`,\n * as `git symbolic-ref HEAD refs/tags/x` leaves it, is named as git status\n * names it, the ref whole.\n */\nexport async function currentBranch(cwd: string): Promise<string | null> {\n const ref = await value(['symbolic-ref', '--quiet', 'HEAD'], cwd);\n return ref !== null && ref.startsWith(HEADS) ? ref.slice(HEADS.length) : ref;\n}\n\n/**\n * The remote's default branch as a local ref, such as `origin/main`, or\n * `null`. This is a ref the harness hands git and spec-brief as the base, so\n * it is the short name git reads as that ref and no other: beside a local\n * branch or a tag named `origin/main`, measured with git 2.55, it is\n * `remotes/origin/main`, where `origin/main` would be read as the branch or\n * the tag (gitrevisions(7), the order a ref name is read in).\n */\nexport async function remoteDefault(cwd: string): Promise<string | null> {\n return value(['symbolic-ref', '--quiet', '--short', 'refs/remotes/origin/HEAD'], cwd);\n}\n\n/**\n * Every local branch. The two are one name but where a tag, or a ref under\n * `refs/`, has the branch's: `main` beside the tag `main` is the ref\n * `heads/main`. A ref name holds no control character\n * (git-check-ref-format(1)), so a tab parts the two.\n */\nexport async function localBranches(cwd: string): Promise<LocalBranch[]> {\n const listed = await value(['for-each-ref', '--format=%(refname:lstrip=2)%09%(refname:short)', HEADS], cwd);\n // for-each-ref fails only where git itself does, outside a repository,\n // where init never runs: the null check is there for the type.\n if (listed === null || listed === '') return [];\n return listed.split('\\n').map((line) => {\n const parts = line.split('\\t');\n return { name: parts[0]!, ref: parts[1]! };\n });\n}\n\nexport async function revision(ref: string, cwd: string): Promise<string | null> {\n return value(['rev-parse', '--verify', '--quiet', `${ref}^{commit}`], cwd);\n}\n\nexport async function mergeBase(a: string, b: string, cwd: string): Promise<string | null> {\n return value(['merge-base', a, b], cwd);\n}\n\n/** Parses `git diff --name-status -z` output. */\nexport function parseNameStatus(output: string): FileChange[] {\n const fields = output.split('\\0');\n const out: FileChange[] = [];\n for (let i = 0; i < fields.length; ) {\n const code = fields[i] as string;\n if (code === '') {\n i += 1;\n continue;\n }\n const letter = code.charAt(0);\n const status = letter as FileChange['status'];\n if (status === 'R' || status === 'C') {\n out.push({ status, from: fields[i + 1] ?? '', path: fields[i + 2] ?? '' });\n i += 3;\n } else {\n out.push({ status, path: fields[i + 1] ?? '' });\n i += 2;\n }\n }\n return out;\n}\n\n/** The files changed from one revision to another, renames found. */\nexport async function changes(from: string, to: string, cwd: string): Promise<FileChange[]> {\n const run = await git(['diff', '--name-status', '-z', '--find-renames', '--no-color', from, to, '--'], cwd);\n if (run.code !== 0) throw new GitError(`git diff ${from} ${to} failed: ${run.stderr.trim()}`);\n return parseNameStatus(run.stdout);\n}\n\n/** The files a commit would change: what is staged. */\nexport async function stagedChanges(cwd: string): Promise<FileChange[]> {\n const run = await git(['diff', '--cached', '--name-status', '-z', '--find-renames', '--no-color', '--'], cwd);\n if (run.code !== 0) throw new GitError(`git diff --cached failed: ${run.stderr.trim()}`);\n return parseNameStatus(run.stdout);\n}\n\n/** A file's content at a revision, or `null` when it does not exist there. */\nexport async function show(ref: string, path: string, cwd: string): Promise<string | null> {\n const run = await git(['show', `${ref}:${path}`], cwd);\n return run.code === 0 ? run.stdout : null;\n}\n\n/** Whether a commit id from {@link blameLine} is the all-zero one of a line nobody committed. */\nexport function isUncommitted(sha: string): boolean {\n return /^0+$/.test(sha);\n}\n\n/**\n * The commit that last changed one line of a file, as of a revision, or of\n * the working tree when `ref` is `null` - where a line nobody committed yet\n * belongs to the all-zero commit.\n */\nexport async function blameLine(ref: string | null, path: string, line: number, cwd: string): Promise<string | null> {\n const run = await git(['blame', '--porcelain', '-L', `${line},${line}`, ...(ref === null ? [] : [ref]), '--', path], cwd);\n // A blame that fails prints nothing on stdout, which names no commit. Its\n // output opens with the commit, so the anchor is equivalent to its mutant.\n const sha = /^([0-9a-f]{40,64}) /.exec(run.stdout)?.[1];\n return sha ?? null;\n}\n\n/** A good signature names its allowed signer's principal; any other names none. */\nexport type Signature =\n | { readonly good: true; readonly principal: string; readonly detail: string }\n | { readonly good: false; readonly principal: null; readonly detail: string };\n\n/**\n * Whether a commit carries a good SSH signature by a principal in the given\n * allowed-signers file. The file is passed in, never read from the commit's\n * own tree: a round cannot vouch for itself (ADR-0006).\n */\nexport async function verifyCommit(sha: string, allowedSignersFile: string, cwd: string): Promise<Signature> {\n const run = await git(\n ['-c', `gpg.ssh.allowedSignersFile=${allowedSignersFile}`, '-c', 'gpg.format=ssh', 'verify-commit', '--raw', sha],\n cwd,\n );\n const text = `${run.stderr}\\n${run.stdout}`;\n const principal = /Good \"git\" signature for (\\S+)/.exec(text)?.[1];\n // git writes the check to stderr, so the trim changes the first line only\n // for output on stdout alone, which verify-commit does not print; and split\n // always answers a first part.\n const detail = text.trim().split('\\n')[0]!;\n // git exits 0 only for a signature by a listed principal, which it names:\n // either condition alone is equivalent to both, and both trust neither.\n return run.code === 0 && principal !== undefined ? { good: true, principal, detail } : { good: false, principal: null, detail };\n}\n\nexport async function addWorktree(path: string, rev: string, cwd: string): Promise<void> {\n const run = await git(['worktree', 'add', '--detach', '--force', path, rev], cwd);\n if (run.code !== 0) throw new GitError(`git worktree add failed: ${run.stderr.trim()}`);\n}\n\n/**\n * Has git remove the worktree at `path`, and no other, with its record of it,\n * and answers whether git did. Git deletes what it can of the worktree's\n * directory and forgets the worktree even when it cannot delete it all. It\n * refuses a worktree whose directory has lost its `.git` file, and removes\n * its record of one whose directory is gone, found by the path it was added\n * at: `git worktree prune` would also forget every other worktree git has\n * lost track of, the person's among them.\n */\nexport async function removeWorktree(path: string, cwd: string): Promise<boolean> {\n // The sandbox asks again when this answers false, and git, asked again\n // about a worktree it has forgotten, changes nothing: the mutant that\n // always answers false is equivalent.\n return (await git(['worktree', 'remove', '--force', path], cwd)).code === 0;\n}\n"]}
|