@blastin-dev/clocktopus-cli 0.2.1 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/dist/src/commands/agent/doctor.d.ts.map +1 -1
- package/dist/src/commands/agent/doctor.js +26 -35
- package/dist/src/commands/agent/hook.d.ts.map +1 -1
- package/dist/src/commands/agent/hook.js +184 -180
- package/dist/src/commands/agent/setup.d.ts +0 -22
- package/dist/src/commands/agent/setup.d.ts.map +1 -1
- package/dist/src/commands/agent/setup.js +40 -62
- package/dist/src/lib/agent-config.d.ts +0 -33
- package/dist/src/lib/agent-config.d.ts.map +1 -1
- package/dist/src/lib/agent-config.js +15 -26
- package/dist/src/lib/agent-hook-state.d.ts +2 -6
- package/dist/src/lib/agent-hook-state.d.ts.map +1 -1
- package/dist/src/lib/agent-hook-state.js +29 -43
- package/dist/src/lib/agents.d.ts +0 -63
- package/dist/src/lib/agents.d.ts.map +1 -1
- package/dist/src/lib/agents.js +19 -26
- package/dist/src/lib/auth.d.ts.map +1 -1
- package/dist/src/lib/auth.js +11 -0
- package/dist/src/lib/claude-settings.d.ts +0 -36
- package/dist/src/lib/claude-settings.d.ts.map +1 -1
- package/dist/src/lib/claude-settings.js +37 -62
- package/dist/src/lib/codex-config.d.ts +0 -79
- package/dist/src/lib/codex-config.d.ts.map +1 -1
- package/dist/src/lib/codex-config.js +74 -116
- package/dist/src/lib/declared-commits.d.ts +43 -0
- package/dist/src/lib/declared-commits.d.ts.map +1 -0
- package/dist/src/lib/declared-commits.js +114 -0
- package/dist/src/lib/declared-commits.test.d.ts +2 -0
- package/dist/src/lib/declared-commits.test.d.ts.map +1 -0
- package/dist/src/lib/declared-commits.test.js +129 -0
- package/dist/src/lib/git-remotes.d.ts +9 -0
- package/dist/src/lib/git-remotes.d.ts.map +1 -0
- package/dist/src/lib/git-remotes.js +50 -0
- package/dist/src/lib/git-remotes.test.d.ts +2 -0
- package/dist/src/lib/git-remotes.test.d.ts.map +1 -0
- package/dist/src/lib/git-remotes.test.js +52 -0
- package/dist/src/lib/opencode-config.d.ts +0 -85
- package/dist/src/lib/opencode-config.d.ts.map +1 -1
- package/dist/src/lib/opencode-config.js +72 -112
- package/package.json +5 -5
|
@@ -2,51 +2,37 @@ import { copyFileSync, existsSync, mkdirSync, readFileSync, renameSync, statSync
|
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
3
|
import { dirname, join } from "node:path";
|
|
4
4
|
import { parse as parseToml, stringify as stringifyToml } from "smol-toml";
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
* `readCodexTelemetry` strips it back off to recover the receiver base.
|
|
27
|
-
* - Codex will not run a `hooks.json` it has not been shown: the first
|
|
28
|
-
* session after this file changes prompts "Hooks need review" and
|
|
29
|
-
* persists the answer under `[hooks.state]`. Setup cannot do that for the
|
|
30
|
-
* user, so it tells them instead — see `setup.ts`.
|
|
31
|
-
*/
|
|
5
|
+
// Reads and edits Codex CLI's configuration on the user's behalf.
|
|
6
|
+
//
|
|
7
|
+
// Codex splits what Claude Code keeps in one file: telemetry in
|
|
8
|
+
// `~/.codex/config.toml` under `[otel]`, hooks in `~/.codex/hooks.json`. Both follow
|
|
9
|
+
// the same two rules as `claude-settings.ts` — never write over something we could
|
|
10
|
+
// not parse, only ever touch keys we put there — plus a third that TOML forces:
|
|
11
|
+
//
|
|
12
|
+
// Splice, don't re-serialise. A round trip through a TOML parser throws away every
|
|
13
|
+
// comment and blank line, and `config.toml` is hand-written and annotated. Only the
|
|
14
|
+
// `[otel]` region is replaced textually; `writeCodexConfig` re-parses the result and
|
|
15
|
+
// refuses to write if anything outside `[otel]` moved, which is what makes that safe.
|
|
16
|
+
//
|
|
17
|
+
// Two Codex behaviours matter here:
|
|
18
|
+
//
|
|
19
|
+
// - `otel.exporter` is the *log* exporter, and Codex POSTs to the URL verbatim — it
|
|
20
|
+
// does not append `/v1/logs` the way the OTel SDK does. The path is part of the
|
|
21
|
+
// configured endpoint, and `readCodexTelemetry` strips it back off.
|
|
22
|
+
// - Codex will not run a `hooks.json` it has not been shown: the first session after
|
|
23
|
+
// this file changes prompts "Hooks need review" and persists the answer under
|
|
24
|
+
// `[hooks.state]`. Setup cannot do that for the user, so it tells them — see
|
|
25
|
+
// `setup.ts`.
|
|
32
26
|
export const CODEX_CONFIG_FILENAME = "config.toml";
|
|
33
27
|
export const CODEX_HOOKS_FILENAME = "hooks.json";
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
* Load-bearing in both directions: written onto the endpoint because Codex
|
|
38
|
-
* sends to the literal URL, and stripped when reading because the hook and
|
|
39
|
-
* `/v1/verify` need the base.
|
|
40
|
-
*/
|
|
28
|
+
// The receiver path Codex's log exporter is pointed at. Load-bearing both ways:
|
|
29
|
+
// written onto the endpoint because Codex sends to the literal URL, and stripped when
|
|
30
|
+
// reading because the hook and `/v1/verify` need the base.
|
|
41
31
|
export const CODEX_LOGS_PATH = "/v1/logs";
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
* prints `warning: clamping SessionEnd hook timeout to 3s` on every single
|
|
47
|
-
* session start otherwise — a permanent warning about a value we chose is
|
|
48
|
-
* worse than the shorter budget, and the hook's own request timeout is 4s.
|
|
49
|
-
*/
|
|
32
|
+
// Seconds Codex will wait for each hook. SessionEnd is 3 rather than 10 because Codex
|
|
33
|
+
// hard-clamps it to 3 and otherwise prints a warning on every session start — a
|
|
34
|
+
// permanent warning about a value we chose is worse than the shorter budget, and the
|
|
35
|
+
// hook's own request timeout is 4s.
|
|
50
36
|
export const CODEX_HOOK_TIMEOUT_SECONDS = { SessionStart: 10, SessionEnd: 3 };
|
|
51
37
|
const HOOK_EVENTS = ["SessionStart", "SessionEnd"];
|
|
52
38
|
/** Exported so callers can tell "none approved" from "some approved". */
|
|
@@ -60,8 +46,8 @@ export class CodexConfigParseError extends Error {
|
|
|
60
46
|
}
|
|
61
47
|
}
|
|
62
48
|
export function codexConfigDir() {
|
|
63
|
-
// Codex honours CODEX_HOME; following it means setup writes where that
|
|
64
|
-
//
|
|
49
|
+
// Codex honours CODEX_HOME; following it means setup writes where that installation
|
|
50
|
+
// actually reads.
|
|
65
51
|
return process.env.CODEX_HOME || join(homedir(), ".codex");
|
|
66
52
|
}
|
|
67
53
|
export function codexConfigPath() {
|
|
@@ -70,9 +56,6 @@ export function codexConfigPath() {
|
|
|
70
56
|
export function codexHooksPath() {
|
|
71
57
|
return join(codexConfigDir(), CODEX_HOOKS_FILENAME);
|
|
72
58
|
}
|
|
73
|
-
/* -------------------------------------------------------------------------
|
|
74
|
-
* config.toml
|
|
75
|
-
* ---------------------------------------------------------------------- */
|
|
76
59
|
export function readCodexConfig(path = codexConfigPath()) {
|
|
77
60
|
if (!existsSync(path)) {
|
|
78
61
|
return { path, exists: false, modifiedAt: null, text: "", config: {} };
|
|
@@ -97,8 +80,8 @@ const OWNED_OTEL_KEYS = ["exporter", "log_user_prompt"];
|
|
|
97
80
|
export function buildCodexOtel(input) {
|
|
98
81
|
const endpoint = `${input.endpoint.replace(/\/$/, "")}${CODEX_LOGS_PATH}`;
|
|
99
82
|
return {
|
|
100
|
-
// The receiver answers protobuf with an explicit 415, and only JSON
|
|
101
|
-
//
|
|
83
|
+
// The receiver answers protobuf with an explicit 415, and only JSON actually works.
|
|
84
|
+
// Codex's default for this exporter is `binary`.
|
|
102
85
|
exporter: {
|
|
103
86
|
"otlp-http": {
|
|
104
87
|
endpoint,
|
|
@@ -106,21 +89,15 @@ export function buildCodexOtel(input) {
|
|
|
106
89
|
headers: { Authorization: `Bearer ${input.token}` },
|
|
107
90
|
},
|
|
108
91
|
},
|
|
109
|
-
// Already the default, set explicitly anyway
|
|
110
|
-
//
|
|
111
|
-
// in flight. It does not cover tool output — Codex has a single log
|
|
112
|
-
// exporter and no switch for that, which is why the receiver's
|
|
92
|
+
// Already the default, set explicitly anyway. It does not cover tool output — Codex
|
|
93
|
+
// has a single log exporter and no switch for that, which is why the receiver's
|
|
113
94
|
// allowlist, not this line, is what actually holds.
|
|
114
95
|
log_user_prompt: false,
|
|
115
96
|
};
|
|
116
97
|
}
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
* Returns the new text. Region detection is deliberately dumb — table
|
|
121
|
-
* headers only — and `writeCodexConfig` verifies the result by re-parsing,
|
|
122
|
-
* so a document this misreads is refused rather than mangled.
|
|
123
|
-
*/
|
|
98
|
+
// Replaces the `[otel]` region of a TOML document, preserving everything else.
|
|
99
|
+
// Region detection is deliberately dumb — table headers only — and `writeCodexConfig`
|
|
100
|
+
// verifies by re-parsing, so a document this misreads is refused rather than mangled.
|
|
124
101
|
export function spliceOtelSection(text, otel) {
|
|
125
102
|
const rendered = otel && Object.keys(otel).length > 0
|
|
126
103
|
? stringifyToml({ otel }).trimEnd()
|
|
@@ -151,8 +128,8 @@ export function spliceOtelSection(text, otel) {
|
|
|
151
128
|
const before = lines.slice(0, start);
|
|
152
129
|
const after = lines.slice(end);
|
|
153
130
|
const middle = rendered ? rendered.split("\n") : [];
|
|
154
|
-
// Drop the blank line that separated a removed section from what follows,
|
|
155
|
-
//
|
|
131
|
+
// Drop the blank line that separated a removed section from what follows, so repeated
|
|
132
|
+
// add/remove cycles cannot pile up empty lines.
|
|
156
133
|
if (!rendered) {
|
|
157
134
|
while (before.length > 0 && before[before.length - 1]?.trim() === "")
|
|
158
135
|
before.pop();
|
|
@@ -161,15 +138,11 @@ export function spliceOtelSection(text, otel) {
|
|
|
161
138
|
}
|
|
162
139
|
return `${[...before, ...middle, ...after].join("\n").trimEnd()}\n`;
|
|
163
140
|
}
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
* before. If the textual splice disturbed anything else — an exotic layout
|
|
170
|
-
* the region scanner misread — the write is refused with the user's file
|
|
171
|
-
* still intact.
|
|
172
|
-
*/
|
|
141
|
+
// Writes config.toml, keeping a one-deep backup and verifying the splice.
|
|
142
|
+
//
|
|
143
|
+
// The verification is the point: it re-parses what is about to be written and compares
|
|
144
|
+
// every top-level key except `otel` against what was there before. If the splice
|
|
145
|
+
// disturbed anything else, the write is refused with the user's file still intact.
|
|
173
146
|
export function writeCodexConfig(input) {
|
|
174
147
|
const path = input.path ?? codexConfigPath();
|
|
175
148
|
let nextConfig;
|
|
@@ -197,9 +170,8 @@ export function writeCodexConfig(input) {
|
|
|
197
170
|
backupPath = `${path}.clocktopus-backup`;
|
|
198
171
|
copyFileSync(path, backupPath);
|
|
199
172
|
}
|
|
200
|
-
// Rename rather than write in place: a crash midway leaves either the old
|
|
201
|
-
//
|
|
202
|
-
// start with.
|
|
173
|
+
// Rename rather than write in place: a crash midway leaves either the old file or the
|
|
174
|
+
// new one, never a truncated config Codex would refuse to start with.
|
|
203
175
|
const temporaryPath = `${path}.clocktopus-tmp`;
|
|
204
176
|
writeFileSync(temporaryPath, input.nextText, {
|
|
205
177
|
encoding: "utf8",
|
|
@@ -310,9 +282,8 @@ export function applyCodexHooks(file, input) {
|
|
|
310
282
|
const existing = asTable(file.hooks);
|
|
311
283
|
const hooks = { ...existing };
|
|
312
284
|
for (const event of HOOK_EVENTS) {
|
|
313
|
-
// Remove-then-append rather than append: re-running setup must not
|
|
314
|
-
//
|
|
315
|
-
// SessionStart POST.
|
|
285
|
+
// Remove-then-append rather than append: re-running setup must not leave two hooks
|
|
286
|
+
// firing per session, which would double every SessionStart POST.
|
|
316
287
|
hooks[event] = [
|
|
317
288
|
...withoutOurHooks(asGroups(existing[event])),
|
|
318
289
|
{
|
|
@@ -321,21 +292,18 @@ export function applyCodexHooks(file, input) {
|
|
|
321
292
|
type: "command",
|
|
322
293
|
command: input.hookCommand,
|
|
323
294
|
timeout: CODEX_HOOK_TIMEOUT_SECONDS[event],
|
|
324
|
-
// No `async` key, deliberately. Codex 0.147 *skips* an async
|
|
325
|
-
//
|
|
326
|
-
//
|
|
327
|
-
//
|
|
328
|
-
// synchronously and warns about it once per session. `agent
|
|
329
|
-
// hook` backgrounds itself instead, which is correct on all of
|
|
330
|
-
// them and quiet on all of them.
|
|
295
|
+
// No `async` key, deliberately. Codex 0.147 *skips* an async SessionStart outright —
|
|
296
|
+
// costing every session its repository context — and every version so far still runs
|
|
297
|
+
// an async SessionEnd synchronously and warns once per session. `agent hook`
|
|
298
|
+
// backgrounds itself instead, which is correct and quiet on all of them.
|
|
331
299
|
},
|
|
332
300
|
],
|
|
333
301
|
},
|
|
334
302
|
];
|
|
335
303
|
}
|
|
336
304
|
return {
|
|
337
|
-
// Codex shows this string when it asks the user to trust the file, so
|
|
338
|
-
//
|
|
305
|
+
// Codex shows this string when it asks the user to trust the file, so it is the one
|
|
306
|
+
// chance to say where it came from.
|
|
339
307
|
description: typeof file.description === "string" && file.description
|
|
340
308
|
? file.description
|
|
341
309
|
: "Clocktopus agent telemetry",
|
|
@@ -379,27 +347,20 @@ export function writeCodexHooks(file, path = codexHooksPath()) {
|
|
|
379
347
|
renameSync(temporaryPath, path);
|
|
380
348
|
return { backupPath };
|
|
381
349
|
}
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
*
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
* is not records spend with no repository attached to it — which is exactly
|
|
397
|
-
* what an `async: true` SessionStart used to produce on Codex 0.147, since
|
|
398
|
-
* the hook was skipped before it could ever be offered for approval.
|
|
399
|
-
*
|
|
400
|
-
* The hash is Codex's own and is not recomputed here; changing `hooks.json`
|
|
401
|
-
* invalidates it and Codex re-prompts, which is the behaviour we want.
|
|
402
|
-
*/
|
|
350
|
+
// Which of our hooks Codex has been shown and told to run.
|
|
351
|
+
//
|
|
352
|
+
// Trust is recorded per hook *entry*, not per file, keyed
|
|
353
|
+
// `<path>:<event>:<group index>:<hook index>` with the event in snake_case:
|
|
354
|
+
//
|
|
355
|
+
// [hooks.state."/home/you/.codex/hooks.json:session_end:0:0"]
|
|
356
|
+
// trusted_hash = "sha256:…"
|
|
357
|
+
//
|
|
358
|
+
// Per-entry granularity is why this reports each event rather than a single boolean:
|
|
359
|
+
// partial trust is a real state, and the one that matters. A machine whose SessionEnd
|
|
360
|
+
// is trusted and whose SessionStart is not records spend with no repository attached.
|
|
361
|
+
//
|
|
362
|
+
// The hash is Codex's own and is not recomputed here; changing `hooks.json`
|
|
363
|
+
// invalidates it and Codex re-prompts, which is the behaviour we want.
|
|
403
364
|
export function readCodexHookTrust(path = codexConfigPath()) {
|
|
404
365
|
let config;
|
|
405
366
|
try {
|
|
@@ -414,18 +375,15 @@ export function readCodexHookTrust(path = codexConfigPath()) {
|
|
|
414
375
|
for (const [key, value] of Object.entries(state)) {
|
|
415
376
|
if (!key.startsWith(prefix))
|
|
416
377
|
continue;
|
|
417
|
-
// `enabled` is optional and version-dependent:
|
|
418
|
-
//
|
|
419
|
-
//
|
|
420
|
-
//
|
|
421
|
-
// pending forever. Absent means approved; only an explicit `false`
|
|
422
|
-
// — a hook the user disabled — counts as untrusted.
|
|
378
|
+
// `enabled` is optional and version-dependent: 0.147 wrote `enabled = true` next to
|
|
379
|
+
// the hash, 0.148 persists `trusted_hash` alone. Requiring it read a freshly approved
|
|
380
|
+
// 0.148 install as pending forever. Absent means approved; only an explicit `false`
|
|
381
|
+
// counts as untrusted.
|
|
423
382
|
const entry = asTable(value);
|
|
424
383
|
if (entry.enabled === false || typeof entry.trusted_hash !== "string")
|
|
425
384
|
continue;
|
|
426
|
-
// `<path>:<event>:<group>:<index>` —
|
|
427
|
-
// path
|
|
428
|
-
// sliced by prefix length rather than split, just in case.
|
|
385
|
+
// `<path>:<event>:<group>:<index>` — sliced by prefix length rather than split, since
|
|
386
|
+
// a path could in principle contain a colon.
|
|
429
387
|
const event = key.slice(prefix.length).split(":")[0];
|
|
430
388
|
const match = HOOK_EVENTS.find((candidate) => toSnakeCase(candidate) === event);
|
|
431
389
|
if (match)
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Arguments for reading the session's slice of the reflog, or `null` when the
|
|
3
|
+
* session's start was never recorded.
|
|
4
|
+
*
|
|
5
|
+
* A null return means *declare nothing*, deliberately. Without a lower bound the
|
|
6
|
+
* reflog would hand back the checkout's entire history; the time-window rule already
|
|
7
|
+
* covers a session that cannot declare, and over-declaring is the one mistake it
|
|
8
|
+
* cannot undo — a `declared` link outranks the window and is permanent.
|
|
9
|
+
*/
|
|
10
|
+
export declare function buildReflogArgs(input: {
|
|
11
|
+
/** ISO timestamp recorded at SessionStart. */
|
|
12
|
+
startedAt?: string;
|
|
13
|
+
/** Upper bound, for the sweep: a session that died hours ago owns nothing since. */
|
|
14
|
+
until?: string;
|
|
15
|
+
}): string[] | null;
|
|
16
|
+
/**
|
|
17
|
+
* The commits created in this checkout, newest first, from `buildReflogArgs` output.
|
|
18
|
+
*
|
|
19
|
+
* Three actions always author a commit here and are kept: `commit` (with its
|
|
20
|
+
* `(initial)`, `(amend)` and `(merge)` variants), `revert` and `cherry-pick`.
|
|
21
|
+
* `checkout` and `reset` move HEAD without writing anything.
|
|
22
|
+
*
|
|
23
|
+
* **`merge` and `pull` are excluded even though a clean merge does author a commit**,
|
|
24
|
+
* because nothing in the reflog separates one you made from one you fast-forwarded
|
|
25
|
+
* onto. Parent count looks like it would and does not — a plain `git pull` on a
|
|
26
|
+
* repository that merges its pull requests lands on a two-parent commit somebody else
|
|
27
|
+
* wrote:
|
|
28
|
+
*
|
|
29
|
+
* parents=14c3726 6d88961 | pull: Fast-forward
|
|
30
|
+
*
|
|
31
|
+
* A conflicted merge is still caught, since resolving one ends in `commit (merge)`.
|
|
32
|
+
* A clean one falls to the time-window rule.
|
|
33
|
+
*
|
|
34
|
+
* `rebase (pick)` is excluded for the matching reason: replaying a branch would
|
|
35
|
+
* declare every commit on it, including work authored long before the session. The
|
|
36
|
+
* cost is that a session which commits and then rebases declares the pre-rebase SHA,
|
|
37
|
+
* which was never pushed and simply joins to nothing.
|
|
38
|
+
*
|
|
39
|
+
* Every exclusion here is recoverable and every wrong inclusion is not: a `declared`
|
|
40
|
+
* link outranks the time window and is permanent.
|
|
41
|
+
*/
|
|
42
|
+
export declare function parseReflogCommits(output: string | undefined): string[];
|
|
43
|
+
//# sourceMappingURL=declared-commits.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"declared-commits.d.ts","sourceRoot":"","sources":["../../../src/lib/declared-commits.ts"],"names":[],"mappings":"AAoBA;;;;;;;;GAQG;AACH,wBAAgB,eAAe,CAAC,KAAK,EAAE;IACrC,8CAA8C;IAC9C,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,oFAAoF;IACpF,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB,GAAG,MAAM,EAAE,GAAG,IAAI,CA2BlB;AAED;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AACH,wBAAgB,kBAAkB,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,GAAG,MAAM,EAAE,CAyBvE"}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
// Resolving the commits a session actually created.
|
|
2
|
+
//
|
|
3
|
+
// The obvious answer, `git rev-list before..head`, answers a different question:
|
|
4
|
+
// "what is reachable from here that was not reachable from there". Merged and pulled
|
|
5
|
+
// history is reachable, so it gets declared — one real session claimed 105 commits
|
|
6
|
+
// reaching two months back (BLA-595) — and a mid-session switch to a *pre-existing*
|
|
7
|
+
// branch makes the session's own earlier commits unreachable, so they vanish.
|
|
8
|
+
//
|
|
9
|
+
// The reflog answers the right question. It records HEAD movements in this checkout,
|
|
10
|
+
// tagged with what caused them, so a `pull` or a `merge` is one entry rather than one
|
|
11
|
+
// per commit it brought along, and a branch switch is irrelevant to what was authored.
|
|
12
|
+
// It is also per-worktree (`.git/worktrees/<name>/logs/HEAD`), so parallel agents in
|
|
13
|
+
// separate worktrees observe disjoint histories and cannot both claim a commit.
|
|
14
|
+
//
|
|
15
|
+
// Two agents in one worktree share a reflog and remain genuinely indistinguishable.
|
|
16
|
+
// That is a real limit, not an oversight.
|
|
17
|
+
/** Ceiling on what one session may declare. */
|
|
18
|
+
const MAX_DECLARED_COMMITS = 200;
|
|
19
|
+
/**
|
|
20
|
+
* Arguments for reading the session's slice of the reflog, or `null` when the
|
|
21
|
+
* session's start was never recorded.
|
|
22
|
+
*
|
|
23
|
+
* A null return means *declare nothing*, deliberately. Without a lower bound the
|
|
24
|
+
* reflog would hand back the checkout's entire history; the time-window rule already
|
|
25
|
+
* covers a session that cannot declare, and over-declaring is the one mistake it
|
|
26
|
+
* cannot undo — a `declared` link outranks the window and is permanent.
|
|
27
|
+
*/
|
|
28
|
+
export function buildReflogArgs(input) {
|
|
29
|
+
// No grace before `startedAt`, deliberately. Both timestamps come from the same
|
|
30
|
+
// machine clock, so there is no skew to absorb, and SessionStart fires before the
|
|
31
|
+
// agent can act, so nothing it wrote can predate the stamp. A backward grace could
|
|
32
|
+
// therefore only admit a commit made *before* the session — the `wip` a developer
|
|
33
|
+
// makes just before launching an agent — as `declared`, which outranks the time
|
|
34
|
+
// window and is permanent. `--prune` cannot undo it either: that cuts at an hour.
|
|
35
|
+
const since = asIso(input.startedAt);
|
|
36
|
+
if (!since)
|
|
37
|
+
return null;
|
|
38
|
+
const args = [
|
|
39
|
+
"reflog",
|
|
40
|
+
"show",
|
|
41
|
+
"HEAD",
|
|
42
|
+
"--format=%H %gs",
|
|
43
|
+
`--since=${since}`,
|
|
44
|
+
];
|
|
45
|
+
const until = asIso(input.until);
|
|
46
|
+
if (until)
|
|
47
|
+
args.push(`--until=${until}`);
|
|
48
|
+
// A working tree containing a file called `HEAD` makes git refuse the whole read —
|
|
49
|
+
// "ambiguous argument 'HEAD': both revision and filename" — which `git()` swallows,
|
|
50
|
+
// so the session would silently declare nothing.
|
|
51
|
+
args.push("--");
|
|
52
|
+
return args;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* The commits created in this checkout, newest first, from `buildReflogArgs` output.
|
|
56
|
+
*
|
|
57
|
+
* Three actions always author a commit here and are kept: `commit` (with its
|
|
58
|
+
* `(initial)`, `(amend)` and `(merge)` variants), `revert` and `cherry-pick`.
|
|
59
|
+
* `checkout` and `reset` move HEAD without writing anything.
|
|
60
|
+
*
|
|
61
|
+
* **`merge` and `pull` are excluded even though a clean merge does author a commit**,
|
|
62
|
+
* because nothing in the reflog separates one you made from one you fast-forwarded
|
|
63
|
+
* onto. Parent count looks like it would and does not — a plain `git pull` on a
|
|
64
|
+
* repository that merges its pull requests lands on a two-parent commit somebody else
|
|
65
|
+
* wrote:
|
|
66
|
+
*
|
|
67
|
+
* parents=14c3726 6d88961 | pull: Fast-forward
|
|
68
|
+
*
|
|
69
|
+
* A conflicted merge is still caught, since resolving one ends in `commit (merge)`.
|
|
70
|
+
* A clean one falls to the time-window rule.
|
|
71
|
+
*
|
|
72
|
+
* `rebase (pick)` is excluded for the matching reason: replaying a branch would
|
|
73
|
+
* declare every commit on it, including work authored long before the session. The
|
|
74
|
+
* cost is that a session which commits and then rebases declares the pre-rebase SHA,
|
|
75
|
+
* which was never pushed and simply joins to nothing.
|
|
76
|
+
*
|
|
77
|
+
* Every exclusion here is recoverable and every wrong inclusion is not: a `declared`
|
|
78
|
+
* link outranks the time window and is permanent.
|
|
79
|
+
*/
|
|
80
|
+
export function parseReflogCommits(output) {
|
|
81
|
+
if (!output?.trim())
|
|
82
|
+
return [];
|
|
83
|
+
const shas = [];
|
|
84
|
+
const seen = new Set();
|
|
85
|
+
for (const line of output.split("\n")) {
|
|
86
|
+
// The SHA can contain no spaces, so the first one ends it; the subject keeps its
|
|
87
|
+
// own.
|
|
88
|
+
const separator = line.indexOf(" ");
|
|
89
|
+
if (separator === -1)
|
|
90
|
+
continue;
|
|
91
|
+
const sha = line.slice(0, separator).trim();
|
|
92
|
+
const subject = line.slice(separator + 1).trim();
|
|
93
|
+
if (!/^[0-9a-f]{7,64}$/i.test(sha))
|
|
94
|
+
continue;
|
|
95
|
+
if (!/^(commit|revert|cherry-pick)\b/.test(subject))
|
|
96
|
+
continue;
|
|
97
|
+
if (seen.has(sha))
|
|
98
|
+
continue;
|
|
99
|
+
seen.add(sha);
|
|
100
|
+
shas.push(sha);
|
|
101
|
+
if (shas.length === MAX_DECLARED_COMMITS)
|
|
102
|
+
break;
|
|
103
|
+
}
|
|
104
|
+
return shas;
|
|
105
|
+
}
|
|
106
|
+
function asIso(value) {
|
|
107
|
+
return asDate(value)?.toISOString() ?? null;
|
|
108
|
+
}
|
|
109
|
+
function asDate(value) {
|
|
110
|
+
if (!value)
|
|
111
|
+
return null;
|
|
112
|
+
const at = new Date(value);
|
|
113
|
+
return Number.isNaN(at.getTime()) ? null : at;
|
|
114
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"declared-commits.test.d.ts","sourceRoot":"","sources":["../../../src/lib/declared-commits.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { buildReflogArgs, parseReflogCommits } from "./declared-commits.js";
|
|
3
|
+
const STARTED_AT = "2026-08-20T05:35:45.728Z";
|
|
4
|
+
const SHA = "418a320a048f839f2200106fbcffbb6f7683d135";
|
|
5
|
+
const reflog = (...lines) => lines.join("\n");
|
|
6
|
+
describe("buildReflogArgs", () => {
|
|
7
|
+
it("bounds the read at the session start exactly", () => {
|
|
8
|
+
expect(buildReflogArgs({ startedAt: STARTED_AT })).toContain(`--since=${STARTED_AT}`);
|
|
9
|
+
});
|
|
10
|
+
/**
|
|
11
|
+
* No backward grace. Both stamps come from one machine clock, and SessionStart fires
|
|
12
|
+
* before the agent can act, so a grace could only admit a commit made *before* the
|
|
13
|
+
* session — as `declared`, which outranks the time window and is permanent.
|
|
14
|
+
*/
|
|
15
|
+
it("admits nothing committed before the session started", () => {
|
|
16
|
+
const args = buildReflogArgs({ startedAt: STARTED_AT });
|
|
17
|
+
const since = args
|
|
18
|
+
.find((arg) => arg.startsWith("--since="))
|
|
19
|
+
.replace("--since=", "");
|
|
20
|
+
expect(new Date(since).getTime()).toBe(new Date(STARTED_AT).getTime());
|
|
21
|
+
});
|
|
22
|
+
/** The sweep runs hours late; a session that died owns nothing committed since. */
|
|
23
|
+
it("takes an upper bound, for the sweep", () => {
|
|
24
|
+
const args = buildReflogArgs({
|
|
25
|
+
startedAt: STARTED_AT,
|
|
26
|
+
until: "2026-08-20T11:56:30.103Z",
|
|
27
|
+
});
|
|
28
|
+
expect(args).toContain("--until=2026-08-20T11:56:30.103Z");
|
|
29
|
+
});
|
|
30
|
+
/**
|
|
31
|
+
* A working tree with a file called `HEAD` otherwise makes git refuse the read
|
|
32
|
+
* outright — "ambiguous argument 'HEAD': both revision and filename".
|
|
33
|
+
*/
|
|
34
|
+
it("separates the revision from any path of the same name", () => {
|
|
35
|
+
expect(buildReflogArgs({ startedAt: STARTED_AT }).at(-1)).toBe("--");
|
|
36
|
+
});
|
|
37
|
+
it("omits the upper bound for a live SessionEnd", () => {
|
|
38
|
+
const args = buildReflogArgs({ startedAt: STARTED_AT });
|
|
39
|
+
expect(args.some((arg) => arg.startsWith("--until="))).toBe(false);
|
|
40
|
+
});
|
|
41
|
+
/**
|
|
42
|
+
* Declaring nothing is the deliberate answer. Without a lower bound the reflog is
|
|
43
|
+
* the checkout's entire history, and a `declared` link outranks the time window, so
|
|
44
|
+
* over-declaring is the one mistake that cannot be undone later.
|
|
45
|
+
*/
|
|
46
|
+
it("refuses to read at all when the session start was never recorded", () => {
|
|
47
|
+
expect(buildReflogArgs({})).toBeNull();
|
|
48
|
+
expect(buildReflogArgs({ startedAt: "not a date" })).toBeNull();
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
describe("parseReflogCommits", () => {
|
|
52
|
+
it("takes the commits this checkout authored", () => {
|
|
53
|
+
const shas = parseReflogCommits(reflog(`${SHA} commit: fix(bla-597): read the reflog`, "cd72bebc2ce64099f27f28e251be57cb53189328 commit (merge): merge the release branch"));
|
|
54
|
+
expect(shas).toEqual([SHA, "cd72bebc2ce64099f27f28e251be57cb53189328"]);
|
|
55
|
+
});
|
|
56
|
+
it("takes a revert and a cherry-pick, which always author here", () => {
|
|
57
|
+
const shas = parseReflogCommits(reflog('aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa revert: Revert "feat: c2"', "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb cherry-pick: feat: c3"));
|
|
58
|
+
expect(shas).toHaveLength(2);
|
|
59
|
+
});
|
|
60
|
+
/**
|
|
61
|
+
* Parent count looks like it would separate a merge you made from one you
|
|
62
|
+
* fast-forwarded onto, and does not: a plain `git pull` on a repository that merges
|
|
63
|
+
* its pull requests lands on a two-parent commit somebody else wrote. So merges are
|
|
64
|
+
* excluded, and a conflicted one is still caught as `commit (merge)`.
|
|
65
|
+
*/
|
|
66
|
+
it("ignores a clean merge rather than risk claiming a pulled one", () => {
|
|
67
|
+
const shas = parseReflogCommits(reflog("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa merge side: Merge made by the 'ort' strategy.", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb pull: Fast-forward"));
|
|
68
|
+
expect(shas).toEqual([]);
|
|
69
|
+
});
|
|
70
|
+
/**
|
|
71
|
+
* The whole point. A pull or a merge is one reflog entry however much history it
|
|
72
|
+
* brings — the range that replaced this declared 118 commits for one such merge.
|
|
73
|
+
*/
|
|
74
|
+
it("ignores history that merely arrived in the checkout", () => {
|
|
75
|
+
const shas = parseReflogCommits(reflog("4257246e3860ef8d834e4e4efbb7ed0da648ad23 pull: Fast-forward", "9f0ae840a64bae586d8e816124278ee0212ad0cc merge develop: Merge made by the 'ort' strategy.", "64d934042b0568a7acded2b059148dbc8f36ac83 checkout: moving from develop to a-branch", "6f59f8ad63a21000406480c17e766ea0706dff70 reset: moving to HEAD~1"));
|
|
76
|
+
expect(shas).toEqual([]);
|
|
77
|
+
});
|
|
78
|
+
/**
|
|
79
|
+
* A rebase writes new objects, but replaying a branch would declare work authored
|
|
80
|
+
* long before the session. Excluded; the pushed SHAs fall to the time-window rule.
|
|
81
|
+
*/
|
|
82
|
+
it("ignores rebase replays", () => {
|
|
83
|
+
const shas = parseReflogCommits(reflog("64d934042b0568a7acded2b059148dbc8f36ac83 rebase (start): checkout develop", "cd72bebc2ce64099f27f28e251be57cb53189328 rebase (pick): fix(bla-595): bound the range", "cd72bebc2ce64099f27f28e251be57cb53189328 rebase (finish): returning to a-branch"));
|
|
84
|
+
expect(shas).toEqual([]);
|
|
85
|
+
});
|
|
86
|
+
it("keeps an amend, whose old and new SHAs are both reflog commits", () => {
|
|
87
|
+
const shas = parseReflogCommits(reflog("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa commit (amend): fix(bla-597): reworded", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb commit: fix(bla-597): original"));
|
|
88
|
+
// Only the amended SHA is ever pushed; the original simply joins to nothing.
|
|
89
|
+
expect(shas).toHaveLength(2);
|
|
90
|
+
});
|
|
91
|
+
it("keeps an initial commit", () => {
|
|
92
|
+
expect(parseReflogCommits(`${SHA} commit (initial): base`)).toEqual([SHA]);
|
|
93
|
+
});
|
|
94
|
+
it("survives a subject containing the field separator", () => {
|
|
95
|
+
const shas = parseReflogCommits(`${SHA} commit: fix(bla-597): read HEAD, then the reflog`);
|
|
96
|
+
expect(shas).toEqual([SHA]);
|
|
97
|
+
});
|
|
98
|
+
it("does not repeat a SHA HEAD returned to", () => {
|
|
99
|
+
const shas = parseReflogCommits(reflog(`${SHA} commit: one`, `${SHA} commit (amend): one, reworded`));
|
|
100
|
+
expect(shas).toEqual([SHA]);
|
|
101
|
+
});
|
|
102
|
+
it("returns nothing for an empty or unreadable reflog", () => {
|
|
103
|
+
expect(parseReflogCommits("")).toEqual([]);
|
|
104
|
+
expect(parseReflogCommits(undefined)).toEqual([]);
|
|
105
|
+
expect(parseReflogCommits("not a reflog line")).toEqual([]);
|
|
106
|
+
});
|
|
107
|
+
it("caps what one session may declare", () => {
|
|
108
|
+
const lines = Array.from({ length: 250 }, (_, i) => `${i.toString(16).padStart(40, "0")} commit: commit ${i}`);
|
|
109
|
+
expect(parseReflogCommits(reflog(...lines))).toHaveLength(200);
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
/**
|
|
113
|
+
* The sweep's upper bound is the state file's mtime, which is only a record of "last
|
|
114
|
+
* activity" on a host that rewrites the file as the session goes. Everywhere else the
|
|
115
|
+
* file is written once at SessionStart, so `until` equals `startedAt` and the window
|
|
116
|
+
* collapses onto a point — which is why `sweptCommits` refuses to read it at all for
|
|
117
|
+
* those hosts rather than relying on this shape.
|
|
118
|
+
*/
|
|
119
|
+
describe("the window a sweep would ask for", () => {
|
|
120
|
+
it("collapses to a point when until is the session start", () => {
|
|
121
|
+
const args = buildReflogArgs({
|
|
122
|
+
startedAt: STARTED_AT,
|
|
123
|
+
until: STARTED_AT,
|
|
124
|
+
});
|
|
125
|
+
const since = args.find((a) => a.startsWith("--since=")).slice(8);
|
|
126
|
+
const until = args.find((a) => a.startsWith("--until=")).slice(8);
|
|
127
|
+
expect(new Date(since).getTime()).toBe(new Date(until).getTime());
|
|
128
|
+
});
|
|
129
|
+
});
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parses `git remote -v` into a deduplicated URL list, `origin` first.
|
|
3
|
+
*
|
|
4
|
+
* Order is load-bearing in one narrow way: the first entry is what a receiver older
|
|
5
|
+
* than BLA-598 reads as the only remote, so keeping `origin` there preserves the
|
|
6
|
+
* previous behaviour exactly.
|
|
7
|
+
*/
|
|
8
|
+
export declare function parseGitRemotes(output: string | undefined): string[];
|
|
9
|
+
//# sourceMappingURL=git-remotes.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"git-remotes.d.ts","sourceRoot":"","sources":["../../../src/lib/git-remotes.ts"],"names":[],"mappings":"AAWA;;;;;;GAMG;AACH,wBAAgB,eAAe,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,GAAG,MAAM,EAAE,CAkCpE"}
|