jj.ts

  1import { execFile } from 'child_process';
  2import { promisify } from 'util';
  3import type {
  4	ChangeInfo,
  5	ChangeStack,
  6	FileChange,
  7	DiffMode,
  8	DiffScope
  9} from '$lib/types/index';
 10import { parseDiff } from './diff-parser.ts';
 11import { getRepoDir } from './repo.ts';
 12import {
 13	getWorktreeApprovals,
 14	approveWorktreeFile,
 15	unapproveWorktreeFile,
 16	getSelectedChangeId,
 17	setSelectedChangeId,
 18	clearApprovals
 19} from './review-store.ts';
 20
 21const execFileAsync = promisify(execFile);
 22
 23// jj takes a repository-wide lock for each command. Several API routes issue
 24// multiple jj calls in parallel, so serialize them through a queue to avoid
 25// "concurrent modification" failures.
 26let queue: Promise<unknown> = Promise.resolve();
 27
 28function jjExec(args: string[]): Promise<string> {
 29	const result = queue.then(() =>
 30		execFileAsync('jj', args, {
 31			cwd: getRepoDir(),
 32			encoding: 'utf-8',
 33			maxBuffer: 64 * 1024 * 1024
 34		})
 35	);
 36	// Keep the queue alive whether or not the command succeeds.
 37	queue = result.then(
 38		() => undefined,
 39		() => undefined
 40	);
 41	return result.then((r) => r.stdout);
 42}
 43
 44let jjAvailableCache: boolean | null = null;
 45
 46export async function isJjAvailable(): Promise<boolean> {
 47	if (jjAvailableCache !== null) return jjAvailableCache;
 48	try {
 49		await execFileAsync('jj', ['--version'], { cwd: getRepoDir() });
 50		jjAvailableCache = true;
 51	} catch {
 52		jjAvailableCache = false;
 53	}
 54	return jjAvailableCache;
 55}
 56
 57// --- Revision helpers ---
 58
 59// git-bug stores issues/identities and Lamport clocks in dedicated refs and
 60// operation-pack commits (e.g. create-clock-12, edit-clock-34, ops, version-4).
 61// Those commits are tooling internals, not code under review, so hide them from
 62// the change stack. Ref tips carry a `bugs/*` or `identities/*` bookmark, while
 63// intermediate commits have none — but every one of them touches a clock file.
 64const BUG_REF_PATTERN = /^(?:bugs|identities)\//;
 65const CLOCK_FILE_PREFIXES = ['create-clock-', 'edit-clock-'];
 66
 67const clockCheck = CLOCK_FILE_PREFIXES.map(
 68	(p) => `diff.files().any(|f| f.path().display().starts_with(${JSON.stringify(p)}))`
 69).join(' || ');
 70
 71const T = {
 72	changeId: 'change_id.short()',
 73	commitId: 'commit_id.short()',
 74	fullCommitId: 'commit_id',
 75	description: 'description.first_line()',
 76	bookmarks: 'bookmarks.join(",")',
 77	workingCopy: 'if(current_working_copy, "1", "0")',
 78	empty: 'if(empty, "1", "0")',
 79	parents: 'parents.map(|p| p.change_id().short()).join(",")',
 80	bugClock: `(${clockCheck})`
 81};
 82
 83const CHANGE_TEMPLATE =
 84	`${T.changeId} ++ "\\t" ++ ${T.commitId} ++ "\\t" ++ ${T.description} ++ "\\t" ++ ` +
 85	`${T.bookmarks} ++ "\\t" ++ ${T.workingCopy} ++ "\\t" ++ ${T.empty} ++ "\\t" ++ ${T.parents} ++ "\\t" ++ ` +
 86	`${T.bugClock} ++ "\\n"`;
 87
 88interface BaseInfo {
 89	revset: string;
 90	changeId: string;
 91	commitId: string;
 92	description: string;
 93}
 94
 95let cachedBaseBranchInfo: { baseBranch: string | null; mergeBase: string | null } | null = null;
 96
 97function invalidateBaseCache(): void {
 98	cachedBaseBranchInfo = null;
 99}
