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:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user