File size: 5,858 Bytes
1dbc34b | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 | /**
* Service for fetching commit log data from a worktree.
*
* Extracts the heavy Git command execution and parsing logic from the
* commit-log route handler so the handler only validates input,
* invokes this service, streams lifecycle events, and sends the response.
*
* Follows the same approach as branch-commit-log-service: a single
* `git log --name-only` call with custom separators to fetch both
* commit metadata and file lists, avoiding N+1 git invocations.
*/
import { execGitCommand } from '../lib/git.js';
// ============================================================================
// Types
// ============================================================================
export interface CommitLogEntry {
hash: string;
shortHash: string;
author: string;
authorEmail: string;
date: string;
subject: string;
body: string;
files: string[];
}
export interface CommitLogResult {
branch: string;
commits: CommitLogEntry[];
total: number;
}
// ============================================================================
// Service
// ============================================================================
/**
* Fetch the commit log for a worktree (HEAD).
*
* Runs a single `git log --name-only` invocation plus `git rev-parse`
* inside the given worktree path and returns a structured result.
*
* @param worktreePath - Absolute path to the worktree / repository
* @param limit - Maximum number of commits to return (clamped 1-100)
*/
export async function getCommitLog(worktreePath: string, limit: number): Promise<CommitLogResult> {
// Clamp limit to a reasonable range
const parsedLimit = Number(limit);
const commitLimit = Math.min(Math.max(1, Number.isFinite(parsedLimit) ? parsedLimit : 20), 100);
// Use custom separators to parse both metadata and file lists from
// a single git log invocation (same approach as branch-commit-log-service).
//
// -m causes merge commits to be diffed against each parent so all
// files touched by the merge are listed (without -m, --name-only
// produces no file output for merge commits because they have 2+ parents).
// This means merge commits appear multiple times in the output (once per
// parent), so we deduplicate by hash below and merge their file lists.
// We over-fetch (2x the limit) to compensate for -m duplicating merge
// commit entries, then trim the result to the requested limit.
// Use ASCII control characters as record separators – these cannot appear in
// git commit messages, so these delimiters are safe regardless of commit
// body content. %x00 and %x01 in git's format string emit literal NUL /
// SOH bytes respectively.
//
// COMMIT_SEP (\x00) – marks the start of each commit record.
// META_END (\x01) – separates commit metadata from the --name-only file list.
//
// Full per-commit layout emitted by git:
// \x00\n<hash>\n<shorthash>\n...\n<subject>\n<body>\x01<files...>
const COMMIT_SEP = '\x00';
const META_END = '\x01';
const fetchLimit = commitLimit * 2;
const logOutput = await execGitCommand(
[
'log',
`--max-count=${fetchLimit}`,
'-m',
'--name-only',
`--format=%x00%n%H%n%h%n%an%n%ae%n%aI%n%s%n%b%x01`,
],
worktreePath
);
// Split output into per-commit blocks and drop the empty first chunk
// (the output starts with a NUL commit separator).
const commitBlocks = logOutput.split(COMMIT_SEP).filter((block) => block.trim());
// Use a Map to deduplicate merge commit entries (which appear once per
// parent when -m is used) while preserving insertion order.
const commitMap = new Map<string, CommitLogEntry>();
for (const block of commitBlocks) {
const metaEndIdx = block.indexOf(META_END);
if (metaEndIdx === -1) continue; // malformed block, skip
// --- Parse metadata (everything before the META_END delimiter) ---
const metaRaw = block.substring(0, metaEndIdx);
const metaLines = metaRaw.split('\n');
// The first line may be empty (newline right after COMMIT_SEP), skip it
const nonEmptyStart = metaLines.findIndex((l) => l.trim() !== '');
if (nonEmptyStart === -1) continue;
const fields = metaLines.slice(nonEmptyStart);
if (fields.length < 6) continue; // need at least hash..subject
const hash = fields[0].trim();
if (!hash) continue; // defensive: skip if hash is empty
const shortHash = fields[1]?.trim() ?? '';
const author = fields[2]?.trim() ?? '';
const authorEmail = fields[3]?.trim() ?? '';
const date = fields[4]?.trim() ?? '';
const subject = fields[5]?.trim() ?? '';
const body = fields.slice(6).join('\n').trim();
// --- Parse file list (everything after the META_END delimiter) ---
const filesRaw = block.substring(metaEndIdx + META_END.length);
const blockFiles = filesRaw
.trim()
.split('\n')
.filter((f) => f.trim());
// Merge file lists for duplicate entries (merge commits with -m)
const existing = commitMap.get(hash);
if (existing) {
// Add new files to the existing entry's file set
const fileSet = new Set(existing.files);
for (const f of blockFiles) fileSet.add(f);
existing.files = [...fileSet];
} else {
commitMap.set(hash, {
hash,
shortHash,
author,
authorEmail,
date,
subject,
body,
files: [...new Set(blockFiles)],
});
}
}
// Trim to the requested limit (we over-fetched to account for -m duplicates)
const commits = [...commitMap.values()].slice(0, commitLimit);
// Get current branch name
const branchOutput = await execGitCommand(['rev-parse', '--abbrev-ref', 'HEAD'], worktreePath);
const branch = branchOutput.trim();
return {
branch,
commits,
total: commits.length,
};
}
|