Files

138 lines
5.0 KiB
JavaScript
Raw Permalink Normal View History

import { getDiff, getRangeDiff, getCommitDiff, getUntrackedDiffs, listUntrackedPaths } from '../git/service.js';
// A walkthrough source resolves to one or more diff *sections*. A section is a
// patch plus the scope its hunk ids live in; keeping staged and working-tree
// changes in separate scopes means a stop written against staged code never
// silently re-anchors onto an unstaged edit of the same lines.
const WORKING_TREE_SCOPES = new Set(['all', 'staged', 'working']);
export class WalkthroughSourceError extends Error {
constructor(message, statusCode = 400, code = undefined) {
super(message);
this.statusCode = statusCode;
if (code) this.code = code;
}
}
/**
* Normalize and validate an untrusted source descriptor from the client.
*/
export function parseSource(raw) {
if (!raw || typeof raw !== 'object') {
throw new WalkthroughSourceError('source is required');
}
if (raw.kind === 'working-tree') {
const scope = typeof raw.scope === 'string' ? raw.scope : 'all';
if (!WORKING_TREE_SCOPES.has(scope)) {
throw new WalkthroughSourceError(`Unknown working-tree scope "${scope}"`);
}
return { kind: 'working-tree', scope };
}
if (raw.kind === 'branch') {
const baseRef = typeof raw.baseRef === 'string' ? raw.baseRef.trim() : '';
const headRef = typeof raw.headRef === 'string' ? raw.headRef.trim() : '';
if (!baseRef || !headRef) {
throw new WalkthroughSourceError('branch sources require baseRef and headRef');
}
return { kind: 'branch', baseRef, headRef };
}
if (raw.kind === 'pr') {
const number = Number(raw.number);
if (!Number.isInteger(number) || number <= 0) {
throw new WalkthroughSourceError('pr sources require a positive number');
}
return { kind: 'pr', number };
}
if (raw.kind === 'commit') {
// Sources are content-addressed: accept a full object id, never a moving ref.
if (!/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/i.test(raw.hash)) {
throw new WalkthroughSourceError('commit sources require a full commit hash');
}
try {
return { kind: 'commit', hash: raw.hash.toLowerCase() };
} catch {
throw new WalkthroughSourceError('commit sources require a full commit hash');
}
}
throw new WalkthroughSourceError(`Unknown source kind "${String(raw.kind)}"`);
}
/**
* Stable string form of a source, used as the pointer key and as part of the
* cache key. Must not change shape casually — it addresses persisted files.
*/
export function sourceKey(source) {
if (source.kind === 'working-tree') return `working-tree:${source.scope}`;
if (source.kind === 'branch') return `branch:${source.baseRef}...${source.headRef}`;
if (source.kind === 'commit') return `commit:${source.hash}`;
return `pr:${source.number}`;
}
// `git diff` never reports untracked files, so a brand-new file would be
// invisible in a walkthrough of local work. The batch helper resolves the
// repository once and bounds how many diff processes run at a time.
const untrackedSections = async (directory) => {
const untracked = await listUntrackedPaths(directory);
if (untracked.length === 0) return [];
const patches = await getUntrackedDiffs(directory, untracked);
return patches.filter((patch) => typeof patch === 'string' && patch.trim());
};
/**
* Resolve a source into diff sections.
*
* @returns {Promise<{sections: Array<{scope: string, patch: string}>, meta: object}>}
*/
export async function loadSourceSections(directory, source, { getPullRequestDiff } = {}) {
if (source.kind === 'working-tree') {
const sections = [];
if (source.scope === 'all' || source.scope === 'staged') {
const patch = await getDiff(directory, { staged: true });
if (patch && patch.trim()) sections.push({ scope: 'staged', patch });
}
if (source.scope === 'all' || source.scope === 'working') {
const patch = await getDiff(directory, { staged: false });
const untracked = await untrackedSections(directory);
const combined = [patch, ...untracked].filter((value) => value && value.trim()).join('\n');
if (combined.trim()) sections.push({ scope: 'working', patch: combined });
}
return { sections, meta: {} };
}
if (source.kind === 'branch') {
const patch = await getRangeDiff(directory, { base: source.baseRef, head: source.headRef, includeWorkingTree: true });
return {
sections: patch && patch.trim() ? [{ scope: 'branch', patch }] : [],
meta: { baseRef: source.baseRef, headRef: source.headRef },
};
}
if (source.kind === 'commit') {
const patch = await getCommitDiff(directory, { hash: source.hash });
return {
sections: patch.trim() ? [{ scope: 'commit', patch }] : [],
meta: { hash: source.hash },
};
}
if (typeof getPullRequestDiff !== 'function') {
throw new WalkthroughSourceError('Pull request diffs are unavailable', 500);
}
const { patch, meta } = await getPullRequestDiff(directory, source.number);
return {
sections: patch && patch.trim() ? [{ scope: `pr:${source.number}`, patch }] : [],
meta: meta || {},
};
}