refactor(coding-agent): consolidated jujutsu logic into a centralized utility module

- Consolidated Jujutsu (jj) integration logic into a centralized utility module with working-copy and status handling.
- Removed deprecated jj-info helper modules and their corresponding test files.
- Updated status line component and associated tests to consume the new centralized jj utility API.
- Added comprehensive test coverage for working-copy label parsing, status summary mapping, and repository root resolution.
This commit is contained in:
can1357
2026-07-23 20:47:42 +02:00
parent c2eeaa640d
commit c64e7146e0
7 changed files with 175 additions and 272 deletions
@@ -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<string | null>();
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<string | null>();
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";
@@ -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();
@@ -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<string | null> {
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 `<type> <path>`
* 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<GitStatusSummary | null> {
try {
const res = await runner(root);
if (res.exitCode !== 0) return null;
return parseJjStatus(res.stdout);
} catch {
return null;
}
}
+125 -4
View File
@@ -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<string | null> {
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<boolean> {
// 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<string | undefined> {
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<string | undefined> {
async function resolveRepoDir(root: string): Promise<string> {
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<string | null> {
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<git.GitStatusSummary | null> {
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<string | null> {
return (await findWorkspaceRoot(cwd)) ?? 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();
});
});
@@ -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<string | null>().promise);
vi.spyOn(git.status, "summary").mockReturnValue(Promise.withResolvers<GitStatus | null>().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<string | null>();
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<string | null>().promise);
vi.spyOn(git.status, "summary").mockReturnValue(Promise.withResolvers<GitStatus | null>().promise);
vi.spyOn(jjInfo, "findJjRoot").mockReturnValue("/fake/jj/root");
vi.spyOn(jjInfo, "queryJjBranch").mockReturnValue(Promise.withResolvers<string | null>().promise); // isolate the status fire
vi.spyOn(jj.repo, "rootSync").mockReturnValue("/fake/jj/root");
vi.spyOn(jj.workingCopy, "label").mockReturnValue(Promise.withResolvers<string | null>().promise); // isolate the status fire
const status = Promise.withResolvers<GitStatus | null>();
vi.spyOn(jjInfo, "queryJjStatus").mockReturnValue(status.promise);
vi.spyOn(jj.status, "summary").mockReturnValue(status.promise);
const onBranchChange = vi.fn();
const component = new StatusLineComponent(makeSession());
@@ -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 });
});
});