@pi-archimedes/footer 2.9.2 → 2.10.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/README.md +1 -0
- package/package.json +2 -2
- package/src/config.ts +8 -0
- package/src/index.ts +3 -3
- package/src/utils/format.test.ts +24 -0
- package/src/utils/format.ts +5 -1
- package/src/utils/git.test.ts +11 -4
- package/src/utils/git.ts +12 -13
- package/src/utils/jj.test.ts +48 -76
- package/src/utils/jj.ts +11 -3
package/README.md
CHANGED
|
@@ -43,6 +43,7 @@ Two honest caveats: the ledger reflects usage and pricing **as reported to the s
|
|
|
43
43
|
| Setting | Type | Default | Description |
|
|
44
44
|
|---------|------|---------|-------------|
|
|
45
45
|
| `splitThreshold` | number | `150` | Minimum terminal columns for the single-line layout. Below it, the footer uses the structured multi-line layout; above it, it stays single-line and wraps only if content overflows. |
|
|
46
|
+
| `branchMaxLength` | number | `0` | Max columns for the branch or jj bookmark name, truncated with `…`. `0` = unlimited. Git status indicators are never truncated. Applies on next session or `/reload`. |
|
|
46
47
|
|
|
47
48
|
In the suite the setting also appears in `/archimedes` (when the panel has a control for it); `/reload` applies it. On/off is managed by the suite: toggle via `/plugins` (`archimedes.footer.enabled`, default on).
|
|
48
49
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pi-archimedes/footer",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.10.0",
|
|
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.
|
|
22
|
+
"@pi-archimedes/core": "2.10.0"
|
|
23
23
|
},
|
|
24
24
|
"peerDependencies": {
|
|
25
25
|
"@earendil-works/pi-coding-agent": ">=0.1.0",
|
package/src/config.ts
CHANGED
|
@@ -3,10 +3,12 @@ import type { SettingItem } from "@earendil-works/pi-tui";
|
|
|
3
3
|
|
|
4
4
|
export interface FooterConfig {
|
|
5
5
|
splitThreshold: number;
|
|
6
|
+
branchMaxLength: number;
|
|
6
7
|
}
|
|
7
8
|
|
|
8
9
|
export const DEFAULT_FOOTER_CONFIG: FooterConfig = {
|
|
9
10
|
splitThreshold: 150,
|
|
11
|
+
branchMaxLength: 0,
|
|
10
12
|
};
|
|
11
13
|
|
|
12
14
|
const NAMESPACE = "archimedes.footer";
|
|
@@ -27,5 +29,11 @@ export function getFooterSettingsItems(): SettingItem[] {
|
|
|
27
29
|
description: "Terminal width threshold for two-line footer split",
|
|
28
30
|
currentValue: String(loadFooterConfig().splitThreshold),
|
|
29
31
|
},
|
|
32
|
+
{
|
|
33
|
+
id: "branchMaxLength",
|
|
34
|
+
label: "Branch Max Length",
|
|
35
|
+
description: "Max columns for the branch/bookmark name (0 = unlimited)",
|
|
36
|
+
currentValue: String(loadFooterConfig().branchMaxLength),
|
|
37
|
+
},
|
|
30
38
|
];
|
|
31
39
|
}
|
package/src/index.ts
CHANGED
|
@@ -18,7 +18,7 @@ import { CostAccumulator } from "./cost-accumulator.js";
|
|
|
18
18
|
import { getGitStatus, isInsideLinkedWorktree } from "./utils/git.js";
|
|
19
19
|
import { getJjBookmark } from "./utils/jj.js";
|
|
20
20
|
import { getContextWindowInfo, getTokenUsageStats, type TokenUsageStats } from "./utils/stats.js";
|
|
21
|
-
import { formatContextBar, formatGitStatusIndicators, formatThinkingIndicator, formatTokenCount, wrapStatusToChunks } from "./utils/format.js";
|
|
21
|
+
import { formatContextBar, formatGitStatusIndicators, formatThinkingIndicator, formatTokenCount, truncateBranch, wrapStatusToChunks } from "./utils/format.js";
|
|
22
22
|
import { footerIcons } from "./utils/icons.js";
|
|
23
23
|
import { packFooterLines, SEP_W, SEPARATOR } from "./utils/layout.js";
|
|
24
24
|
|
|
@@ -36,7 +36,7 @@ export function registerFooter(pi: ExtensionAPI): void {
|
|
|
36
36
|
});
|
|
37
37
|
|
|
38
38
|
pi.on("session_start", (_event, ctx: ExtensionContext) => {
|
|
39
|
-
const splitThreshold = loadFooterConfig()
|
|
39
|
+
const { splitThreshold, branchMaxLength } = loadFooterConfig();
|
|
40
40
|
|
|
41
41
|
// Create cost accumulator for subagent costs
|
|
42
42
|
footerAccumulator = new CostAccumulator();
|
|
@@ -87,7 +87,7 @@ export function registerFooter(pi: ExtensionAPI): void {
|
|
|
87
87
|
// System info sections: dir | branch [+status] | model | thinking | ext-statuses
|
|
88
88
|
const leftSections = [
|
|
89
89
|
colorize("syntaxFunction", " " + footerIcons.directory + currentDirectory),
|
|
90
|
-
currentBranch ? colorize("success", branchIcon + " " + currentBranch + (gitStatusStr ? " " + gitStatusStr : "")) : "",
|
|
90
|
+
currentBranch ? colorize("success", branchIcon + " " + truncateBranch(currentBranch, branchMaxLength) + (gitStatusStr ? " " + gitStatusStr : "")) : "",
|
|
91
91
|
colorize("syntaxType", footerIcons.model + " " + activeModel),
|
|
92
92
|
thinkingIndicatorStr,
|
|
93
93
|
...extensionStatusChunks,
|
package/src/utils/format.test.ts
CHANGED
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
formatContextBar,
|
|
7
7
|
formatGitStatusIndicators,
|
|
8
8
|
formatThinkingIndicator,
|
|
9
|
+
truncateBranch,
|
|
9
10
|
wrapStatusToChunks,
|
|
10
11
|
} from "./format.js";
|
|
11
12
|
import { thinkingLevelColors, thinkingLevelIcons, type ColorFn } from "./icons.js";
|
|
@@ -14,6 +15,29 @@ import { thinkingLevelColors, thinkingLevelIcons, type ColorFn } from "./icons.j
|
|
|
14
15
|
// Passthrough — we test structure, not actual coloring.
|
|
15
16
|
const mockColor: ColorFn = (_token, text) => text;
|
|
16
17
|
|
|
18
|
+
// ── truncateBranch ──────────────────────────────────────────────────────────
|
|
19
|
+
|
|
20
|
+
describe("truncateBranch", () => {
|
|
21
|
+
it("returns the name unchanged when max is 0 (unlimited)", () => {
|
|
22
|
+
const name = "feature/a-very-long-branch-name";
|
|
23
|
+
expect(truncateBranch(name, 0)).toBe(name);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("returns the name unchanged when it is shorter than max", () => {
|
|
27
|
+
expect(truncateBranch("main", 10)).toBe("main");
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it.each([
|
|
31
|
+
["feature/a-very-long-branch-name", 10],
|
|
32
|
+
["feature/x", 1], // smallest limit: ellipsis only
|
|
33
|
+
["機能ブランチ名前", 5], // wide chars: odd limit can't split a 2-column char
|
|
34
|
+
])("truncates %s to %i columns ending with an ellipsis", (name, max) => {
|
|
35
|
+
const result = truncateBranch(name, max);
|
|
36
|
+
expect(visibleWidth(result)).toBeLessThanOrEqual(max);
|
|
37
|
+
expect(stripAnsi(result).endsWith("…")).toBe(true);
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
|
|
17
41
|
// ── formatTokenCount ────────────────────────────────────────────────────────
|
|
18
42
|
|
|
19
43
|
describe("formatTokenCount", () => {
|
package/src/utils/format.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { visibleWidth } from "@earendil-works/pi-tui";
|
|
1
|
+
import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
2
2
|
import { footerIcons, gitDisplayIcons, gitStatusColors, thinkingLevelColors, thinkingLevelIcons, type ColorFn } from "./icons.js";
|
|
3
3
|
|
|
4
4
|
// Token counts use binary units (1K = 1024)
|
|
@@ -50,6 +50,10 @@ export function formatGitStatusIndicators(
|
|
|
50
50
|
return statusParts.join("");
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
+
export function truncateBranch(name: string, max: number): string {
|
|
54
|
+
return max > 0 ? truncateToWidth(name, max, "…") : name;
|
|
55
|
+
}
|
|
56
|
+
|
|
53
57
|
export function formatThinkingIndicator(thinkingLevel: string, colorize: ColorFn): string {
|
|
54
58
|
return colorize(thinkingLevelColors[thinkingLevel] || "dim", `${thinkingLevelIcons[thinkingLevel] || "◑"} ${thinkingLevel}`);
|
|
55
59
|
}
|
package/src/utils/git.test.ts
CHANGED
|
@@ -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
|
|
82
|
-
const mockExecSync = vi.fn(() =>
|
|
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(
|
|
86
|
-
expect(result
|
|
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
|
-
|
|
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
|
|
57
|
-
if (
|
|
58
|
-
const
|
|
59
|
-
if (
|
|
60
|
-
|
|
61
|
-
|
|
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(
|
|
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(
|
|
118
|
+
const output = execSync(GIT_STATUS_CMD, {
|
|
120
119
|
cwd: process.cwd(),
|
|
121
120
|
encoding: "utf8",
|
|
122
121
|
stdio: ["pipe", "pipe", "pipe"],
|
package/src/utils/jj.test.ts
CHANGED
|
@@ -1,98 +1,70 @@
|
|
|
1
|
-
import { describe, it, expect, vi
|
|
1
|
+
import { describe, it, expect, vi } from "vitest";
|
|
2
2
|
import { resolve } from "path";
|
|
3
3
|
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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(
|
|
66
|
-
|
|
67
|
-
|
|
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("
|
|
76
|
-
await load({
|
|
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()).
|
|
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");
|
|
87
|
-
expect(
|
|
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
|
-
|
|
90
|
-
|
|
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(
|
|
64
|
+
now.mockReturnValue(15_000);
|
|
94
65
|
getJjBookmark();
|
|
95
|
-
(execFile.mock.calls[1]![3] as
|
|
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("
|
|
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
|
-
/**
|
|
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
|
-
|
|
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 {
|