diff --git a/packages/coding-agent/src/modes/components/status-line/component.jj-cache.test.ts b/packages/coding-agent/src/modes/components/status-line/component.jj-cache.test.ts index 6c8709ab9..6d2d7a027 100644 --- a/packages/coding-agent/src/modes/components/status-line/component.jj-cache.test.ts +++ b/packages/coding-agent/src/modes/components/status-line/component.jj-cache.test.ts @@ -6,9 +6,9 @@ import { getProjectDir, setProjectDir } from "@oh-my-pi/pi-utils"; import { Settings, settings } from "../../../config/settings"; import type { AgentSession } from "../../../session/agent-session"; import * as git from "../../../utils/git"; +import * as jj from "../../../utils/jj"; import { getThemeByName, setThemeInstance } from "../../theme/theme"; import { StatusLineComponent } from "./component"; -import * as jjInfo from "./jj-info"; // Minimal session the git-only status line render path touches: state.messages // (token-rate scan), model window, streaming flag, and the async-job snapshot. @@ -84,9 +84,9 @@ beforeEach(() => { // jj status: return a clean summary so #getJjStatus never falls through to // the real `git status` subprocess. Keeps the git segment's status empty so // the visible content is exactly the branch label. - spyOn(jjInfo, "queryJjStatus").mockResolvedValue({ staged: 0, unstaged: 0, untracked: 0 }), + spyOn(jj.status, "summary").mockResolvedValue({ staged: 0, unstaged: 0, untracked: 0 }), // Map each controlled project dir to a stable virtual jj root. - spyOn(jjInfo, "findJjRoot").mockImplementation(cwd => { + spyOn(jj.repo, "rootSync").mockImplementation(cwd => { if (cwd === tmpA) return ROOT_A; if (cwd === tmpB) return ROOT_B; return null; @@ -104,7 +104,7 @@ describe("StatusLineComponent jj cache coherence", () => { it("invalidate() drops the throttled jj branch cache within its TTL and refetches", async () => { // A live jj bookmark label; a second query for the SAME root returns a new // label, simulating a colocated bookmark/HEAD move mid-TTL. - const branchSpy = spyOn(jjInfo, "queryJjBranch").mockResolvedValue("bookmark-v1"); + const branchSpy = spyOn(jj.workingCopy, "label").mockResolvedValue("bookmark-v1"); spies.push(branchSpy); const statusLine = new StatusLineComponent(makeSession()); @@ -140,7 +140,7 @@ describe("StatusLineComponent jj cache coherence", () => { // Root A's query hangs on a deferred so it is still in flight when we // switch repos; root B resolves with its own label. const deferredA = Promise.withResolvers(); - const branchSpy = spyOn(jjInfo, "queryJjBranch").mockImplementation(async root => { + const branchSpy = spyOn(jj.workingCopy, "label").mockImplementation(async root => { if (root === ROOT_A) return deferredA.promise; if (root === ROOT_B) return "branch-B"; return null; @@ -192,7 +192,7 @@ describe("StatusLineComponent jj cache coherence", () => { // pre-invalidation result. The generation token must reject it. const deferred = Promise.withResolvers(); let call = 0; - const branchSpy = spyOn(jjInfo, "queryJjBranch").mockImplementation(async () => { + const branchSpy = spyOn(jj.workingCopy, "label").mockImplementation(async () => { call++; // First query (pre-invalidation) hangs; later queries return the fresh label. return call === 1 ? deferred.promise : "bookmark-fresh"; diff --git a/packages/coding-agent/src/modes/components/status-line/component.ts b/packages/coding-agent/src/modes/components/status-line/component.ts index 9f91a126b..e9233532e 100644 --- a/packages/coding-agent/src/modes/components/status-line/component.ts +++ b/packages/coding-agent/src/modes/components/status-line/component.ts @@ -10,12 +10,12 @@ import type { OAuthAccountIdentity } from "../../../session/auth-storage"; import { limitMatchesActiveAccount } from "../../../slash-commands/helpers/active-oauth-account"; import { type ActiveRepoContext, resolveActiveRepoContextSync } from "../../../utils/active-repo-context"; import * as git from "../../../utils/git"; +import * as jj from "../../../utils/jj"; import { getSessionAccentAnsi, getSessionAccentHex } from "../../../utils/session-color"; import { calculateTokensPerSecond } from "../../../utils/token-rate"; import { sanitizeStatusText } from "../../shared"; import { theme } from "../../theme/theme"; import { canReuseCachedPr, createPrCacheContext, isSamePrCacheContext, type PrCacheContext } from "./git-utils"; -import * as jjInfo from "./jj-info"; import { getPreset } from "./presets"; import { renderSegment, type SegmentContext } from "./segments"; import { getSeparator } from "./separators"; @@ -27,6 +27,8 @@ import type { StatusLineSettings, } from "./types"; +const JJ_REFRESH_TTL_MS = 5000; + // ═══════════════════════════════════════════════════════════════════════════ // Context-usage memo // ═══════════════════════════════════════════════════════════════════════════ @@ -722,7 +724,7 @@ export class StatusLineComponent implements Component { #jjRootFor(cwd: string): string | null { if (this.#jjRoot === undefined || this.#jjRootCwd !== cwd) { this.#jjRootCwd = cwd; - this.#jjRoot = jjInfo.findJjRoot(cwd); + this.#jjRoot = jj.repo.rootSync(cwd); this.#cachedJjBranch = null; this.#jjBranchLastFetch = 0; this.#cachedJjStatus = null; @@ -739,7 +741,7 @@ export class StatusLineComponent implements Component { const cwd = effectiveGitCwd ?? this.#resolveActiveRepoCache().effectiveGitCwd; const root = this.#jjRootFor(cwd); if (!root) return null; - if (this.#jjBranchInFlight || Date.now() - this.#jjBranchLastFetch < jjInfo.JJ_BRANCH_TTL_MS) { + if (this.#jjBranchInFlight || Date.now() - this.#jjBranchLastFetch < JJ_REFRESH_TTL_MS) { return this.#cachedJjBranch; } this.#jjBranchInFlight = true; @@ -747,7 +749,7 @@ export class StatusLineComponent implements Component { (async () => { let next: string | null = null; try { - next = await jjInfo.queryJjBranch(root); + next = await jj.workingCopy.label(root); } finally { this.#jjBranchInFlight = false; // Advance the throttle only if no reset raced this query; a reset @@ -773,7 +775,7 @@ export class StatusLineComponent implements Component { const cwd = effectiveGitCwd ?? this.#resolveActiveRepoCache().effectiveGitCwd; const root = this.#jjRootFor(cwd); if (!root) return null; - if (this.#jjStatusInFlight || Date.now() - this.#jjStatusLastFetch < jjInfo.JJ_BRANCH_TTL_MS) { + if (this.#jjStatusInFlight || Date.now() - this.#jjStatusLastFetch < JJ_REFRESH_TTL_MS) { return this.#cachedJjStatus; } this.#jjStatusInFlight = true; @@ -781,7 +783,7 @@ export class StatusLineComponent implements Component { (async () => { let next: { staged: number; unstaged: number; untracked: number } | null = null; try { - next = await jjInfo.queryJjStatus(root); + next = await jj.status.summary(root); } finally { this.#jjStatusInFlight = false; if (this.#jjCacheGeneration === generation) this.#jjStatusLastFetch = Date.now(); diff --git a/packages/coding-agent/src/modes/components/status-line/jj-info.ts b/packages/coding-agent/src/modes/components/status-line/jj-info.ts deleted file mode 100644 index ef7861cc8..000000000 --- a/packages/coding-agent/src/modes/components/status-line/jj-info.ts +++ /dev/null @@ -1,127 +0,0 @@ -import * as fs from "node:fs"; -import * as path from "node:path"; -import { $ } from "bun"; -import type { GitStatusSummary } from "../../../utils/git"; - -/** - * Throttle for the working-copy jj query. The statusline calls into jj only - * while the `git` segment is shown in a colocated jj repo (where git HEAD is - * parked detached), so a moderate TTL keeps the subprocess rate negligible - * while a HEAD change still forces an immediate refresh (see - * `#invalidateGitCaches`). - */ -export const JJ_BRANCH_TTL_MS = 5000; - -/** - * The statusline labels the working copy by its bookmark, not its commit — in - * jj a bookmark doesn't follow `@`, so `@` itself is usually unbookmarked and - * the meaningful name lives on the nearest ancestor. The revset selects `@` - * plus that nearest ancestor bookmark; the template emits one - * `change_id|local_bookmarks` line per commit (newest — `@` — first). - */ -const JJ_BRANCH_REVSET = "@ | heads(::@ & bookmarks())"; -const JJ_BRANCH_TEMPLATE = 'change_id.shortest(8) ++ "|" ++ local_bookmarks ++ "\\n"'; - -/** - * Walk up from `cwd` to the colocated jj workspace root — the nearest ancestor - * directory holding a `.jj` entry. Returns `null` when none exists. Sync on - * purpose: it feeds the synchronous statusline render path and is cached per - * cwd by the caller, so it runs at most once per directory. - */ -export function findJjRoot(cwd: string): string | null { - let dir = path.resolve(cwd); - for (;;) { - if (fs.existsSync(path.join(dir, ".jj"))) return dir; - const parent = path.dirname(dir); - if (parent === dir) return null; - dir = parent; - } -} - -/** - * Parse the `change_id|local_bookmarks` lines (newest first, so `@` leads) into - * the display label: the nearest bookmark wins — `@`'s own when it has one, else - * the nearest ancestor's — falling back to `@`'s change-id when no bookmark - * exists in the ancestry. Returns `null` for empty output. - */ -export function formatJjBranch(raw: string): string | null { - let changeId: string | null = null; - for (const line of raw.split("\n")) { - const sep = line.indexOf("|"); - const change = (sep === -1 ? line : line.slice(0, sep)).trim(); - const bookmarks = sep === -1 ? "" : line.slice(sep + 1).trim(); - if (changeId === null && change) changeId = change; - if (bookmarks) return bookmarks.replace(/\s+/g, " "); - } - return changeId; -} - -/** Runs the jj query in `root`; injectable so the parse boundary is testable without jj. */ -export type JjRunner = (root: string) => Promise<{ exitCode: number; stdout: string }>; - -const defaultRunner: JjRunner = async root => { - // `--ignore-working-copy` keeps the query read-only (never snapshots the - // working copy), so it is safe to run on every refresh. - const res = - await $`jj log --no-graph --ignore-working-copy --color never -r ${JJ_BRANCH_REVSET} -T ${JJ_BRANCH_TEMPLATE}` - .cwd(root) - .quiet() - .nothrow(); - return { exitCode: res.exitCode, stdout: res.text() }; -}; - -/** - * Query the jj working-copy bookmark label for `root`. Returns - * `null` on any failure — non-zero exit (not a jj repo) or a missing `jj` - * binary — so the caller cleanly falls back to git's detached-HEAD label. - */ -export async function queryJjBranch(root: string, runner: JjRunner = defaultRunner): Promise { - try { - const res = await runner(root); - if (res.exitCode !== 0) return null; - return formatJjBranch(res.stdout); - } catch { - return null; - } -} - -/** - * jj working-copy status: `jj diff -r @ --summary` emits one ` ` - * line per changed file (M/A/D/R/C). Mapped to the git status shape for the - * shared renderer — jj has no index, so `staged` is always 0; added files (new - * in `@`) read as untracked, every other change as unstaged. - */ -export function parseJjStatus(raw: string): GitStatusSummary { - let unstaged = 0; - let untracked = 0; - for (const line of raw.split("\n")) { - const type = line.trim()[0]; - if (!type) continue; - if (type === "A") untracked++; - else unstaged++; - } - return { staged: 0, unstaged, untracked }; -} - -const defaultStatusRunner: JjRunner = async root => { - const res = await $`jj diff -r @ --summary --ignore-working-copy --color never`.cwd(root).quiet().nothrow(); - return { exitCode: res.exitCode, stdout: res.text() }; -}; - -/** - * Query the jj working-copy status counts for `root` — the changes in `@` - * relative to its parent. Returns `null` on any failure (not a jj repo, no `jj` - * binary) so the caller falls back to git status; a clean `@` yields all zeros. - */ -export async function queryJjStatus( - root: string, - runner: JjRunner = defaultStatusRunner, -): Promise { - try { - const res = await runner(root); - if (res.exitCode !== 0) return null; - return parseJjStatus(res.stdout); - } catch { - return null; - } -} diff --git a/packages/coding-agent/src/utils/jj.ts b/packages/coding-agent/src/utils/jj.ts index 655decf97..16ec4a513 100644 --- a/packages/coding-agent/src/utils/jj.ts +++ b/packages/coding-agent/src/utils/jj.ts @@ -1,4 +1,4 @@ -import * as fs from "node:fs/promises"; +import * as fs from "node:fs"; import * as path from "node:path"; import { $which } from "@oh-my-pi/pi-utils"; import { LRUCache } from "lru-cache/raw"; @@ -63,6 +63,8 @@ export class JjCommandError extends Error { // ════════════════════════════════════════════════════════════════════════════ // Internal: Core execution // ════════════════════════════════════════════════════════════════════════════ +const WORKING_COPY_LABEL_REVSET = "@ | heads(::@ & bookmarks())"; +const WORKING_COPY_LABEL_TEMPLATE = 'change_id.shortest(8) ++ "|" ++ local_bookmarks ++ "\\n"'; function ensureAvailable(): void { if (!$which("jj")) { @@ -121,6 +123,19 @@ async function runText(cwd: string, args: readonly string[], options: CommandOpt return (await runChecked(cwd, args, options)).stdout; } +async function runOptionalText( + cwd: string, + args: readonly string[], + options: CommandOptions = {}, +): Promise { + try { + const result = await jj(cwd, args, options); + return result.exitCode === 0 ? result.stdout : null; + } catch { + return null; + } +} + function splitLines(text: string): string[] { return text .split("\n") @@ -135,6 +150,30 @@ function buildDiffArgs(options: DiffOptions): string[] { return args; } +function parseWorkingCopyLabel(raw: string): string | null { + let changeId: string | null = null; + for (const line of raw.split("\n")) { + const sep = line.indexOf("|"); + const change = (sep === -1 ? line : line.slice(0, sep)).trim(); + const bookmarks = sep === -1 ? "" : line.slice(sep + 1).trim(); + if (changeId === null && change) changeId = change; + if (bookmarks) return bookmarks.replace(/\s+/g, " "); + } + return changeId; +} + +function parseStatusSummary(raw: string): git.GitStatusSummary { + let unstaged = 0; + let untracked = 0; + for (const line of raw.split("\n")) { + const type = line.trim()[0]; + if (!type) continue; + if (type === "A") untracked++; + else unstaged++; + } + return { staged: 0, unstaged, untracked }; +} + // ════════════════════════════════════════════════════════════════════════════ // Internal: Repository resolution // ════════════════════════════════════════════════════════════════════════════ @@ -153,7 +192,16 @@ async function hasJjWorkspaceMetadata(dir: string): Promise { // of the default workspace. Either form is a real workspace, so match on // `.jj/repo` presence rather than the inner `store/` directory. try { - await fs.stat(path.join(dir, ".jj", "repo")); + await fs.promises.stat(path.join(dir, ".jj", "repo")); + return true; + } catch { + return false; + } +} + +function hasJjWorkspaceMetadataSync(dir: string): boolean { + try { + fs.statSync(path.join(dir, ".jj", "repo")); return true; } catch { return false; @@ -180,6 +228,21 @@ async function findWorkspaceRoot(cwd: string): Promise { return undefined; } +function findWorkspaceRootSync(cwd: string): string | undefined { + const key = path.resolve(cwd); + if (workspaceRootCache.has(key)) return workspaceRootCache.get(key)?.root; + + for (let dir: string | undefined = key; dir; dir = parentOf(dir)) { + if (hasJjWorkspaceMetadataSync(dir)) { + workspaceRootCache.set(key, { root: dir }); + return dir; + } + } + + workspaceRootCache.set(key, {}); + return undefined; +} + /** * Resolve the `.jj/repo` directory backing a workspace root, following the file * indirection used by non-default workspaces. `jj workspace add` writes a FILE at @@ -190,8 +253,8 @@ async function findWorkspaceRoot(cwd: string): Promise { async function resolveRepoDir(root: string): Promise { const jjDir = path.join(root, ".jj"); const repoPath = path.join(jjDir, "repo"); - if ((await fs.stat(repoPath)).isFile()) { - const target = (await fs.readFile(repoPath, "utf8")).trim(); + if ((await fs.promises.stat(repoPath)).isFile()) { + const target = (await fs.promises.readFile(repoPath, "utf8")).trim(); return path.resolve(jjDir, target); } return repoPath; @@ -221,6 +284,56 @@ export const diff = Object.assign( }, ); +// ════════════════════════════════════════════════════════════════════════════ +// API: working copy +// ════════════════════════════════════════════════════════════════════════════ + +/** Jujutsu working-copy metadata used by status displays. */ +export const workingCopy = { + /** + * Label `@` with its nearest bookmark, falling back to its short change ID. + * Returns `null` when `jj` is unavailable or the query fails. + */ + async label(cwd: string, signal?: AbortSignal): Promise { + const raw = await runOptionalText( + cwd, + [ + "log", + "--no-graph", + "--ignore-working-copy", + "-r", + WORKING_COPY_LABEL_REVSET, + "-T", + WORKING_COPY_LABEL_TEMPLATE, + ], + { signal }, + ); + return raw === null ? null : parseWorkingCopyLabel(raw); + }, + + /** Parse working-copy label query output. */ + parseLabel: parseWorkingCopyLabel, +}; + +// ════════════════════════════════════════════════════════════════════════════ +// API: status +// ════════════════════════════════════════════════════════════════════════════ + +/** Jujutsu working-copy status derived from the changes in `@`. */ +export const status = { + /** + * Count changes in `@` relative to its parent using the Git status shape. + * Jujutsu has no index, so `staged` is always zero. + */ + async summary(cwd: string, signal?: AbortSignal): Promise { + const raw = await runOptionalText(cwd, ["diff", "-r", "@", "--summary", "--ignore-working-copy"], { signal }); + return raw === null ? null : parseStatusSummary(raw); + }, + + /** Parse `jj diff --summary` output into status counts. */ + parse: parseStatusSummary, +}; + // ════════════════════════════════════════════════════════════════════════════ // API: repo // ════════════════════════════════════════════════════════════════════════════ @@ -231,6 +344,14 @@ export const repo = { workspaceRootCache.clear(); }, + /** + * Resolve the current workspace root synchronously from on-disk metadata. + * Intended for render paths that cannot await filesystem I/O. + */ + rootSync(cwd: string): string | null { + return findWorkspaceRootSync(cwd) ?? null; + }, + /** Resolve the current Jujutsu workspace root, or `null` when `cwd` is not in a JJ repository. */ async root(cwd: string): Promise { return (await findWorkspaceRoot(cwd)) ?? null; diff --git a/packages/coding-agent/test/status-line-jj-info.test.ts b/packages/coding-agent/test/status-line-jj-info.test.ts deleted file mode 100644 index 21dbd84e8..000000000 --- a/packages/coding-agent/test/status-line-jj-info.test.ts +++ /dev/null @@ -1,123 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import * as fs from "node:fs"; -import * as os from "node:os"; -import * as path from "node:path"; -import { - findJjRoot, - formatJjBranch, - parseJjStatus, - queryJjBranch, - queryJjStatus, -} from "@oh-my-pi/pi-coding-agent/modes/components/status-line/jj-info"; - -describe("formatJjBranch", () => { - test("returns the bookmark on @ over the change-id", () => { - expect(formatJjBranch("kvisqosn|feature-x\n")).toBe("feature-x"); - }); - - test("returns the nearest ancestor bookmark when @ has none", () => { - expect(formatJjBranch("kvisqosn|\nqlnsqysu|polo-integration\n")).toBe("polo-integration"); - }); - - test("prefers @'s own bookmark over an ancestor bookmark", () => { - expect(formatJjBranch("kvisqosn|on-branch\nqlnsqysu|ancestor\n")).toBe("on-branch"); - }); - - test("collapses multiple bookmarks at the nearest commit", () => { - expect(formatJjBranch("qlnsqysu|foo bar\n")).toBe("foo bar"); - }); - - test("falls back to @'s change-id when no bookmark in ancestry", () => { - expect(formatJjBranch("kvisqosn|\n")).toBe("kvisqosn"); - }); - - test("returns null for empty or whitespace-only output", () => { - expect(formatJjBranch("")).toBeNull(); - expect(formatJjBranch(" \n\t ")).toBeNull(); - }); -}); - -describe("findJjRoot", () => { - test("walks up to the nearest ancestor holding .jj", async () => { - const root = await fs.promises.mkdtemp(path.join(os.tmpdir(), "jj-root-")); - try { - await fs.promises.mkdir(path.join(root, ".jj")); - const nested = path.join(root, "packages", "coding-agent"); - await fs.promises.mkdir(nested, { recursive: true }); - expect(findJjRoot(nested)).toBe(root); - expect(findJjRoot(root)).toBe(root); - } finally { - await fs.promises.rm(root, { recursive: true, force: true }); - } - }); - - test("returns null when no .jj ancestor exists", async () => { - const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "no-jj-")); - try { - expect(findJjRoot(dir)).toBeNull(); - } finally { - await fs.promises.rm(dir, { recursive: true, force: true }); - } - }); -}); - -describe("queryJjBranch", () => { - test("returns the nearest bookmark from runner stdout", async () => { - const desc = await queryJjBranch("/repo", async () => ({ - exitCode: 0, - stdout: "kvisqosn|\nqlnsqysu|polo-integration\n", - })); - expect(desc).toBe("polo-integration"); - }); - - test("returns null on non-zero exit (not a jj repo)", async () => { - const desc = await queryJjBranch("/repo", async () => ({ exitCode: 1, stdout: "" })); - expect(desc).toBeNull(); - }); - - test("returns null when the runner throws (jj binary absent)", async () => { - const desc = await queryJjBranch("/repo", async () => { - throw new Error("spawn jj ENOENT"); - }); - expect(desc).toBeNull(); - }); - - test("treats empty successful output as null", async () => { - const desc = await queryJjBranch("/repo", async () => ({ exitCode: 0, stdout: "\n" })); - expect(desc).toBeNull(); - }); -}); - -describe("parseJjStatus / queryJjStatus", () => { - test("maps jj diff --summary types to the status shape (A -> untracked, else unstaged)", () => { - expect(parseJjStatus("M a.ts\nA b.ts\nA c.ts\nD d.ts\nM e.ts\n")).toEqual({ - staged: 0, - unstaged: 3, - untracked: 2, - }); - }); - - test("a clean working copy is all zeros", () => { - expect(parseJjStatus("")).toEqual({ staged: 0, unstaged: 0, untracked: 0 }); - }); - - test("queryJjStatus parses runner stdout", async () => { - expect(await queryJjStatus("/repo", async () => ({ exitCode: 0, stdout: "M x.ts\nA y.ts\n" }))).toEqual({ - staged: 0, - unstaged: 1, - untracked: 1, - }); - }); - - test("queryJjStatus returns null on non-zero exit (not a jj repo)", async () => { - expect(await queryJjStatus("/repo", async () => ({ exitCode: 1, stdout: "" }))).toBeNull(); - }); - - test("queryJjStatus returns null when the runner throws (jj binary absent)", async () => { - expect( - await queryJjStatus("/repo", async () => { - throw new Error("spawn jj ENOENT"); - }), - ).toBeNull(); - }); -}); diff --git a/packages/coding-agent/test/status-line-vcs-refresh.test.ts b/packages/coding-agent/test/status-line-vcs-refresh.test.ts index cadbccb98..2913ebb3e 100644 --- a/packages/coding-agent/test/status-line-vcs-refresh.test.ts +++ b/packages/coding-agent/test/status-line-vcs-refresh.test.ts @@ -15,10 +15,10 @@ import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "bun:te import { resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; import type { StatusLineSettings } from "@oh-my-pi/pi-coding-agent/modes/components/status-line"; import { StatusLineComponent } from "@oh-my-pi/pi-coding-agent/modes/components/status-line"; -import * as jjInfo from "@oh-my-pi/pi-coding-agent/modes/components/status-line/jj-info"; import { initTheme } from "@oh-my-pi/pi-coding-agent/modes/theme/theme"; import type { GitRefHead } from "@oh-my-pi/pi-coding-agent/utils/git"; import * as git from "@oh-my-pi/pi-coding-agent/utils/git"; +import * as jj from "@oh-my-pi/pi-coding-agent/utils/jj"; import { getProjectDir, setProjectDir } from "@oh-my-pi/pi-utils"; type GitStatus = { staged: number; unstaged: number; untracked: number }; @@ -119,9 +119,9 @@ describe("StatusLineComponent repaints when an async VCS fetch resolves", () => vi.spyOn(git.head, "resolveSync").mockReturnValue(null); // no git branch -> jj overlay vi.spyOn(git.branch, "default").mockReturnValue(Promise.withResolvers().promise); vi.spyOn(git.status, "summary").mockReturnValue(Promise.withResolvers().promise); // isolate the jj fire - vi.spyOn(jjInfo, "findJjRoot").mockReturnValue("/fake/jj/root"); + vi.spyOn(jj.repo, "rootSync").mockReturnValue("/fake/jj/root"); const label = Promise.withResolvers(); - vi.spyOn(jjInfo, "queryJjBranch").mockReturnValue(label.promise); + vi.spyOn(jj.workingCopy, "label").mockReturnValue(label.promise); const onBranchChange = vi.fn(); const component = new StatusLineComponent(makeSession()); @@ -143,10 +143,10 @@ describe("StatusLineComponent repaints when an async VCS fetch resolves", () => vi.spyOn(git.head, "resolveSync").mockReturnValue(null); // no git -> jj repo vi.spyOn(git.branch, "default").mockReturnValue(Promise.withResolvers().promise); vi.spyOn(git.status, "summary").mockReturnValue(Promise.withResolvers().promise); - vi.spyOn(jjInfo, "findJjRoot").mockReturnValue("/fake/jj/root"); - vi.spyOn(jjInfo, "queryJjBranch").mockReturnValue(Promise.withResolvers().promise); // isolate the status fire + vi.spyOn(jj.repo, "rootSync").mockReturnValue("/fake/jj/root"); + vi.spyOn(jj.workingCopy, "label").mockReturnValue(Promise.withResolvers().promise); // isolate the status fire const status = Promise.withResolvers(); - vi.spyOn(jjInfo, "queryJjStatus").mockReturnValue(status.promise); + vi.spyOn(jj.status, "summary").mockReturnValue(status.promise); const onBranchChange = vi.fn(); const component = new StatusLineComponent(makeSession()); diff --git a/packages/coding-agent/test/utils/jj.test.ts b/packages/coding-agent/test/utils/jj.test.ts index 230e82b8f..9f59bc495 100644 --- a/packages/coding-agent/test/utils/jj.test.ts +++ b/packages/coding-agent/test/utils/jj.test.ts @@ -27,6 +27,7 @@ describe("jj workspace detection", () => { await fs.mkdir(path.join(dir, ".jj", "repo", "store"), { recursive: true }); await fs.mkdir(nested, { recursive: true }); + expect(jj.repo.rootSync(nested)).toBe(dir); expect(await jj.repo.root(nested)).toBe(dir); expect(await jj.repo.is(nested)).toBe(true); }); @@ -37,6 +38,7 @@ describe("jj workspace detection", () => { await fs.mkdir(path.join(dir, ".jj", "repo", "store"), { recursive: true }); await fs.mkdir(nested, { recursive: true }); + expect(jj.repo.rootSync(nested)).toBe(dir); expect(await jj.repo.root(nested)).toBe(dir); await removeWithRetries(path.join(dir, ".jj")); @@ -48,6 +50,7 @@ describe("jj workspace detection", () => { const dir = await createTempDir(); await fs.mkdir(path.join(dir, ".jj"), { recursive: true }); + expect(jj.repo.rootSync(dir)).toBeNull(); expect(await jj.repo.root(dir)).toBeNull(); expect(await jj.repo.is(dir)).toBe(false); }); @@ -62,6 +65,7 @@ describe("jj workspace detection", () => { await fs.mkdir(path.join(secondary, ".jj", "working_copy"), { recursive: true }); await fs.writeFile(path.join(secondary, ".jj", "repo"), path.join("..", "..", ".jj", "repo")); + expect(jj.repo.rootSync(secondary)).toBe(secondary); expect(await jj.repo.is(secondary)).toBe(true); expect(await jj.repo.root(secondary)).toBe(secondary); }); @@ -154,3 +158,29 @@ describe("isPureJjRepo", () => { expect(await jj.isPureJjRepo(inner)).toBe(false); }); }); + +describe("jj working-copy label", () => { + it("uses the nearest bookmark, then falls back to the current change ID", () => { + expect(jj.workingCopy.parseLabel("kvisqosn|on-branch\nqlnsqysu|ancestor\n")).toBe("on-branch"); + expect(jj.workingCopy.parseLabel("kvisqosn|\nqlnsqysu|ancestor\n")).toBe("ancestor"); + expect(jj.workingCopy.parseLabel("kvisqosn|\n")).toBe("kvisqosn"); + }); + + it("returns null for empty output", () => { + expect(jj.workingCopy.parseLabel(" \n\t ")).toBeNull(); + }); +}); + +describe("jj status", () => { + it("maps added files to untracked and all other changes to unstaged", () => { + expect(jj.status.parse("M a.ts\nA b.ts\nA c.ts\nD d.ts\nM e.ts\n")).toEqual({ + staged: 0, + unstaged: 3, + untracked: 2, + }); + }); + + it("reports a clean working copy as all zeros", () => { + expect(jj.status.parse("")).toEqual({ staged: 0, unstaged: 0, untracked: 0 }); + }); +});