@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 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 and the stage list
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 frozen` at the root `cwd`: 2 on bad arguments, a
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, { io, cwd, env }) => {
34
- const parsed = command === "records" ? parseRecordsArgs(args) : parseFrozenArgs(args);
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 === "records" ? RECORDS_USAGE : FROZEN_USAGE);
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.`;
@@ -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
- const tree = treeContext(repo, planned.ref, planned.branchBase, renamedFrom);
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;
@@ -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
  };
@@ -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"))
@@ -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, and to refuse a passing run that broke its stage
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. Every other failure of its
15
- // own (telemetry, watchdog, quiet output) is fail-open.
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
- return { branch, fingerprint, base, tree, clean };
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 { refusal, warning } = code === 0 && !imposed && !signal
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)
@@ -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.5.16",
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",