@warren-dean/code-kit 0.12.2 → 0.13.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/bin/code-kit.mjs CHANGED
@@ -106,6 +106,8 @@ import {
106
106
  import { DEPENDENCY_APPROVAL, dependencyApproval } from '../hooks/lib/dependencies.mjs';
107
107
  import { dependencyRuleProblems, npmFacts, packageOf } from '../hooks/lib/registry.mjs';
108
108
  import { heldTo, laneOfBranch, verifyBranch } from '../hooks/lib/verify.mjs';
109
+ import { skippedLine } from '../hooks/lib/checks.mjs';
110
+ import { TESTS_APPROVAL } from '../hooks/lib/regression.mjs';
109
111
 
110
112
  const out = (s) => process.stdout.write(`${s}\n`);
111
113
  // Piped into `head` and the like: stop quietly when the reader closes.
@@ -398,6 +400,8 @@ const VERIFY_GROUPS = {
398
400
  layers: 'Layer rules',
399
401
  baseline: 'Baseline',
400
402
  design: 'Designs before screens',
403
+ tests: 'Weakened tests',
404
+ regression: 'Fixes and their tests',
401
405
  checks: 'Checks',
402
406
  };
403
407
 
@@ -522,9 +526,11 @@ function merge(branch) {
522
526
 
523
527
  /**
524
528
  * Verifies `branch`, checks included, and merges it into the base (`--into`, or the first protected
525
- * branch). Returns { into, message }; throws a MergeStop when nothing was merged.
529
+ * branch). With `commit`, the commit the lead's review passed, it verifies and merges exactly that
530
+ * commit, and refuses if the branch has moved on from it. Returns { into, message }; throws a
531
+ * MergeStop when nothing was merged.
526
532
  */
527
- function mergeOne(branch, config, byPerson) {
533
+ function mergeOne(branch, config, byPerson, commit = null) {
528
534
  const refuse = (message) => {
529
535
  throw new MergeStop('refused', message);
530
536
  };
@@ -583,13 +589,20 @@ function mergeOne(branch, config, byPerson) {
583
589
  // temporary worktree, where checks that need an install fail rather than being skipped.
584
590
  const verifyIn = checkedOut(branch) ?? worktree(branch, true);
585
591
  clean(verifyIn, branch);
592
+ const tip = g(verifyIn, 'rev-parse', 'HEAD').trim();
593
+ if (commit && tip !== commit)
594
+ fail(
595
+ `Nothing was merged: ${branch} has moved on since its review passed (${commit.slice(0, 7)} → ${tip.slice(0, 7)}). Review it again, then queue it.`,
596
+ 'moved',
597
+ );
586
598
  const lane = laneOfBranch(branch, config);
587
- const { changed, found } = verifyBranch({
599
+ const { changed, found, skipped } = verifyBranch({
588
600
  root: verifyIn,
589
601
  base: into,
590
602
  config,
591
603
  lane,
592
604
  checks: true,
605
+ branch,
593
606
  });
594
607
  const problems = Object.entries(VERIFY_GROUPS).flatMap(([key, title]) =>
595
608
  found[key].map((p) => `${title}: ${p.split('\n').join('\n ')}`),
@@ -609,8 +622,8 @@ function mergeOne(branch, config, byPerson) {
609
622
  '-m',
610
623
  `Merge ${branch} into ${into}`,
611
624
  '-m',
612
- `Verified with code-kit verify (${changed.length} file(s)${lane ? `, held to ${heldTo(lane)}` : ''}) and merged ${byPerson ? 'by the person, from the code-kit pane' : 'by the lead within the delegated rules'}.`,
613
- branch,
625
+ `Verified with code-kit verify (${changed.length} file(s)${lane ? `, held to ${heldTo(lane)}` : ''}) at ${tip.slice(0, 7)}${commit ? ', the commit the review passed,' : ''} and merged ${byPerson ? 'by the person, from the code-kit pane' : 'by the lead within the delegated rules'}.${skipped.length ? ` ${notRun(skipped)}` : ''}`,
626
+ tip,
614
627
  );
615
628
  } catch (e) {
616
629
  try {
@@ -644,7 +657,7 @@ function mergeOne(branch, config, byPerson) {
644
657
  }
645
658
  return {
646
659
  into,
647
- message: `Merged ${branch} into ${into}: verify passed on ${changed.length} file(s)${lane ? `, held to ${heldTo(lane)}` : ''}.${removed}`,
660
+ message: `Merged ${branch} into ${into}: verify passed on ${changed.length} file(s)${lane ? `, held to ${heldTo(lane)}` : ''}, at ${tip.slice(0, 7)}.${skipped.length ? ` ${notRun(skipped)}` : ''}${removed}`,
648
661
  };
649
662
  } finally {
650
663
  cleanup();
@@ -669,16 +682,24 @@ function queue() {
669
682
  ? `Took ${branch} out of the merge queue.`
670
683
  : `${branch} isn't waiting in the merge queue.`,
671
684
  );
685
+ let commit;
672
686
  try {
673
- execFileSync('git', ['rev-parse', '--verify', '--quiet', `refs/heads/${branch}`], {
674
- stdio: 'ignore',
675
- });
687
+ commit = execFileSync('git', ['rev-parse', '--verify', '--quiet', `refs/heads/${branch}`], {
688
+ encoding: 'utf8',
689
+ stdio: ['ignore', 'pipe', 'ignore'],
690
+ }).trim();
676
691
  } catch {
677
692
  die(`There is no branch ${branch}.`);
678
693
  }
679
- if (!enqueue(root, branch)) return out(`${branch} is already in the merge queue.`);
694
+ // The commit the review passed: the queue merges exactly it, and a branch that moves is reviewed again.
695
+ const added = enqueue(root, branch, commit);
696
+ if (!added) return out(`${branch} is already in the merge queue, at ${commit.slice(0, 7)}.`);
697
+ if (added === 'updated')
698
+ return out(`${branch} keeps its place in the merge queue, now at ${commit.slice(0, 7)}.`);
680
699
  const place = waiting(readQueue(root)).length;
681
- return out(`Queued ${branch} to merge${place > 1 ? `, ${place - 1} ahead of it` : ', next'}.`);
700
+ return out(
701
+ `Queued ${branch} at ${commit.slice(0, 7)} to merge${place > 1 ? `, ${place - 1} ahead of it` : ', next'}.`,
702
+ );
682
703
  }
683
704
  if (sub === 'rules') return rulesOntoBase(root, config);
684
705
  if (sub === 'merge') {
@@ -687,7 +708,7 @@ function queue() {
687
708
  const head = waiting(entries)[0];
688
709
  if (!head) return out('The merge queue is empty.');
689
710
  try {
690
- const { message } = mergeOne(head.branch, config, byPerson);
711
+ const { message } = mergeOne(head.branch, config, byPerson, head.commit ?? null);
691
712
  settle(root, head.branch, 'merged');
692
713
  const left = waiting(readQueue(root));
693
714
  return out(
@@ -697,6 +718,12 @@ function queue() {
697
718
  if (!(e instanceof MergeStop)) throw e;
698
719
  // MQ-3: a conflict or a failed verify goes back to the lane; a refusal leaves the queue as it is.
699
720
  if (e.kind === 'refused') die(e.message);
721
+ // A branch that moved after its review leaves the queue for the lead to review it again; it
722
+ // isn't sent back, since nothing is wrong with it yet.
723
+ if (e.kind === 'moved') {
724
+ settle(root, head.branch, 'moved', 'changed after its review');
725
+ die(`${e.message}\n${head.branch} leaves the queue until then.`);
726
+ }
700
727
  const into = intoName ?? config.branches.protected[0];
701
728
  const after = lastMerged(entries);
702
729
  const why =
@@ -722,7 +749,11 @@ function queue() {
722
749
  const left = waiting(entries);
723
750
  if (!left.length) out('The merge queue is empty.');
724
751
  else out(`${left.length} waiting to merge, in order:`);
725
- left.forEach((e, i) => out(` ${i + 1}. ${e.branch} (passed review ${e.passed})`));
752
+ left.forEach((e, i) =>
753
+ out(
754
+ ` ${i + 1}. ${e.branch} (passed review ${e.passed}${e.commit ? ` at ${e.commit.slice(0, 7)}` : ''})`,
755
+ ),
756
+ );
726
757
  }
727
758
 
728
759
  /**
@@ -906,6 +937,7 @@ function story(id) {
906
937
  config,
907
938
  lane,
908
939
  checks: false,
940
+ branch: s.branch,
909
941
  });
910
942
  report.verify = {
911
943
  checks: false,
@@ -1099,18 +1131,21 @@ function verify() {
1099
1131
  }
1100
1132
  }
1101
1133
  const lane = laneOfBranch(branch, config);
1102
- const { changed, found } = verifyBranch({
1134
+ const { changed, found, skipped } = verifyBranch({
1103
1135
  root: resolve('.'),
1104
1136
  base: from,
1105
1137
  config,
1106
1138
  lane,
1107
1139
  checks: !flag('--no-checks'),
1140
+ // REG-1 is code-kit's own, so CI's --no-checks still proves a fix.
1141
+ fixes: true,
1142
+ branch,
1108
1143
  });
1109
1144
  if (flag('--json')) {
1110
1145
  const count = Object.values(found).reduce((n, list) => n + list.length, 0);
1111
1146
  out(
1112
1147
  JSON.stringify(
1113
- { base: from, branch, lane, changed: changed.length, found, problems: count },
1148
+ { base: from, branch, lane, changed: changed.length, found, problems: count, skipped },
1114
1149
  null,
1115
1150
  2,
1116
1151
  ),
@@ -1128,10 +1163,15 @@ function verify() {
1128
1163
  out(`\n${title}:`);
1129
1164
  for (const p of found[key]) out(` ${p.split('\n').join('\n ')}`);
1130
1165
  }
1166
+ if (skipped.length) out(`\n${notRun(skipped)}`);
1131
1167
  if (count) die(`\n${count} problem(s). Fix them on this branch.`);
1132
- out('No problems.');
1168
+ out(skipped.length ? 'No problems in what ran.' : 'No problems.');
1133
1169
  }
1134
1170
 
1171
+ /** The checks that didn't run, for verify's and a merge's report. */
1172
+ const notRun = (skipped) =>
1173
+ `Not run, because a tool isn't installed here: ${skipped.map(skippedLine).join('; ')}. Install it where verify runs, or set "ifMissing": "fail" on the check so a missing tool fails it.`;
1174
+
1135
1175
  /**
1136
1176
  * ONB-7: whether a branch's changes need the security reviewer, and why: sign-in, permissions, outside
1137
1177
  * input, personal data, encryption, payments, secrets, commands built from strings, new dependencies,
@@ -1299,7 +1339,7 @@ async function approve(given) {
1299
1339
  );
1300
1340
  if (laneName !== undefined && !config.lanes[laneName])
1301
1341
  die(`There is no lane "${laneName}". Lanes: ${Object.keys(config.lanes).join(', ')}.`);
1302
- const known = new Set(['kit', ...config.protected.map((p) => p.approval)]);
1342
+ const known = new Set(['kit', TESTS_APPROVAL, ...config.protected.map((p) => p.approval)]);
1303
1343
  const isPackage = (n) => /^(@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/.test(n);
1304
1344
  const names = given.map((n) =>
1305
1345
  n.startsWith(DEPENDENCY_APPROVAL) || known.has(n) || !isPackage(n) ? n : dependencyApproval(n),
@@ -1327,6 +1367,10 @@ async function approve(given) {
1327
1367
  );
1328
1368
  const outside = [];
1329
1369
  for (const name of names) {
1370
+ if (name === TESTS_APPROVAL) {
1371
+ outside.push(`${name}: weakening a test is the person's to allow, and is never delegated`);
1372
+ continue;
1373
+ }
1330
1374
  if (name.startsWith(DEPENDENCY_APPROVAL)) {
1331
1375
  if (!rules.dependencies) {
1332
1376
  outside.push(`${name}: dependencies aren't delegated`);
@@ -15,6 +15,7 @@ import { CONFIG_FILE } from './lib/config.mjs';
15
15
  import { laneOfBranch, storyOfBranch, teamOf, teamProvider, whoami } from './lib/team.mjs';
16
16
  import { matchesAny } from './lib/glob.mjs';
17
17
  import { noteBefore } from './lane-audit.mjs';
18
+ import { TESTS_APPROVAL, commitWeakenings } from './lib/regression.mjs';
18
19
  import {
19
20
  APPROVAL_LOG,
20
21
  actorFor,
@@ -452,6 +453,40 @@ function recordApprovals() {
452
453
  grantedAt: null,
453
454
  });
454
455
  }
456
+ // REG-2: a commit that weakens a test waits for the person's `tests` approval, which nobody else can
457
+ // give; it's recorded in the log, so verify accepts the branch, and used up by this commit.
458
+ const weakened = commitWeakenings(
459
+ root,
460
+ files.filter((f) => !arrivesWithMerge(root, f, { staged: true })),
461
+ config,
462
+ );
463
+ if (weakened.length) {
464
+ const granted = approval(root, TESTS_APPROVAL, actor);
465
+ const said = (w) => `it ${w.reasons.join(', and ')}`;
466
+ if (!granted) {
467
+ const [w, ...more] = weakened;
468
+ recordRequest(root, actor, TESTS_APPROVAL, w.file, `A weakened test: ${said(w)}.`);
469
+ const personOnly = { ...config, approvals: { ...config.approvals, delegate: null } };
470
+ block(
471
+ `Blocked: ${w.file} is a weakened test (${said(w)}); committing it needs a person's approval in force.
472
+ ` +
473
+ (more.length
474
+ ? `Also weakened: ${more.map((x) => `${x.file} (${said(x)})`).join('; ')}.
475
+ `
476
+ : '') +
477
+ `A weakened test can hide a regression. Fix the code so the test passes as it was. If the test itself is wrong, ask the person: neither the lead nor a lane can approve it. They run
478
+ ${approvalHowTo(TESTS_APPROVAL, actor, root, 'weakening tests in one commit', personOnly)}`,
479
+ );
480
+ }
481
+ for (const w of weakened)
482
+ record(w.file, {
483
+ approval: TESTS_APPROVAL,
484
+ reason: granted.reason,
485
+ grantedAt: granted.grantedAt,
486
+ weakened: w.reasons,
487
+ });
488
+ useApproval(root, TESTS_APPROVAL, actor);
489
+ }
455
490
  // A dependency approval is used up by the commit that adds its package to a manifest or lockfile.
456
491
  const manifests = files.filter((f) => isDependencyFile(f));
457
492
  if (manifests.length && actor.kind !== 'readonly') {
@@ -15,8 +15,12 @@ function directories(check, files, root) {
15
15
  return [...new Set(dirs)].map((d) => join(root, d)).filter((d) => existsSync(d));
16
16
  }
17
17
 
18
- /** The first failure as { title, output }, or null. `actor` decides `owners`; the lead always may. */
19
- function runCheck(check, files, root, actor) {
18
+ /**
19
+ * The first failure as { title, output }, or null. `actor` decides `owners`; the lead always may. A
20
+ * command that isn't installed skips the rest of the check, unless `ifMissing` is "fail", and is
21
+ * added to `skipped` as { check, command, where }, so a pass never hides a check that didn't run.
22
+ */
23
+ function runCheck(check, files, root, actor, skipped) {
20
24
  if (check.owners && !(actor.kind === 'lead' || check.owners.includes(actor.lane))) {
21
25
  return {
22
26
  title: `The ${actor.label} changed ${check.name} files it does not own:`,
@@ -37,6 +41,7 @@ function runCheck(check, files, root, actor) {
37
41
  return {
38
42
  title: `${check.name}: \`${command}\` is not installed${where}. Install it so the check can run.`,
39
43
  };
44
+ skipped.push({ check: check.name, command, where: where.replace(/^ in /, '') || null });
40
45
  return null; // not installed yet (fresh clone): the check can't run
41
46
  }
42
47
  if (res.error || res.status === null)
@@ -53,15 +58,23 @@ function runCheck(check, files, root, actor) {
53
58
  return null;
54
59
  }
55
60
 
56
- /** Runs every check whose files changed, in order; the first failure, or null when all pass. */
61
+ /**
62
+ * Runs every check whose files changed, in order: { failure, skipped }, where `failure` is the first
63
+ * failure or null, and `skipped` lists the checks that couldn't run because a tool isn't installed.
64
+ */
57
65
  export function runChecks(config, changed, root, actor) {
66
+ const skipped = [];
58
67
  for (const check of config.checks) {
59
68
  const files = changed.filter(
60
69
  (f) => matchesAny(f, check.files) && !matchesAny(f, check.exclude),
61
70
  );
62
71
  if (!files.length) continue;
63
- const failure = runCheck(check, files, root, actor);
64
- if (failure) return failure;
72
+ const failure = runCheck(check, files, root, actor, skipped);
73
+ if (failure) return { failure, skipped };
65
74
  }
66
- return null;
75
+ return { failure: null, skipped };
67
76
  }
77
+
78
+ /** A skipped check in a sentence: "typecheck (`tsc` isn't installed in apps/web)". */
79
+ export const skippedLine = (s) =>
80
+ `${s.check} (\`${s.command}\` isn't installed${s.where ? ` in ${s.where}` : ''})`;
@@ -93,6 +93,8 @@ export function withDefaults(c) {
93
93
  shell: { block: c.shell?.block ?? [], restricted: c.shell?.restricted ?? [] },
94
94
  postEdit: c.postEdit ?? [],
95
95
  checks: c.checks ?? [],
96
+ tests: c.tests ?? [],
97
+ regression: { prepare: c.regression?.prepare ?? null },
96
98
  branches: { protected: c.branches?.protected ?? ['main', 'master'] },
97
99
  approvals: { lead: c.approvals?.lead === true, delegate: delegateRules(c.approvals?.delegate) },
98
100
  harness: harnessSettings(c.harness),
@@ -211,6 +213,12 @@ export function validate(c) {
211
213
  need(e.exclude === undefined || isList(e.exclude), `${at}.exclude must be a list of globs`);
212
214
  });
213
215
  each('checks', (e, at) => validateCheck(e, at, lanes, need));
216
+ need(c.tests === undefined || isList(c.tests), '"tests" must be a list of globs: the test files');
217
+ need(
218
+ c.regression === undefined ||
219
+ (isObj(c.regression) && (c.regression.prepare === undefined || isStr(c.regression.prepare))),
220
+ '"regression" must be { prepare?: a command run in a fresh checkout before a fix\'s tests }',
221
+ );
214
222
  const known = ADAPTERS.map((a) => a.name);
215
223
  need(
216
224
  c.adapters === undefined ||
@@ -26,7 +26,10 @@ const git = (root, ...args) => {
26
26
  }
27
27
  };
28
28
 
29
- /** The spec-design brief: { path, status, openQuestions: [question, ...] }, or null. */
29
+ /**
30
+ * The spec-design brief: { path, status, openQuestions: [question, ...], open: [{ question, owner,
31
+ * blocks }] }, or null. `open` reads the table's Owner and Blocks columns where it has them.
32
+ */
30
33
  export function readBrief(root, config) {
31
34
  const specs = config?.docs?.specs;
32
35
  const candidates = [specs && `${posix.dirname(specs)}/brief.md`, 'docs/brief.md'].filter(Boolean);
@@ -39,12 +42,27 @@ export function readBrief(root, config) {
39
42
  const start = lines.findIndex((l) => /^##\s+Open questions/i.test(l));
40
43
  const section = start < 0 ? [] : lines.slice(start + 1);
41
44
  const end = section.findIndex((l) => /^##\s/.test(l));
42
- const openQuestions = section
43
- .slice(0, end < 0 ? undefined : end)
44
- .filter((l) => /^\|/.test(l) && !/^\|\s*-/.test(l) && !/^\|\s*Question\s*\|/i.test(l))
45
- .map((l) => l.split('|')[1].trim())
46
- .filter((q) => q && !q.startsWith('{{'));
47
- return { path, status: status ?? null, openQuestions };
45
+ const rows = section.slice(0, end < 0 ? undefined : end).filter((l) => /^\|/.test(l));
46
+ const cells = (l) =>
47
+ l
48
+ .split('|')
49
+ .slice(1, -1)
50
+ .map((c) => c.trim());
51
+ const header = rows.find((l) => /^\|\s*Question\s*\|/i.test(l));
52
+ const columns = header ? cells(header).map((c) => c.toLowerCase()) : ['question'];
53
+ const at = (name) => columns.findIndex((c) => c.startsWith(name));
54
+ const open = rows
55
+ .filter((l) => l !== header && !/^\|\s*-/.test(l))
56
+ .map((l) => {
57
+ const c = cells(l);
58
+ return {
59
+ question: c[0] ?? '',
60
+ owner: at('owner') > 0 ? c[at('owner')] || null : null,
61
+ blocks: at('block') > 0 ? c[at('block')] || null : null,
62
+ };
63
+ })
64
+ .filter((q) => q.question && !q.question.startsWith('{{'));
65
+ return { path, status: status ?? null, openQuestions: open.map((q) => q.question), open };
48
66
  }
49
67
 
50
68
  const hasMarkdown = (dir) => {
@@ -87,7 +105,15 @@ export function nextStep(root, base) {
87
105
  const brief = readBrief(root, config);
88
106
  if (brief?.openQuestions.length) {
89
107
  attention.push(`${brief.openQuestions.length} open question(s) in ${brief.path}.`);
90
- briefQuestions = brief.openQuestions.map((question) => ({ question, path: brief.path }));
108
+ // A question the brief gives to the lead alone (Owner: Lead) is the lead's to settle, not the person's.
109
+ briefQuestions = brief.open
110
+ .filter(
111
+ (q) =>
112
+ !q.owner ||
113
+ !/\blead\b/i.test(q.owner) ||
114
+ /owner|person|you|product|client/i.test(q.owner),
115
+ )
116
+ .map((q) => ({ ...q, path: brief.path }));
91
117
  }
92
118
 
93
119
  if (!config) {
@@ -1,7 +1,7 @@
1
1
  // Requirements and stories, read from the project's docs. Specs (docs.specs) hold requirements as
2
2
  // headings `### BOOK-4 Short name` (`(removed)` at the end drops one); the plan (docs.plan) holds
3
3
  // stories as `### ST-12 Title` followed by `**Lane:**`, `**Requirements:**`, `**Depends on:**` and
4
- // `**Status:**` lines. A lane builds a story on the branch `<lane>/st-12`. The lane `lead` marks the
4
+ // `**Status:**` lines, and for a fix, `**Fixes:**`. A lane builds a story on the branch `<lane>/st-12`. The lane `lead` marks the
5
5
  // lead's own stories (contracts, foundations, spikes), which the lead builds rather than dispatches;
6
6
  // `**Requirements:** none` marks a story that delivers no requirement, such as a spike.
7
7
  import { execFileSync } from 'node:child_process';
@@ -52,7 +52,7 @@ const field = (lines, name) =>
52
52
  .trim() ?? '';
53
53
 
54
54
  /**
55
- * Every story in the plan: [{ id, title, lane, requirements, dependsOn, status, milestone }]. A story's
55
+ * Every story in the plan: [{ id, title, lane, requirements, dependsOn, status, fixes, milestone }]. A story's
56
56
  * milestone is the `## ` heading above it ("Milestone 5: Your own key"), or null.
57
57
  */
58
58
  export function readStories(root, planPath) {
@@ -75,6 +75,8 @@ export function readStories(root, planPath) {
75
75
  lane: field(body, 'Lane'),
76
76
  requirements: [...field(body, 'Requirements').matchAll(REQUIREMENT)].map((r) => r[1]),
77
77
  noRequirements: /^none\b/i.test(field(body, 'Requirements')),
78
+ // REG-1: what a fix story fixes, or null.
79
+ fixes: field(body, 'Fixes') || null,
78
80
  dependsOn: [...field(body, 'Depends on').matchAll(/\bST-\d+\b/g)].map((r) => r[0]),
79
81
  status: STATES.includes(status) ? status : 'todo',
80
82
  milestone,
@@ -11,7 +11,10 @@ const DONE_KEPT = 20;
11
11
 
12
12
  const queueFile = (root) => join(projectWorktrees(root)[0] ?? root, QUEUE_FILE);
13
13
 
14
- /** The queue's entries: [{ branch, passed, state, at?, reason? }], waiting ones in merge order. */
14
+ /**
15
+ * The queue's entries: [{ branch, commit, passed, state, at?, reason? }], waiting ones in merge order.
16
+ * `commit` is the one the lead's review passed; entries queued before 0.13.0 have none.
17
+ */
15
18
  export function readQueue(root) {
16
19
  try {
17
20
  const entries = JSON.parse(readFileSync(queueFile(root), 'utf8'));
@@ -35,13 +38,26 @@ function writeQueue(root, entries) {
35
38
  /** The branches still to merge, in order (MQ-1). */
36
39
  export const waiting = (entries) => entries.filter((e) => ['waiting', 'merging'].includes(e.state));
37
40
 
38
- /** Adds a branch the lead's review passed, at the back; a branch already waiting keeps its place. */
39
- export function enqueue(root, branch, now = Date.now()) {
41
+ /**
42
+ * Adds a branch the lead's review passed at `commit`, at the back; a branch already waiting keeps its
43
+ * place, with the commit its latest review passed.
44
+ */
45
+ export function enqueue(root, branch, commit, now = Date.now()) {
40
46
  const entries = readQueue(root);
41
- if (waiting(entries).some((e) => e.branch === branch)) return false;
47
+ const queued = waiting(entries).find((e) => e.branch === branch);
48
+ if (queued) {
49
+ if (queued.commit === commit) return false;
50
+ writeQueue(
51
+ root,
52
+ entries.map((e) =>
53
+ e === queued ? { ...e, commit, passed: new Date(now).toISOString() } : e,
54
+ ),
55
+ );
56
+ return 'updated';
57
+ }
42
58
  writeQueue(root, [
43
59
  ...entries.filter((e) => e.branch !== branch),
44
- { branch, passed: new Date(now).toISOString(), state: 'waiting' },
60
+ { branch, commit, passed: new Date(now).toISOString(), state: 'waiting' },
45
61
  ]);
46
62
  return true;
47
63
  }
@@ -57,7 +73,10 @@ export function dequeue(root, branch) {
57
73
  return true;
58
74
  }
59
75
 
60
- /** Sets a waiting branch's state: merging, merged, sent back (with why), or waiting again. */
76
+ /**
77
+ * Sets a waiting branch's state: merging, merged, sent back (with why), moved (it changed after its
78
+ * review, so it's reviewed again), or waiting again.
79
+ */
61
80
  export function settle(root, branch, state, reason = null, now = Date.now()) {
62
81
  const entries = readQueue(root).map((e) =>
63
82
  e.branch === branch && ['waiting', 'merging'].includes(e.state)