feat: add diffBranchesStat and diffFileSingle to BranchManager

Adds FileStatEntry type and two new primitives to the BranchManager
port and SimpleGitBranchManager adapter, enabling split diff
procedures in the tRPC layer without returning raw multi-megabyte diffs.

- FileStatEntry captures path, status, additions/deletions, oldPath
  (renames), and optional projectId for multi-project routing
- diffBranchesStat uses --name-status + --numstat, detects binary
  files (shown as - / - in numstat), handles spaces in filenames
- diffFileSingle returns raw unified diff for a single file path
This commit is contained in:
Lukas May
2026-03-06 19:33:47 +01:00
parent 2eccde0ee1
commit 9894cdd06f
6 changed files with 290 additions and 2 deletions

View File

@@ -11,11 +11,31 @@ import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { simpleGit } from 'simple-git';
import type { BranchManager } from './branch-manager.js';
import type { MergeResult, MergeabilityResult, BranchCommit } from './types.js';
import type { MergeResult, MergeabilityResult, BranchCommit, FileStatEntry } from './types.js';
import { createModuleLogger } from '../logger/index.js';
const log = createModuleLogger('branch-manager');
/**
* Normalize a numstat path to the new path for rename entries.
* Handles patterns like:
* - "{old.txt => new.txt}" → "new.txt"
* - "dir/{old.txt => new.txt}" → "dir/new.txt"
* - "old_dir/file.txt => new_dir/file.txt" → "new_dir/file.txt"
*/
function normalizeNumstatPath(pathStr: string): string {
const braceMatch = pathStr.match(/^(.*)\{(.*) => (.*)\}(.*)$/);
if (braceMatch) {
const [, prefix, , newPart, suffix] = braceMatch;
return `${prefix}${newPart}${suffix}`;
}
const arrowMatch = pathStr.match(/^.* => (.+)$/);
if (arrowMatch) {
return arrowMatch[1];
}
return pathStr;
}
export class SimpleGitBranchManager implements BranchManager {
async ensureBranch(repoPath: string, branch: string, baseBranch: string): Promise<void> {
const git = simpleGit(repoPath);
@@ -97,6 +117,91 @@ export class SimpleGitBranchManager implements BranchManager {
return diff;
}
async diffBranchesStat(repoPath: string, baseBranch: string, headBranch: string): Promise<FileStatEntry[]> {
const git = simpleGit(repoPath);
const range = `${baseBranch}...${headBranch}`;
const [nameStatusRaw, numStatRaw] = await Promise.all([
git.raw(['diff', '--name-status', range]),
git.raw(['diff', '--numstat', range]),
]);
if (!nameStatusRaw.trim()) return [];
// Parse numstat: "<additions>\t<deletions>\t<path>"
// Binary files: "-\t-\t<path>"
const numStatMap = new Map<string, { additions: number; deletions: number; binary: boolean }>();
for (const line of numStatRaw.split('\n')) {
if (!line.trim()) continue;
const tabIdx1 = line.indexOf('\t');
const tabIdx2 = line.indexOf('\t', tabIdx1 + 1);
if (tabIdx1 === -1 || tabIdx2 === -1) continue;
const addStr = line.slice(0, tabIdx1);
const delStr = line.slice(tabIdx1 + 1, tabIdx2);
const pathStr = line.slice(tabIdx2 + 1);
const binary = addStr === '-' && delStr === '-';
// Normalize rename paths like "{old => new}" or "dir/{old => new}/file" to new path
const newPath = normalizeNumstatPath(pathStr);
numStatMap.set(newPath, {
additions: binary ? 0 : parseInt(addStr, 10),
deletions: binary ? 0 : parseInt(delStr, 10),
binary,
});
}
// Parse name-status: "<status>\t<path>" or "<Rxx>\t<oldPath>\t<newPath>"
const entries: FileStatEntry[] = [];
for (const line of nameStatusRaw.split('\n')) {
if (!line.trim()) continue;
const parts = line.split('\t');
if (parts.length < 2) continue;
const statusCode = parts[0];
let status: FileStatEntry['status'];
let filePath: string;
let oldPath: string | undefined;
if (statusCode.startsWith('R')) {
status = 'renamed';
oldPath = parts[1];
filePath = parts[2];
} else if (statusCode === 'A') {
status = 'added';
filePath = parts[1];
} else if (statusCode === 'M') {
status = 'modified';
filePath = parts[1];
} else if (statusCode === 'D') {
status = 'deleted';
filePath = parts[1];
} else {
status = 'modified';
filePath = parts[1];
}
const numStat = numStatMap.get(filePath);
if (numStat?.binary) {
status = 'binary';
}
const entry: FileStatEntry = {
path: filePath,
status,
additions: numStat?.additions ?? 0,
deletions: numStat?.deletions ?? 0,
};
if (oldPath !== undefined) entry.oldPath = oldPath;
entries.push(entry);
}
return entries;
}
async diffFileSingle(repoPath: string, baseBranch: string, headBranch: string, filePath: string): Promise<string> {
const git = simpleGit(repoPath);
return git.diff([`${baseBranch}...${headBranch}`, '--', filePath]);
}
async deleteBranch(repoPath: string, branch: string): Promise<void> {
const git = simpleGit(repoPath);
const exists = await this.branchExists(repoPath, branch);