@jwilger/pi-development-system 0.8.1 → 0.8.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jwilger/pi-development-system",
3
- "version": "0.8.1",
3
+ "version": "0.8.2",
4
4
  "description": "A pi extension package representing a seasoned approach to software development using a full AI SDLC.",
5
5
  "keywords": [
6
6
  "pi-package"
@@ -41,7 +41,8 @@ check now uses expiry; clock-skew tolerance is deliberately not added.
41
41
 
42
42
  Do not start unrelated work while trunk CI is red. Fix it first; a red build
43
43
  hides every later failure. A push to a red trunk is a hard stop unless the
44
- commit is `fix(ci): ...` and its diff only repairs the failing build. If `gh`
44
+ commit is a `fix` and Jev reads its diff as only repairing the failing run (it needs
45
+ the failing log and Jev online; otherwise the push is a hard stop). If `gh`
45
46
  is missing or unauthenticated, CI reads as unknown and the push is allowed:
46
47
  check CI by other means before relying on it.
47
48
 
@@ -10,15 +10,17 @@ const HEREDOC =
10
10
  /<<-?[ \t]*(['"]?)([A-Za-z_][A-Za-z0-9_]*)\1[^\n]*\n([\s\S]*?)\n[ \t]*\2[ \t]*(?:\n|$)/g;
11
11
 
12
12
  /** Heredoc bodies are data: take them out so their quotes cannot confuse line splitting. */
13
- function stripHeredocs(command: string): { rest: string; bodies: string[] } {
14
- const bodies: string[] = [];
15
- const rest = command.replace(HEREDOC, (_all, _quote, _tag, body: string) => {
16
- bodies.push(body);
13
+ function stripHeredocs(command: string): { rest: string; bodies: Heredoc[] } {
14
+ const bodies: Heredoc[] = [];
15
+ const rest = command.replace(HEREDOC, (...m: unknown[]) => {
16
+ bodies.push({ body: String(m[3]), at: Number(m[m.length - 2]) });
17
17
  return "\n";
18
18
  });
19
19
  return { rest, bodies };
20
20
  }
21
21
 
22
+ type Heredoc = { readonly body: string; readonly at: number };
23
+
22
24
  type Options = {
23
25
  messages: string[];
24
26
  trailers: string[];
@@ -39,12 +41,26 @@ const LONG: ReadonlyArray<readonly [string, Parsed["kind"]]> = [
39
41
  ["--trailer", "trailer"],
40
42
  ];
41
43
 
44
+ /** The option a `--name`/`--na=value` spelling means; git accepts any unambiguous prefix (3+ chars). */
45
+ function longOption(arg: string): { kind: Parsed["kind"]; inline: string | undefined } | undefined {
46
+ if (!arg.startsWith("--")) return undefined;
47
+ const eq = arg.indexOf("=");
48
+ const name = eq < 0 ? arg : arg.slice(0, eq);
49
+ if (name.length < 5) return undefined;
50
+ const hit = LONG.find(([long]) => long.startsWith(name));
51
+ return hit === undefined
52
+ ? undefined
53
+ : { kind: hit[1], inline: eq < 0 ? undefined : arg.slice(eq + 1) };
54
+ }
55
+
42
56
  /** Reads the message/file/trailer option starting at args[i], in any of its spellings. */
43
57
  function parseArg(args: readonly string[], i: number): Parsed | undefined {
44
58
  const a = args[i] ?? "";
45
- for (const [name, kind] of LONG) {
46
- if (a === name) return { kind, value: args[i + 1], next: i + 2 };
47
- if (a.startsWith(`${name}=`)) return { kind, value: a.slice(name.length + 1), next: i + 1 };
59
+ const long = longOption(a);
60
+ if (long !== undefined) {
61
+ return long.inline === undefined
62
+ ? { kind: long.kind, value: args[i + 1], next: i + 2 }
63
+ : { kind: long.kind, value: long.inline, next: i + 1 };
48
64
  }
49
65
  if (a === "-F") return { kind: "file", value: args[i + 1], next: i + 2 };
50
66
  if (/^-F./.test(a)) return { kind: "file", value: a.slice(2), next: i + 1 };
@@ -75,24 +91,44 @@ const unknown = (trailers: readonly string[] = []): CommitExtraction => ({
75
91
  trailers,
76
92
  });
77
93
 
78
- /** The commit message a `git commit` shell command will use, or why it cannot be known. */
79
- export function extractCommit(command: string): CommitExtraction {
80
- const { rest, bodies } = stripHeredocs(command);
81
- const heredoc = bodies[0]?.trim();
82
- const resolution = resolveGit(rest);
83
- const commit = resolution.invocations.find((g) => g.sub === "commit");
84
- if (commit === undefined) {
85
- return resolution.opaque && /\bcommit\b/.test(command) ? unknown() : { kind: "not-commit" };
86
- }
87
- const options = readOptions(commit.args);
88
- if (options.file === "-" && heredoc !== undefined) return { kind: "message", message: heredoc };
89
- if (options.file !== undefined) return { kind: "file", path: options.file };
90
- if (options.opaque) {
94
+ const STDIN_PATHS = new Set(["-", "/dev/stdin", "/proc/self/fd/0"]);
95
+
96
+ /** The heredoc that is this commit's input: the first one written after the word `commit`. */
97
+ const heredocFor = (command: string, bodies: readonly Heredoc[]): string | undefined => {
98
+ const from = command.search(/\bcommit\b/);
99
+ return bodies.find((h) => h.at >= from)?.body.trim();
100
+ };
101
+
102
+ function extractOne(args: readonly string[], heredoc: string | undefined): CommitExtraction {
103
+ const options = readOptions(args);
104
+ if (options.file !== undefined && STDIN_PATHS.has(options.file)) {
91
105
  return heredoc === undefined
92
106
  ? unknown(options.trailers)
93
107
  : { kind: "message", message: heredoc };
94
108
  }
95
- if (options.messages.length === 0) return unknown(options.trailers);
109
+ if (options.file !== undefined) return { kind: "file", path: options.file };
110
+ const parts = [...options.messages];
111
+ if (options.opaque) {
112
+ if (heredoc === undefined) return unknown(options.trailers);
113
+ parts.push(heredoc);
114
+ }
115
+ if (parts.length === 0) return unknown(options.trailers);
96
116
  const trailers = options.trailers.length === 0 ? "" : `\n\n${options.trailers.join("\n")}`;
97
- return { kind: "message", message: `${options.messages.join("\n\n")}${trailers}` };
117
+ return { kind: "message", message: `${parts.join("\n\n")}${trailers}` };
118
+ }
119
+
120
+ /** One extraction per `git commit` in the command (empty when it commits nothing). */
121
+ export function extractCommits(command: string): CommitExtraction[] {
122
+ const { rest, bodies } = stripHeredocs(command);
123
+ const resolution = resolveGit(rest);
124
+ const heredoc = heredocFor(command, bodies);
125
+ const commits = resolution.invocations.filter((g) => g.sub === "commit");
126
+ const found = commits.map((g) => extractOne(g.args, heredoc));
127
+ const hidden = resolution.opaque && /\bcommit\b/.test(command);
128
+ return hidden ? [...found, unknown()] : found;
129
+ }
130
+
131
+ /** The first commit a shell command makes: its message, or why it cannot be known. */
132
+ export function extractCommit(command: string): CommitExtraction {
133
+ return extractCommits(command)[0] ?? { kind: "not-commit" };
98
134
  }
@@ -93,13 +93,36 @@ export function findForbiddenTrailers(
93
93
  });
94
94
  }
95
95
 
96
+ const AI_TRAILER_ANYWHERE = /(?:co-authored-by|generated-by)\s*[:=]/gi;
97
+
98
+ /** AI-attribution trailer keys anywhere in free text (command lines, quoted strings, printf bodies). */
99
+ export function findForbiddenTrailerKeys(text: string): string[] {
100
+ return [...text.matchAll(AI_TRAILER_ANYWHERE)].map((m) => m[0].trim());
101
+ }
102
+
96
103
  const isBullets = (paragraph: string): boolean =>
97
104
  paragraph.split("\n").every((line) => /^\s*(?:[-*+]|\d+[.)])\s/.test(line));
98
105
 
99
106
  const wordCount = (text: string): number => text.split(/\s+/).filter((w) => w !== "").length;
100
107
 
108
+ const PROSE_TRAILER_WORDS = 5;
109
+
110
+ /** A trailer-shaped line whose value is a sentence ("Reason: the cache was stale…") is prose. */
111
+ const isProseTrailer = (line: string): boolean => {
112
+ const m = TRAILER.exec(line.trim());
113
+ return m !== null && m[1] !== "BREAKING CHANGE" && wordCount(m[2] ?? "") >= PROSE_TRAILER_WORDS;
114
+ };
115
+
101
116
  /** A body needs at least one prose paragraph (not a file list) that says more than a few words. */
102
117
  export function hasRationaleBody(message: string): boolean {
103
- const { body } = split(message);
104
- return body.some((paragraph) => !isBullets(paragraph) && wordCount(paragraph) >= 5);
118
+ const { body, trailers } = split(message);
119
+ const trailerBlockLines =
120
+ withoutComments(message)
121
+ .split(/\n\s*\n/)
122
+ .at(-1)
123
+ ?.split("\n") ?? [];
124
+ const proseTrailer = trailers.length > 0 && trailerBlockLines.some(isProseTrailer);
125
+ return (
126
+ proseTrailer || body.some((paragraph) => !isBullets(paragraph) && wordCount(paragraph) >= 5)
127
+ );
105
128
  }
@@ -106,7 +106,15 @@ export const DATA_ONLY = new Set([
106
106
  ]);
107
107
  export const SHELLS = new Set(["bash", "sh", "zsh", "dash", "ksh"]);
108
108
  export const OPAQUE = new Set(["xargs", "ssh", "find", "parallel", "watch"]);
109
- export const GLOBAL_WITH_VALUE = new Set(["-c", "-C", "--git-dir", "--work-tree", "--namespace"]);
109
+ export const GLOBAL_WITH_VALUE = new Set([
110
+ "-c",
111
+ "-C",
112
+ "--git-dir",
113
+ "--work-tree",
114
+ "--namespace",
115
+ "--config-env",
116
+ "--super-prefix",
117
+ ]);
110
118
  const HOOK_SKIP_ENV = new Set(["LEFTHOOK=0", "HUSKY=0"]);
111
119
 
112
120
  export const basename = (path: string): string => path.slice(path.lastIndexOf("/") + 1);
@@ -14,7 +14,12 @@ import {
14
14
  WRAPPERS,
15
15
  } from "./git-intent.ts";
16
16
 
17
- export type GitInvocation = { readonly sub: string; readonly args: readonly string[] };
17
+ export type GitInvocation = {
18
+ readonly sub: string;
19
+ readonly args: readonly string[];
20
+ /** Directory the command runs in, relative to the starting one (from `cd` and `git -C`), when it moved. */
21
+ readonly dir?: string;
22
+ };
18
23
 
19
24
  export type GitResolution = {
20
25
  readonly invocations: readonly GitInvocation[];
@@ -22,7 +27,7 @@ export type GitResolution = {
22
27
  readonly opaque: boolean;
23
28
  };
24
29
 
25
- type Collector = { invocations: GitInvocation[]; opaque: boolean };
30
+ type Collector = { invocations: GitInvocation[]; opaque: boolean; dir: string | undefined };
26
31
 
27
32
  const firstCommand = (tokens: readonly string[]): number => {
28
33
  const at = tokens.findIndex((t) => !(isAssignment(t) || WRAPPERS.has(t) || KEYWORDS.has(t)));
@@ -32,16 +37,24 @@ const firstCommand = (tokens: readonly string[]): number => {
32
37
  const gitIndex = (tokens: readonly string[]): number =>
33
38
  tokens.findIndex((t) => basename(t) === "git");
34
39
 
40
+ const joinDir = (base: string | undefined, next: string): string =>
41
+ next.startsWith("/") || base === undefined ? next : `${base}/${next}`;
42
+
35
43
  /** `git [global options] <sub> <args…>` (tokens start at the word after `git`). */
36
44
  function visitGit(rest: readonly string[], into: Collector): void {
37
45
  let j = 0;
46
+ let dir = into.dir;
38
47
  while (j < rest.length && (rest[j] ?? "").startsWith("-")) {
48
+ if (rest[j] === "-C") dir = joinDir(dir, rest[j + 1] ?? ".");
39
49
  j += GLOBAL_WITH_VALUE.has(rest[j] ?? "") ? 2 : 1;
40
50
  }
41
51
  const sub = rest[j];
42
52
  if (sub === undefined) return;
43
53
  if (sub.startsWith("$")) into.opaque = true;
44
- else into.invocations.push({ sub, args: rest.slice(j + 1) });
54
+ else {
55
+ const args = rest.slice(j + 1);
56
+ into.invocations.push(dir === undefined ? { sub, args } : { sub, args, dir });
57
+ }
45
58
  }
46
59
 
47
60
  /** A shell or `eval`: its script is more command text to resolve. */
@@ -55,12 +68,20 @@ function visitScript(base: string, rest: readonly string[], into: Collector): vo
55
68
  if (script !== undefined) merge(into, resolveGit(script));
56
69
  }
57
70
 
71
+ /** `cd <dir>`: later git commands run there (a `$`-built target is unknowable, so it is ignored). */
72
+ function visitCd(rest: readonly string[], into: Collector): void {
73
+ const target = rest.find((t) => !t.startsWith("-"));
74
+ if (target === undefined || /[$`]/.test(target) || target === "-") return;
75
+ into.dir = joinDir(into.dir, target);
76
+ }
77
+
58
78
  function visitSegment(tokens: readonly string[], into: Collector): void {
59
79
  const head = tokens[firstCommand(tokens)];
60
80
  if (head === undefined) return;
61
81
  const rest = tokens.slice(firstCommand(tokens) + 1);
62
82
  const base = basename(head);
63
83
  if (head.startsWith("$")) into.opaque = true;
84
+ else if (base === "cd") visitCd(rest, into);
64
85
  else if (SHELLS.has(base) || base === "eval") visitScript(base, rest, into);
65
86
  else if (OPAQUE.has(base)) into.opaque = into.opaque || gitIndex(rest) >= 0;
66
87
  else if (base === "git") visitGit(rest, into);
@@ -80,7 +101,7 @@ function merge(into: Collector, other: GitResolution): void {
80
101
  * `timeout`, `if … then`), `bash -c`/`eval` scripts and `$(…)` substitutions. Heredoc bodies are data.
81
102
  */
82
103
  export function resolveGit(command: string): GitResolution {
83
- const into: Collector = { invocations: [], opaque: false };
104
+ const into: Collector = { invocations: [], opaque: false, dir: undefined };
84
105
  let heredocEnd: string | undefined;
85
106
  for (const line of splitLines(command)) {
86
107
  if (heredocEnd !== undefined) {
@@ -5,6 +5,8 @@ export type PushTarget = {
5
5
  /** Destination branch names named by refspecs; empty means "the current branch's default". */
6
6
  readonly branches: readonly string[];
7
7
  readonly allBranches: boolean;
8
+ /** Where the push runs when `cd`/`git -C` moved it away from the session's directory. */
9
+ readonly dir?: string | undefined;
8
10
  };
9
11
 
10
12
  const VALUE_OPTIONS = new Set(["--repo", "-o", "--push-option", "--receive-pack", "--exec"]);
@@ -12,7 +14,7 @@ const isDynamic = (refspec: string): boolean => /[$`*?[]/.test(refspec);
12
14
 
13
15
  const destination = (refspec: string): string => {
14
16
  const dst = refspec.includes(":") ? refspec.slice(refspec.lastIndexOf(":") + 1) : refspec;
15
- return dst.replace(/^\+/, "").replace(/^refs\/heads\//, "");
17
+ return dst.replace(/^\+/, "").replace(/^(?:refs\/)?heads\//, "");
16
18
  };
17
19
 
18
20
  type ParsedArgs = { positional: string[]; all: boolean; repo: string | undefined };
@@ -34,7 +36,7 @@ function positionalArgs(args: readonly string[]): ParsedArgs {
34
36
  return parsed;
35
37
  }
36
38
 
37
- const toTarget = (args: readonly string[]): PushTarget => {
39
+ const toTarget = (args: readonly string[], dir: string | undefined): PushTarget => {
38
40
  const { positional, all, repo } = positionalArgs(args);
39
41
  // With --repo, every positional argument is a refspec.
40
42
  const [remote, ...refspecs] = repo === undefined ? positional : [repo, ...positional];
@@ -44,6 +46,7 @@ const toTarget = (args: readonly string[]): PushTarget => {
44
46
  remote,
45
47
  branches: destinations.filter(named),
46
48
  allBranches: all || destinations.some(isDynamic),
49
+ ...(dir === undefined ? {} : { dir }),
47
50
  };
48
51
  };
49
52
 
@@ -51,7 +54,7 @@ const toTarget = (args: readonly string[]): PushTarget => {
51
54
  export function pushTargets(command: string): PushTarget[] {
52
55
  const resolution = resolveGit(command);
53
56
  const targets = resolution.invocations.flatMap((g) =>
54
- g.sub === "push" ? [toTarget(g.args)] : [],
57
+ g.sub === "push" ? [toTarget(g.args, g.dir)] : [],
55
58
  );
56
59
  // git run through something opaque (xargs, `$CMD`): a push cannot be ruled out, so assume the worst.
57
60
  const opaquePush = resolution.opaque && /\bpush\b/.test(command);
@@ -15,11 +15,15 @@ export function redactSecrets(text: string): string {
15
15
  "$1$2[redacted]$2",
16
16
  )
17
17
  .replace(
18
- /\b(\w*(?:token|key|secret|password|passwd|pwd)\w*\s*(?::=|[:=])[ \t]*)[^\s"'`,;]{4,}/gi,
18
+ /\b(\w*(?:token|key|secret|password|passwd|passphrase|pwd)\w*\s*(?::=|[:=])[ \t]*)[^\s"'`,;]{4,}/gi,
19
19
  "$1[redacted]",
20
20
  )
21
21
  .replace(/\bsk-[A-Za-z0-9_-]{16,}/g, "[redacted]")
22
22
  .replace(/\bAKIA[0-9A-Z]{16}\b/g, "[redacted]")
23
+ .replace(/\b[sr]k_(?:live|test)_[A-Za-z0-9]{16,}/g, "[redacted]")
24
+ .replace(/\bAIza[0-9A-Za-z_-]{35}/g, "[redacted]")
25
+ .replace(/(\s-u\s+[^\s:]+:)\S+/g, "$1[redacted]")
26
+ .replace(/\b(Cookie:\s*)[^\n]+/gi, "$1[redacted]")
23
27
  .replace(/\bxox[abprs]-[A-Za-z0-9-]{10,}/g, "[redacted]")
24
28
  .replace(/\bglpat-[A-Za-z0-9_-]{16,}/g, "[redacted]")
25
29
  .replace(/\bnpm_[A-Za-z0-9]{30,}/g, "[redacted]")
@@ -1,12 +1,13 @@
1
- import { readFileSync } from "node:fs";
1
+ import { readFileSync, statSync } from "node:fs";
2
2
  import { resolve } from "node:path";
3
3
  import {
4
4
  type ExtensionAPI,
5
5
  type ExtensionContext,
6
6
  isToolCallEventType,
7
7
  } from "@earendil-works/pi-coding-agent";
8
- import { type CommitExtraction, extractCommit } from "../core/commit-command.ts";
8
+ import { type CommitExtraction, extractCommits } from "../core/commit-command.ts";
9
9
  import {
10
+ findForbiddenTrailerKeys,
10
11
  findForbiddenTrailers,
11
12
  hasRationaleBody,
12
13
  parseConventionalCommit,
@@ -38,7 +39,9 @@ const MIXED = gateId("commit.mixed-change");
38
39
 
39
40
  const readMessageFile = (cwd: string, path: string): string | undefined => {
40
41
  try {
41
- return readFileSync(resolve(cwd, path), "utf8");
42
+ const file = resolve(cwd, path);
43
+ if (!statSync(file).isFile()) return undefined; // never read a FIFO or device (it can block)
44
+ return readFileSync(file, "utf8");
42
45
  } catch {
43
46
  return undefined;
44
47
  }
@@ -108,34 +111,52 @@ const blockReason = (need: Need): string =>
108
111
  `departing is deliberate call devsys_record_departure with gate "${need.gate}", what you are doing ` +
109
112
  "instead, why, and the cost if wrong; then retry.";
110
113
 
114
+ const forbiddenReason = (found: readonly string[]): string =>
115
+ `commit.forbidden-trailer: this commit carries an AI attribution (${found.join("; ")}). ` +
116
+ "Commits in this repository have no Co-Authored-By or generated-by trailers and this is not " +
117
+ "something to depart from. Remove the trailer and commit again.";
118
+
119
+ /** AI trailers in any message, --trailer value, or the command text itself (heredocs, printf, echo). */
120
+ function forbiddenIn(
121
+ command: string,
122
+ extractions: readonly CommitExtraction[],
123
+ messages: readonly (string | undefined)[],
124
+ ): string[] {
125
+ const trailers = extractions.flatMap((e) => (e.kind === "unknown" ? e.trailers : []));
126
+ const text = [...messages, ...trailers].join("\n");
127
+ return [...findForbiddenTrailers(text), ...findForbiddenTrailerKeys(command)];
128
+ }
129
+
130
+ async function needsOf(
131
+ deps: CommitGuardDeps,
132
+ ctx: ExtensionContext,
133
+ message: string,
134
+ ): Promise<Need[]> {
135
+ const needs = messageNeeds(message);
136
+ const bodyPresent = !needs.some((n) => n.why.startsWith("the message has no body"));
137
+ const viaJev = await jevNeeds(deps, ctx, message, bodyPresent);
138
+ return [...needs, ...viaJev.filter((n) => !needs.some((m) => m.gate === n.gate))];
139
+ }
140
+
111
141
  /** Soft gates on `git commit`: rationale, Conventional shape, structural/behavioural separation; AI trailers are refused. */
112
142
  export function registerCommitGuard(deps: CommitGuardDeps): void {
113
143
  deps.pi.on("tool_call", async (event, ctx) => {
114
144
  if (!isToolCallEventType("bash", event)) return undefined;
115
145
  const command = event.input.command;
116
- const extracted = extractCommit(command);
117
- if (extracted.kind === "not-commit") return undefined;
118
- const message = messageOf(extracted, ctx.cwd);
119
-
120
- // Safety net: whatever the message source, the command text itself must not carry an AI trailer
121
- // (heredocs written to a file first, --trailer values on amends, messages built elsewhere).
122
- const trailers = extracted.kind === "unknown" ? extracted.trailers.join("\n") : "";
123
- const forbidden = findForbiddenTrailers(`${message ?? ""}\n${trailers}\n${command}`);
124
- if (forbidden.length > 0) {
125
- return {
126
- block: true,
127
- reason:
128
- `commit.forbidden-trailer: this message carries an AI attribution (${forbidden.join("; ")}). ` +
129
- "Commits in this repository have no Co-Authored-By or generated-by trailers and this is not " +
130
- "something to depart from. Remove the trailer and commit again.",
131
- };
132
- }
133
- if (message === undefined) return undefined;
146
+ const extractions = extractCommits(command);
147
+ if (extractions.length === 0) return undefined;
148
+ const messages = extractions.map((e) => messageOf(e, ctx.cwd));
149
+
150
+ const forbidden = forbiddenIn(command, extractions, messages);
151
+ if (forbidden.length > 0) return { block: true, reason: forbiddenReason(forbidden) };
134
152
 
135
- const needs = messageNeeds(message);
136
- const bodyPresent = !needs.some((n) => n.why.startsWith("the message has no body"));
137
- const viaJev = await jevNeeds(deps, ctx, message, bodyPresent);
138
- const all = [...needs, ...viaJev.filter((n) => !needs.some((m) => m.gate === n.gate))];
153
+ const known = messages.flatMap((m) => (m === undefined ? [] : [m]));
154
+ const all: Need[] = [];
155
+ for (const message of known) {
156
+ for (const need of await needsOf(deps, ctx, message)) {
157
+ if (!all.some((n) => n.gate === need.gate)) all.push(need);
158
+ }
159
+ }
139
160
  const uses = all.map((need) => ({ need, use: departureUse(deps.state, need.gate) }));
140
161
  const uncovered = uses.find(({ use }) => !use.hasOpen());
141
162
  if (uncovered !== undefined) return { block: true, reason: blockReason(uncovered.need) };
@@ -1,3 +1,4 @@
1
+ import { resolve } from "node:path";
1
2
  import {
2
3
  type ExtensionAPI,
3
4
  type ExtensionContext,
@@ -52,7 +53,8 @@ async function pushesTrunk(
52
53
  ): Promise<boolean> {
53
54
  for (const t of targets) {
54
55
  if (t.allBranches || t.branches.includes(trunk)) return true;
55
- if (t.branches.length === 0 && (await currentBranch(exec, cwd)) === trunk) return true;
56
+ const where = t.dir === undefined ? cwd : resolve(cwd, t.dir);
57
+ if (t.branches.length === 0 && (await currentBranch(exec, where)) === trunk) return true;
56
58
  }
57
59
  return false;
58
60
  }
@@ -73,9 +75,11 @@ async function repairsRedTrunk(
73
75
  const range = `${config.delivery.remote}/${config.delivery.trunk}..HEAD`;
74
76
  const diff = await deps.exec("git", ["diff", range], { cwd: ctx.cwd, timeout: 10_000 });
75
77
  if (diff.code !== 0 || diff.stdout.trim() === "") return false;
78
+ const failingLog = await getFailureLog(deps.exec, ctx.cwd, runId);
79
+ if (failingLog.trim() === "") return false; // nothing to compare the diff against: fail closed
76
80
  const judged = await judgeFixRelated(deps.jev(ctx), {
77
81
  diff: diff.stdout,
78
- failingLog: await getFailureLog(deps.exec, ctx.cwd, runId),
82
+ failingLog,
79
83
  message: message.stdout,
80
84
  });
81
85
  return judged.ok && judged.value >= FIX_RELATED_THRESHOLD;