repositories / pi-ext
pi-ext
bugabingas pi extensions
owned by admin
extensions/vcs-status/index.ts
Raw/**
* VCS status footer segment producer.
*
* Detects colocated jj before git, jj-only repos after git, and git status via porcelain v2.
* jj queries use --ignore-working-copy to avoid snapshots.
*/
import { execFile } from "node:child_process";
import { readFile, stat } from "node:fs/promises";
import { basename, dirname, isAbsolute, join, resolve } from "node:path";
import { promisify } from "node:util";
import type {
ExtensionAPI,
ExtensionContext,
} from "@earendil-works/pi-coding-agent";
import { closeDebug, dbg, span } from "./src/debug.ts";
import {
type FooterSegment,
offerFooterSegment,
removeFooterSegment,
} from "./src/pi-ext-footer-segment.ts";
import {
MAX_TIMER_MS,
parseNumberSetting,
resolveSetting,
type SettingDeclaration,
} from "./src/pi-ext-settings.ts";
import { isStaleContextError } from "./src/pi-ext-stale-context.ts";
const execFileAsync = promisify(execFile);
const CWD_STATUS_ID = "cwd";
const VCS_STATUS_ID = "vcs";
const TIMEOUT_SETTING: SettingDeclaration<number> = {
key: "vcs-status.timeoutMs",
env: "PI_VCS_STATUS_TIMEOUT_MS",
default: 2500,
parse: (raw, source) => {
const value = parseNumberSetting(raw, source);
return value !== undefined &&
Number.isInteger(value) &&
value > 0 &&
value <= MAX_TIMER_MS
? value
: undefined;
},
};
const VCS_GIT = "git";
const VCS_JJ = "jj";
const DOT_GIT = ".git";
const DOT_JJ = ".jj";
const HEAD_FILE = "HEAD";
const OBJECTS_DIR = "objects";
const REFS_DIR = "refs";
const GITDIR_PREFIX = "gitdir: ";
const REF_HEADS_PREFIX = "ref: refs/heads/";
const DETACHED_LABEL = "HEAD";
const FLAG_EMPTY = "∅";
const FLAG_CONFLICT = "×";
const FLAG_DIVERGENT = "≈";
const JJ_TEMPLATE =
'"{\\"change\\":" ++ change_id.short().escape_json() ++ ",\\"bookmarks\\":" ++ stringify(local_bookmarks.join(",")).escape_json() ++ ",\\"empty\\":" ++ json(empty) ++ ",\\"conflict\\":" ++ json(conflict) ++ ",\\"divergent\\":" ++ json(divergent) ++ "}\\n"';
const ICONS = {
git: "",
jj: String.fromCodePoint(0xf329),
ahead: "↑",
behind: "↓",
diverged: "⇕",
conflicted: "=",
untracked: "?",
stashed: "$",
modified: "!",
staged: "+",
renamed: "»",
deleted: "✘",
};
type GitStatus = {
branch?: string;
ahead: number;
behind: number;
conflicted: number;
untracked: number;
stashed: boolean;
modified: number;
staged: number;
renamed: number;
deleted: number;
};
type JjStatus = {
available: boolean;
change?: string;
bookmarks?: string;
empty?: boolean;
conflict?: boolean;
divergent?: boolean;
};
type GitVcs = { kind: typeof VCS_GIT; gitDir: string };
type JjVcs = { kind: typeof VCS_JJ; root: string };
type Vcs = GitVcs | JjVcs;
type VcsSegment = { icon: string; text?: string };
const emptyGitStatus = (): GitStatus => ({
branch: undefined,
ahead: 0,
behind: 0,
conflicted: 0,
untracked: 0,
stashed: false,
modified: 0,
staged: 0,
renamed: 0,
deleted: 0,
});
async function pathType(path: string): Promise<"file" | "dir" | undefined> {
try {
const s = await stat(path);
if (s.isDirectory()) return "dir";
if (s.isFile()) return "file";
} catch {
return undefined;
}
}
function expandHome(path: string): string {
if (!path.startsWith("~")) return path;
const home = process.env.HOME;
return home ? join(home, path.slice(1)) : path;
}
function resolveGitPath(path: string, base: string): string {
const expanded = expandHome(path);
return isAbsolute(expanded) ? resolve(expanded) : resolve(base, expanded);
}
async function gitDirFromFile(
dotgit: string,
base: string,
): Promise<string | undefined> {
try {
const line = (await readFile(dotgit, "utf8")).trim();
if (!line.startsWith(GITDIR_PREFIX)) return undefined;
return resolveGitPath(line.slice(GITDIR_PREFIX.length), base);
} catch {
return undefined;
}
}
async function isConventionalBareGitDir(dir: string): Promise<boolean> {
return (
basename(dir) === DOT_GIT &&
(await pathType(join(dir, HEAD_FILE))) === "file" &&
(await pathType(join(dir, OBJECTS_DIR))) === "dir" &&
(await pathType(join(dir, REFS_DIR))) === "dir"
);
}
function envGitVcs(cwd: string): GitVcs | undefined {
const gitDir = process.env.GIT_DIR;
if (!gitDir) return undefined;
return { kind: VCS_GIT, gitDir: resolveGitPath(gitDir, cwd) };
}
async function findGitOrColocatedJj(cwd: string): Promise<Vcs | undefined> {
let dir = resolve(cwd);
while (true) {
const dotgit = join(dir, DOT_GIT);
const type = await pathType(dotgit);
if (type === "dir") {
if ((await pathType(join(dir, DOT_JJ))) === "dir")
return { kind: VCS_JJ, root: dir };
return { kind: VCS_GIT, gitDir: dotgit };
}
if (type === "file") {
const gitDir = await gitDirFromFile(dotgit, dir);
if (gitDir) {
if ((await pathType(join(dir, DOT_JJ))) === "dir")
return { kind: VCS_JJ, root: dir };
return { kind: VCS_GIT, gitDir };
}
}
if (await isConventionalBareGitDir(dir))
return { kind: VCS_GIT, gitDir: dir };
const parent = dirname(dir);
if (parent === dir) return envGitVcs(cwd);
dir = parent;
}
}
async function findJjOnly(cwd: string): Promise<JjVcs | undefined> {
let dir = resolve(cwd);
while (true) {
if ((await pathType(join(dir, DOT_JJ))) === "dir")
return { kind: VCS_JJ, root: dir };
const parent = dirname(dir);
if (parent === dir) return undefined;
dir = parent;
}
}
async function findVcs(cwd: string): Promise<Vcs | undefined> {
return (await findGitOrColocatedJj(cwd)) ?? (await findJjOnly(cwd));
}
function branchFromHead(head: string): string {
return head.startsWith(REF_HEADS_PREFIX)
? head.slice(REF_HEADS_PREFIX.length)
: DETACHED_LABEL;
}
async function gitBranchFromHead(gitDir: string): Promise<string | undefined> {
try {
return branchFromHead(
(await readFile(join(gitDir, HEAD_FILE), "utf8")).trim(),
);
} catch {
return undefined;
}
}
function parsePorcelain(stdout: string, hasStash = false): GitStatus {
const s = emptyGitStatus();
s.stashed = hasStash;
for (const line of stdout.split(/\r?\n/)) {
if (!line) continue;
if (line.startsWith("# branch.head ")) {
const b = line.slice("# branch.head ".length).trim();
s.branch = b === "(detached)" ? DETACHED_LABEL : b || undefined;
continue;
}
if (line.startsWith("# branch.ab ")) {
const m = line.match(/\+(\d+)\s+-(\d+)/);
if (m) {
s.ahead = Number(m[1]);
s.behind = Number(m[2]);
}
continue;
}
if (line.startsWith("# stash ")) {
s.stashed = Number(line.slice("# stash ".length).trim()) > 0;
continue;
}
if (line.startsWith("#")) continue;
if (line.startsWith("? ")) {
s.untracked++;
continue;
}
if (line.startsWith("u ")) {
s.conflicted++;
continue;
}
if (!(line.startsWith("1 ") || line.startsWith("2 "))) continue;
const xy = line.split(" ")[1] ?? "..";
const x = xy[0] ?? ".",
y = xy[1] ?? ".";
if (x === "R") s.renamed++;
else if (x === "D") s.deleted++;
else if (x !== "." && x !== " ") s.staged++;
if (y === "M") s.modified++;
else if (y === "D") s.deleted++;
}
return s;
}
async function readGitStatus(
cwd: string,
gitDir: string,
timeout: number,
): Promise<GitStatus> {
const headBranch = await gitBranchFromHead(gitDir);
try {
const { stdout } = await execFileAsync(
"git",
["status", "--porcelain=2", "--branch", "--show-stash"],
{
cwd,
timeout,
},
);
const status = parsePorcelain(
typeof stdout === "string" ? stdout : String(stdout),
);
status.branch = headBranch ?? status.branch;
return status;
} catch {
return { ...emptyGitStatus(), branch: headBranch };
}
}
async function readJjStatus(
root: string,
timeout: number,
): Promise<JjStatus | undefined> {
try {
const { stdout } = await execFileAsync(
"jj",
[
"-R",
root,
"--ignore-working-copy",
"--no-pager",
"--color",
"never",
"log",
"--no-graph",
"-r",
"@",
"-T",
JJ_TEMPLATE,
],
{ timeout },
);
return {
...JSON.parse(typeof stdout === "string" ? stdout : String(stdout)),
available: true,
};
} catch (error: any) {
return error?.code === "ENOENT" ? { available: false } : undefined;
}
}
function gitLabel(status: GitStatus): string | undefined {
if (!status.branch) return undefined;
const allStatus = [
status.conflicted > 0 ? ICONS.conflicted : "",
status.stashed ? ICONS.stashed : "",
status.deleted > 0 ? ICONS.deleted : "",
status.renamed > 0 ? ICONS.renamed : "",
status.modified > 0 ? ICONS.modified : "",
status.staged > 0 ? ICONS.staged : "",
status.untracked > 0 ? ICONS.untracked : "",
].join("");
const ab =
status.ahead > 0 && status.behind > 0
? ICONS.diverged
: status.ahead > 0
? ICONS.ahead
: status.behind > 0
? ICONS.behind
: "";
const statusBlock = allStatus || ab ? `[${allStatus}${ab}]` : "";
return `${status.branch}${statusBlock ? ` ${statusBlock}` : ""}`;
}
function suffix(value?: string): string {
return value ? ` ${value}` : "";
}
function jjFlags(status: JjStatus): string {
return [
status.empty ? FLAG_EMPTY : "",
status.conflict ? FLAG_CONFLICT : "",
status.divergent ? FLAG_DIVERGENT : "",
].join("");
}
function jjLabel(status?: JjStatus): string | undefined {
if (!status) return undefined;
if (!status.available) return "?";
if (!status.change) return undefined;
return `@ ${status.change}${suffix(status.bookmarks)}${suffix(jjFlags(status))}`;
}
async function readVcsStatus(
cwd: string,
timeout: number,
): Promise<VcsSegment | undefined> {
const vcs = await findVcs(cwd);
if (!vcs) return undefined;
if (vcs.kind === VCS_JJ)
return {
icon: ICONS.jj,
text: jjLabel(await readJjStatus(vcs.root, timeout)),
};
return {
icon: ICONS.git,
text: gitLabel(await readGitStatus(cwd, vcs.gitDir, timeout)),
};
}
function ignoreStaleContext(fn: () => void) {
try {
fn();
} catch (error) {
if (!isStaleContextError(error)) throw error;
}
}
function clearSegments(pi: ExtensionAPI) {
ignoreStaleContext(() => removeFooterSegment(pi, "cwd"));
ignoreStaleContext(() => removeFooterSegment(pi, "vcs"));
}
function offerSegment(pi: ExtensionAPI, segment: FooterSegment): boolean {
let accepted = false;
ignoreStaleContext(() => {
accepted = offerFooterSegment(pi, segment);
});
return accepted;
}
function removeSegment(pi: ExtensionAPI, id: string): boolean {
let accepted = false;
ignoreStaleContext(() => {
accepted = removeFooterSegment(pi, id);
});
return accepted;
}
function cwdLabel(cwd: string): string {
const home = process.env.HOME;
return home && cwd.startsWith(home) ? `~${cwd.slice(home.length)}` : cwd;
}
function emitVcs(
pi: ExtensionAPI,
ctx: ExtensionContext,
cwd: string,
vcs: VcsSegment | undefined,
mode: ExtensionContext["mode"],
) {
const path = cwdLabel(cwd);
let accepted = false;
if (mode === "tui") {
const cwdAccepted = offerSegment(pi, {
id: "cwd",
text: path,
icon: "",
color: "syntaxOperator",
zone: "workspace",
order: 0,
});
const vcsAccepted = vcs?.text
? offerSegment(pi, {
id: "vcs",
text: vcs.text,
icon: vcs.icon,
color: "syntaxKeyword",
zone: "workspace",
order: 1,
})
: removeSegment(pi, "vcs");
accepted = cwdAccepted && vcsAccepted;
}
if (!accepted) clearSegments(pi);
ignoreStaleContext(() =>
ctx.ui.setStatus(CWD_STATUS_ID, accepted ? undefined : path),
);
ignoreStaleContext(() =>
ctx.ui.setStatus(
VCS_STATUS_ID,
accepted || !vcs?.text ? undefined : `${vcs.icon} ${vcs.text}`,
),
);
}
export const __test = {
branchFromHead,
findVcs,
gitLabel,
jjLabel,
parsePorcelain,
};
export default function vcsStatusExtension(pi: ExtensionAPI) {
let generation = 0;
let hasUI = false;
let timeoutMs = TIMEOUT_SETTING.default;
const refresh = async (ctx: ExtensionContext) => {
const finish = span?.("status.refresh");
let outcome: "success" | "no_vcs" | "stale" | "no_ui" | "error" = "success";
try {
const ticket = generation;
let cwd: string;
let mode: ExtensionContext["mode"];
try {
cwd = ctx.cwd;
mode = ctx.mode;
} catch (error) {
if (isStaleContextError(error)) {
outcome = "stale";
finish?.("finish", { outcome });
return;
}
throw error;
}
if (!hasUI) {
outcome = "no_ui";
finish?.("finish", { outcome });
return;
}
const vcs = await readVcsStatus(cwd, timeoutMs);
if (!vcs) outcome = "no_vcs";
if (ticket !== generation) {
outcome = "stale";
finish?.("finish", { outcome });
return;
}
emitVcs(pi, ctx, cwd, vcs, mode);
finish?.("finish", { outcome });
} catch (error) {
finish?.("error", { outcome: "error" });
throw error;
}
};
pi.on("session_start", async (_e, ctx) => {
dbg?.("session.start");
generation++;
hasUI = ctx.hasUI;
const setting = resolveSetting(pi, ctx, TIMEOUT_SETTING);
timeoutMs = setting.ok ? setting.value : TIMEOUT_SETTING.default;
if (!setting.ok)
ctx.ui.notify(
`${setting.error}; using default ${TIMEOUT_SETTING.default} ms`,
"warning",
);
void refresh(ctx);
});
pi.on("agent_end", async (_e, ctx) => {
if (hasUI) void refresh(ctx);
});
pi.on("tool_execution_end", async (_e, ctx) => {
if (hasUI) void refresh(ctx);
});
pi.on("session_shutdown", async (_e, ctx) => {
dbg?.("session.shutdown");
closeDebug();
generation++;
clearSegments(pi);
ignoreStaleContext(() => {
if (ctx.hasUI) {
ctx.ui.setStatus(CWD_STATUS_ID, undefined);
ctx.ui.setStatus(VCS_STATUS_ID, undefined);
}
});
});
}