refactor(jj): restructured exports into namespaced repo and diff objects

- Grouped `isRepository`, `workspaceRoot`, and `clearWorkspaceRootCache` under a `repo` namespace (`repo.is`, `repo.root`, `repo.clearRootCache`).
- Promoted `diff` to a named export with a `changedFiles` sub-method via `Object.assign`.
- Added `ensureAvailable` check and `nameOnly` support to `diff`.
- Updated callers and tests to use the new API surface.
This commit is contained in:
can1357
2026-06-03 00:13:05 +02:00
parent 9b4a951919
commit bce01ce0db
4 changed files with 120 additions and 34 deletions
@@ -449,7 +449,7 @@ async function getGitStatus(api: CustomCommandAPI): Promise<string> {
}
async function getUncommittedReviewDiff(api: CustomCommandAPI): Promise<CurrentReviewDiff> {
if (await jj.isRepository(api.cwd)) {
if (await jj.repo.is(api.cwd)) {
return {
diffText: await jj.diff(api.cwd),
diffInstruction: JJ_UNCOMMITTED_DIFF_INSTRUCTION,
+107 -21
View File
@@ -1,7 +1,12 @@
import * as fs from "node:fs/promises";
import * as path from "node:path";
import { $which } from "@oh-my-pi/pi-utils";
import { LRUCache } from "lru-cache/raw";
// ════════════════════════════════════════════════════════════════════════════
// Types
// ════════════════════════════════════════════════════════════════════════════
/** Result from a completed `jj` subprocess invocation. */
export interface JjCommandResult {
/** Process exit code reported by `jj`. */
@@ -12,10 +17,20 @@ export interface JjCommandResult {
stderr: string;
}
/** Options for `jj diff --git` invocations. */
/** Resolved Jujutsu workspace metadata. */
export interface JjRepository {
/** Root directory containing the `.jj` workspace metadata. */
repoRoot: string;
/** Path to the workspace store directory used to verify a real JJ checkout. */
storeDir: string;
}
/** Options for `jj diff` invocations. */
export interface DiffOptions {
/** Optional file paths to restrict the diff with `-- <files>`. */
readonly files?: readonly string[];
/** Return only changed file names instead of Git-format diff text. */
readonly nameOnly?: boolean;
/** Optional abort signal passed to the spawned `jj` process. */
readonly signal?: AbortSignal;
}
@@ -24,6 +39,10 @@ interface CommandOptions {
readonly signal?: AbortSignal;
}
// ════════════════════════════════════════════════════════════════════════════
// Error
// ════════════════════════════════════════════════════════════════════════════
/** Error thrown when a checked `jj` command exits non-zero. */
export class JjCommandError extends Error {
/** Arguments passed after the common `jj --no-pager --color=never` prefix. */
@@ -40,6 +59,17 @@ export class JjCommandError extends Error {
}
}
// ════════════════════════════════════════════════════════════════════════════
// Internal: Core execution
// ════════════════════════════════════════════════════════════════════════════
function ensureAvailable(): void {
if (!$which("jj")) {
throw new Error("jj is not installed.");
}
}
function formatCommandFailure(
args: readonly string[],
result: Pick<JjCommandResult, "exitCode" | "stdout" | "stderr">,
@@ -79,6 +109,7 @@ async function runChecked(
args: readonly string[],
options: CommandOptions = {},
): Promise<JjCommandResult> {
ensureAvailable();
const result = await jj(cwd, args, options);
if (result.exitCode !== 0) {
throw new JjCommandError(args, result);
@@ -86,14 +117,34 @@ async function runChecked(
return result;
}
async function runText(cwd: string, args: readonly string[], options: CommandOptions = {}): Promise<string> {
return (await runChecked(cwd, args, options)).stdout;
}
function splitLines(text: string): string[] {
return text
.split("\n")
.map(line => line.trim())
.filter(Boolean);
}
function buildDiffArgs(options: DiffOptions): string[] {
const args = ["diff", "--git"];
const args = ["diff"];
args.push(options.nameOnly ? "--name-only" : "--git");
if (options.files?.length) args.push("--", ...options.files);
return args;
}
// ════════════════════════════════════════════════════════════════════════════
// Internal: Repository resolution
// ════════════════════════════════════════════════════════════════════════════
interface WorkspaceRootCacheEntry {
readonly root?: string;
}
const WORKSPACE_ROOT_CACHE_MAX_ENTRIES = 256;
const workspaceRootCache = new LRUCache<string, string | null>({ max: WORKSPACE_ROOT_CACHE_MAX_ENTRIES });
const workspaceRootCache = new LRUCache<string, WorkspaceRootCacheEntry>({ max: WORKSPACE_ROOT_CACHE_MAX_ENTRIES });
async function hasJjWorkspaceMetadata(dir: string): Promise<boolean> {
try {
@@ -110,35 +161,70 @@ function parentOf(dir: string): string | undefined {
async function findWorkspaceRoot(cwd: string): Promise<string | undefined> {
const key = path.resolve(cwd);
if (workspaceRootCache.has(key)) return workspaceRootCache.get(key) ?? undefined;
if (workspaceRootCache.has(key)) return workspaceRootCache.get(key)?.root;
for (let dir: string | undefined = key; dir; dir = parentOf(dir)) {
if (await hasJjWorkspaceMetadata(dir)) {
workspaceRootCache.set(key, dir);
workspaceRootCache.set(key, { root: dir });
return dir;
}
}
workspaceRootCache.set(key, null);
workspaceRootCache.set(key, {});
return undefined;
}
/** Clear cached workspace roots. Intended for tests that mutate JJ metadata under an existing path. */
export function clearWorkspaceRootCache(): void {
workspaceRootCache.clear();
function repositoryFromRoot(root: string): JjRepository {
return {
repoRoot: root,
storeDir: path.join(root, ".jj", "repo", "store"),
};
}
/** Resolve the current Jujutsu workspace root, or `undefined` when `cwd` is not in a JJ repository. */
export async function workspaceRoot(cwd: string): Promise<string | undefined> {
return findWorkspaceRoot(cwd);
}
/** Return whether `cwd` is inside a Jujutsu repository. */
export async function isRepository(cwd: string): Promise<boolean> {
return (await workspaceRoot(cwd)) !== undefined;
}
// ════════════════════════════════════════════════════════════════════════════
// API: diff
// ════════════════════════════════════════════════════════════════════════════
/** Run `jj diff --git` for the current workspace commit and return the raw Git-format diff text. */
export async function diff(cwd: string, options: DiffOptions = {}): Promise<string> {
return (await runChecked(cwd, buildDiffArgs(options), { signal: options.signal })).stdout;
}
export const diff = Object.assign(
async function diff(cwd: string, options: DiffOptions = {}): Promise<string> {
return runText(cwd, buildDiffArgs(options), { signal: options.signal });
},
{
/** List changed file paths. */
async changedFiles(
cwd: string,
options: Pick<DiffOptions, "files" | "signal"> = {},
): Promise<string[]> {
return splitLines(await diff(cwd, { ...options, nameOnly: true }));
},
},
);
// ════════════════════════════════════════════════════════════════════════════
// API: repo
// ════════════════════════════════════════════════════════════════════════════
export const repo = {
/** Clear cached workspace roots. Intended for tests that mutate JJ metadata under an existing path. */
clearRootCache(): void {
workspaceRootCache.clear();
},
/** 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;
},
/** Full Jujutsu workspace metadata. */
async resolve(cwd: string): Promise<JjRepository | null> {
const root = await repo.root(cwd);
return root ? repositoryFromRoot(root) : null;
},
/** Check whether `cwd` is inside a Jujutsu repository. */
async is(cwd: string): Promise<boolean> {
return (await repo.root(cwd)) !== null;
},
};