claude-attribution 1.6.0 → 1.8.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.
@@ -0,0 +1,501 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { mkdir, readFile, writeFile } from "fs/promises";
3
+ import { join } from "path";
4
+ import { saveCheckpoint } from "../attribution/checkpoint.ts";
5
+ import type { AttributionResult } from "../attribution/differ.ts";
6
+ import { NOTES_REF, writeNote } from "../attribution/git-notes.ts";
7
+ import { pushNotesRef } from "../attribution/notes-sync.ts";
8
+ import { writeMinimap, type MinimapResult } from "../attribution/minimap.ts";
9
+ import { readInstalledRepoRegistry } from "../setup/installed-repos.ts";
10
+ import {
11
+ CLI_BIN,
12
+ configureGitIdentity,
13
+ REPO_ROOT,
14
+ commitAll,
15
+ createTempContext,
16
+ currentSha,
17
+ initGitRepo,
18
+ readJson,
19
+ runCommand,
20
+ writeJsonl,
21
+ writeTranscript,
22
+ } from "./integration-helpers.ts";
23
+
24
+ describe("integration", () => {
25
+ test("post-commit writes durable note metadata", async () => {
26
+ const ctx = await createTempContext("claude-attribution-commit");
27
+ try {
28
+ await initGitRepo(ctx.repo);
29
+ const filePath = join(ctx.repo, "src.ts");
30
+ await writeFile(filePath, "const value = 1;\n");
31
+ await commitAll(ctx.repo, "initial");
32
+
33
+ const sessionId = "session-commit-1";
34
+ await mkdir(join(ctx.repo, ".claude", "attribution-state"), {
35
+ recursive: true,
36
+ });
37
+ await writeFile(
38
+ join(ctx.repo, ".claude", "attribution-state", "current-session"),
39
+ sessionId,
40
+ );
41
+ await saveCheckpoint(sessionId, filePath, "before");
42
+ await writeFile(filePath, "const value = 2;\nconst nextValue = 3;\n");
43
+ await saveCheckpoint(sessionId, filePath, "after");
44
+
45
+ await writeJsonl(join(ctx.repo, ".claude", "logs", "tool-usage.jsonl"), [
46
+ {
47
+ timestamp: "2026-03-01T10:00:00.000Z",
48
+ session: sessionId,
49
+ tool: "Bash",
50
+ },
51
+ ]);
52
+ await writeJsonl(
53
+ join(ctx.repo, ".claude", "logs", "agent-activity.jsonl"),
54
+ [
55
+ {
56
+ timestamp: "2026-03-01T10:02:00.000Z",
57
+ session: sessionId,
58
+ event: "SubagentStart",
59
+ subagentType: "code-review",
60
+ },
61
+ ],
62
+ );
63
+ await writeTranscript(ctx.home, ctx.repo, sessionId, [
64
+ {
65
+ type: "human",
66
+ timestamp: "2026-03-01T10:00:00.000Z",
67
+ },
68
+ {
69
+ type: "assistant",
70
+ timestamp: "2026-03-01T10:02:00.000Z",
71
+ message: {
72
+ model: "claude-sonnet-4-6",
73
+ usage: {
74
+ input_tokens: 1200,
75
+ output_tokens: 300,
76
+ cache_creation_input_tokens: 50,
77
+ cache_read_input_tokens: 400,
78
+ },
79
+ content: [
80
+ { type: "tool_use", name: "WebSearch", input: {} },
81
+ {
82
+ type: "tool_use",
83
+ name: "Skill",
84
+ input: { skill: "pr" },
85
+ },
86
+ ],
87
+ },
88
+ },
89
+ ]);
90
+
91
+ await commitAll(ctx.repo, "feature work");
92
+ await runCommand(CLI_BIN, ["hook", "post-commit"], {
93
+ cwd: ctx.repo,
94
+ env: {
95
+ HOME: ctx.home,
96
+ COPILOT_CLI: "1",
97
+ COPILOT_CLI_BINARY_VERSION: "1.0.14",
98
+ },
99
+ });
100
+
101
+ const note = JSON.parse(
102
+ (
103
+ await runCommand(
104
+ "git",
105
+ ["notes", "--ref", "claude-attribution", "show", "HEAD"],
106
+ { cwd: ctx.repo },
107
+ )
108
+ ).stdout,
109
+ ) as AttributionResult;
110
+
111
+ expect(note.modelUsage?.[0]?.modelFull).toBe("claude-sonnet-4-6");
112
+ expect(note.modelUsage?.[0]?.calls).toBe(1);
113
+ expect(note.sessionMetrics?.toolCounts).toEqual({ WebSearch: 1 });
114
+ expect(note.sessionMetrics?.skillNames).toEqual(["pr"]);
115
+ expect(note.sessionMetrics?.agentCounts).toEqual({ "code-review": 1 });
116
+ expect(note.sessionMetrics?.humanPromptCount).toBe(1);
117
+ expect(note.sessionMetrics?.activeMinutes).toBe(2);
118
+ expect(note.assistantRuntime).toEqual({
119
+ vendor: "copilot",
120
+ client: "GitHub Copilot CLI",
121
+ clientVersion: "1.0.14",
122
+ });
123
+ } finally {
124
+ await ctx.cleanup();
125
+ }
126
+ });
127
+
128
+ test("metrics renders from note-backed data without local logs", async () => {
129
+ const ctx = await createTempContext("claude-attribution-metrics");
130
+ try {
131
+ await initGitRepo(ctx.repo);
132
+ await writeFile(join(ctx.repo, "app.ts"), "export const answer = 42;\n");
133
+ await commitAll(ctx.repo, "initial");
134
+
135
+ const sha = await currentSha(ctx.repo);
136
+ const note: AttributionResult = {
137
+ commit: sha,
138
+ session: "session-metrics-1",
139
+ branch: "main",
140
+ timestamp: "2026-03-01T10:00:00.000Z",
141
+ files: [
142
+ {
143
+ path: "app.ts",
144
+ ai: 1,
145
+ human: 0,
146
+ mixed: 0,
147
+ total: 1,
148
+ pctAi: 100,
149
+ },
150
+ ],
151
+ totals: { ai: 1, human: 0, mixed: 0, total: 1, pctAi: 100 },
152
+ modelUsage: [
153
+ {
154
+ modelFull: "claude-sonnet-4-6",
155
+ modelShort: "Sonnet",
156
+ calls: 2,
157
+ inputTokens: 2000,
158
+ outputTokens: 500,
159
+ cacheCreationTokens: 100,
160
+ cacheReadTokens: 800,
161
+ },
162
+ ],
163
+ sessionMetrics: {
164
+ toolCounts: { WebSearch: 2 },
165
+ agentCounts: { "code-review": 1 },
166
+ skillNames: ["pr"],
167
+ humanPromptCount: 3,
168
+ activeMinutes: 15,
169
+ aiMinutes: 10,
170
+ humanMinutes: 5,
171
+ },
172
+ assistantRuntime: {
173
+ vendor: "copilot",
174
+ client: "GitHub Copilot CLI",
175
+ clientVersion: "1.0.14",
176
+ },
177
+ };
178
+ await writeNote(note, ctx.repo, sha);
179
+ const minimap: MinimapResult = {
180
+ commit: sha,
181
+ timestamp: "2026-03-01T10:00:00.000Z",
182
+ files: [
183
+ {
184
+ path: "app.ts",
185
+ ai_hashes: "abc123abc123abc1",
186
+ ai: 1,
187
+ human: 0,
188
+ total: 1,
189
+ pctAi: 100,
190
+ },
191
+ ],
192
+ totals: { ai: 1, human: 0, total: 1, pctAi: 100 },
193
+ };
194
+ await writeMinimap(minimap, ctx.repo, sha);
195
+
196
+ const metrics = await runCommand(CLI_BIN, ["metrics"], {
197
+ cwd: ctx.repo,
198
+ env: { HOME: ctx.home },
199
+ });
200
+
201
+ expect(metrics.stdout).toContain("**Codebase: ~100% AI**");
202
+ expect(metrics.stdout).toContain("**This PR:** 1 lines changed");
203
+ expect(metrics.stdout).toContain("**Session:** 3 prompts · 15m total (10m AI · 5m human)");
204
+ expect(metrics.stdout).toContain(
205
+ "**Assistant runtime:** GitHub Copilot CLI (v1.0.14)",
206
+ );
207
+ expect(metrics.stdout).toContain("**Skills:** /pr");
208
+ expect(metrics.stdout).toContain("**Agents:** code-review");
209
+ expect(metrics.stdout).toContain("**External tools:** WebSearch ×2");
210
+ expect(metrics.stdout).toContain("**Estimated cost:**");
211
+ } finally {
212
+ await ctx.cleanup();
213
+ }
214
+ });
215
+
216
+ test("metrics stays backward-compatible with older notes", async () => {
217
+ const ctx = await createTempContext("claude-attribution-backcompat");
218
+ try {
219
+ await initGitRepo(ctx.repo);
220
+ await writeFile(join(ctx.repo, "legacy.ts"), "export const oldValue = 1;\n");
221
+ await commitAll(ctx.repo, "initial");
222
+ const sha = await currentSha(ctx.repo);
223
+
224
+ await writeNote(
225
+ {
226
+ commit: sha,
227
+ session: "legacy-session",
228
+ branch: "main",
229
+ timestamp: "2026-03-01T10:00:00.000Z",
230
+ files: [
231
+ {
232
+ path: "legacy.ts",
233
+ ai: 1,
234
+ human: 0,
235
+ mixed: 0,
236
+ total: 1,
237
+ pctAi: 100,
238
+ },
239
+ ],
240
+ totals: { ai: 1, human: 0, mixed: 0, total: 1, pctAi: 100 },
241
+ },
242
+ ctx.repo,
243
+ sha,
244
+ );
245
+ await writeMinimap(
246
+ {
247
+ commit: sha,
248
+ timestamp: "2026-03-01T10:00:00.000Z",
249
+ files: [
250
+ {
251
+ path: "legacy.ts",
252
+ ai_hashes: "abc123abc123abc1",
253
+ ai: 1,
254
+ human: 0,
255
+ total: 1,
256
+ pctAi: 100,
257
+ },
258
+ ],
259
+ totals: { ai: 1, human: 0, total: 1, pctAi: 100 },
260
+ },
261
+ ctx.repo,
262
+ sha,
263
+ );
264
+
265
+ const metrics = await runCommand(CLI_BIN, ["metrics"], {
266
+ cwd: ctx.repo,
267
+ env: { HOME: ctx.home },
268
+ });
269
+
270
+ expect(metrics.stdout).toContain("**Codebase: ~100% AI**");
271
+ expect(metrics.stdout).toContain("`legacy.ts` — 100% AI (1 lines)");
272
+ expect(metrics.stdout).not.toContain("Assistant runtime:");
273
+ } finally {
274
+ await ctx.cleanup();
275
+ }
276
+ });
277
+
278
+ test("minimap preserves AI attribution across renames", async () => {
279
+ const ctx = await createTempContext("claude-attribution-rename");
280
+ try {
281
+ await initGitRepo(ctx.repo);
282
+ await writeFile(join(ctx.repo, "a.ts"), "export const renamed = true;\n");
283
+ await commitAll(ctx.repo, "initial");
284
+ await runCommand(CLI_BIN, ["init", "--ai"], {
285
+ cwd: ctx.repo,
286
+ env: { HOME: ctx.home },
287
+ });
288
+
289
+ await runCommand("git", ["mv", "a.ts", "b.ts"], { cwd: ctx.repo });
290
+ await commitAll(ctx.repo, "rename file");
291
+ await runCommand(CLI_BIN, ["hook", "post-commit"], {
292
+ cwd: ctx.repo,
293
+ env: { HOME: ctx.home },
294
+ });
295
+
296
+ const minimap = JSON.parse(
297
+ (
298
+ await runCommand(
299
+ "git",
300
+ ["notes", "--ref", "refs/notes/claude-attribution-map", "show", "HEAD"],
301
+ { cwd: ctx.repo },
302
+ )
303
+ ).stdout,
304
+ ) as MinimapResult;
305
+ const renamed = minimap.files.find((file: MinimapResult["files"][number]) =>
306
+ file.path === "b.ts",
307
+ );
308
+ expect(renamed).toBeDefined();
309
+ expect(renamed?.ai).toBe(1);
310
+ expect(renamed?.human).toBe(1);
311
+ } finally {
312
+ await ctx.cleanup();
313
+ }
314
+ });
315
+
316
+ test("notes sync recovers from remote ref rejection", async () => {
317
+ const rootCtx = await createTempContext("claude-attribution-notes-sync");
318
+ try {
319
+ const remote = join(rootCtx.root, "remote.git");
320
+ await runCommand("git", ["init", "--bare", remote]);
321
+
322
+ const source = join(rootCtx.root, "source");
323
+ await mkdir(source, { recursive: true });
324
+ await initGitRepo(source);
325
+ await writeFile(join(source, "app.ts"), "export const version = 1;\n");
326
+ await commitAll(source, "commit a");
327
+ await writeFile(join(source, "app.ts"), "export const version = 2;\n");
328
+ await commitAll(source, "commit b");
329
+ await runCommand("git", ["branch", "-M", "main"], { cwd: source });
330
+ await runCommand("git", ["remote", "add", "origin", remote], {
331
+ cwd: source,
332
+ });
333
+ await runCommand("git", ["push", "-u", "origin", "main"], {
334
+ cwd: source,
335
+ });
336
+
337
+ const repo1 = join(rootCtx.root, "repo1");
338
+ const repo2 = join(rootCtx.root, "repo2");
339
+ await runCommand("git", ["clone", remote, repo1]);
340
+ await runCommand("git", ["clone", remote, repo2]);
341
+ await configureGitIdentity(repo1);
342
+ await configureGitIdentity(repo2);
343
+ await runCommand("git", ["checkout", "main"], { cwd: repo1 });
344
+ await runCommand("git", ["checkout", "main"], { cwd: repo2 });
345
+
346
+ const commitA = (
347
+ await runCommand("git", ["rev-list", "--max-parents=0", "HEAD"], {
348
+ cwd: repo1,
349
+ })
350
+ ).stdout.trim();
351
+ const commitB = (
352
+ await runCommand("git", ["rev-parse", "HEAD"], { cwd: repo2 })
353
+ ).stdout.trim();
354
+
355
+ await writeNote(
356
+ {
357
+ commit: commitA,
358
+ session: null,
359
+ branch: "main",
360
+ timestamp: "2026-03-01T10:00:00.000Z",
361
+ files: [],
362
+ totals: { ai: 0, human: 0, mixed: 0, total: 0, pctAi: 0 },
363
+ },
364
+ repo1,
365
+ commitA,
366
+ );
367
+ await pushNotesRef(repo1, NOTES_REF);
368
+
369
+ await writeNote(
370
+ {
371
+ commit: commitB,
372
+ session: null,
373
+ branch: "main",
374
+ timestamp: "2026-03-01T10:05:00.000Z",
375
+ files: [],
376
+ totals: { ai: 0, human: 0, mixed: 0, total: 0, pctAi: 0 },
377
+ },
378
+ repo2,
379
+ commitB,
380
+ );
381
+ await pushNotesRef(repo2, NOTES_REF);
382
+
383
+ await runCommand("git", ["fetch", "origin", `${NOTES_REF}:${NOTES_REF}`], {
384
+ cwd: repo1,
385
+ });
386
+ const listed = (
387
+ await runCommand(
388
+ "git",
389
+ ["notes", "--ref", "claude-attribution", "list"],
390
+ { cwd: repo1 },
391
+ )
392
+ ).stdout;
393
+ expect(listed).toContain(commitA);
394
+ expect(listed).toContain(commitB);
395
+ } finally {
396
+ await rootCtx.cleanup();
397
+ }
398
+ });
399
+
400
+ test("install and uninstall update hooks and remove legacy refspecs", async () => {
401
+ const ctx = await createTempContext("claude-attribution-install");
402
+ try {
403
+ await initGitRepo(ctx.repo);
404
+ await writeFile(join(ctx.repo, "README.md"), "test repo\n");
405
+ await commitAll(ctx.repo, "initial");
406
+
407
+ const remote = join(ctx.root, "remote.git");
408
+ await runCommand("git", ["init", "--bare", remote]);
409
+ await runCommand("git", ["remote", "add", "origin", remote], {
410
+ cwd: ctx.repo,
411
+ });
412
+ await runCommand(
413
+ "git",
414
+ [
415
+ "config",
416
+ "--add",
417
+ "remote.origin.push",
418
+ "refs/notes/claude-attribution:refs/notes/claude-attribution",
419
+ ],
420
+ { cwd: ctx.repo },
421
+ );
422
+ await runCommand(
423
+ "git",
424
+ [
425
+ "config",
426
+ "--add",
427
+ "remote.origin.push",
428
+ "refs/notes/claude-attribution-map:refs/notes/claude-attribution-map",
429
+ ],
430
+ { cwd: ctx.repo },
431
+ );
432
+
433
+ await runCommand(CLI_BIN, ["install", ctx.repo], {
434
+ cwd: REPO_ROOT,
435
+ env: { HOME: ctx.home },
436
+ });
437
+
438
+ const settings = (await readJson(
439
+ join(ctx.repo, ".claude", "settings.json"),
440
+ )) as { hooks?: { PostToolUse?: Array<{ hooks?: Array<{ command?: string }> }> } };
441
+ const postToolCommands =
442
+ settings.hooks?.PostToolUse?.flatMap((entry) =>
443
+ entry.hooks?.map((hook) => hook.command ?? "") ?? [],
444
+ ) ?? [];
445
+ const postCommitHook = await readFile(
446
+ join(ctx.repo, ".git", "hooks", "post-commit"),
447
+ "utf8",
448
+ );
449
+ const refspecsAfterInstall = (
450
+ await runCommand(
451
+ "git",
452
+ ["config", "--get-all", "remote.origin.push"],
453
+ { cwd: ctx.repo, allowFailure: true },
454
+ )
455
+ ).stdout;
456
+ const installedReposAfterInstall = await readInstalledRepoRegistry(ctx.home);
457
+
458
+ expect(postToolCommands.join("\n")).toContain(
459
+ "claude-attribution hook post-tool-use",
460
+ );
461
+ expect(postCommitHook).toContain("claude-attribution hook post-commit");
462
+ expect(refspecsAfterInstall).not.toContain("refs/notes/claude-attribution");
463
+ expect(installedReposAfterInstall.map((record) => record.path)).toContain(
464
+ ctx.repo,
465
+ );
466
+
467
+ await runCommand(CLI_BIN, ["uninstall", ctx.repo], {
468
+ cwd: REPO_ROOT,
469
+ env: { HOME: ctx.home },
470
+ });
471
+
472
+ const refspecsAfterUninstall = (
473
+ await runCommand(
474
+ "git",
475
+ ["config", "--get-all", "remote.origin.push"],
476
+ { cwd: ctx.repo, allowFailure: true },
477
+ )
478
+ ).stdout;
479
+ const settingsAfter = await readFile(
480
+ join(ctx.repo, ".claude", "settings.json"),
481
+ "utf8",
482
+ );
483
+ const postCommitAfter = await readFile(
484
+ join(ctx.repo, ".git", "hooks", "post-commit"),
485
+ "utf8",
486
+ ).catch(() => "");
487
+ const installedReposAfterUninstall = await readInstalledRepoRegistry(
488
+ ctx.home,
489
+ );
490
+
491
+ expect(refspecsAfterUninstall).not.toContain("refs/notes/claude-attribution");
492
+ expect(settingsAfter).not.toContain("claude-attribution hook post-tool-use");
493
+ expect(postCommitAfter).not.toContain("claude-attribution");
494
+ expect(installedReposAfterUninstall.map((record) => record.path)).not.toContain(
495
+ ctx.repo,
496
+ );
497
+ } finally {
498
+ await ctx.cleanup();
499
+ }
500
+ });
501
+ });
@@ -0,0 +1,88 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { writeFile } from "fs/promises";
3
+ import { join } from "path";
4
+ import {
5
+ buildAiSinceMinimap,
6
+ buildAllAiMinimap,
7
+ listMinimapNotes,
8
+ readMinimap,
9
+ writeMinimap,
10
+ } from "../attribution/minimap.ts";
11
+ import {
12
+ createTempContext,
13
+ currentSha,
14
+ initGitRepo,
15
+ runCommand,
16
+ } from "./integration-helpers.ts";
17
+
18
+ describe("minimap bulk helpers", () => {
19
+ test("builds, writes, and reads all-AI minimaps", async () => {
20
+ const ctx = await createTempContext("claude-attribution-minimap-all-ai");
21
+ try {
22
+ await initGitRepo(ctx.repo);
23
+ await writeFile(join(ctx.repo, "ai.txt"), "alpha");
24
+ await writeFile(join(ctx.repo, "keep.txt"), "beta");
25
+ await writeFile(join(ctx.repo, "binary.bin"), Buffer.from([0, 1, 2, 3]));
26
+ await runCommand("git", ["add", "."], { cwd: ctx.repo });
27
+ await runCommand("git", ["commit", "-m", "initial"], { cwd: ctx.repo });
28
+ const sha = await currentSha(ctx.repo);
29
+
30
+ expect(await readMinimap(ctx.repo, sha)).toBeNull();
31
+ expect(await listMinimapNotes(ctx.repo)).toEqual([]);
32
+
33
+ const result = await buildAllAiMinimap(ctx.repo);
34
+ expect(result.commit).toBe(sha);
35
+ expect(result.files.map((file) => file.path).sort()).toEqual([
36
+ "ai.txt",
37
+ "keep.txt",
38
+ ]);
39
+ expect(result.totals).toMatchObject({ ai: 2, human: 0, total: 2, pctAi: 100 });
40
+
41
+ await writeMinimap(result, ctx.repo, sha);
42
+ const stored = await readMinimap(ctx.repo, sha);
43
+ expect(stored?.totals).toEqual(result.totals);
44
+ expect(await listMinimapNotes(ctx.repo)).toEqual([sha]);
45
+ } finally {
46
+ await ctx.cleanup();
47
+ }
48
+ });
49
+
50
+ test("marks only recently changed files as AI in ai-since minimaps", async () => {
51
+ const ctx = await createTempContext("claude-attribution-minimap-ai-since");
52
+ try {
53
+ await initGitRepo(ctx.repo);
54
+ await writeFile(join(ctx.repo, "changed.txt"), "before");
55
+ await writeFile(join(ctx.repo, "unchanged.txt"), "steady");
56
+ await runCommand("git", ["add", "."], { cwd: ctx.repo });
57
+ await runCommand("git", ["commit", "-m", "baseline"], {
58
+ cwd: ctx.repo,
59
+ env: {
60
+ GIT_AUTHOR_DATE: "2020-01-01T00:00:00Z",
61
+ GIT_COMMITTER_DATE: "2020-01-01T00:00:00Z",
62
+ },
63
+ });
64
+
65
+ await writeFile(join(ctx.repo, "changed.txt"), "after");
66
+ await writeFile(join(ctx.repo, "binary.bin"), Buffer.from([0, 1, 2, 3]));
67
+ await runCommand("git", ["add", "."], { cwd: ctx.repo });
68
+ await runCommand("git", ["commit", "-m", "recent update"], {
69
+ cwd: ctx.repo,
70
+ env: {
71
+ GIT_AUTHOR_DATE: "2030-01-01T00:00:00Z",
72
+ GIT_COMMITTER_DATE: "2030-01-01T00:00:00Z",
73
+ },
74
+ });
75
+
76
+ const result = await buildAiSinceMinimap(ctx.repo, "2025-01-01");
77
+ const changed = result.files.find((file) => file.path === "changed.txt");
78
+ const unchanged = result.files.find((file) => file.path === "unchanged.txt");
79
+
80
+ expect(changed).toMatchObject({ ai: 1, human: 0, total: 1, pctAi: 100 });
81
+ expect(unchanged).toMatchObject({ ai: 0, human: 1, total: 1, pctAi: 0 });
82
+ expect(result.files.find((file) => file.path === "binary.bin")).toBeUndefined();
83
+ expect(result.totals).toMatchObject({ ai: 1, human: 1, total: 2, pctAi: 50 });
84
+ } finally {
85
+ await ctx.cleanup();
86
+ }
87
+ });
88
+ });
@@ -0,0 +1,38 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import {
3
+ buildRemoteMergeRef,
4
+ isRetryableNotesPushError,
5
+ } from "../attribution/notes-sync.ts";
6
+
7
+ describe("buildRemoteMergeRef", () => {
8
+ test("derives the temporary remote merge ref from the notes ref", () => {
9
+ expect(buildRemoteMergeRef("refs/notes/claude-attribution")).toBe(
10
+ "refs/notes/claude-attribution-remote",
11
+ );
12
+ });
13
+ });
14
+
15
+ describe("isRetryableNotesPushError", () => {
16
+ test("matches fetch-first push rejections", () => {
17
+ const error = Object.assign(new Error("push failed"), {
18
+ stderr:
19
+ "! [rejected] refs/notes/claude-attribution -> refs/notes/claude-attribution (fetch first)\nerror: failed to push some refs",
20
+ });
21
+ expect(isRetryableNotesPushError(error)).toBe(true);
22
+ });
23
+
24
+ test("matches non-fast-forward push rejections", () => {
25
+ const error = Object.assign(new Error("push failed"), {
26
+ stderr:
27
+ "! [rejected] refs/notes/claude-attribution -> refs/notes/claude-attribution (non-fast-forward)\nerror: failed to push some refs",
28
+ });
29
+ expect(isRetryableNotesPushError(error)).toBe(true);
30
+ });
31
+
32
+ test("does not match unrelated push failures", () => {
33
+ const error = Object.assign(new Error("push failed"), {
34
+ stderr: "fatal: could not read from remote repository",
35
+ });
36
+ expect(isRetryableNotesPushError(error)).toBe(false);
37
+ });
38
+ });
@@ -0,0 +1,33 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { detectAssistantRuntime } from "../attribution/runtime.ts";
3
+
4
+ describe("detectAssistantRuntime", () => {
5
+ test("detects GitHub Copilot CLI from environment", () => {
6
+ expect(
7
+ detectAssistantRuntime({
8
+ COPILOT_CLI: "1",
9
+ COPILOT_CLI_BINARY_VERSION: "1.2.3",
10
+ }),
11
+ ).toEqual({
12
+ vendor: "copilot",
13
+ client: "GitHub Copilot CLI",
14
+ clientVersion: "1.2.3",
15
+ });
16
+ });
17
+
18
+ test("falls back to Claude runtime from Anthropic model env", () => {
19
+ expect(
20
+ detectAssistantRuntime({
21
+ ANTHROPIC_DEFAULT_SONNET_MODEL: "claude-sonnet-4-6",
22
+ }),
23
+ ).toEqual({
24
+ vendor: "claude",
25
+ client: "Claude Code",
26
+ modelFamily: "claude-sonnet-4-6",
27
+ });
28
+ });
29
+
30
+ test("returns null when no runtime hints are available", () => {
31
+ expect(detectAssistantRuntime({})).toBeNull();
32
+ });
33
+ });