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:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user