@veda-hq/dvara 0.5.16 → 0.6.4
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/README.md +47 -11
- package/dist/cli.js +71 -6
- package/dist/config.js +8 -0
- package/dist/head.js +30 -0
- package/dist/records/gate.js +8 -1
- package/dist/records/git.js +85 -1
- package/dist/records/identity.js +18 -0
- package/dist/supervisor.js +17 -6
- package/dist/trailers.js +61 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -40,10 +40,17 @@ file in your repository; dvara runs it.
|
|
|
40
40
|
stage may be marked again where it stands, a name may
|
|
41
41
|
appear more than once in a list, and dvara's own `startup` and
|
|
42
42
|
`runtime-selection` need no entry. A run that marks no stage reads no list.
|
|
43
|
+
- **A push judges what git pushes.** Git picks the commits a push sends
|
|
44
|
+
before `pre-push` starts; the hook's checks read the worktree. A run of
|
|
45
|
+
`pre-push` (not a preflight) records HEAD when it starts and again when the
|
|
46
|
+
hook ends, and a passing run whose HEAD moved in between (something
|
|
47
|
+
committed, merged or checked out in the worktree mid-push) is refused
|
|
48
|
+
(exit 1), naming both commits: push again. A HEAD that cannot be read at
|
|
49
|
+
either end changes nothing. The refusal is the row's `error`.
|
|
43
50
|
|
|
44
51
|
Telemetry, quiet output and a watchdog that fails to load never change a
|
|
45
|
-
hook's verdict. Only a signal, an enforcing watchdog's stop
|
|
46
|
-
do.
|
|
52
|
+
hook's verdict. Only a signal, an enforcing watchdog's stop, the stage list
|
|
53
|
+
and a HEAD that moved during a push do.
|
|
47
54
|
|
|
48
55
|
## A hook under dvara
|
|
49
56
|
|
|
@@ -86,15 +93,16 @@ rename it in its messages (default "preflight" and "Preflight").
|
|
|
86
93
|
`.dvara.json` at the repository's root, or the file `DVARA_CONFIG` names. Every
|
|
87
94
|
field is optional; an unknown field is an error.
|
|
88
95
|
|
|
89
|
-
| Field | Default | Meaning
|
|
90
|
-
| ------------------- | ------------------------------------------------ |
|
|
91
|
-
| `historyDir` | `.dvara` | where the history, the quiet log and scratch files go
|
|
92
|
-
| `quietHooks` | `["pre-commit"]` | hooks whose passing output is one line
|
|
93
|
-
| `watchdog` | `null` | a watchdog module, relative to the root
|
|
94
|
-
| `baseRefs` | `origin/main`, `origin/develop`, `origin/master` | the refs tried for the base a run is judged against
|
|
95
|
-
| `stages` | `{}` | each hook's stages in order, which a passing run must keep to; `dvara stages <hook>` prints them
|
|
96
|
-
| `stageList` | `null` | a JSON file holding the same map instead, relative to the root and read when a run ends; not both
|
|
97
|
-
| `stageListRequired` | `["pre-push"]` | hooks a list file that cannot be read refuses; any other hook passes with a warning
|
|
96
|
+
| Field | Default | Meaning |
|
|
97
|
+
| ------------------- | ------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------ |
|
|
98
|
+
| `historyDir` | `.dvara` | where the history, the quiet log and scratch files go |
|
|
99
|
+
| `quietHooks` | `["pre-commit"]` | hooks whose passing output is one line |
|
|
100
|
+
| `watchdog` | `null` | a watchdog module, relative to the root |
|
|
101
|
+
| `baseRefs` | `origin/main`, `origin/develop`, `origin/master` | the refs tried for the base a run is judged against; to `dvara records`, every one that resolves is pushed history |
|
|
102
|
+
| `stages` | `{}` | each hook's stages in order, which a passing run must keep to; `dvara stages <hook>` prints them |
|
|
103
|
+
| `stageList` | `null` | a JSON file holding the same map instead, relative to the root and read when a run ends; not both |
|
|
104
|
+
| `stageListRequired` | `["pre-push"]` | hooks a list file that cannot be read refuses; any other hook passes with a warning |
|
|
105
|
+
| `trailerKeys` | `[]` | the repository's own trailer keys, which `dvara trailers` checks beside git's |
|
|
98
106
|
|
|
99
107
|
`records`, an object, holds the settings of `dvara records` and `dvara frozen`
|
|
100
108
|
(below). Each field is optional; an unknown one is an error.
|
|
@@ -167,6 +175,13 @@ grows and clones disagree on their length:
|
|
|
167
175
|
- `unresolved-id`: a full id the change adds that is not a commit in this
|
|
168
176
|
repository, compared with the ids the file cited at the merge base. No
|
|
169
177
|
baseline holds these: they are new by construction.
|
|
178
|
+
- `unpushed-id`: a full id the change adds that is a commit here but on no
|
|
179
|
+
pushed history: neither HEAD (nor, at pre-commit, a merge's other parent)
|
|
180
|
+
nor any of `baseRefs` or `--base` reaches it. A commit made here and never
|
|
181
|
+
pushed, or one on a branch since deleted, resolves in this clone and in no
|
|
182
|
+
CI clone. One `git rev-list` answers for every citation. A shallow clone
|
|
183
|
+
cannot tell where a commit stands, so it judges none. No baseline holds
|
|
184
|
+
these either.
|
|
170
185
|
|
|
171
186
|
In Markdown, a fence's captured output is not read for tokens; its command
|
|
172
187
|
lines are (`$ ` lines of a transcript, every line of an `sh` fence). Fences are
|
|
@@ -205,6 +220,26 @@ In a merge, a path staged exactly as a merged-in parent has it is that
|
|
|
205
220
|
parent's change and passes; a hand resolution counts. With no frozen paths
|
|
206
221
|
configured it passes without asking git. Exit 0, 1 or 2 as above.
|
|
207
222
|
|
|
223
|
+
## Trailers: `dvara trailers`
|
|
224
|
+
|
|
225
|
+
Git reads a message's trailers only from its last paragraph, so a trailer
|
|
226
|
+
line above a blank line is prose to git: the commit lands without it, and
|
|
227
|
+
nothing says so. For a commit-msg hook, `dvara trailers <commit-msg-file>`
|
|
228
|
+
refuses (exit 1) a line whose key is `Co-Authored-By`, `Signed-off-by` or one
|
|
229
|
+
of `trailerKeys` or a `--key KEY` (any case, `Key: value` at the start of the line) in any
|
|
230
|
+
paragraph before the last, or in the title's paragraph, which git never reads
|
|
231
|
+
trailers from (so a message of one paragraph keeps none). The title line
|
|
232
|
+
itself is never judged. Each is named with its line number. The file is
|
|
233
|
+
read raw: comment lines (`#`) are dropped, the scissors line ends the
|
|
234
|
+
message, whitespace-only lines separate paragraphs and trailing ones start
|
|
235
|
+
none. Exit 2: bad arguments, or a config or file it cannot read.
|
|
236
|
+
|
|
237
|
+
```bash
|
|
238
|
+
#!/usr/bin/env bash
|
|
239
|
+
# .husky/commit-msg
|
|
240
|
+
exec node node_modules/@veda-hq/dvara/dist/cli.js trailers --key Slice "$1"
|
|
241
|
+
```
|
|
242
|
+
|
|
208
243
|
## Commands
|
|
209
244
|
|
|
210
245
|
```sh
|
|
@@ -213,5 +248,6 @@ dvara stages <hook> # the stages .dvara.json lists for it
|
|
|
213
248
|
dvara shell <stages|keep-going> # the path of a shell helper
|
|
214
249
|
dvara records <--staged | --all [--base REF] [--write-baseline | --survey] | --base REF>
|
|
215
250
|
dvara frozen <--staged | --base REF [--head REF]>
|
|
251
|
+
dvara trailers [--key KEY]… <commit-msg-file> # refuse trailer lines git would not read
|
|
216
252
|
dvara --version
|
|
217
253
|
```
|
package/dist/cli.js
CHANGED
|
@@ -9,6 +9,7 @@ import { parseArgs as parseRecordsArgs, records, USAGE as RECORDS_USAGE, } from
|
|
|
9
9
|
import { runner } from "./records/git.js";
|
|
10
10
|
import { listFor, readStageLists } from "./stages.js";
|
|
11
11
|
import { supervise } from "./supervisor.js";
|
|
12
|
+
import { GIT_TRAILER_KEYS, misplacedTrailers, refusal } from "./trailers.js";
|
|
12
13
|
export const USAGE = [
|
|
13
14
|
"usage: dvara run <hook-file> [hook args…] run a git hook under the supervisor",
|
|
14
15
|
" dvara stages <hook> the hook stages the config lists, one per line",
|
|
@@ -17,8 +18,11 @@ export const USAGE = [
|
|
|
17
18
|
" the record gate: no new non-durable paths, a baseline that only falls",
|
|
18
19
|
" dvara frozen <--staged | --base REF [--head REF]>",
|
|
19
20
|
" refuse changes to the frozen paths",
|
|
21
|
+
" dvara trailers [--key KEY]… <commit-msg-file>",
|
|
22
|
+
" refuse trailer lines git would not read (commit-msg)",
|
|
20
23
|
" dvara --version",
|
|
21
24
|
].join("\n");
|
|
25
|
+
export const TRAILERS_USAGE = "usage: dvara trailers [--key KEY]… <commit-msg-file>";
|
|
22
26
|
const SHELL_HELPERS = new Set(["stages", "keep-going"]);
|
|
23
27
|
export const ownVersion = () => {
|
|
24
28
|
const pkg = join(import.meta.dirname, "..", "package.json");
|
|
@@ -26,19 +30,80 @@ export const ownVersion = () => {
|
|
|
26
30
|
};
|
|
27
31
|
/** The path of a shell helper this package ships. */
|
|
28
32
|
export const shellHelper = (name) => resolve(import.meta.dirname, "..", "shell", `${name}.sh`);
|
|
33
|
+
const CHECK_USAGE = {
|
|
34
|
+
records: RECORDS_USAGE,
|
|
35
|
+
frozen: FROZEN_USAGE,
|
|
36
|
+
trailers: TRAILERS_USAGE,
|
|
37
|
+
};
|
|
38
|
+
/** A trailer key, as `--key` and `trailerKeys` take one. */
|
|
39
|
+
const TRAILER_KEY = /^[A-Za-z0-9-]+$/;
|
|
40
|
+
/**
|
|
41
|
+
* `dvara trailers` takes the commit message file git passes, and any number
|
|
42
|
+
* of `--key <Key>`: a repository's own keys, given on the command line so a
|
|
43
|
+
* hook can name them without a config field an older dvara would refuse.
|
|
44
|
+
*/
|
|
45
|
+
export const parseTrailersArgs = (args) => {
|
|
46
|
+
const files = [];
|
|
47
|
+
const keys = [];
|
|
48
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
49
|
+
const arg = args[i] ?? "";
|
|
50
|
+
if (arg !== "--key") {
|
|
51
|
+
files.push(arg);
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
const key = args[i + 1];
|
|
55
|
+
if (key === undefined || !TRAILER_KEY.test(key))
|
|
56
|
+
return { error: "--key takes a trailer key: letters, digits and -" };
|
|
57
|
+
keys.push(key);
|
|
58
|
+
i += 1;
|
|
59
|
+
}
|
|
60
|
+
const [file] = files;
|
|
61
|
+
return file === undefined || files.length !== 1
|
|
62
|
+
? { error: "give one commit message file" }
|
|
63
|
+
: { file, keys };
|
|
64
|
+
};
|
|
65
|
+
/**
|
|
66
|
+
* `dvara trailers <file>`: 1 when a known trailer line sits above the last
|
|
67
|
+
* paragraph. The file is read relative to `cwd`, as git runs a hook at the
|
|
68
|
+
* top of the working tree and passes `.git/COMMIT_EDITMSG`.
|
|
69
|
+
*/
|
|
70
|
+
const trailers = (file, keys, { io, cwd }) => {
|
|
71
|
+
let text;
|
|
72
|
+
try {
|
|
73
|
+
text = readFileSync(resolve(cwd, file), "utf8");
|
|
74
|
+
}
|
|
75
|
+
catch (error) {
|
|
76
|
+
throw new Error(`cannot read ${file}: ${error.message}`, {
|
|
77
|
+
cause: error,
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
const misplaced = misplacedTrailers(text, [...GIT_TRAILER_KEYS, ...keys]);
|
|
81
|
+
if (misplaced.length === 0)
|
|
82
|
+
return 0;
|
|
83
|
+
for (const line of refusal(file, misplaced))
|
|
84
|
+
io.err(line);
|
|
85
|
+
return 1;
|
|
86
|
+
};
|
|
29
87
|
/**
|
|
30
|
-
* `dvara records` or `dvara
|
|
31
|
-
* config it cannot read, or git failing.
|
|
88
|
+
* `dvara records`, `dvara frozen` or `dvara trailers` at the root `cwd`: 2 on
|
|
89
|
+
* bad arguments, a config or file it cannot read, or git failing.
|
|
32
90
|
*/
|
|
33
|
-
const checkCommand = (command, args,
|
|
34
|
-
const
|
|
91
|
+
const checkCommand = (command, args, context) => {
|
|
92
|
+
const { io, cwd, env } = context;
|
|
93
|
+
const parsed = command === "records"
|
|
94
|
+
? parseRecordsArgs(args)
|
|
95
|
+
: command === "frozen"
|
|
96
|
+
? parseFrozenArgs(args)
|
|
97
|
+
: parseTrailersArgs(args);
|
|
35
98
|
if ("error" in parsed) {
|
|
36
99
|
io.err(`dvara ${command}: ${parsed.error}`);
|
|
37
|
-
io.err(command
|
|
100
|
+
io.err(CHECK_USAGE[command]);
|
|
38
101
|
return 2;
|
|
39
102
|
}
|
|
40
103
|
try {
|
|
41
104
|
const config = loadConfig(cwd, env);
|
|
105
|
+
if ("file" in parsed)
|
|
106
|
+
return trailers(parsed.file, [...config.trailerKeys, ...parsed.keys], context);
|
|
42
107
|
const repo = { cwd, env };
|
|
43
108
|
if ("mode" in parsed)
|
|
44
109
|
return records(parsed, repo, io.out, {
|
|
@@ -72,7 +137,7 @@ export const run = async (argv, context) => {
|
|
|
72
137
|
io.out(shellHelper(rest[0] ?? ""));
|
|
73
138
|
return 0;
|
|
74
139
|
}
|
|
75
|
-
if (command === "records" || command === "frozen")
|
|
140
|
+
if (command === "records" || command === "frozen" || command === "trailers")
|
|
76
141
|
return checkCommand(command, rest, context);
|
|
77
142
|
if ((command === "run" && rest.length > 0) ||
|
|
78
143
|
(command === "stages" && rest.length === 1)) {
|
package/dist/config.js
CHANGED
|
@@ -12,6 +12,7 @@ export const DEFAULTS = {
|
|
|
12
12
|
stages: {},
|
|
13
13
|
stageList: null,
|
|
14
14
|
stageListRequired: ["pre-push"],
|
|
15
|
+
trailerKeys: [],
|
|
15
16
|
records: {
|
|
16
17
|
baseline: ".dvara/records-baseline.json",
|
|
17
18
|
skip: [],
|
|
@@ -159,6 +160,13 @@ export const parseConfig = (text) => {
|
|
|
159
160
|
}
|
|
160
161
|
if ("stageListRequired" in entries)
|
|
161
162
|
config.stageListRequired = strings(entries.stageListRequired, "stageListRequired");
|
|
163
|
+
if ("trailerKeys" in entries) {
|
|
164
|
+
const keys = strings(entries.trailerKeys, "trailerKeys");
|
|
165
|
+
// Git reads a key as letters, digits and `-`; a key with other characters matches no line.
|
|
166
|
+
if (!keys.every((key) => /^[A-Za-z0-9-]+$/.test(key)))
|
|
167
|
+
throw new Error("trailerKeys must hold trailer keys: letters, digits and -");
|
|
168
|
+
config.trailerKeys = keys;
|
|
169
|
+
}
|
|
162
170
|
if ("records" in entries)
|
|
163
171
|
config.records = parseRecordsConfig(entries.records);
|
|
164
172
|
return config;
|
package/dist/head.js
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// HEAD at both ends of a push hook. Git has chosen what it pushes before
|
|
2
|
+
// pre-push starts; the hook's checks read the worktree. Something that
|
|
3
|
+
// commits, merges or checks out in the worktree while the hook runs (an app
|
|
4
|
+
// merging main in, a second session) leaves the checks judging one commit
|
|
5
|
+
// while git pushes another, and the push goes out unchecked.
|
|
6
|
+
import { execFileSync } from "node:child_process";
|
|
7
|
+
/** The commit HEAD names, or null when git cannot say (no commit yet, no repository). */
|
|
8
|
+
export const readHead = (root, env) => {
|
|
9
|
+
try {
|
|
10
|
+
return execFileSync("git", ["rev-parse", "HEAD"], {
|
|
11
|
+
cwd: root,
|
|
12
|
+
env,
|
|
13
|
+
// git's complaint is not the hook's output.
|
|
14
|
+
stdio: "pipe",
|
|
15
|
+
})
|
|
16
|
+
.toString()
|
|
17
|
+
.trim();
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
// fail-open: a HEAD git cannot read is no evidence that it moved.
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
};
|
|
24
|
+
/**
|
|
25
|
+
* The refusal for a hook during which HEAD moved, or null. A HEAD that could
|
|
26
|
+
* not be read at either end is no evidence of a move, so it refuses nothing.
|
|
27
|
+
*/
|
|
28
|
+
export const headMoved = (hook, start, end) => start === null || end === null || start === end
|
|
29
|
+
? null
|
|
30
|
+
: `dvara: HEAD moved from ${start} to ${end} while ${hook} ran (something committed, merged or checked out in this worktree mid-push), so the checks did not judge what git is pushing. Push again.`;
|
package/dist/records/gate.js
CHANGED
|
@@ -241,7 +241,14 @@ const runChecks = (options, repo, log, settings) => {
|
|
|
241
241
|
baseRenames ??= renamesIn(repo, planned.ref === ":" ? ["--cached", base] : [base, "HEAD"]);
|
|
242
242
|
return baseRenames.get(path);
|
|
243
243
|
};
|
|
244
|
-
|
|
244
|
+
// Pushed history: HEAD's, and that of the ref CI names and the base refs.
|
|
245
|
+
const pushed = [
|
|
246
|
+
...new Set([
|
|
247
|
+
...(options.base === null ? [] : [options.base]),
|
|
248
|
+
...settings.baseRefs,
|
|
249
|
+
]),
|
|
250
|
+
];
|
|
251
|
+
const tree = treeContext(repo, planned.ref, planned.branchBase, renamedFrom, pushed);
|
|
245
252
|
const recording = options.writeBaseline || options.survey;
|
|
246
253
|
let baseline = { files: pathMap() };
|
|
247
254
|
let before = null;
|
package/dist/records/git.js
CHANGED
|
@@ -195,9 +195,32 @@ export const resolveRef = (repo, ref) => {
|
|
|
195
195
|
]);
|
|
196
196
|
return out === null ? null : out.trim();
|
|
197
197
|
};
|
|
198
|
+
/**
|
|
199
|
+
* Of `commits`, the ones no commit of `tips` reaches, in one walk:
|
|
200
|
+
* `git rev-list <commits> --not <tips>` prints exactly the commits reachable
|
|
201
|
+
* from the first and from none of the second, so a cited commit that is an
|
|
202
|
+
* ancestor of a tip (or a tip itself) is not printed. The walk covers only
|
|
203
|
+
* what the tips do not reach, which for a branch's citations is a handful of
|
|
204
|
+
* commits. Only commits are asked: rev-list walks commits and lists no blob
|
|
205
|
+
* or tree, so one passed here would read as reached.
|
|
206
|
+
*/
|
|
207
|
+
export const unreachable = (repo, commits, tips) => {
|
|
208
|
+
if (commits.length === 0)
|
|
209
|
+
return new Set();
|
|
210
|
+
const input = [...commits, ...tips.map((tip) => `^${tip}`)].join("\n");
|
|
211
|
+
const printed = new Set(git(repo, ["rev-list", "--stdin"], `${input}\n`).split("\n"));
|
|
212
|
+
return new Set(commits.filter((commit) => printed.has(commit)));
|
|
213
|
+
};
|
|
214
|
+
/**
|
|
215
|
+
* Whether the clone is shallow. Its history stops at a cut, so a commit
|
|
216
|
+
* below the cut that the store has anyway cannot be shown an ancestor of
|
|
217
|
+
* anything: a shallow clone cannot judge where a commit stands.
|
|
218
|
+
*/
|
|
219
|
+
export const isShallow = (repo) => git(repo, ["rev-parse", "--is-shallow-repository"]).trim() === "true";
|
|
198
220
|
export const noQuestions = () => ({
|
|
199
221
|
shas: new Set(),
|
|
200
222
|
bases: new Set(),
|
|
223
|
+
history: new Set(),
|
|
201
224
|
});
|
|
202
225
|
/**
|
|
203
226
|
* A context that answers without git and records each question. Running the
|
|
@@ -210,6 +233,13 @@ export const recordingContext = (tree, asked) => ({
|
|
|
210
233
|
asked.shas.add(sha);
|
|
211
234
|
return new Map(shas.map((sha) => [sha, "commit"]));
|
|
212
235
|
},
|
|
236
|
+
// Every id passed for a commit above, so blobs and missing ids are asked
|
|
237
|
+
// here too: `warm` sends git only the ones that are commits.
|
|
238
|
+
onHistory: (commits) => {
|
|
239
|
+
for (const commit of commits)
|
|
240
|
+
asked.history.add(commit);
|
|
241
|
+
return null;
|
|
242
|
+
},
|
|
213
243
|
baseText: (path) => {
|
|
214
244
|
if (typeof tree.base !== "string")
|
|
215
245
|
return tree.base;
|
|
@@ -220,13 +250,57 @@ export const recordingContext = (tree, asked) => ({
|
|
|
220
250
|
/** A tree context over one git tree, with an optional merge base for branch rules. */
|
|
221
251
|
export const treeContext = (repo, ref, base,
|
|
222
252
|
/** The path a file had at the base, when the branch renamed it. */
|
|
223
|
-
renamedFrom = () => undefined
|
|
253
|
+
renamedFrom = () => undefined,
|
|
254
|
+
/**
|
|
255
|
+
* The base refs whose history counts as pushed, beside HEAD: a cited
|
|
256
|
+
* commit off all of them exists in this clone only.
|
|
257
|
+
*/
|
|
258
|
+
pushed = []) => {
|
|
224
259
|
const modes = listModes(repo, ref);
|
|
225
260
|
const present = new Set(modes.keys());
|
|
226
261
|
const cache = new Map();
|
|
227
262
|
const baseCache = new Map();
|
|
228
263
|
/** Object type by name; null when the store does not have it. */
|
|
229
264
|
const objects = new Map();
|
|
265
|
+
/** Whether each commit judged is off pushed history. */
|
|
266
|
+
const off = new Map();
|
|
267
|
+
let standing = null;
|
|
268
|
+
/** What pushed history is here, asked once and only when a commit needs it. */
|
|
269
|
+
const history = () => {
|
|
270
|
+
if (standing !== null)
|
|
271
|
+
return standing;
|
|
272
|
+
if (isShallow(repo))
|
|
273
|
+
return (standing = { shallow: true, tips: [], refs: [] });
|
|
274
|
+
const tips = [];
|
|
275
|
+
const refs = [];
|
|
276
|
+
// A staged merge commits MERGE_HEAD as a parent, so its history is the
|
|
277
|
+
// new commit's too. A ref that does not resolve here is no tip.
|
|
278
|
+
for (const name of [
|
|
279
|
+
"HEAD",
|
|
280
|
+
...(ref === ":" ? ["MERGE_HEAD"] : []),
|
|
281
|
+
...pushed,
|
|
282
|
+
]) {
|
|
283
|
+
const id = resolveRef(repo, name);
|
|
284
|
+
if (id === null)
|
|
285
|
+
continue;
|
|
286
|
+
tips.push(id);
|
|
287
|
+
if (pushed.includes(name) && !refs.includes(name))
|
|
288
|
+
refs.push(name);
|
|
289
|
+
}
|
|
290
|
+
return (standing = { shallow: false, tips, refs });
|
|
291
|
+
};
|
|
292
|
+
const judge = (commits) => {
|
|
293
|
+
probe(commits);
|
|
294
|
+
const fresh = [...new Set(commits)].filter((commit) => objects.get(commit) === "commit" && !off.has(commit));
|
|
295
|
+
if (fresh.length === 0)
|
|
296
|
+
return;
|
|
297
|
+
const { shallow, tips } = history();
|
|
298
|
+
if (shallow)
|
|
299
|
+
return;
|
|
300
|
+
const unreached = unreachable(repo, fresh, tips);
|
|
301
|
+
for (const commit of fresh)
|
|
302
|
+
off.set(commit, unreached.has(commit));
|
|
303
|
+
};
|
|
230
304
|
const readInto = (into, rev, wanted) => {
|
|
231
305
|
const missing = wanted.filter((path) => !into.has(path));
|
|
232
306
|
const names = missing.map((path) => objectName(rev, path));
|
|
@@ -297,6 +371,15 @@ renamedFrom = () => undefined) => {
|
|
|
297
371
|
}
|
|
298
372
|
return types;
|
|
299
373
|
},
|
|
374
|
+
onHistory: (commits) => {
|
|
375
|
+
judge(commits);
|
|
376
|
+
if (standing?.shallow)
|
|
377
|
+
return null;
|
|
378
|
+
return {
|
|
379
|
+
unreached: new Set(commits.filter((commit) => off.get(commit) === true)),
|
|
380
|
+
refs: standing?.refs ?? [],
|
|
381
|
+
};
|
|
382
|
+
},
|
|
300
383
|
baseText: (path) => {
|
|
301
384
|
if (typeof base !== "string")
|
|
302
385
|
return base;
|
|
@@ -307,6 +390,7 @@ renamedFrom = () => undefined) => {
|
|
|
307
390
|
if (typeof base === "string")
|
|
308
391
|
readBases(base, [...asked.bases]);
|
|
309
392
|
probe([...asked.shas]);
|
|
393
|
+
judge([...asked.history]);
|
|
310
394
|
},
|
|
311
395
|
};
|
|
312
396
|
};
|
package/dist/records/identity.js
CHANGED
|
@@ -244,8 +244,26 @@ export const commitIdentity = (records) => {
|
|
|
244
244
|
if (full.length === 0)
|
|
245
245
|
return found;
|
|
246
246
|
const types = tree.typesOf([...new Set(full.map((f) => f.sha))]);
|
|
247
|
+
// A commit this clone has may still be one CI's clone lacks: one made
|
|
248
|
+
// here and never pushed, or on a branch since deleted. Only history HEAD
|
|
249
|
+
// or a base ref reaches is fetched there. Asked of every commit, however
|
|
250
|
+
// the line names it: CI fails to resolve a bare id the same way.
|
|
251
|
+
const commits = [...types]
|
|
252
|
+
.filter(([, t]) => t === "commit")
|
|
253
|
+
.map(([s]) => s);
|
|
254
|
+
const history = commits.length === 0 ? null : tree.onHistory(commits);
|
|
247
255
|
for (const { n, sha, asCommit } of full) {
|
|
248
256
|
const type = types.get(sha);
|
|
257
|
+
if (type === "commit" && history?.unreached.has(sha)) {
|
|
258
|
+
const refs = history.refs.length === 0
|
|
259
|
+
? "a base ref (none resolves in this clone)"
|
|
260
|
+
: history.refs.join(" or ");
|
|
261
|
+
found.push({
|
|
262
|
+
...finding(path, n, "unpushed-id", `${sha} is not on pushed history: it is neither an ancestor of HEAD nor of ${refs}, so CI cannot resolve it. Cite a commit that is merged or on this branch.`),
|
|
263
|
+
ratchet: false,
|
|
264
|
+
});
|
|
265
|
+
continue;
|
|
266
|
+
}
|
|
249
267
|
// Git prints blob and tree ids too: every object resolves, unless the
|
|
250
268
|
// line names it a commit.
|
|
251
269
|
if (type !== undefined && (!asCommit || type === "commit"))
|
package/dist/supervisor.js
CHANGED
|
@@ -9,10 +9,11 @@ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExte
|
|
|
9
9
|
// The hook supervisor: runs a git hook under bash, forwards the terminal's
|
|
10
10
|
// signals to it, and writes one history row per run. It changes the hook's
|
|
11
11
|
// verdict only to report a signalled run as cancelled, to let an enforcing
|
|
12
|
-
// watchdog's stop decide it,
|
|
12
|
+
// watchdog's stop decide it, to refuse a passing run that broke its stage
|
|
13
13
|
// list (stages.ts), or, for a hook in stageListRequired, whose list cannot be
|
|
14
|
-
// read
|
|
15
|
-
// own (telemetry, watchdog, quiet output) is
|
|
14
|
+
// read, and to refuse a passing pre-push during which HEAD moved (head.ts).
|
|
15
|
+
// Every other failure of its own (telemetry, watchdog, quiet output) is
|
|
16
|
+
// fail-open.
|
|
16
17
|
import { execFileSync, spawn, spawnSync, } from "node:child_process";
|
|
17
18
|
import { createHash, randomUUID } from "node:crypto";
|
|
18
19
|
import { appendFileSync, mkdirSync, readFileSync, rmSync, writeFileSync, } from "node:fs";
|
|
@@ -20,6 +21,7 @@ import { cpus, totalmem } from "node:os";
|
|
|
20
21
|
import { relative, resolve } from "node:path";
|
|
21
22
|
import { pathToFileURL } from "node:url";
|
|
22
23
|
import { hookKind, invocationContext, perfLevelNames } from "./context.js";
|
|
24
|
+
import { headMoved, readHead } from "./head.js";
|
|
23
25
|
import { isQuiet, successLine, successNotices } from "./quiet.js";
|
|
24
26
|
import { parseRecords, parseStages, treeUsage } from "./records.js";
|
|
25
27
|
import { readStageLists, stageVerdict } from "./stages.js";
|
|
@@ -100,7 +102,8 @@ export const judgedAgainst = (root, env, baseRefs) => {
|
|
|
100
102
|
const clean = diff === null || untracked === null
|
|
101
103
|
? null
|
|
102
104
|
: diff === "" && untracked === "";
|
|
103
|
-
|
|
105
|
+
const head = revision?.trim() || null;
|
|
106
|
+
return { head, branch, fingerprint, base, tree, clean };
|
|
104
107
|
};
|
|
105
108
|
/**
|
|
106
109
|
* Run `hook` under supervision and resolve with the exit code git must see.
|
|
@@ -167,7 +170,7 @@ export const supervise = async ([hook, ...args], options) => {
|
|
|
167
170
|
for (const [name, listener] of listeners)
|
|
168
171
|
signals.off(name, listener);
|
|
169
172
|
};
|
|
170
|
-
const { branch, fingerprint, base, tree, clean } = judgedAgainst(root, env, config.baseRefs);
|
|
173
|
+
const { head, branch, fingerprint, base, tree, clean } = judgedAgainst(root, env, config.baseRefs);
|
|
171
174
|
let watchdogModule = null;
|
|
172
175
|
if (config.watchdog) {
|
|
173
176
|
try {
|
|
@@ -284,9 +287,17 @@ export const supervise = async ([hook, ...args], options) => {
|
|
|
284
287
|
const ended = Date.now();
|
|
285
288
|
const marks = parseStages(readOrNull(events), bootstrapMs);
|
|
286
289
|
// Only a run that would pass is checked: a failure keeps its own story.
|
|
287
|
-
const
|
|
290
|
+
const passing = code === 0 && !imposed && !signal;
|
|
291
|
+
const { refusal: stageRefusal, warning } = passing
|
|
288
292
|
? stageVerdict(hookName, marks.map((mark) => mark.name), () => readStageLists(root, config), config.stageListRequired)
|
|
289
293
|
: { refusal: null, warning: null };
|
|
294
|
+
// A push, not a preflight: only a push has a commit git sends. Asked
|
|
295
|
+
// after the stage list, so a run that broke both says both.
|
|
296
|
+
const moved = passing && kind === "pre-push"
|
|
297
|
+
? headMoved(hookName, head, readHead(root, env))
|
|
298
|
+
: null;
|
|
299
|
+
const refusals = [stageRefusal, moved].filter((r) => r !== null);
|
|
300
|
+
const refusal = refusals.length === 0 ? null : refusals.join("\n");
|
|
290
301
|
if (refusal !== null)
|
|
291
302
|
code = 1;
|
|
292
303
|
if (quiet)
|
package/dist/trailers.js
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// `dvara trailers <commit-msg-file>`, for a commit-msg hook. Git reads a
|
|
2
|
+
// message's trailers (Co-Authored-By, Signed-off-by, a repository's own) only
|
|
3
|
+
// from its last paragraph. A trailer line above a blank line is prose to git:
|
|
4
|
+
// the commit lands, and the trailer is silently not there. This finds such
|
|
5
|
+
// lines, so the hook can refuse the commit before that happens.
|
|
6
|
+
/** The keys git and its hosts use themselves; a repository adds its own in `trailerKeys`. */
|
|
7
|
+
export const GIT_TRAILER_KEYS = [
|
|
8
|
+
"Co-Authored-By",
|
|
9
|
+
"Signed-off-by",
|
|
10
|
+
];
|
|
11
|
+
/**
|
|
12
|
+
* Git's scissors line, with its default comment character: everything below
|
|
13
|
+
* it (the diff `commit -v` shows) is not the message.
|
|
14
|
+
*/
|
|
15
|
+
const SCISSORS = "# ------------------------ >8 ------------------------";
|
|
16
|
+
/** A trailer key, as git reads one: letters, digits and `-`, then a colon. */
|
|
17
|
+
const KEY = /^([A-Za-z0-9-]+)[ \t]*:/;
|
|
18
|
+
/**
|
|
19
|
+
* The lines in `message` whose key is one of `keys` (any case) and that sit
|
|
20
|
+
* in a paragraph before the last, or in the title's paragraph (the title line
|
|
21
|
+
* aside), which git never reads trailers from. Comment lines are dropped, as git drops
|
|
22
|
+
* them, so a comment neither splits a paragraph nor ends one; blank lines
|
|
23
|
+
* (whitespace only) separate paragraphs; trailing ones start no new one.
|
|
24
|
+
*/
|
|
25
|
+
export const misplacedTrailers = (message, keys) => {
|
|
26
|
+
const known = new Set(keys.map((key) => key.toLowerCase()));
|
|
27
|
+
const paragraphs = [];
|
|
28
|
+
let open = null;
|
|
29
|
+
for (const [i, text] of message.split(/\r?\n/).entries()) {
|
|
30
|
+
if (text === SCISSORS)
|
|
31
|
+
break;
|
|
32
|
+
if (text.startsWith("#"))
|
|
33
|
+
continue;
|
|
34
|
+
if (text.trim() === "") {
|
|
35
|
+
open = null;
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
if (open === null) {
|
|
39
|
+
open = [];
|
|
40
|
+
paragraphs.push(open);
|
|
41
|
+
}
|
|
42
|
+
open.push({ line: i + 1, text });
|
|
43
|
+
}
|
|
44
|
+
// Git reads no trailers from the title's paragraph either, so a message of
|
|
45
|
+
// one paragraph keeps none; the title line itself is never a trailer.
|
|
46
|
+
const unread = paragraphs.length > 1 ? paragraphs.slice(0, -1) : paragraphs;
|
|
47
|
+
return unread
|
|
48
|
+
.flat()
|
|
49
|
+
.slice(1)
|
|
50
|
+
.filter(({ text }) => {
|
|
51
|
+
const key = KEY.exec(text)?.[1];
|
|
52
|
+
return key !== undefined && known.has(key.toLowerCase());
|
|
53
|
+
});
|
|
54
|
+
};
|
|
55
|
+
export const ADVICE = "Git reads trailers only from the last paragraph. Move these lines into the final block with no blank line between them (one `-F <file>` message), then check with `git log -1 --format=%(trailers)`.";
|
|
56
|
+
/** The refusal for `misplaced` lines of `file`, one line per entry. */
|
|
57
|
+
export const refusal = (file, misplaced) => [
|
|
58
|
+
`❌ trailers: ${file} has trailer lines git does not read (above a blank line, or in the title's paragraph):`,
|
|
59
|
+
...misplaced.map(({ line, text }) => ` ${file}:${line} ${text}`),
|
|
60
|
+
ADVICE,
|
|
61
|
+
];
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@veda-hq/dvara",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.4",
|
|
4
4
|
"description": "Git hooks for repositories coding agents work in: runs each hook under a supervisor that times its stages, forwards signals and keeps one history row per run, with quiet output on success and a keep-going mode for preflights.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"git hooks",
|