@jwilger/pi-development-system 0.8.2 → 0.8.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/package.json +1 -1
- package/skills/delivery-discipline/SKILL.md +12 -2
- package/src/core/commit-command.ts +44 -33
- package/src/core/commit-message.ts +2 -1
- package/src/core/git-invocations.ts +22 -9
- package/src/core/push-command.ts +31 -5
- package/src/gates/commit-guard.ts +21 -6
- package/src/gates/push-guard.ts +11 -2
- package/src/state/ci-watch.ts +5 -1
package/package.json
CHANGED
|
@@ -22,6 +22,14 @@ the issue time. Sessions outliving their grant is a security defect, so the
|
|
|
22
22
|
check now uses expiry; clock-skew tolerance is deliberately not added.
|
|
23
23
|
```
|
|
24
24
|
|
|
25
|
+
```text
|
|
26
|
+
refactor(billing): extract invoice totals into a pure function
|
|
27
|
+
|
|
28
|
+
Totals were computed inline in three handlers, so each fix had to land three
|
|
29
|
+
times. Behaviour is unchanged (tests untouched); the next commit adds the
|
|
30
|
+
rounding rule on top of this seam.
|
|
31
|
+
```
|
|
32
|
+
|
|
25
33
|
- Do not write a body that only restates the diff. The diff already says what;
|
|
26
34
|
the body is the only place the reason survives.
|
|
27
35
|
- Do not add `Co-Authored-By`, "Generated with" or any AI trailer. This is
|
|
@@ -41,8 +49,10 @@ check now uses expiry; clock-skew tolerance is deliberately not added.
|
|
|
41
49
|
|
|
42
50
|
Do not start unrelated work while trunk CI is red. Fix it first; a red build
|
|
43
51
|
hides every later failure. A push to a red trunk is a hard stop unless the
|
|
44
|
-
commit is
|
|
45
|
-
|
|
52
|
+
commit is `fix(ci): …` (or another `fix`), touches only what the failing log names, and
|
|
53
|
+
stays small (an unpushed diff over 8000 characters is never exempt); Jev must read it as
|
|
54
|
+
only repairing the failing run, which needs the failing log and Jev online. Otherwise the
|
|
55
|
+
push is a hard stop. If `gh`
|
|
46
56
|
is missing or unauthenticated, CI reads as unknown and the push is allowed:
|
|
47
57
|
check CI by other means before relying on it.
|
|
48
58
|
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { splitLines } from "./git-intent.ts";
|
|
1
2
|
import { resolveGit } from "./git-invocations.ts";
|
|
2
3
|
|
|
3
4
|
export type CommitExtraction =
|
|
@@ -7,20 +8,21 @@ export type CommitExtraction =
|
|
|
7
8
|
| { readonly kind: "unknown"; readonly trailers: readonly string[] };
|
|
8
9
|
|
|
9
10
|
const HEREDOC =
|
|
10
|
-
/<<-?[ \t]*(['"]?)([A-Za-z_][A-Za-z0-9_]*)\1[^\n]
|
|
11
|
-
|
|
12
|
-
/**
|
|
13
|
-
|
|
14
|
-
|
|
11
|
+
/<<-?[ \t]*(['"]?)([A-Za-z_][A-Za-z0-9_]*)\1([^\n]*)\n([\s\S]*?)\n[ \t]*\2[ \t]*(?:\n|$)/g;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Heredoc bodies are data: take them out so their quotes cannot confuse line splitting. The rest of
|
|
15
|
+
* the opening line stays (`| git commit -F -`) with a `#HD<n>` marker naming the removed body.
|
|
16
|
+
*/
|
|
17
|
+
function stripHeredocs(command: string): { rest: string; bodies: string[] } {
|
|
18
|
+
const bodies: string[] = [];
|
|
15
19
|
const rest = command.replace(HEREDOC, (...m: unknown[]) => {
|
|
16
|
-
bodies.push(
|
|
17
|
-
return
|
|
20
|
+
bodies.push(String(m[4]).trim());
|
|
21
|
+
return `${String(m[3])} #HD${bodies.length - 1}\n`;
|
|
18
22
|
});
|
|
19
23
|
return { rest, bodies };
|
|
20
24
|
}
|
|
21
25
|
|
|
22
|
-
type Heredoc = { readonly body: string; readonly at: number };
|
|
23
|
-
|
|
24
26
|
type Options = {
|
|
25
27
|
messages: string[];
|
|
26
28
|
trailers: string[];
|
|
@@ -35,10 +37,11 @@ type Parsed = {
|
|
|
35
37
|
readonly next: number;
|
|
36
38
|
};
|
|
37
39
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
["--
|
|
41
|
-
["--
|
|
40
|
+
/** Option, kind and the shortest prefix git still resolves uniquely (`--fi` would be `--fixup`). */
|
|
41
|
+
const LONG: ReadonlyArray<readonly [string, Parsed["kind"], number]> = [
|
|
42
|
+
["--message", "message", 4],
|
|
43
|
+
["--file", "file", 5],
|
|
44
|
+
["--trailer", "trailer", 4],
|
|
42
45
|
];
|
|
43
46
|
|
|
44
47
|
/** The option a `--name`/`--na=value` spelling means; git accepts any unambiguous prefix (3+ chars). */
|
|
@@ -46,11 +49,9 @@ function longOption(arg: string): { kind: Parsed["kind"]; inline: string | undef
|
|
|
46
49
|
if (!arg.startsWith("--")) return undefined;
|
|
47
50
|
const eq = arg.indexOf("=");
|
|
48
51
|
const name = eq < 0 ? arg : arg.slice(0, eq);
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
return hit
|
|
52
|
-
? undefined
|
|
53
|
-
: { kind: hit[1], inline: eq < 0 ? undefined : arg.slice(eq + 1) };
|
|
52
|
+
const hit = LONG.find(([long, , min]) => name.length >= min && long.startsWith(name));
|
|
53
|
+
if (hit === undefined) return undefined;
|
|
54
|
+
return { kind: hit[1], inline: eq < 0 ? undefined : arg.slice(eq + 1) };
|
|
54
55
|
}
|
|
55
56
|
|
|
56
57
|
/** Reads the message/file/trailer option starting at args[i], in any of its spellings. */
|
|
@@ -71,17 +72,22 @@ function parseArg(args: readonly string[], i: number): Parsed | undefined {
|
|
|
71
72
|
return undefined;
|
|
72
73
|
}
|
|
73
74
|
|
|
75
|
+
/** A value that is itself a substitution or variable (not prose that mentions backticks). */
|
|
76
|
+
const OPAQUE_VALUE = /^\s*(?:\$\(|`)|^\$\{?\w+\}?$/;
|
|
77
|
+
|
|
78
|
+
function record(options: Options, parsed: Parsed): void {
|
|
79
|
+
if (parsed.kind === "file") options.file = parsed.value;
|
|
80
|
+
else if (parsed.kind === "trailer") options.trailers.push(parsed.value ?? "");
|
|
81
|
+
else if (parsed.value === undefined || OPAQUE_VALUE.test(parsed.value)) options.opaque = true;
|
|
82
|
+
else options.messages.push(parsed.value);
|
|
83
|
+
}
|
|
84
|
+
|
|
74
85
|
function readOptions(args: readonly string[]): Options {
|
|
75
86
|
const options: Options = { messages: [], trailers: [], file: undefined, opaque: false };
|
|
76
|
-
let i = 0;
|
|
77
|
-
while (i < args.length) {
|
|
87
|
+
for (let i = 0; i < args.length; ) {
|
|
78
88
|
const parsed = parseArg(args, i);
|
|
89
|
+
if (parsed !== undefined) record(options, parsed);
|
|
79
90
|
i = parsed?.next ?? i + 1;
|
|
80
|
-
if (parsed === undefined) continue;
|
|
81
|
-
if (parsed.kind === "file") options.file = parsed.value;
|
|
82
|
-
else if (parsed.kind === "trailer") options.trailers.push(parsed.value ?? "");
|
|
83
|
-
else if (parsed.value === undefined || /\$\(|`/.test(parsed.value)) options.opaque = true;
|
|
84
|
-
else options.messages.push(parsed.value);
|
|
85
91
|
}
|
|
86
92
|
return options;
|
|
87
93
|
}
|
|
@@ -92,18 +98,22 @@ const unknown = (trailers: readonly string[] = []): CommitExtraction => ({
|
|
|
92
98
|
});
|
|
93
99
|
|
|
94
100
|
const STDIN_PATHS = new Set(["-", "/dev/stdin", "/proc/self/fd/0"]);
|
|
101
|
+
const MARKER = /#HD(\d+)/g;
|
|
95
102
|
|
|
96
|
-
/**
|
|
97
|
-
const
|
|
98
|
-
const
|
|
99
|
-
|
|
103
|
+
/** Body of the first heredoc opened on this line or later (its stdin, or the `$(cat <<EOF)` message). */
|
|
104
|
+
const heredocFrom = (lines: readonly string[], line: number, bodies: readonly string[]) => {
|
|
105
|
+
for (const text of lines.slice(line)) {
|
|
106
|
+
const hit = [...text.matchAll(MARKER)][0];
|
|
107
|
+
if (hit !== undefined) return bodies[Number(hit[1])];
|
|
108
|
+
}
|
|
109
|
+
return undefined;
|
|
100
110
|
};
|
|
101
111
|
|
|
102
112
|
function extractOne(args: readonly string[], heredoc: string | undefined): CommitExtraction {
|
|
103
113
|
const options = readOptions(args);
|
|
104
114
|
if (options.file !== undefined && STDIN_PATHS.has(options.file)) {
|
|
105
115
|
return heredoc === undefined
|
|
106
|
-
?
|
|
116
|
+
? { kind: "file", path: "-" }
|
|
107
117
|
: { kind: "message", message: heredoc };
|
|
108
118
|
}
|
|
109
119
|
if (options.file !== undefined) return { kind: "file", path: options.file };
|
|
@@ -121,9 +131,10 @@ function extractOne(args: readonly string[], heredoc: string | undefined): Commi
|
|
|
121
131
|
export function extractCommits(command: string): CommitExtraction[] {
|
|
122
132
|
const { rest, bodies } = stripHeredocs(command);
|
|
123
133
|
const resolution = resolveGit(rest);
|
|
124
|
-
const
|
|
125
|
-
const
|
|
126
|
-
|
|
134
|
+
const lines = splitLines(rest);
|
|
135
|
+
const found = resolution.invocations.flatMap((g) =>
|
|
136
|
+
g.sub === "commit" ? [extractOne(g.args, heredocFrom(lines, g.line, bodies))] : [],
|
|
137
|
+
);
|
|
127
138
|
const hidden = resolution.opaque && /\bcommit\b/.test(command);
|
|
128
139
|
return hidden ? [...found, unknown()] : found;
|
|
129
140
|
}
|
|
@@ -93,7 +93,8 @@ export function findForbiddenTrailers(
|
|
|
93
93
|
});
|
|
94
94
|
}
|
|
95
95
|
|
|
96
|
-
const AI_TRAILER_ANYWHERE =
|
|
96
|
+
const AI_TRAILER_ANYWHERE =
|
|
97
|
+
/(?:co-authored-by|generated-by)\s*[:=]|signed-off-by\s*[:=][^\n]*\b(?:ai|claude|copilot|codex|gpt|chatgpt|openai|anthropic|gemini|llm)\b/gi;
|
|
97
98
|
|
|
98
99
|
/** AI-attribution trailer keys anywhere in free text (command lines, quoted strings, printf bodies). */
|
|
99
100
|
export function findForbiddenTrailerKeys(text: string): string[] {
|
|
@@ -19,6 +19,8 @@ export type GitInvocation = {
|
|
|
19
19
|
readonly args: readonly string[];
|
|
20
20
|
/** Directory the command runs in, relative to the starting one (from `cd` and `git -C`), when it moved. */
|
|
21
21
|
readonly dir?: string;
|
|
22
|
+
/** Index of the (newline-split) line of the top-level command this invocation sits on. */
|
|
23
|
+
readonly line: number;
|
|
22
24
|
};
|
|
23
25
|
|
|
24
26
|
export type GitResolution = {
|
|
@@ -27,7 +29,12 @@ export type GitResolution = {
|
|
|
27
29
|
readonly opaque: boolean;
|
|
28
30
|
};
|
|
29
31
|
|
|
30
|
-
type Collector = {
|
|
32
|
+
type Collector = {
|
|
33
|
+
invocations: GitInvocation[];
|
|
34
|
+
opaque: boolean;
|
|
35
|
+
dir: string | undefined;
|
|
36
|
+
line: number;
|
|
37
|
+
};
|
|
31
38
|
|
|
32
39
|
const firstCommand = (tokens: readonly string[]): number => {
|
|
33
40
|
const at = tokens.findIndex((t) => !(isAssignment(t) || WRAPPERS.has(t) || KEYWORDS.has(t)));
|
|
@@ -53,7 +60,8 @@ function visitGit(rest: readonly string[], into: Collector): void {
|
|
|
53
60
|
if (sub.startsWith("$")) into.opaque = true;
|
|
54
61
|
else {
|
|
55
62
|
const args = rest.slice(j + 1);
|
|
56
|
-
|
|
63
|
+
const line = into.line;
|
|
64
|
+
into.invocations.push(dir === undefined ? { sub, args, line } : { sub, args, dir, line });
|
|
57
65
|
}
|
|
58
66
|
}
|
|
59
67
|
|
|
@@ -85,14 +93,17 @@ function visitSegment(tokens: readonly string[], into: Collector): void {
|
|
|
85
93
|
else if (SHELLS.has(base) || base === "eval") visitScript(base, rest, into);
|
|
86
94
|
else if (OPAQUE.has(base)) into.opaque = into.opaque || gitIndex(rest) >= 0;
|
|
87
95
|
else if (base === "git") visitGit(rest, into);
|
|
88
|
-
else
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
96
|
+
else visitWrapped(base, rest, into);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Wrapper with options (`sudo -E git …`, `timeout 5 git …`): resolve from the git token. */
|
|
100
|
+
function visitWrapped(base: string, rest: readonly string[], into: Collector): void {
|
|
101
|
+
const at = gitIndex(rest);
|
|
102
|
+
if (!DATA_ONLY.has(base) && at >= 0) visitSegment(rest.slice(at), into);
|
|
92
103
|
}
|
|
93
104
|
|
|
94
105
|
function merge(into: Collector, other: GitResolution): void {
|
|
95
|
-
into.invocations.push(...other.invocations);
|
|
106
|
+
into.invocations.push(...other.invocations.map((g) => ({ ...g, line: into.line })));
|
|
96
107
|
if (other.opaque) into.opaque = true;
|
|
97
108
|
}
|
|
98
109
|
|
|
@@ -101,9 +112,11 @@ function merge(into: Collector, other: GitResolution): void {
|
|
|
101
112
|
* `timeout`, `if … then`), `bash -c`/`eval` scripts and `$(…)` substitutions. Heredoc bodies are data.
|
|
102
113
|
*/
|
|
103
114
|
export function resolveGit(command: string): GitResolution {
|
|
104
|
-
const into: Collector = { invocations: [], opaque: false, dir: undefined };
|
|
115
|
+
const into: Collector = { invocations: [], opaque: false, dir: undefined, line: 0 };
|
|
105
116
|
let heredocEnd: string | undefined;
|
|
106
|
-
|
|
117
|
+
const lines = splitLines(command);
|
|
118
|
+
for (const [index, line] of lines.entries()) {
|
|
119
|
+
into.line = index;
|
|
107
120
|
if (heredocEnd !== undefined) {
|
|
108
121
|
if (line.trim() === heredocEnd) heredocEnd = undefined;
|
|
109
122
|
continue;
|
package/src/core/push-command.ts
CHANGED
|
@@ -5,6 +5,10 @@ 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
|
+
/** The refspecs name `HEAD`, so the current branch is pushed too. */
|
|
9
|
+
readonly usesHead: boolean;
|
|
10
|
+
/** `--tags` with no refspec: only tags are pushed, no branch. */
|
|
11
|
+
readonly tagsOnly: boolean;
|
|
8
12
|
/** Where the push runs when `cd`/`git -C` moved it away from the session's directory. */
|
|
9
13
|
readonly dir?: string | undefined;
|
|
10
14
|
};
|
|
@@ -12,17 +16,26 @@ export type PushTarget = {
|
|
|
12
16
|
const VALUE_OPTIONS = new Set(["--repo", "-o", "--push-option", "--receive-pack", "--exec"]);
|
|
13
17
|
const isDynamic = (refspec: string): boolean => /[$`*?[]/.test(refspec);
|
|
14
18
|
|
|
19
|
+
/** A dry run pushes nothing. */
|
|
20
|
+
const isDryRun = (args: readonly string[]): boolean =>
|
|
21
|
+
args.some((a) => a === "--dry-run" || a === "-n" || /^-[a-zA-Z]*n[a-zA-Z]*$/.test(a));
|
|
22
|
+
|
|
15
23
|
const destination = (refspec: string): string => {
|
|
16
24
|
const dst = refspec.includes(":") ? refspec.slice(refspec.lastIndexOf(":") + 1) : refspec;
|
|
17
25
|
return dst.replace(/^\+/, "").replace(/^(?:refs\/)?heads\//, "");
|
|
18
26
|
};
|
|
19
27
|
|
|
20
|
-
type ParsedArgs = {
|
|
28
|
+
type ParsedArgs = {
|
|
29
|
+
positional: string[];
|
|
30
|
+
all: boolean;
|
|
31
|
+
tags: boolean;
|
|
32
|
+
repo: string | undefined;
|
|
33
|
+
};
|
|
21
34
|
|
|
22
35
|
const VALUE_PREFIX = "--repo=";
|
|
23
36
|
|
|
24
37
|
function positionalArgs(args: readonly string[]): ParsedArgs {
|
|
25
|
-
const parsed: ParsedArgs = { positional: [], all: false, repo: undefined };
|
|
38
|
+
const parsed: ParsedArgs = { positional: [], all: false, tags: false, repo: undefined };
|
|
26
39
|
let valueFor: "repo" | "skip" | undefined;
|
|
27
40
|
for (const a of args) {
|
|
28
41
|
if (valueFor === "repo") parsed.repo = a;
|
|
@@ -31,13 +44,14 @@ function positionalArgs(args: readonly string[]): ParsedArgs {
|
|
|
31
44
|
else if (a.startsWith(VALUE_PREFIX)) parsed.repo = a.slice(VALUE_PREFIX.length);
|
|
32
45
|
else if (VALUE_OPTIONS.has(a)) valueFor = "skip";
|
|
33
46
|
else if (a === "--all" || a === "--mirror") parsed.all = true;
|
|
47
|
+
else if (a === "--tags") parsed.tags = true;
|
|
34
48
|
else if (!a.startsWith("-")) parsed.positional.push(a);
|
|
35
49
|
}
|
|
36
50
|
return parsed;
|
|
37
51
|
}
|
|
38
52
|
|
|
39
53
|
const toTarget = (args: readonly string[], dir: string | undefined): PushTarget => {
|
|
40
|
-
const { positional, all, repo } = positionalArgs(args);
|
|
54
|
+
const { positional, all, tags, repo } = positionalArgs(args);
|
|
41
55
|
// With --repo, every positional argument is a refspec.
|
|
42
56
|
const [remote, ...refspecs] = repo === undefined ? positional : [repo, ...positional];
|
|
43
57
|
const destinations = refspecs.map(destination);
|
|
@@ -46,6 +60,8 @@ const toTarget = (args: readonly string[], dir: string | undefined): PushTarget
|
|
|
46
60
|
remote,
|
|
47
61
|
branches: destinations.filter(named),
|
|
48
62
|
allBranches: all || destinations.some(isDynamic),
|
|
63
|
+
usesHead: destinations.includes("HEAD"),
|
|
64
|
+
tagsOnly: tags && refspecs.length === 0 && !all,
|
|
49
65
|
...(dir === undefined ? {} : { dir }),
|
|
50
66
|
};
|
|
51
67
|
};
|
|
@@ -54,11 +70,21 @@ const toTarget = (args: readonly string[], dir: string | undefined): PushTarget
|
|
|
54
70
|
export function pushTargets(command: string): PushTarget[] {
|
|
55
71
|
const resolution = resolveGit(command);
|
|
56
72
|
const targets = resolution.invocations.flatMap((g) =>
|
|
57
|
-
g.sub === "push" ? [toTarget(g.args, g.dir)] : [],
|
|
73
|
+
g.sub === "push" && !isDryRun(g.args) ? [toTarget(g.args, g.dir)] : [],
|
|
58
74
|
);
|
|
59
75
|
// git run through something opaque (xargs, `$CMD`): a push cannot be ruled out, so assume the worst.
|
|
60
76
|
const opaquePush = resolution.opaque && /\bpush\b/.test(command);
|
|
61
77
|
return opaquePush
|
|
62
|
-
? [
|
|
78
|
+
? [
|
|
79
|
+
...targets,
|
|
80
|
+
{
|
|
81
|
+
remote: undefined,
|
|
82
|
+
branches: [],
|
|
83
|
+
allBranches: true,
|
|
84
|
+
usesHead: false,
|
|
85
|
+
tagsOnly: false,
|
|
86
|
+
dir: undefined,
|
|
87
|
+
},
|
|
88
|
+
]
|
|
63
89
|
: targets;
|
|
64
90
|
}
|
|
@@ -61,15 +61,21 @@ function messageNeeds(message: string): Need[] {
|
|
|
61
61
|
: [{ gate: RATIONALE, why: "the message has no body explaining why the change was made" }];
|
|
62
62
|
}
|
|
63
63
|
|
|
64
|
-
/**
|
|
64
|
+
/** `-a`/`--all`, or a `git add` in the same command, widen the commit beyond what is staged. */
|
|
65
|
+
const stagesEverything = (command: string): boolean =>
|
|
66
|
+
/(?:^|\s)(?:-[a-zA-Z]*a[a-zA-Z]*|--all|--include)(?:\s|$)|\bgit\s+add\b/.test(command);
|
|
67
|
+
|
|
68
|
+
/** What is about to be committed: the staged changes, or every tracked change when the commit stages them itself. */
|
|
65
69
|
async function pendingDiff(
|
|
66
70
|
exec: Exec,
|
|
67
71
|
cwd: string,
|
|
72
|
+
command: string,
|
|
68
73
|
): Promise<{ stat: string; diff: string } | undefined> {
|
|
69
74
|
try {
|
|
75
|
+
const base = stagesEverything(command) ? ["diff", "HEAD"] : ["diff", "--cached"];
|
|
70
76
|
const [stat, diff] = await Promise.all([
|
|
71
|
-
exec("git", [
|
|
72
|
-
exec("git",
|
|
77
|
+
exec("git", [...base, "--stat"], { cwd, timeout: 10_000 }),
|
|
78
|
+
exec("git", base, { cwd, timeout: 10_000 }),
|
|
73
79
|
]);
|
|
74
80
|
if (stat.code !== 0 || diff.code !== 0 || diff.stdout.trim() === "") return undefined;
|
|
75
81
|
return { stat: stat.stdout, diff: diff.stdout };
|
|
@@ -83,8 +89,9 @@ async function jevNeeds(
|
|
|
83
89
|
ctx: ExtensionContext,
|
|
84
90
|
message: string,
|
|
85
91
|
bodyPresent: boolean,
|
|
92
|
+
command: string,
|
|
86
93
|
): Promise<Need[]> {
|
|
87
|
-
const pending = await pendingDiff(deps.exec, ctx.cwd);
|
|
94
|
+
const pending = await pendingDiff(deps.exec, ctx.cwd, command);
|
|
88
95
|
if (pending === undefined) return [];
|
|
89
96
|
const judged = await judgeCommit(deps.jev(ctx), {
|
|
90
97
|
message,
|
|
@@ -131,10 +138,11 @@ async function needsOf(
|
|
|
131
138
|
deps: CommitGuardDeps,
|
|
132
139
|
ctx: ExtensionContext,
|
|
133
140
|
message: string,
|
|
141
|
+
command: string,
|
|
134
142
|
): Promise<Need[]> {
|
|
135
143
|
const needs = messageNeeds(message);
|
|
136
144
|
const bodyPresent = !needs.some((n) => n.why.startsWith("the message has no body"));
|
|
137
|
-
const viaJev = await jevNeeds(deps, ctx, message, bodyPresent);
|
|
145
|
+
const viaJev = await jevNeeds(deps, ctx, message, bodyPresent, command);
|
|
138
146
|
return [...needs, ...viaJev.filter((n) => !needs.some((m) => m.gate === n.gate))];
|
|
139
147
|
}
|
|
140
148
|
|
|
@@ -152,8 +160,15 @@ export function registerCommitGuard(deps: CommitGuardDeps): void {
|
|
|
152
160
|
|
|
153
161
|
const known = messages.flatMap((m) => (m === undefined ? [] : [m]));
|
|
154
162
|
const all: Need[] = [];
|
|
163
|
+
// A -F file that cannot be read now (written by this same command, or stdin) cannot be checked.
|
|
164
|
+
if (extractions.some((e, i) => e.kind === "file" && messages[i] === undefined)) {
|
|
165
|
+
all.push({
|
|
166
|
+
gate: RATIONALE,
|
|
167
|
+
why: "the message file cannot be read before the commit runs, so its rationale cannot be checked; use -m or a heredoc",
|
|
168
|
+
});
|
|
169
|
+
}
|
|
155
170
|
for (const message of known) {
|
|
156
|
-
for (const need of await needsOf(deps, ctx, message)) {
|
|
171
|
+
for (const need of await needsOf(deps, ctx, message, command)) {
|
|
157
172
|
if (!all.some((n) => n.gate === need.gate)) all.push(need);
|
|
158
173
|
}
|
|
159
174
|
}
|
package/src/gates/push-guard.ts
CHANGED
|
@@ -32,6 +32,7 @@ const gate = (id: string): GateId => {
|
|
|
32
32
|
if (isParseError(parsed)) throw new Error(parsed.message);
|
|
33
33
|
return parsed;
|
|
34
34
|
};
|
|
35
|
+
const FIX_DIFF_LIMIT = 8000;
|
|
35
36
|
const RED_TRUNK = gate("push.red-trunk");
|
|
36
37
|
const DELIVERY_MODE = gate("push.delivery-mode");
|
|
37
38
|
|
|
@@ -54,7 +55,12 @@ async function pushesTrunk(
|
|
|
54
55
|
for (const t of targets) {
|
|
55
56
|
if (t.allBranches || t.branches.includes(trunk)) return true;
|
|
56
57
|
const where = t.dir === undefined ? cwd : resolve(cwd, t.dir);
|
|
57
|
-
if (t.
|
|
58
|
+
if (t.tagsOnly) continue;
|
|
59
|
+
if (t.branches.length === 0 || t.usesHead) {
|
|
60
|
+
// No refspec: the current branch decides. When it cannot be read, assume it is the trunk.
|
|
61
|
+
const branch = await currentBranch(exec, where);
|
|
62
|
+
if (branch === undefined || branch === trunk) return true;
|
|
63
|
+
}
|
|
58
64
|
}
|
|
59
65
|
return false;
|
|
60
66
|
}
|
|
@@ -74,7 +80,10 @@ async function repairsRedTrunk(
|
|
|
74
80
|
if (message.code !== 0 || !parsed.ok || parsed.value.type !== "fix") return false;
|
|
75
81
|
const range = `${config.delivery.remote}/${config.delivery.trunk}..HEAD`;
|
|
76
82
|
const diff = await deps.exec("git", ["diff", range], { cwd: ctx.cwd, timeout: 10_000 });
|
|
77
|
-
|
|
83
|
+
// Jev only reads a bounded diff; a longer range could hide unrelated change past what it sees.
|
|
84
|
+
if (diff.code !== 0 || diff.stdout.trim() === "" || diff.stdout.length > FIX_DIFF_LIMIT) {
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
78
87
|
const failingLog = await getFailureLog(deps.exec, ctx.cwd, runId);
|
|
79
88
|
if (failingLog.trim() === "") return false; // nothing to compare the diff against: fail closed
|
|
80
89
|
const judged = await judgeFixRelated(deps.jev(ctx), {
|
package/src/state/ci-watch.ts
CHANGED
|
@@ -23,7 +23,11 @@ export async function watchCi(options: WatchOptions): Promise<CiState> {
|
|
|
23
23
|
branch: options.branch,
|
|
24
24
|
cwd: options.cwd,
|
|
25
25
|
});
|
|
26
|
-
|
|
26
|
+
// `gh` missing or no runs at all (no sha) is unknown, not a run that has yet to appear.
|
|
27
|
+
const stale =
|
|
28
|
+
options.expectSha !== undefined &&
|
|
29
|
+
trunk.headSha !== undefined &&
|
|
30
|
+
trunk.headSha !== options.expectSha;
|
|
27
31
|
const status = stale ? "pending" : trunk.status;
|
|
28
32
|
last = trunk.headSha === undefined ? { status } : { status, sha: trunk.headSha };
|
|
29
33
|
options.onObserved(last);
|