import { execFile, spawn } from "node:child_process";
import { mkdir } from "node:fs/promises";
import { promisify } from "node:util";
import type { ChangeMeta, ChangeDetail, TreeEntry } from "@pijulab/shared";
const execFileAsync = promisify(execFile);
export class PijulRepo {
private constructor(private absPath: string) {}
static async init(absPath: string): Promise<PijulRepo> {
await mkdir(absPath, { recursive: true });
await execFileAsync("pijul", ["init"], { cwd: absPath });
return new PijulRepo(absPath);
}
static open(absPath: string): PijulRepo {
return new PijulRepo(absPath);
}
async listChannels(): Promise<string[]> {
const { stdout } = await execFileAsync("pijul", ["channel", "list"], {
cwd: this.absPath,
});
return stdout
.split("\n")
.map((line) => line.replace(/^\*?\s*/, "").trim())
.filter(Boolean);
}
async listTree(channel: string, subpath = ""): Promise<TreeEntry[]> {
const args = ["ls", "--channel", channel];
if (subpath) args.push(subpath);
let stdout: string;
try {
({ stdout } = await execFileAsync("pijul", args, { cwd: this.absPath }));
} catch {
return [];
}
return stdout
.split("\n")
.filter(Boolean)
.map((line) => {
const isDir = line.endsWith("/");
const name = isDir ? line.slice(0, -1) : line;
const path = subpath ? `${subpath}/${name}` : name;
return { name, kind: isDir ? ("dir" as const) : ("file" as const), path };
});
}
readFile(channel: string, filePath: string): Promise<Buffer> {
return new Promise((resolve, reject) => {
const chunks: Buffer[] = [];
const proc = spawn("pijul", ["cat", "--channel", channel, filePath], {
cwd: this.absPath,
});
proc.stdout.on("data", (chunk: Buffer) => chunks.push(Buffer.from(chunk)));
proc.on("close", (code) => {
if (code !== 0) reject(new Error(`pijul cat exited with ${code}`));
else resolve(Buffer.concat(chunks));
});
proc.on("error", reject);
});
}
async log(channel: string, limit = 50): Promise<ChangeMeta[]> {
let stdout: string;
try {
({ stdout } = await execFileAsync(
"pijul",
["log", "--channel", channel, "--output-format", "json", "--limit", String(limit)],
{ cwd: this.absPath },
));
} catch {
return [];
}
const text = stdout.trim();
if (!text) return [];
// pijul may output a JSON array or NDJSON
if (text.startsWith("[")) {
return JSON.parse(text) as ChangeMeta[];
}
return text
.split("\n")
.filter(Boolean)
.map((line) => JSON.parse(line) as ChangeMeta);
}
async getChange(hash: string): Promise<ChangeDetail | null> {
let stdout: string;
try {
({ stdout } = await execFileAsync("pijul", ["change", hash], {
cwd: this.absPath,
}));
} catch {
return null;
}
// pijul change outputs a human-readable format; capture raw diff
const lines = stdout.split("\n");
let message = "";
const authors: Array<{ name: string; email?: string }> = [];
let timestamp = "";
const diffLines: string[] = [];
let headerDone = false;
for (const line of lines) {
if (!headerDone) {
if (line.startsWith("Author:")) {
authors.push({ name: line.replace("Author:", "").trim() });
} else if (line.startsWith("Date:")) {
timestamp = line.replace("Date:", "").trim();
} else if (line.startsWith("Hash:") || line.startsWith("Change:")) {
// skip
} else if (line.trim() === "") {
headerDone = true;
}
} else {
if (!message) {
message = line.trim();
} else {
diffLines.push(line);
}
}
}
return { hash, message, authors, timestamp, diff: diffLines.join("\n") };
}
}