pi-gauntlet 8.3.0 → 8.4.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/CHANGELOG.md +4 -0
- package/README.md +3 -3
- package/bin/gauntlet-performance.mjs +477 -64
- package/bin/gauntlet-performance.test.mjs +27 -27
- package/bin/gauntlet-spec-index.mjs +290 -47
- package/bin/gauntlet-spec-index.test.mjs +42 -18
- package/bin/gauntlet-telemetry-seal.mjs +220 -77
- package/bin/gauntlet-telemetry-seal.test.mjs +222 -457
- package/extensions/lib/gauntlet-settings.test.ts +12 -14
- package/extensions/lib/gauntlet-settings.ts +7 -16
- package/extensions/lib/telemetry-branch.test.ts +102 -0
- package/extensions/lib/telemetry-branch.ts +145 -0
- package/extensions/lib/telemetry-diff.test.ts +2 -2
- package/extensions/lib/telemetry-diff.ts +1 -1
- package/extensions/lib/telemetry-leg.ts +163 -0
- package/extensions/lib/telemetry-record.ts +2 -0
- package/extensions/lib/vcs.ts +6 -23
- package/extensions/telemetry-leg-failures.test.ts +138 -0
- package/extensions/telemetry-leg-fields.test.ts +130 -0
- package/extensions/telemetry-leg-no-write.test.ts +90 -0
- package/extensions/telemetry-leg-rounds.test.ts +157 -0
- package/extensions/telemetry-leg-trackers.test.ts +114 -0
- package/extensions/telemetry-leg.test.ts +151 -0
- package/extensions/telemetry-settlement-contract.test.ts +67 -0
- package/extensions/telemetry.test.ts +44 -27
- package/extensions/telemetry.ts +68 -48
- package/extensions/test-support/git-env.mjs +13 -0
- package/package.json +1 -1
- package/skills/brainstorming/SKILL.md +1 -1
- package/skills/check-delivery/SKILL.md +2 -0
- package/skills/finishing-a-development-branch/SKILL.md +13 -13
- package/skills/finishing-a-development-branch/reference/verification-set.md +2 -2
- package/skills/gatekeep-pr/reference/batch.md +3 -1
- package/skills/gatekeep-pr/reference/cleanup.md +1 -1
- package/skills/gatekeep-pr/reference/post-selection-loop.md +7 -1
- package/skills/gauntlet-resume/reference/seed.md +1 -6
- package/skills/shape-ticket/SKILL.md +1 -1
- package/skills/shape-ticket/reference/repo-audit.md +2 -2
|
@@ -12,7 +12,6 @@ import {
|
|
|
12
12
|
planDirsFor,
|
|
13
13
|
resolveVerifyBeforeShip,
|
|
14
14
|
resolveTelemetry,
|
|
15
|
-
DEFAULT_TELEMETRY_DIR,
|
|
16
15
|
DEFAULT_TELEMETRY_BUCKETS,
|
|
17
16
|
DEFAULT_TEST_COMMANDS,
|
|
18
17
|
buildTestCmdRegex,
|
|
@@ -250,11 +249,10 @@ test("verifyBeforeShip: default vs override", () => {
|
|
|
250
249
|
assert.equal(o.warningReference, "doc/t.md");
|
|
251
250
|
});
|
|
252
251
|
|
|
253
|
-
test("telemetry: absent block -> enabled, default
|
|
252
|
+
test("telemetry: absent block -> enabled, default buckets, no directory key or warning", () => {
|
|
254
253
|
const r = resolveTelemetry({});
|
|
255
254
|
assert.equal(r.enabled, true);
|
|
256
|
-
assert.equal(r
|
|
257
|
-
assert.equal(r.dir, ".pi/gauntlet/telemetry");
|
|
255
|
+
assert.equal("dir" in r, false);
|
|
258
256
|
assert.deepEqual(r.buckets, DEFAULT_TELEMETRY_BUCKETS);
|
|
259
257
|
assert.equal(r.warning, undefined);
|
|
260
258
|
});
|
|
@@ -265,10 +263,10 @@ test("telemetry: enabled false is the only way to disable", () => {
|
|
|
265
263
|
assert.equal(resolveTelemetry({ telemetry: { enabled: 0 } }).enabled, true);
|
|
266
264
|
});
|
|
267
265
|
|
|
268
|
-
test("telemetry: dir
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
266
|
+
test("telemetry: retired dir setting is ignored with one migration warning", () => {
|
|
267
|
+
for (const dir of [
|
|
268
|
+
"telemetry/",
|
|
269
|
+
"a/../b",
|
|
272
270
|
"",
|
|
273
271
|
" ",
|
|
274
272
|
5,
|
|
@@ -284,9 +282,9 @@ test("telemetry: dir accepts a non-empty relative path, otherwise default + warn
|
|
|
284
282
|
"a/../../outside",
|
|
285
283
|
"..",
|
|
286
284
|
]) {
|
|
287
|
-
const r = resolveTelemetry({ telemetry: { dir
|
|
288
|
-
assert.equal(r
|
|
289
|
-
assert.
|
|
285
|
+
const r = resolveTelemetry(mergeGauntlet(undefined, { telemetry: { dir } }));
|
|
286
|
+
assert.equal("dir" in r, false);
|
|
287
|
+
assert.equal(r.warning, "piGauntlet.telemetry.dir is retired; records live on the gauntlet/telemetry branch - set piGauntlet.telemetry.remote to redirect them");
|
|
290
288
|
}
|
|
291
289
|
});
|
|
292
290
|
|
|
@@ -307,9 +305,9 @@ test("telemetry: malformed buckets -> default + warning", () => {
|
|
|
307
305
|
}
|
|
308
306
|
});
|
|
309
307
|
|
|
310
|
-
test("telemetry: dir and buckets
|
|
311
|
-
const r = resolveTelemetry({ telemetry: { dir: 1, buckets: 2 } });
|
|
312
|
-
assert.
|
|
308
|
+
test("telemetry: retired dir and invalid buckets each warn once", () => {
|
|
309
|
+
const r = resolveTelemetry(mergeGauntlet(undefined, { telemetry: { dir: 1, buckets: 2 } }));
|
|
310
|
+
assert.equal(r.warning, "piGauntlet.telemetry.dir is retired; records live on the gauntlet/telemetry branch - set piGauntlet.telemetry.remote to redirect them; telemetry.buckets is not an object of non-empty glob arrays; using the defaults");
|
|
313
311
|
});
|
|
314
312
|
|
|
315
313
|
test("DEFAULT_TEST_COMMANDS + buildTestCmdRegex match the documented entrypoints", () => {
|
|
@@ -13,7 +13,7 @@ export interface PiGauntlet {
|
|
|
13
13
|
escalationLoop?: { implModel?: unknown };
|
|
14
14
|
loopEngine?: { maxIterationsPerRow?: unknown; escalateAfter?: unknown; maxIterations?: unknown };
|
|
15
15
|
phaseModels?: { plan?: unknown; implement?: unknown; verify?: unknown; ship?: unknown };
|
|
16
|
-
telemetry?: { enabled?: unknown;
|
|
16
|
+
telemetry?: { enabled?: unknown; remote?: unknown; buckets?: unknown };
|
|
17
17
|
}
|
|
18
18
|
|
|
19
19
|
// Whole-object second-level merge: each piGauntlet key present in the repo layer
|
|
@@ -230,7 +230,7 @@ export const DEFAULT_TELEMETRY_BUCKETS: [string, string[]][] = [
|
|
|
230
230
|
|
|
231
231
|
export interface TelemetryResolved {
|
|
232
232
|
enabled: boolean;
|
|
233
|
-
|
|
233
|
+
remote?: string;
|
|
234
234
|
buckets: [string, string[]][];
|
|
235
235
|
warning: string | undefined;
|
|
236
236
|
}
|
|
@@ -239,19 +239,8 @@ export function resolveTelemetry(g: PiGauntlet): TelemetryResolved {
|
|
|
239
239
|
const t = g.telemetry;
|
|
240
240
|
const warnings: string[] = [];
|
|
241
241
|
const enabled = t?.enabled !== false;
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
if (t?.dir !== undefined) {
|
|
245
|
-
const value = nonEmptyString(t.dir) ? t.dir.trim().replace(/\/+$/, "") : "";
|
|
246
|
-
const canonical = value.replace(/\\/g, "/");
|
|
247
|
-
const normalized = path.posix.normalize(canonical);
|
|
248
|
-
if (
|
|
249
|
-
value &&
|
|
250
|
-
path.win32.parse(canonical).root === "" &&
|
|
251
|
-
normalized !== ".." &&
|
|
252
|
-
!normalized.startsWith("../")
|
|
253
|
-
) dir = normalized;
|
|
254
|
-
else warnings.push("telemetry.dir must be a non-empty path relative to the git toplevel; using the default");
|
|
242
|
+
if (t && Object.hasOwn(t, "dir")) {
|
|
243
|
+
warnings.push("piGauntlet.telemetry.dir is retired; records live on the gauntlet/telemetry branch - set piGauntlet.telemetry.remote to redirect them");
|
|
255
244
|
}
|
|
256
245
|
|
|
257
246
|
let buckets = DEFAULT_TELEMETRY_BUCKETS;
|
|
@@ -267,7 +256,9 @@ export function resolveTelemetry(g: PiGauntlet): TelemetryResolved {
|
|
|
267
256
|
else warnings.push("telemetry.buckets is not an object of non-empty glob arrays; using the defaults");
|
|
268
257
|
}
|
|
269
258
|
|
|
270
|
-
|
|
259
|
+
const remote = nonEmptyString(t?.remote) ? t.remote.trim() : undefined;
|
|
260
|
+
if (t?.remote !== undefined && !remote) warnings.push("telemetry.remote must be a non-empty URL; using origin");
|
|
261
|
+
return { enabled, ...(remote ? { remote } : {}), buckets, warning: joinWarn(warnings) };
|
|
271
262
|
}
|
|
272
263
|
|
|
273
264
|
export function settingsErrorWarning(errors: string[]): string {
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { spawnSync } from "node:child_process";
|
|
3
|
+
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync, chmodSync } from "node:fs";
|
|
4
|
+
import { isolatedGitEnv } from "../test-support/git-env.mjs";
|
|
5
|
+
import { tmpdir } from "node:os";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import { test } from "node:test";
|
|
8
|
+
|
|
9
|
+
const library = new URL("./telemetry-branch.ts", import.meta.url).href;
|
|
10
|
+
|
|
11
|
+
function fixture() {
|
|
12
|
+
const scratch = mkdtempSync(join(tmpdir(), "telemetry-identity-"));
|
|
13
|
+
const root = join(scratch, "primary product");
|
|
14
|
+
const home = join(scratch, "home");
|
|
15
|
+
mkdirSync(root); mkdirSync(home);
|
|
16
|
+
const env = { ...process.env, HOME: home, ...isolatedGitEnv(scratch) };
|
|
17
|
+
const run = (binary: string, args: string[], cwd = root) => {
|
|
18
|
+
const result = spawnSync(binary, args, { cwd, env, encoding: "utf8", timeout: 10_000 });
|
|
19
|
+
assert.equal(result.status, 0, `${binary} ${args.join(" ")}: ${result.stderr}`);
|
|
20
|
+
return result.stdout.trim();
|
|
21
|
+
};
|
|
22
|
+
const git = (...args: string[]) => run("git", ["-c", "commit.gpgsign=false", ...args]);
|
|
23
|
+
git("init", "-b", "main");
|
|
24
|
+
const identity = (cwd = root) => JSON.parse(run(process.execPath, ["--input-type=module", "--eval",
|
|
25
|
+
`import { telemetryRepo } from ${JSON.stringify(library)}; console.log(JSON.stringify(telemetryRepo(process.cwd()) ?? null));`,
|
|
26
|
+
], cwd));
|
|
27
|
+
return { scratch, root, git, identity, run };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
test("repository identity normalizes origin URL forms and keeps every namespace segment", async (t) => {
|
|
31
|
+
const f = fixture();
|
|
32
|
+
for (const [name, url, expected] of [
|
|
33
|
+
["scp", "git@host.test:team/product.git", "team/product"],
|
|
34
|
+
["ssh", "ssh://git@host.test/team/product.git", "team/product"],
|
|
35
|
+
["ssh port and nested namespace", "ssh://git@host.test:2222/group/sub/product.git/", "group/sub/product"],
|
|
36
|
+
["https", "https://host.test/team/product.git/", "team/product"],
|
|
37
|
+
["nested scp", "git@host.test:group/sub/product.git", "group/sub/product"],
|
|
38
|
+
["nested https", "https://host.test/group/other/product/", "group/other/product"],
|
|
39
|
+
["case preserved", "https://host.test/Team/Product.git", "Team/Product"],
|
|
40
|
+
]) {
|
|
41
|
+
await t.test(name, () => {
|
|
42
|
+
f.git("config", "remote.origin.url", url);
|
|
43
|
+
assert.equal(f.identity(), expected);
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
mkdirSync(join(f.root, ".pi"));
|
|
47
|
+
writeFileSync(join(f.root, ".pi/settings.json"), JSON.stringify({ piGauntlet: { telemetry: { remote: "https://elsewhere.test/central/telemetry.git" } } }));
|
|
48
|
+
assert.equal(f.identity(), "Team/Product", "telemetry.remote never changes product identity");
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("failed telemetry ref commit reports committed: false and preserves the feature index", (t) => {
|
|
52
|
+
const f = fixture();
|
|
53
|
+
t.after(() => rmSync(f.scratch, { recursive: true, force: true }));
|
|
54
|
+
writeFileSync(join(f.root, "file"), "feature\n");
|
|
55
|
+
f.git("add", "file");
|
|
56
|
+
f.git("commit", "-m", "initial");
|
|
57
|
+
const before = f.git("rev-parse", "HEAD");
|
|
58
|
+
const index = readFileSync(join(f.root, ".git/index"));
|
|
59
|
+
const hook = join(f.root, ".git/hooks/reference-transaction");
|
|
60
|
+
writeFileSync(hook, '#!/bin/sh\nif [ "$1" = prepared ]; then echo rejected >&2; exit 1; fi\n');
|
|
61
|
+
chmodSync(hook, 0o755);
|
|
62
|
+
const result = JSON.parse(f.run(process.execPath, ["--input-type=module", "--eval",
|
|
63
|
+
`import { syncTelemetryBranch } from ${JSON.stringify(library)}; console.log(JSON.stringify(syncTelemetryBranch(process.cwd(), new Map([["team/product/brainstorming/x.yaml", "schema: 1\\n"]]))));`,
|
|
64
|
+
]));
|
|
65
|
+
assert.equal(result.ok, false);
|
|
66
|
+
assert.equal(result.committed, false);
|
|
67
|
+
assert.equal(result.pushed, false);
|
|
68
|
+
assert.match(result.reason, /rejected|reference transaction/);
|
|
69
|
+
assert.equal(f.git("rev-parse", "HEAD"), before);
|
|
70
|
+
assert.deepEqual(readFileSync(join(f.root, ".git/index")), index);
|
|
71
|
+
assert.equal(f.git("for-each-ref", "refs/heads/gauntlet/telemetry"), "");
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("origin-less identity uses repo from the first-found overrides Issue tracker section", () => {
|
|
75
|
+
const f = fixture();
|
|
76
|
+
mkdirSync(join(f.root, ".pi"));
|
|
77
|
+
mkdirSync(join(f.root, "doc"));
|
|
78
|
+
writeFileSync(join(f.root, "doc/gauntlet-overrides.md"), "## Issue tracker\n- repo: doc/product\n");
|
|
79
|
+
assert.equal(f.identity(), "doc/product");
|
|
80
|
+
writeFileSync(join(f.root, "gauntlet-overrides.md"), "## Issue tracker\n- repo: root/product\n");
|
|
81
|
+
assert.equal(f.identity(), "root/product");
|
|
82
|
+
writeFileSync(join(f.root, ".pi/gauntlet-overrides.md"), "## Other\n- repo: wrong/product\n## Issue tracker\n- tracker: none\n- repo: group/sub/product\n## Later\n- repo: also/wrong\n");
|
|
83
|
+
assert.equal(f.identity(), "group/sub/product");
|
|
84
|
+
f.git("config", "remote.origin.url", "git@host.test:origin/product.git");
|
|
85
|
+
assert.equal(f.identity(), "origin/product", "origin takes precedence over overrides");
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("local fallback uses the primary directory from linked worktrees and ignores later overrides", () => {
|
|
89
|
+
const f = fixture();
|
|
90
|
+
assert.equal(f.identity(), "local/primary product");
|
|
91
|
+
mkdirSync(join(f.root, ".pi"));
|
|
92
|
+
writeFileSync(join(f.root, ".pi/gauntlet-overrides.md"), "## Issue tracker\n- repo:\n- tracker: none\n## Other\n- repo: wrong/product\n");
|
|
93
|
+
writeFileSync(join(f.root, "gauntlet-overrides.md"), "## Issue tracker\n- repo: ignored/product\n");
|
|
94
|
+
assert.equal(f.identity(), "local/primary product");
|
|
95
|
+
f.git("commit", "--allow-empty", "-m", "initial");
|
|
96
|
+
const linked = join(f.scratch, "different worktree");
|
|
97
|
+
f.git("worktree", "add", "-b", "feature", linked);
|
|
98
|
+
const nested = join(linked, "nested");
|
|
99
|
+
mkdirSync(nested);
|
|
100
|
+
assert.equal(f.identity(linked), "local/primary product");
|
|
101
|
+
assert.equal(f.identity(nested), "local/primary product");
|
|
102
|
+
});
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { basename, join } from "node:path";
|
|
4
|
+
import { checkoutOfSync } from "./checkout.ts";
|
|
5
|
+
import { gitRun } from "./vcs.ts";
|
|
6
|
+
|
|
7
|
+
export const TELEMETRY_REF = "refs/heads/gauntlet/telemetry";
|
|
8
|
+
|
|
9
|
+
export function trackerRefs(text: string): string[] {
|
|
10
|
+
const tokens = text.match(/https?:\/\/[^\s<>\])]+|[\w.-]+\/[\w.-]+#\d+|#\d+|\b[A-Z][A-Z0-9]*-\d+\b|\bgh-\d+\b/g) ?? [];
|
|
11
|
+
return [...new Set(tokens.map(ref => ref.replace(/[.,;]+$/, ""))
|
|
12
|
+
.filter(ref => !ref.startsWith("http") || /\/(?:issues\/\d+|pull\/\d+|issue\/[A-Z][A-Z0-9]*-\d+)(?:\/[^\s]*)?$/.test(ref))
|
|
13
|
+
.map(ref => ref.startsWith("gh-") ? `#${ref.slice(3)}` : ref))];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function telemetryDir(root: string): string | undefined {
|
|
17
|
+
const result = gitRun(["rev-parse", "--path-format=absolute", "--git-common-dir"], root);
|
|
18
|
+
return result.ok ? join(result.stdout, "gauntlet", "telemetry") : undefined;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function telemetryRepo(root: string): string {
|
|
22
|
+
// Read the configured URL, not get-url's transport rewrite (which may be local).
|
|
23
|
+
const result = gitRun(["config", "--get", "remote.origin.url"], root);
|
|
24
|
+
const match = result.stdout.match(/^(?:https?:\/\/[^/]+\/|ssh:\/\/(?:[^@/]+@)?[^/]+\/|(?:[^@\s]+@)?[^:\s/]+:)(.+)$/);
|
|
25
|
+
const path = match?.[1].replace(/^\/+|\/+$/g, "").replace(/\.git$/, "");
|
|
26
|
+
if (result.ok && path) return path;
|
|
27
|
+
|
|
28
|
+
const top = checkoutOfSync(root)?.toplevel ?? root;
|
|
29
|
+
for (const file of [".pi/gauntlet-overrides.md", "gauntlet-overrides.md", "doc/gauntlet-overrides.md"]) {
|
|
30
|
+
const path = join(top, file);
|
|
31
|
+
if (!existsSync(path)) continue;
|
|
32
|
+
const section = readFileSync(path, "utf8").split(/^##\s+Issue tracker\s*$/m)[1]?.split(/^##\s/m)[0] ?? "";
|
|
33
|
+
const repo = /^-[\t ]*repo:[\t ]*(\S[^\r\n]*)$/m.exec(section)?.[1].trim();
|
|
34
|
+
if (repo) return repo;
|
|
35
|
+
break;
|
|
36
|
+
}
|
|
37
|
+
const worktrees = gitRun(["worktree", "list", "--porcelain", "-z"], root);
|
|
38
|
+
const primary = worktrees.ok ? /^worktree ([^\0]+)/.exec(worktrees.stdout)?.[1] : undefined;
|
|
39
|
+
return `local/${basename(primary ?? top)}`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const trackingRef = (remote: string) => `refs/remotes/${remote === "origin" ? "origin" : "telemetry"}/gauntlet/telemetry`;
|
|
43
|
+
const fetchBranch = (root: string, remote: string) => gitRun(["fetch", "--no-tags", remote, `+${TELEMETRY_REF}:${trackingRef(remote)}`], root);
|
|
44
|
+
|
|
45
|
+
export function syncTelemetryBranch(root: string, records: ReadonlyMap<string, string>, remote = "origin"): { ok: boolean; committed: boolean; pushed: boolean; reason?: string; warning?: string } {
|
|
46
|
+
const scratch = mkdtempSync(join(tmpdir(), "gauntlet-telemetry-index-"));
|
|
47
|
+
const env = { GIT_INDEX_FILE: join(scratch, "index") };
|
|
48
|
+
const git = (args: string[], input?: string) => {
|
|
49
|
+
const result = gitRun(args, root, undefined, { env, input });
|
|
50
|
+
if (!result.ok) throw new Error(result.stderr);
|
|
51
|
+
return result.stdout;
|
|
52
|
+
};
|
|
53
|
+
const tracking = trackingRef(remote);
|
|
54
|
+
let fetchWarning: string | undefined;
|
|
55
|
+
const fetch = () => {
|
|
56
|
+
const result = fetchBranch(root, remote);
|
|
57
|
+
fetchWarning = result.ok ? undefined : `fetch failed: ${result.stderr}`;
|
|
58
|
+
return result;
|
|
59
|
+
};
|
|
60
|
+
const resolve = (ref: string) => {
|
|
61
|
+
const result = gitRun(["rev-parse", "--verify", ref], root);
|
|
62
|
+
return result.ok ? result.stdout : undefined;
|
|
63
|
+
};
|
|
64
|
+
const commit = (tree: string, parent: string | undefined, message: string) =>
|
|
65
|
+
git(["-c", "commit.gpgsign=false", "commit-tree", tree, ...(parent ? ["-p", parent] : []), "-m", message]);
|
|
66
|
+
let committed = false;
|
|
67
|
+
try {
|
|
68
|
+
let tip = resolve(TELEMETRY_REF);
|
|
69
|
+
committed = records.size === 0 && !!tip;
|
|
70
|
+
fetch();
|
|
71
|
+
let upstream = resolve(tracking);
|
|
72
|
+
if (records.size) {
|
|
73
|
+
const parent = tip ?? upstream;
|
|
74
|
+
git(parent ? ["read-tree", parent] : ["read-tree", "--empty"]);
|
|
75
|
+
for (const [path, content] of records) {
|
|
76
|
+
const blob = git(["hash-object", "-w", "--stdin"], content);
|
|
77
|
+
git(["update-index", "--add", "--cacheinfo", "100644", blob, path]);
|
|
78
|
+
}
|
|
79
|
+
const tree = git(["write-tree"]);
|
|
80
|
+
const next = parent && git(["rev-parse", `${parent}^{tree}`]) === tree
|
|
81
|
+
? parent : commit(tree, parent, "telemetry: update records");
|
|
82
|
+
git(["update-ref", TELEMETRY_REF, next, tip ?? ""]);
|
|
83
|
+
tip = next;
|
|
84
|
+
committed = true;
|
|
85
|
+
}
|
|
86
|
+
const pending = () => tip ? git(["rev-list", "--reverse", "--topo-order", tip, ...(upstream ? [`^${upstream}`] : [])]).split("\n").filter(Boolean) : [];
|
|
87
|
+
if (!pending().length) return { ok: true, committed, pushed: false, warning: fetchWarning };
|
|
88
|
+
const push = () => gitRun(["push", remote, `${TELEMETRY_REF}:${TELEMETRY_REF}`], root);
|
|
89
|
+
const first = push();
|
|
90
|
+
if (first.ok) return { ok: true, committed, pushed: true, warning: fetchWarning };
|
|
91
|
+
const fetched = fetch();
|
|
92
|
+
if (!fetched.ok) throw new Error(`${first.stderr}; refresh failed: ${fetched.stderr}`);
|
|
93
|
+
upstream = resolve(tracking);
|
|
94
|
+
let replayed = upstream;
|
|
95
|
+
for (const sha of pending()) {
|
|
96
|
+
git(replayed ? ["read-tree", replayed] : ["read-tree", "--empty"]);
|
|
97
|
+
// Apply only this commit's changes, not its whole tree: remote-only records survive.
|
|
98
|
+
const changes = git(["diff-tree", "--root", "--no-commit-id", "--raw", "--no-abbrev", "--no-renames", "-r", "-z", sha]).split("\0");
|
|
99
|
+
for (let i = 0; i + 1 < changes.length; i += 2) {
|
|
100
|
+
const [, mode, , blob] = changes[i].split(" ");
|
|
101
|
+
const path = changes[i + 1];
|
|
102
|
+
git(mode === "000000" ? ["update-index", "--force-remove", "--", path] : ["update-index", "--add", "--cacheinfo", mode, blob, path]);
|
|
103
|
+
}
|
|
104
|
+
replayed = commit(git(["write-tree"]), replayed, git(["log", "-1", "--format=%B", sha]));
|
|
105
|
+
}
|
|
106
|
+
// Publish the replay atomically only after every pending commit has survived it.
|
|
107
|
+
if (replayed) git(["update-ref", TELEMETRY_REF, replayed, tip!]);
|
|
108
|
+
const second = push();
|
|
109
|
+
if (!second.ok) throw new Error(second.stderr);
|
|
110
|
+
return { ok: true, committed, pushed: true };
|
|
111
|
+
} catch (error) {
|
|
112
|
+
const reason = (error as Error).message;
|
|
113
|
+
return { ok: false, committed, pushed: false, reason, warning: [fetchWarning, `telemetry: not pushed (${reason})`].filter(Boolean).join("; ") };
|
|
114
|
+
} finally {
|
|
115
|
+
rmSync(scratch, { recursive: true, force: true });
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function readTelemetryBranch(root: string, { remote = "origin", prefix = "" }: { remote?: string; prefix?: string } = {}): { records: { path: string; bytes: string }[]; warning?: string } {
|
|
120
|
+
const records: { path: string; bytes: string }[] = [];
|
|
121
|
+
let warning: string | undefined;
|
|
122
|
+
try {
|
|
123
|
+
const synced = syncTelemetryBranch(root, new Map(), remote);
|
|
124
|
+
warning = synced.warning;
|
|
125
|
+
if (synced.pushed) {
|
|
126
|
+
const fetched = fetchBranch(root, remote);
|
|
127
|
+
warning = fetched.ok ? undefined : `fetch failed: ${fetched.stderr}`;
|
|
128
|
+
}
|
|
129
|
+
const tip = gitRun(["rev-parse", "--verify", trackingRef(remote)], root);
|
|
130
|
+
if (!tip.ok) return { records, warning: [warning, "no telemetry ref fetched yet"].filter(Boolean).join("; ") };
|
|
131
|
+
const tree = gitRun(["ls-tree", "-r", "-z", tip.stdout, ...(prefix ? ["--", prefix] : [])], root);
|
|
132
|
+
if (!tree.ok) throw new Error(tree.stderr);
|
|
133
|
+
for (const entry of tree.stdout.split("\0").filter(Boolean)) {
|
|
134
|
+
const [meta, path] = entry.split("\t");
|
|
135
|
+
const [, type, sha] = meta.split(" ");
|
|
136
|
+
if (type !== "blob" || !path.startsWith(prefix) || !path.endsWith(".yaml")) continue;
|
|
137
|
+
const blob = gitRun(["cat-file", "blob", sha], root, undefined, { raw: true });
|
|
138
|
+
if (!blob.ok) throw new Error(blob.stderr);
|
|
139
|
+
records.push({ path, bytes: blob.stdout });
|
|
140
|
+
}
|
|
141
|
+
return { records, warning };
|
|
142
|
+
} catch (error) {
|
|
143
|
+
return { records, warning: [warning, `read failed: ${(error as Error).message}`].filter(Boolean).join("; ") };
|
|
144
|
+
}
|
|
145
|
+
}
|
|
@@ -16,7 +16,7 @@ test("aggregateNumstat sums per bucket over the given file set; binary rows coun
|
|
|
16
16
|
});
|
|
17
17
|
});
|
|
18
18
|
|
|
19
|
-
test("computeGitDiff: merge-base, name-only minus spec/plan/dir, numstat buckets,
|
|
19
|
+
test("computeGitDiff: merge-base, name-only minus spec/plan/dir, numstat buckets, all product commits counted", async () => {
|
|
20
20
|
const calls: string[][] = [];
|
|
21
21
|
const git = (args: string[]) => {
|
|
22
22
|
calls.push(args);
|
|
@@ -31,7 +31,7 @@ test("computeGitDiff: merge-base, name-only minus spec/plan/dir, numstat buckets
|
|
|
31
31
|
assert.deepEqual(out.diff, { base: "abc123", commits: 9, buckets: { code: { files: 1, insertions: 100, deletions: 5 }, test: { files: 1, insertions: 40, deletions: 0 }, docs: { files: 1, insertions: 2, deletions: 1 } } });
|
|
32
32
|
assert.equal(out.warning, undefined);
|
|
33
33
|
assert.deepEqual(calls[0], ["merge-base", "HEAD", "main"]);
|
|
34
|
-
assert.deepEqual(calls.at(-1), ["rev-list", "--count", "
|
|
34
|
+
assert.deepEqual(calls.at(-1), ["rev-list", "--count", "abc123..HEAD"]);
|
|
35
35
|
});
|
|
36
36
|
|
|
37
37
|
test("computeGitDiff: custom plan dir excludes only configured plans", async () => {
|
|
@@ -57,6 +57,6 @@ export async function computeGitDiff(o: { git: Runner; cwd: string; spec: string
|
|
|
57
57
|
const names = await o.git(["diff", "--name-only", `${base}...HEAD`], o.cwd);
|
|
58
58
|
const files = modifiedFilesFrom(names.stdout, o.spec, o.dir, o.planDirs);
|
|
59
59
|
const numstat = await o.git(["diff", "--numstat", `${base}...HEAD`], o.cwd);
|
|
60
|
-
const count = await o.git(["rev-list", "--count",
|
|
60
|
+
const count = await o.git(["rev-list", "--count", `${base}..HEAD`], o.cwd);
|
|
61
61
|
return { modified_files: files, diff: { base, commits: Number(count.stdout.trim()) || 0, buckets: aggregateNumstat(numstat.stdout, new Set(files), o.buckets) } };
|
|
62
62
|
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { stringify } from "yaml";
|
|
3
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
import type { Deps } from "../telemetry.ts";
|
|
5
|
+
import { checkoutOf } from "./checkout.ts";
|
|
6
|
+
import { syncTelemetryBranch, telemetryRepo, trackerRefs } from "./telemetry-branch.ts";
|
|
7
|
+
import { textOf } from "./telemetry-collect.ts";
|
|
8
|
+
import { addTokens, compact, emptyAccumulators, zeroTokens, type Accumulators, type Tokens } from "./telemetry-record.ts";
|
|
9
|
+
|
|
10
|
+
const skills = ["shape-ticket", "gatekeep-pr", "check-delivery"] as const;
|
|
11
|
+
type LegSkill = (typeof skills)[number];
|
|
12
|
+
export interface LegItem {
|
|
13
|
+
kind: "ticket" | "pr";
|
|
14
|
+
key?: string;
|
|
15
|
+
ticket?: string;
|
|
16
|
+
outcome: string;
|
|
17
|
+
}
|
|
18
|
+
export interface LegRecord {
|
|
19
|
+
skill: LegSkill;
|
|
20
|
+
session: string;
|
|
21
|
+
author: { name?: string; email?: string };
|
|
22
|
+
gauntlet_version?: string;
|
|
23
|
+
models: string[];
|
|
24
|
+
tokens: Omit<Tokens, "cost">;
|
|
25
|
+
cost: number;
|
|
26
|
+
repo: string;
|
|
27
|
+
ts: string;
|
|
28
|
+
items: LegItem[];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const legSkill = (name: string | undefined): LegSkill | undefined => skills.find((skill) => skill === name);
|
|
32
|
+
|
|
33
|
+
const ticketRef = (ref: string) => trackerRefs(ref).includes(ref);
|
|
34
|
+
const outcomes: Record<LegSkill, string> = {
|
|
35
|
+
"shape-ticket": "written|created|closed|close failed \\(.*\\)|close by hand|dropped|declined|stopped|no-op|unfetched",
|
|
36
|
+
"gatekeep-pr": "approved|merged|closed-pr|final|merge-queued|stopped|stuck - .+",
|
|
37
|
+
"check-delivery": "delivered|manual|declined|already recorded|skipped|failed - .+",
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export function settlementItems(skill: LegSkill, text: string, trackerless = false): LegItem[] | undefined {
|
|
41
|
+
const [marker, ...lines] = text.split("\n");
|
|
42
|
+
if (!/^gauntlet: (?:done|stuck)\b/.test(marker)) return undefined;
|
|
43
|
+
const row = new RegExp(`^(.+?): (${outcomes[skill]})$`);
|
|
44
|
+
const items = lines.flatMap((line): LegItem[] => {
|
|
45
|
+
let ticket: string | undefined;
|
|
46
|
+
const annotation = / \(([^()]+)\)$/.exec(line);
|
|
47
|
+
if (skill === "gatekeep-pr" && annotation && (trackerless || ticketRef(annotation[1]))) {
|
|
48
|
+
ticket = annotation[1];
|
|
49
|
+
line = line.slice(0, annotation.index);
|
|
50
|
+
}
|
|
51
|
+
const match = row.exec(line);
|
|
52
|
+
if (!match || match[1].startsWith("# ") || match[1].startsWith("#### ")) return [];
|
|
53
|
+
const [, ref, outcome] = match;
|
|
54
|
+
const pr = /^#\d+@[a-f\d]+$/i.test(ref) || /^https?:\/\/\S+\/pull\/\d+(?:@[a-f\d]+)?$/i.test(ref);
|
|
55
|
+
if (trackerless || pr || ticketRef(ref)) return [compact({ kind: pr || skill === "gatekeep-pr" ? "pr" : "ticket", key: ref, ticket, outcome }) as LegItem];
|
|
56
|
+
if (skill === "shape-ticket" && (outcome === "dropped" || outcome === "declined")) return [{ kind: "ticket", outcome }];
|
|
57
|
+
return [];
|
|
58
|
+
});
|
|
59
|
+
return items;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function registerLegTelemetry(pi: ExtensionAPI, deps: Deps, runBound: () => boolean): () => Accumulators | undefined {
|
|
63
|
+
let active: { skill: LegSkill; provisional: boolean; session: string; ts: string; acc: Accumulators; models: Set<string> } | undefined;
|
|
64
|
+
let terminal: string | undefined;
|
|
65
|
+
let lastSkill: LegSkill | undefined;
|
|
66
|
+
const trackerless = (root: string): boolean => {
|
|
67
|
+
for (const path of [".pi/gauntlet-overrides.md", "gauntlet-overrides.md", "doc/gauntlet-overrides.md"]) {
|
|
68
|
+
const text = deps.fs.readFile(join(root, path));
|
|
69
|
+
if (text === undefined) continue;
|
|
70
|
+
const section = text.split(/^##\s+Issue tracker\s*$/m)[1]?.split(/^##\s/m)[0] ?? "";
|
|
71
|
+
return /^-\s*tracker:\s*none\s*$/m.test(section);
|
|
72
|
+
}
|
|
73
|
+
return false;
|
|
74
|
+
};
|
|
75
|
+
const enabled = (ctx: ExtensionContext) => Number(process.env.PI_SUBAGENT_DEPTH ?? "0") === 0 && deps.settings(ctx.cwd).telemetry.enabled;
|
|
76
|
+
const reset = () => { active = undefined; terminal = undefined; };
|
|
77
|
+
const start = (skill: LegSkill | undefined, ctx: ExtensionContext, provisional = false) => {
|
|
78
|
+
if (!skill || !enabled(ctx) || runBound()) return;
|
|
79
|
+
if (active) {
|
|
80
|
+
if (active.provisional && !provisional) {
|
|
81
|
+
active.skill = skill;
|
|
82
|
+
active.provisional = false;
|
|
83
|
+
lastSkill = skill;
|
|
84
|
+
}
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
lastSkill = skill;
|
|
88
|
+
active = { skill, provisional, session: ctx.sessionManager.getSessionId(), ts: deps.now(), acc: emptyAccumulators(), models: new Set() };
|
|
89
|
+
if (ctx.model) active.models.add(`${ctx.model.provider}/${ctx.model.id}`);
|
|
90
|
+
terminal = undefined;
|
|
91
|
+
};
|
|
92
|
+
const notify = (reason: string) => pi.sendMessage({
|
|
93
|
+
customType: "telemetry-warning", content: `telemetry: not pushed (${reason.split("\n")[0]})`, display: true,
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
for (const event of ["session_start", "session_switch", "session_fork", "session_tree", "session_shutdown"] as const) {
|
|
97
|
+
pi.on(event, async () => { reset(); lastSkill = undefined; });
|
|
98
|
+
}
|
|
99
|
+
pi.on("input", async (event, ctx) => {
|
|
100
|
+
terminal = undefined;
|
|
101
|
+
if (event.source !== "extension") start(legSkill(/^\/skill:([^\s]+)/.exec(event.text)?.[1]), ctx);
|
|
102
|
+
});
|
|
103
|
+
pi.on("before_agent_start", async (event, ctx) => {
|
|
104
|
+
terminal = undefined;
|
|
105
|
+
if (event.messageType === "user") {
|
|
106
|
+
const skill = legSkill(/^<skill name="([^"]+)" location="/.exec(event.prompt)?.[1]);
|
|
107
|
+
start(skill ?? lastSkill, ctx, !skill);
|
|
108
|
+
}
|
|
109
|
+
});
|
|
110
|
+
pi.on("tool_call", async (event, ctx) => {
|
|
111
|
+
terminal = undefined;
|
|
112
|
+
if (event.toolName !== "read") return;
|
|
113
|
+
const path = (event.input as { path?: unknown }).path;
|
|
114
|
+
if (typeof path === "string") start(legSkill(/(?:^|\/)skills\/([^/]+)\/SKILL\.md$/.exec(path)?.[1]), ctx);
|
|
115
|
+
});
|
|
116
|
+
pi.on("model_select", async (event, ctx) => {
|
|
117
|
+
if (active && enabled(ctx) && !runBound()) active.models.add(`${event.model.provider}/${event.model.id}`);
|
|
118
|
+
});
|
|
119
|
+
pi.on("message_end", async (event, ctx) => {
|
|
120
|
+
if (event.message.role !== "assistant") return;
|
|
121
|
+
if (active && enabled(ctx) && !runBound()) {
|
|
122
|
+
if (event.message.provider && event.message.model) active.models.add(`${event.message.provider}/${event.message.model}`);
|
|
123
|
+
const text = textOf(event.message.content);
|
|
124
|
+
terminal = /^gauntlet: (?:done|stuck)\b/.test(text) ? text : undefined;
|
|
125
|
+
} else terminal = undefined;
|
|
126
|
+
});
|
|
127
|
+
// agent_end can precede queued follow-on work; only the final live message at settlement seals.
|
|
128
|
+
pi.on("agent_settled", async (event, ctx) => {
|
|
129
|
+
if (!active || !terminal || event.aborted || !enabled(ctx) || runBound()) return;
|
|
130
|
+
const run = active;
|
|
131
|
+
const text = terminal;
|
|
132
|
+
try {
|
|
133
|
+
const checkout = await checkoutOf(ctx.cwd, deps.git, deps.jj);
|
|
134
|
+
if (!checkout || checkout.via !== "git") {
|
|
135
|
+
reset();
|
|
136
|
+
return notify(checkout ? "jj workspace: no git remote" : "no repository");
|
|
137
|
+
}
|
|
138
|
+
const items = settlementItems(run.skill, text, trackerless(checkout.toplevel));
|
|
139
|
+
if (!items) return;
|
|
140
|
+
reset();
|
|
141
|
+
const repo = (deps.telemetryRepo ?? telemetryRepo)(checkout.toplevel);
|
|
142
|
+
const [name, email] = await Promise.all([
|
|
143
|
+
deps.git(["config", "user.name"], checkout.toplevel),
|
|
144
|
+
deps.git(["config", "user.email"], checkout.toplevel),
|
|
145
|
+
]);
|
|
146
|
+
const { cost, ...tokens } = Object.values(run.acc.phases).reduce((total, phase) => addTokens(total, phase.tokens ?? zeroTokens()), zeroTokens());
|
|
147
|
+
for (const persona of Object.values(run.acc.personas)) for (const model of persona.models) run.models.add(model);
|
|
148
|
+
const record: LegRecord = {
|
|
149
|
+
skill: run.skill, session: run.session, ts: run.ts, repo, items,
|
|
150
|
+
author: compact({ name: name.stdout.trim() || undefined, email: email.stdout.trim() || undefined }),
|
|
151
|
+
gauntlet_version: deps.settings(ctx.cwd).versions["pi-gauntlet"], models: [...run.models], tokens, cost,
|
|
152
|
+
};
|
|
153
|
+
const stamp = run.ts.replace(/[-:]/g, "");
|
|
154
|
+
const path = `${repo}/${run.skill}/${stamp}-${run.session}.yaml`;
|
|
155
|
+
const result = (deps.syncTelemetryBranch ?? syncTelemetryBranch)(checkout.toplevel, new Map([[path, stringify(record)]]), deps.settings(ctx.cwd).telemetry.remote);
|
|
156
|
+
if (!result.ok) notify(result.reason ?? "unknown failure");
|
|
157
|
+
} catch (error) {
|
|
158
|
+
reset();
|
|
159
|
+
notify(String((error as Error).message ?? error));
|
|
160
|
+
}
|
|
161
|
+
});
|
|
162
|
+
return () => runBound() ? undefined : active?.acc;
|
|
163
|
+
}
|
|
@@ -103,6 +103,8 @@ export const validLegs = (legs: unknown): legs is TelemetryLeg[] => Array.isArra
|
|
|
103
103
|
((typeof leg.sha === "string" && leg.sha.trim().length > 0 && leg.pr === undefined) ||
|
|
104
104
|
(typeof leg.pr === "string" && /^https?:\/\/\S+$/.test(leg.pr) && leg.sha === undefined)));
|
|
105
105
|
export interface TelemetryRecord {
|
|
106
|
+
repo?: string;
|
|
107
|
+
tickets?: string[];
|
|
106
108
|
legs?: TelemetryLeg[];
|
|
107
109
|
schema: 1; spec: string; run_id: string; branch?: string; status: RecordStatus; created_at: string; approved_at?: string; shipped_at?: string; abandoned_at?: string;
|
|
108
110
|
supersedes?: string[]; fixes?: string[]; versions?: Record<string, string>; author?: { name?: string; email?: string }; agent_overrides?: Record<string, unknown>;
|
package/extensions/lib/vcs.ts
CHANGED
|
@@ -6,21 +6,23 @@ import { checkoutOfSync } from "./checkout.ts";
|
|
|
6
6
|
import { computeGitDiff, computeJjDiff, JJ_FORK_POINT, type Buckets, type DiffOutcome, type RunResult } from "./telemetry-diff.ts";
|
|
7
7
|
|
|
8
8
|
export interface VcsResult extends RunResult { ok: boolean }
|
|
9
|
-
export type VcsRun = (args: string[], cwd: string, timeoutMs?: number) => VcsResult;
|
|
9
|
+
export type VcsRun = (args: string[], cwd: string, timeoutMs?: number, options?: { input?: string; env?: NodeJS.ProcessEnv; raw?: boolean }) => VcsResult;
|
|
10
10
|
export interface Vcs { kind: "git" | "jj"; root: string; run: VcsRun }
|
|
11
11
|
|
|
12
12
|
// Prompts off so a credential or editor request cannot hang a non-interactive caller.
|
|
13
13
|
const NO_PROMPT_ENV = { ...process.env, GIT_TERMINAL_PROMPT: "0", GIT_EDITOR: "true" };
|
|
14
14
|
|
|
15
|
-
const runBinary = (bin: "git" | "jj"): VcsRun => (args, cwd, timeoutMs = 10_000) => {
|
|
15
|
+
const runBinary = (bin: "git" | "jj"): VcsRun => (args, cwd, timeoutMs = 10_000, options = {}) => {
|
|
16
16
|
// jj diff --git returns full patches; spawnSync's 1 MiB default buffer truncates them (ENOBUFS).
|
|
17
|
-
const r = spawnSync(bin, args, { cwd, encoding: "utf8", env: NO_PROMPT_ENV, timeout: timeoutMs, maxBuffer: 64 * 1024 * 1024 });
|
|
17
|
+
const r = spawnSync(bin, args, { cwd, encoding: "utf8", input: options.input, env: { ...NO_PROMPT_ENV, ...options.env }, timeout: timeoutMs, maxBuffer: 64 * 1024 * 1024 });
|
|
18
18
|
const err = r.error as NodeJS.ErrnoException | undefined;
|
|
19
19
|
const timedOut = err?.code === "ETIMEDOUT";
|
|
20
20
|
const stderr = timedOut ? "timed out" : (r.stderr ?? "").trim().split("\n")[0] || err?.message || `${bin} exited ${r.status}`;
|
|
21
|
-
return { ok: !timedOut && r.status === 0, code: timedOut ? 1 : (r.status ?? 1), stdout: (r.stdout ?? "").trim(), stderr };
|
|
21
|
+
return { ok: !timedOut && r.status === 0, code: timedOut ? 1 : (r.status ?? 1), stdout: options.raw ? (r.stdout ?? "") : (r.stdout ?? "").trim(), stderr };
|
|
22
22
|
};
|
|
23
23
|
|
|
24
|
+
export const gitRun: VcsRun = runBinary("git");
|
|
25
|
+
|
|
24
26
|
export function vcsFor(absPath: string): Vcs | undefined {
|
|
25
27
|
const co = checkoutOfSync(absPath);
|
|
26
28
|
return co && { kind: co.via, root: co.toplevel, run: runBinary(co.via) };
|
|
@@ -53,22 +55,3 @@ export function changedNames(vcs: Vcs, base: string): VcsResult {
|
|
|
53
55
|
if (!fork) return { ok: false, code: 1, stdout: "", stderr: "jj mainline unresolved (trunk() is root(); no main/master bookmark)" };
|
|
54
56
|
return vcs.run(["--color=never", "diff", "--name-only", "--from", fork, "--to", "@"], vcs.root);
|
|
55
57
|
}
|
|
56
|
-
|
|
57
|
-
// git: sealed == tracked and clean (ls-files --error-unmatch, diff --quiet HEAD).
|
|
58
|
-
// jj: no commit step, so a shipped record is always sealed.
|
|
59
|
-
export function recordSealed(vcs: Vcs, rel: string): boolean {
|
|
60
|
-
if (vcs.kind === "jj") return true;
|
|
61
|
-
return vcs.run(["ls-files", "--error-unmatch", "--", rel], vcs.root).ok
|
|
62
|
-
&& vcs.run(["diff", "--quiet", "HEAD", "--", rel], vcs.root).ok;
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
// jj has no commit step: `file track --include-ignored` is the `git add -f` equivalent - it
|
|
66
|
-
// snapshots the stamped record into the working-copy change even under a gitignored dir.
|
|
67
|
-
export function commitRecordFile(vcs: Vcs, rel: string, message: string): VcsResult {
|
|
68
|
-
if (vcs.kind === "jj") return vcs.run(["file", "track", "--include-ignored", "--", rel], vcs.root);
|
|
69
|
-
const add = vcs.run(["add", "-f", "--", rel], vcs.root);
|
|
70
|
-
const commit = add.ok ? vcs.run(["commit", "-q", "-m", message, "--", rel], vcs.root, 30_000) : add;
|
|
71
|
-
// Unstage the -f add so a retry sees the same state.
|
|
72
|
-
if (!commit.ok) vcs.run(["reset", "-q", "--", rel], vcs.root);
|
|
73
|
-
return commit;
|
|
74
|
-
}
|