100
101/**
102 * Resolve the accepted base. We use the local `main` bookmark (the thing the UI
103 * moves on Approve) rather than `trunk()`, which would follow `main@origin` and
104 * ignore local approvals.
105 */
106async function resolveBase(): Promise<BaseInfo | null> {
107	for (const candidate of ['main', 'main@origin', 'trunk()']) {
108		try {
109			const out = await jjExec([
110				'log',
111				'-r',
112				candidate,
113				'--no-graph',
114				'-T',
115				`${T.changeId} ++ "\\t" ++ ${T.fullCommitId} ++ "\\t" ++ ${T.description}`
116			]);
117			const line = out.trim().split('\n')[0];
118			if (!line) continue;
119			const [changeId, commitId, description] = line.split('\t');
120			if (!changeId || !commitId) continue;
121			// trunk() falls back to the root commit when there is no main line.
122			if (/^0+$/.test(commitId)) return null;
123			return { revset: candidate, changeId, commitId, description: description ?? '' };
124		} catch {
125			// candidate doesn't resolve — try the next one
126		}
127	}
128	return null;
129}
130
131async function getWorkingCopyChangeId(): Promise<string | null> {
132	try {
133		const out = await jjExec(['log', '-r', '@', '--no-graph', '-T', T.changeId]);
134		return out.trim().split('\n')[0] || null;
135	} catch {
136		return null;
137	}
138}
139
140function parseChanges(raw: string): ChangeInfo[] {
141	const out: ChangeInfo[] = [];
142	for (const line of raw.split('\n')) {
143		if (!line.trim()) continue;
144		const [changeId, commitId, description, bookmarks, wc, empty, parents, bugClock] =
145			line.split('\t');
146		if (!changeId || !commitId) continue;
147		if (empty === '1') continue; // skip empty working-copy commits
148		const bookmarkList = (bookmarks || '').split(',').filter(Boolean);
149		// Skip git-bug issue/identity commits and Lamport-clock updates.
150		if (bugClock === 'true' || bookmarkList.some((b) => BUG_REF_PATTERN.test(b))) continue;
151		out.push({
152			changeId,
153			commitId,
154			description: description || '',
155			bookmarks: bookmarkList,
156			isWorkingCopy: wc === '1',
157			parents: (parents || '').split(',').filter(Boolean)
158		});
159	}
160	return out;
161}
162
163// Maps new path -> status/oldPath by inspecting Git-format diff headers.
164function parseGitStatuses(raw: string): Map<string, { status: FileChange['status']; oldPath?: string }> {
165	const map = new Map<string, { status: FileChange['status']; oldPath?: string }>();
166	const chunks = raw.split(/^diff --git /m).filter(Boolean);
167	for (const chunk of chunks) {
168		const lines = chunk.split('\n');
169		const header = lines[0]?.match(/a\/(.+?) b\/(.+)/);
170		if (!header) continue;
171		const oldPath = header[1];
172		const newPath = header[2];
173
174		let status: FileChange['status'] = 'modified';
175		if (lines.some((l) => l.startsWith('new file mode'))) status = 'added';
176		else if (lines.some((l) => l.startsWith('deleted file mode'))) status = 'deleted';
177		else if (oldPath !== newPath) status = 'renamed';
178
179		map.set(newPath, { status, oldPath: status === 'renamed' ? oldPath : undefined });
180	}
181	return map;
182}
183
184function filesFromRaw(raw: string): FileChange[] {
185	const parsed = parseDiff(raw);
186	const statuses = parseGitStatuses(raw);
187	const approvals = getWorktreeApprovals();
188
189	return parsed.map((f) => {
190		const meta = statuses.get(f.path);
191		let additions = 0;
192		let deletions = 0;
193		for (const hunk of f.hunks) {
194			for (const line of hunk.lines) {
195				if (line.type === 'add') additions++;
196				else if (line.type === 'del') deletions++;
197			}
198		}
199		return {
200			path: f.path,
201			oldPath: meta?.oldPath ?? f.oldPath,
202			status: meta?.status ?? 'modified',
203			staged: false,
204			approved: approvals.has(f.path),
205			additions,
206			deletions
207		};
208	});
209}
210
211// Diff revision arguments. `jj diff -r @` already handles merge commits: when @
212// has multiple parents jj auto-merges them and diffs the merged result against
213// @, which is exactly "changes since the parents". Do not "simplify" this to an
214// explicit parents() revset. When a change is selected for review we diff that
215// revision instead of the working copy.
216async function revArgs(scope?: DiffScope): Promise<string[]> {
217	const rev = getSelectedChangeId() || '@';
218	if (scope === 'worktree') {
219		const { mergeBase } = await getBaseBranchInfo();
220		if (mergeBase) return ['--from', mergeBase, '--to', rev];
221	}
222	return ['-r', rev];
223}
224
225async function diffFor(paths: string[], scope?: DiffScope): Promise<string> {
226	const args = ['diff', '--git', '--color=never', ...(await revArgs(scope))];
227	if (paths.length > 0) args.push('--', ...paths);
228	try {
229		return await jjExec(args);
230	} catch {
231		return '';
232	}
233}
234
235export async function getStatus(): Promise<FileChange[]> {
236	return filesFromRaw(await diffFor([], 'uncommitted'));
237}
238
239export async function getWorktreeStatus(): Promise<FileChange[]> {
240	return filesFromRaw(await diffFor([], 'worktree'));
241}
242
243export async function getBaseBranchInfo(): Promise<{ baseBranch: string | null; mergeBase: string | null }> {
244	if (cachedBaseBranchInfo) return cachedBaseBranchInfo;
245
246	const base = await resolveBase();
247	if (!base) {
248		cachedBaseBranchInfo = { baseBranch: null, mergeBase: null };
249	} else {
250		cachedBaseBranchInfo = {
251			baseBranch: base.revset === 'trunk()' ? 'trunk' : 'main',
252			mergeBase: base.commitId
253		};
254	}
255	return cachedBaseBranchInfo;
256}
257
258export async function getOldRef(scope: DiffScope = 'uncommitted'): Promise<string> {
259	const selected = getSelectedChangeId();
260	if (scope === 'worktree') {
261		const { mergeBase } = await getBaseBranchInfo();
262		if (mergeBase) return mergeBase;
263	}
264	return selected ? `${selected}-` : '@-';
265}
266
267/** Revision whose file contents are the "new" side of the diff, or null for the working tree. */
268export async function getNewRef(): Promise<string | null> {
269	return getSelectedChangeId();
270}
271
272export async function getDiffByMode(mode: DiffMode, filePath?: string, scope?: DiffScope): Promise<string> {
273	if (mode === 'unstaged') {
274		return getUnstagedDiff(filePath, scope);
275	}
276	return diffFor(filePath ? [filePath] : [], scope);
277}
278
279// jj has no staging area. "Needs Review" therefore means "not yet approved" —
280// approval is tracked in memory rather than in an index.
281async function getUnstagedDiff(filePath?: string, scope?: DiffScope): Promise<string> {
282	const approved = getWorktreeApprovals();
283
284	if (filePath) {
285		if (approved.has(filePath)) return '';
286		return diffFor([filePath], scope);
287	}
288
289	const status = scope === 'worktree' ? await getWorktreeStatus() : await getStatus();
290	const unapproved = status.filter((f) => !f.approved).map((f) => f.path);
291	if (unapproved.length === 0) return '';
292	return diffFor(unapproved, scope);
293}
294
295export async function getFileContent(filePath: string, ref: string = '@-'): Promise<string> {
296	const rev = ref === 'HEAD' ? '@-' : ref;
297	try {
298		return await jjExec(['file', 'show', '-r', rev, '--', filePath]);
299	} catch {
300		return '';
301	}
302}
303
304// --- Stacked change navigation ---
305
306export async function getChangeStack(): Promise<ChangeStack> {
307	// The base can move outside this process (or on Approve), so refresh it each
308	// time the stack is requested. Polling calls this regularly.
309	invalidateBaseCache();
310
311	const base = await resolveBase();
312	const workingCopy = await getWorkingCopyChangeId();
313
314	let changes: ChangeInfo[] = [];
315	if (base) {
316		const raw = await jjExec([
317			'log',
318			'-r',
319			`${base.changeId}..`,
320			'--no-graph',
321			'-T',
322			CHANGE_TEMPLATE
323		]);
324		changes = parseChanges(raw);
325	}
326
327	// Drop a stale selection (approved, abandoned, or filtered as empty).
328	let selected = getSelectedChangeId();
329	if (selected && !changes.some((c) => c.changeId === selected)) {
330		selected = null;
331		setSelectedChangeId(null);
332	}
333
334	return {
335		main: base
336			? { changeId: base.changeId, commitId: base.commitId, description: base.description }
337			: null,
338		current: selected ?? workingCopy,
339		selected,
340		workingCopy,
341		changes
342	};
343}
344
345export async function selectChange(changeId: string): Promise<ChangeStack> {
346	try {
347		const out = await jjExec(['log', '-r', changeId, '--no-graph', '-T', T.changeId]);
348		if (!out.trim()) throw new Error('unknown');
349	} catch {
350		throw new Error(`Unknown change: ${changeId}`);
351	}
352	setSelectedChangeId(changeId);
353	clearApprovals();
354	return getChangeStack();
355}
356
357export async function approveChange(changeId?: string): Promise<ChangeStack> {
358	const stack = await getChangeStack();
359	const target = changeId || stack.selected || stack.workingCopy || null;
360	if (!target) {
361		throw new Error('No change to approve.');
362	}
363	// Forward-only: only changes that are descendants of the current main can be
364	// approved (they are exactly the entries in `main..`).
365	if (!stack.changes.some((c) => c.changeId === target)) {
366		throw new Error(
367			'Cannot approve: this change is not a descendant of main. main can only move forward.'
368		);
369	}
370
371	await jjExec(['bookmark', 'set', 'main', '-r', target]);
372	invalidateBaseCache();
373	clearApprovals();
374
375	// The approved change is now main; advance the view to its child if any.
376	setSelectedChangeId(null);
377	const updated = await getChangeStack();
378	const child = updated.changes.find((c) => c.parents.includes(target));
379	if (child) setSelectedChangeId(child.changeId);
380
381	return getChangeStack();
382}
383
384export async function approveFile(filePath: string, _scope?: DiffScope): Promise<void> {
385	approveWorktreeFile(filePath);
386}
387
388export async function unapproveFile(filePath: string, _scope?: DiffScope): Promise<void> {
389	unapproveWorktreeFile(filePath);
390}
391
392export async function resetFile(filePath: string): Promise<void> {
393	await jjExec(['restore', '--from', '@-', '--', filePath]);
394}
395
396export async function resetHunk(_filePath: string, _hunkHeader: string): Promise<void> {
397	throw new Error('Hunk reset is not supported in jj repositories; reset the whole file instead.');
398}
399
400export async function commitStaged(_message: string): Promise<string> {
401	throw new Error('jj changes are committed automatically; there is nothing to commit.');
402}
403
404export async function commitAll(_message: string): Promise<string> {
405	throw new Error('jj changes are committed automatically; there is nothing to commit.');
406}
407
408export async function getRepoInfo(): Promise<{ branch: string; repoName: string }> {
409	const repoDir = getRepoDir();
410	const repoName = repoDir.split('/').pop() || 'unknown';
411
412	let branch = '@';
413	try {
414		const bookmarks = (
415			await jjExec(['log', '-r', '@', '--no-graph', '-T', 'bookmarks.join(" ")'])
416		).trim();
417		if (bookmarks) {
418			branch = bookmarks.split(/\s+/)[0];
419		} else {
420			const changeId = (
421				await jjExec(['log', '-r', '@', '--no-graph', '-T', 'commit_id.short()'])
422			).trim();
423			if (changeId) branch = changeId;
424		}
425	} catch {
426		// Leave the placeholder branch
427	}
428	return { branch, repoName };
429}