@pi-archimedes/footer 2.9.2 → 2.9.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-archimedes/footer",
3
- "version": "2.9.2",
3
+ "version": "2.9.3",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/danielcherubini/pi-archimedes.git"
@@ -19,7 +19,7 @@
19
19
  "./config": "./src/config.ts"
20
20
  },
21
21
  "dependencies": {
22
- "@pi-archimedes/core": "2.9.2"
22
+ "@pi-archimedes/core": "2.9.3"
23
23
  },
24
24
  "peerDependencies": {
25
25
  "@earendil-works/pi-coding-agent": ">=0.1.0",
@@ -71,6 +71,12 @@ describe("getGitStatus", () => {
71
71
  expect(result.unstaged).toBe(0);
72
72
  });
73
73
 
74
+ it("counts intent-to-add files (jj's new files) as unstaged", async () => {
75
+ const mockExecSync = vi.fn(() => "1 .A N... 000000 000000 100644 0000 0000 new.txt\n");
76
+ await loadModule(mockExecSync);
77
+ expect(getGitStatus()).toMatchObject({ staged: 0, unstaged: 1, untracked: 0 });
78
+ });
79
+
74
80
  it("parse untracked lines", async () => {
75
81
  const mockExecSync = vi.fn(() => "? untracked1.txt\n? untracked2.txt\n");
76
82
  await loadModule(mockExecSync);
@@ -78,12 +84,13 @@ describe("getGitStatus", () => {
78
84
  expect(result.untracked).toBe(2);
79
85
  });
80
86
 
81
- it("parse branch summary (ahead/behind)", async () => {
82
- const mockExecSync = vi.fn(() => "## main...origin/main 3 2\n");
87
+ it("parse branch headers (ahead/behind)", async () => {
88
+ const mockExecSync = vi.fn((_cmd: string) =>
89
+ "# branch.oid abc123\n# branch.head main\n# branch.upstream origin/main\n# branch.ab +3 -2\n");
83
90
  await loadModule(mockExecSync);
84
91
  const result = getGitStatus();
85
- expect(result.ahead).toBe(3);
86
- expect(result.behind).toBe(2);
92
+ expect(mockExecSync.mock.calls[0]![0]).toContain("--branch"); // headers only print with --branch
93
+ expect(result).toEqual({ staged: 0, unstaged: 0, untracked: 0, ahead: 3, behind: 2 });
87
94
  });
88
95
 
89
96
  it("malformed lines ignored gracefully", async () => {
package/src/utils/git.ts CHANGED
@@ -10,7 +10,10 @@ export interface GitStatus {
10
10
  }
11
11
 
12
12
  const STAGED_INDEX_STATES = ["A", "M", "D", "R", "C", "U", "T"] as const;
13
- const UNSTAGED_WORKTREE_STATES = ["M", "D", "U"] as const;
13
+ // "A" = intent-to-add (`git add -N`); jj marks every new file this way in colocated repos
14
+ const UNSTAGED_WORKTREE_STATES = ["M", "D", "U", "A"] as const;
15
+
16
+ const GIT_STATUS_CMD = "git status --porcelain=v2 --branch -uall";
14
17
 
15
18
  // Cache TTL: 2 seconds — git status doesn't change on every render
16
19
  const GIT_CACHE_TTL_MS = 2_000;
@@ -53,16 +56,12 @@ function parseGitOutput(output: string): GitStatus {
53
56
  const status: GitStatus = { staged: 0, unstaged: 0, untracked: 0, ahead: 0, behind: 0 };
54
57
 
55
58
  for (const line of output.trim().split("\n")) {
56
- // Branch summary: "## branch_name ... upstream ahead behind"
57
- if (/^## /.test(line)) {
58
- const branchParts = line.slice(3).trim().split(/\s+/);
59
- if (branchParts.length >= 3) {
60
- const commitsAhead = Number(branchParts[branchParts.length - 2]);
61
- const commitsBehind = Number(branchParts[branchParts.length - 1]);
62
- if (!isNaN(commitsAhead) && !isNaN(commitsBehind) && branchParts[branchParts.length - 3]) {
63
- status.ahead = Math.max(0, commitsAhead);
64
- status.behind = Math.max(0, commitsBehind);
65
- }
59
+ // Branch headers: "# branch.ab +<ahead> -<behind>" (only when an upstream is set)
60
+ if (line.startsWith("# ")) {
61
+ const ab = line.match(/^# branch\.ab \+(\d+) -(\d+)/);
62
+ if (ab) {
63
+ status.ahead = Number(ab[1]);
64
+ status.behind = Number(ab[2]);
66
65
  }
67
66
  continue;
68
67
  }
@@ -88,7 +87,7 @@ function scheduleGitRefresh(): void {
88
87
  gitRefreshTimer = setTimeout(() => {
89
88
  gitRefreshTimer = undefined;
90
89
  try {
91
- const output = execSync("git status --porcelain=v2 -uall", {
90
+ const output = execSync(GIT_STATUS_CMD, {
92
91
  cwd: process.cwd(),
93
92
  encoding: "utf8",
94
93
  stdio: ["pipe", "pipe", "pipe"],
@@ -116,7 +115,7 @@ export function getGitStatus(): GitStatus {
116
115
  // First call — sync fetch + schedule background refresh
117
116
  const emptyStatus: GitStatus = { staged: 0, unstaged: 0, untracked: 0, ahead: 0, behind: 0 };
118
117
  try {
119
- const output = execSync("git status --porcelain=v2 -uall", {
118
+ const output = execSync(GIT_STATUS_CMD, {
120
119
  cwd: process.cwd(),
121
120
  encoding: "utf8",
122
121
  stdio: ["pipe", "pipe", "pipe"],
@@ -1,98 +1,70 @@
1
- import { describe, it, expect, vi, afterEach } from "vitest";
1
+ import { describe, it, expect, vi } from "vitest";
2
2
  import { resolve } from "path";
3
3
 
4
- describe("getJjBookmark", () => {
5
- let getJjBookmark: () => string | null;
6
- let execFileSync: ReturnType<typeof vi.fn>;
7
- let execFile: ReturnType<typeof vi.fn>;
8
- let existing: Set<string>;
9
-
10
- // `paths` = repo markers that exist, relative to cwd. Default is a colocated jj repo.
11
- async function load(opts: { paths?: string[]; output?: string; throws?: boolean } = {}) {
12
- vi.resetModules();
13
- execFileSync = vi.fn(() => {
14
- if (opts.throws) throw new Error("ENOENT");
15
- return opts.output ?? "";
16
- });
17
- execFile = vi.fn();
18
- vi.doMock("child_process", () => ({ execFileSync, execFile }));
19
- existing = new Set((opts.paths ?? [".jj", ".git"]).map((p) => resolve(process.cwd(), p)));
20
- vi.doMock("fs", () => ({ existsSync: vi.fn((p: string) => existing.has(p)) }));
21
- getJjBookmark = (await import("./jj.js")).getJjBookmark;
22
- }
23
-
24
- afterEach(() => {
25
- vi.restoreAllMocks();
26
- });
27
-
28
- it("returns null without spawning jj outside a jj repo", async () => {
29
- await load({ paths: [".git"] });
30
- expect(getJjBookmark()).toBeNull();
31
- expect(execFileSync).not.toHaveBeenCalled();
32
- });
33
-
34
- it("detects a jj repo initialised after a cached negative result", async () => {
35
- await load({ paths: [".git"], output: "main\n" });
36
- const now = vi.spyOn(Date, "now").mockReturnValue(1_000);
37
- expect(getJjBookmark()).toBeNull();
38
-
39
- existing.add(resolve(process.cwd(), ".jj")); // `jj git init --colocate`
40
- now.mockReturnValue(5_000);
41
- expect(getJjBookmark()).toBe("main");
42
- });
43
-
44
- it("leaves a git repo nested inside a jj tree to git", async () => {
45
- await load({ paths: [".git", "../.jj"] });
46
- expect(getJjBookmark()).toBeNull();
47
- expect(execFileSync).not.toHaveBeenCalled();
4
+ // `paths` = repo markers that exist, relative to cwd. Default: colocated jj repo.
5
+ async function load({ paths = [".jj", ".git"], output = "main\n", throws = false } = {}) {
6
+ vi.resetModules();
7
+ const existing = new Set(paths.map((p) => resolve(process.cwd(), p)));
8
+ const execFileSync = vi.fn((_cmd: string, _args: string[]) => {
9
+ if (throws) throw new Error("ENOENT");
10
+ return output;
48
11
  });
12
+ const execFile = vi.fn();
13
+ vi.doMock("child_process", () => ({ execFileSync, execFile }));
14
+ vi.doMock("fs", () => ({ existsSync: (p: string) => existing.has(p) }));
15
+ const { getJjBookmark } = await import("./jj.js");
16
+ return { getJjBookmark, execFileSync, execFile, existing };
17
+ }
49
18
 
50
- it("finds a jj repo from a subdirectory", async () => {
51
- await load({ paths: ["../.jj"], output: "main\n" });
52
- expect(getJjBookmark()).toBe("main");
53
- });
54
-
55
- it("prefers the first bookmark line over the @ change-id line", async () => {
56
- await load({ output: "@mooqvmmx\nmain\nother\n" });
57
- expect(getJjBookmark()).toBe("main");
58
- });
59
-
60
- it("returns the bookmark when @ itself is bookmarked", async () => {
61
- await load({ output: "feature-x\n" });
62
- expect(getJjBookmark()).toBe("feature-x");
19
+ describe("getJjBookmark", () => {
20
+ it.each([
21
+ { name: "plain git repo", paths: [".git"], expected: null },
22
+ { name: "git repo nested in a jj tree", paths: [".git", "../.jj"], expected: null },
23
+ { name: "colocated jj repo", paths: [".jj", ".git"], expected: "main" },
24
+ { name: "subdirectory of a jj repo", paths: ["../.jj"], expected: "main" },
25
+ ])("detects $name", async ({ paths, expected }) => {
26
+ const { getJjBookmark, execFileSync } = await load({ paths });
27
+ expect(getJjBookmark()).toBe(expected);
28
+ if (!expected) expect(execFileSync).not.toHaveBeenCalled(); // git-only users never spawn jj
63
29
  });
64
30
 
65
- it("falls back to the change id when no ancestor is bookmarked", async () => {
66
- await load({ output: "@mooqvmmx\n" });
67
- expect(getJjBookmark()).toBe("mooqvmmx");
31
+ it.each([
32
+ { output: "@mooqvmmx\nmain\nother\n", expected: "main" }, // first bookmark line beats @
33
+ { output: "feature-x\n", expected: "feature-x" }, // @ itself bookmarked
34
+ { output: "main release v2\n", expected: "main +2" }, // capped for layout width
35
+ { output: "@mooqvmmx\n", expected: "mooqvmmx" }, // no bookmark → change id
36
+ ])("parses $output", async ({ output, expected }) => {
37
+ const { getJjBookmark } = await load({ output });
38
+ expect(getJjBookmark()).toBe(expected);
68
39
  });
69
40
 
70
41
  it("returns null when jj fails", async () => {
71
- await load({ throws: true });
42
+ const { getJjBookmark } = await load({ throws: true });
72
43
  expect(getJjBookmark()).toBeNull();
73
44
  });
74
45
 
75
- it("serves the cache within the TTL and refreshes in the background after", async () => {
76
- await load({ output: "main\n" });
46
+ it("caches, refreshes in the background, and notices a late `jj git init`", async () => {
47
+ const { getJjBookmark, execFileSync, execFile, existing } = await load({ paths: [".git"] });
77
48
  const now = vi.spyOn(Date, "now").mockReturnValue(1_000);
78
- expect(getJjBookmark()).toBe("main");
79
- expect(getJjBookmark()).toBe("main");
80
- expect(execFileSync).toHaveBeenCalledTimes(1);
81
- // Never snapshot the working copy from a render (op-log writes, repo lock)
82
- expect(execFileSync.mock.calls[0]![1]).toContain("--ignore-working-copy");
83
- expect(execFile).not.toHaveBeenCalled();
49
+ expect(getJjBookmark()).toBeNull();
84
50
 
51
+ existing.add(resolve(process.cwd(), ".jj")); // negative detection expires
85
52
  now.mockReturnValue(5_000);
86
- expect(getJjBookmark()).toBe("main"); // stale value while refreshing
87
- expect(execFile).toHaveBeenCalledTimes(1);
53
+ expect(getJjBookmark()).toBe("main");
54
+ expect(getJjBookmark()).toBe("main"); // within TTL: no second spawn
55
+ expect(execFileSync).toHaveBeenCalledTimes(1);
56
+ expect(execFileSync.mock.calls[0]![1]).toContain("--ignore-working-copy"); // never snapshot from a render
88
57
 
89
- const done = execFile.mock.calls[0]![3] as (e: Error | null, out: string) => void;
90
- done(new Error("lock timeout"), "");
58
+ type Done = (e: Error | null, out: string) => void;
59
+ now.mockReturnValue(10_000);
60
+ expect(getJjBookmark()).toBe("main"); // stale while refreshing
61
+ (execFile.mock.calls[0]![3] as Done)(new Error("lock timeout"), "");
91
62
  expect(getJjBookmark()).toBe("main"); // failed refresh keeps stale value
92
63
 
93
- now.mockReturnValue(10_000);
64
+ now.mockReturnValue(15_000);
94
65
  getJjBookmark();
95
- (execFile.mock.calls[1]![3] as typeof done)(null, "feature-x\n");
66
+ (execFile.mock.calls[1]![3] as Done)(null, "feature-x\n");
96
67
  expect(getJjBookmark()).toBe("feature-x");
68
+ now.mockRestore();
97
69
  });
98
70
  });
package/src/utils/jj.ts CHANGED
@@ -13,7 +13,7 @@ const JJ_ARGS = [
13
13
  "--no-graph",
14
14
  "--ignore-working-copy",
15
15
  "--color", "never",
16
- "-T", 'if(local_bookmarks, local_bookmarks.map(|b| b.name()).join(","), "@" ++ change_id.shortest(8)) ++ "\\n"',
16
+ "-T", 'if(local_bookmarks, local_bookmarks.map(|b| b.name()).join(" "), "@" ++ change_id.shortest(8)) ++ "\\n"',
17
17
  ];
18
18
 
19
19
  let jjRepoCache: { cwd: string; isJj: boolean; timestamp: number } | undefined;
@@ -38,10 +38,18 @@ function isJjRepo(cwd: string): boolean {
38
38
  return isJj;
39
39
  }
40
40
 
41
- /** First bookmark line wins; otherwise the `@<change-id>` line without its marker. */
41
+ /**
42
+ * First bookmark line wins; otherwise the `@<change-id>` line without its marker.
43
+ * Several bookmarks on one commit collapse to `first +N` — footer chunks are
44
+ * atomic, so an unbounded list could push other sections onto their own lines.
45
+ */
42
46
  function parseJjOutput(output: string): string | null {
43
47
  const lines = output.split("\n").filter(Boolean);
44
- return lines.find((l) => !l.startsWith("@")) ?? (lines[0]?.slice(1) || null);
48
+ const bookmarks = lines.find((l) => !l.startsWith("@"));
49
+ if (!bookmarks) return lines[0]?.slice(1) || null;
50
+ // Space-joined by the template: git ref names can't contain spaces
51
+ const [first, ...rest] = bookmarks.split(" ");
52
+ return rest.length ? `${first} +${rest.length}` : first!;
45
53
  }
46
54
 
47
55
  function refreshInBackground(cwd: string): void {