189 lines
5.5 KiB
TypeScript
189 lines
5.5 KiB
TypeScript
/**
|
|
* Branch list helpers
|
|
*
|
|
* Listing, parsing, and enriching takt-managed branches
|
|
* with metadata (diff stats, original instruction, task slug).
|
|
* Used by the /list command.
|
|
*/
|
|
|
|
import { execFileSync } from 'node:child_process';
|
|
import { createLogger } from '../../shared/utils/index.js';
|
|
|
|
import type { BranchInfo, BranchListItem } from './types.js';
|
|
|
|
export type { BranchInfo, BranchListItem };
|
|
|
|
const log = createLogger('branchList');
|
|
|
|
const TAKT_BRANCH_PREFIX = 'takt/';
|
|
|
|
/**
|
|
* Manages takt branch listing and metadata enrichment.
|
|
*/
|
|
export class BranchManager {
|
|
/** Detect the default branch name (main or master) */
|
|
detectDefaultBranch(cwd: string): string {
|
|
try {
|
|
const ref = execFileSync(
|
|
'git', ['symbolic-ref', 'refs/remotes/origin/HEAD'],
|
|
{ cwd, encoding: 'utf-8', stdio: 'pipe' },
|
|
).trim();
|
|
const parts = ref.split('/');
|
|
return parts[parts.length - 1] || 'main';
|
|
} catch {
|
|
try {
|
|
execFileSync('git', ['rev-parse', '--verify', 'main'], {
|
|
cwd, encoding: 'utf-8', stdio: 'pipe',
|
|
});
|
|
return 'main';
|
|
} catch {
|
|
try {
|
|
execFileSync('git', ['rev-parse', '--verify', 'master'], {
|
|
cwd, encoding: 'utf-8', stdio: 'pipe',
|
|
});
|
|
return 'master';
|
|
} catch {
|
|
return 'main';
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/** List all takt-managed branches */
|
|
listTaktBranches(projectDir: string): BranchInfo[] {
|
|
try {
|
|
const output = execFileSync(
|
|
'git', ['branch', '--list', 'takt/*', '--format=%(refname:short) %(objectname:short)'],
|
|
{ cwd: projectDir, encoding: 'utf-8', stdio: 'pipe' },
|
|
);
|
|
return BranchManager.parseTaktBranches(output);
|
|
} catch (err) {
|
|
log.error('Failed to list takt branches', { error: String(err) });
|
|
return [];
|
|
}
|
|
}
|
|
|
|
/** Parse `git branch --list` formatted output into BranchInfo entries */
|
|
static parseTaktBranches(output: string): BranchInfo[] {
|
|
const entries: BranchInfo[] = [];
|
|
const lines = output.trim().split('\n');
|
|
|
|
for (const line of lines) {
|
|
const trimmed = line.trim();
|
|
if (!trimmed) continue;
|
|
|
|
const spaceIdx = trimmed.lastIndexOf(' ');
|
|
if (spaceIdx === -1) continue;
|
|
|
|
const branch = trimmed.slice(0, spaceIdx);
|
|
const commit = trimmed.slice(spaceIdx + 1);
|
|
|
|
if (branch.startsWith(TAKT_BRANCH_PREFIX)) {
|
|
entries.push({ branch, commit });
|
|
}
|
|
}
|
|
|
|
return entries;
|
|
}
|
|
|
|
/** Get the number of files changed between the default branch and a given branch */
|
|
getFilesChanged(cwd: string, defaultBranch: string, branch: string): number {
|
|
try {
|
|
const output = execFileSync(
|
|
'git', ['diff', '--numstat', `${defaultBranch}...${branch}`],
|
|
{ cwd, encoding: 'utf-8', stdio: 'pipe' },
|
|
);
|
|
return output.trim().split('\n').filter(l => l.length > 0).length;
|
|
} catch {
|
|
return 0;
|
|
}
|
|
}
|
|
|
|
/** Extract a human-readable task slug from a takt branch name */
|
|
static extractTaskSlug(branch: string): string {
|
|
const name = branch.replace(TAKT_BRANCH_PREFIX, '');
|
|
const withoutTimestamp = name.replace(/^\d{8,}T?\d{0,6}-?/, '');
|
|
return withoutTimestamp || name;
|
|
}
|
|
|
|
/**
|
|
* Extract the original task instruction from the first commit message on a branch.
|
|
* The first commit on a takt branch has the format: "takt: {original instruction}".
|
|
*/
|
|
getOriginalInstruction(
|
|
cwd: string,
|
|
defaultBranch: string,
|
|
branch: string,
|
|
): string {
|
|
try {
|
|
const output = execFileSync(
|
|
'git',
|
|
['log', '--format=%s', '--reverse', `${defaultBranch}..${branch}`],
|
|
{ cwd, encoding: 'utf-8', stdio: 'pipe' },
|
|
).trim();
|
|
|
|
if (!output) return '';
|
|
|
|
const firstLine = output.split('\n')[0] || '';
|
|
const TAKT_COMMIT_PREFIX = 'takt:';
|
|
if (firstLine.startsWith(TAKT_COMMIT_PREFIX)) {
|
|
return firstLine.slice(TAKT_COMMIT_PREFIX.length).trim();
|
|
}
|
|
|
|
return firstLine;
|
|
} catch {
|
|
return '';
|
|
}
|
|
}
|
|
|
|
/** Build list items from branch list, enriching with diff stats */
|
|
buildListItems(
|
|
projectDir: string,
|
|
branches: BranchInfo[],
|
|
defaultBranch: string,
|
|
): BranchListItem[] {
|
|
return branches.map(br => ({
|
|
info: br,
|
|
filesChanged: this.getFilesChanged(projectDir, defaultBranch, br.branch),
|
|
taskSlug: BranchManager.extractTaskSlug(br.branch),
|
|
originalInstruction: this.getOriginalInstruction(projectDir, defaultBranch, br.branch),
|
|
}));
|
|
}
|
|
}
|
|
|
|
// ---- Backward-compatible module-level functions ----
|
|
|
|
const defaultManager = new BranchManager();
|
|
|
|
export function detectDefaultBranch(cwd: string): string {
|
|
return defaultManager.detectDefaultBranch(cwd);
|
|
}
|
|
|
|
export function listTaktBranches(projectDir: string): BranchInfo[] {
|
|
return defaultManager.listTaktBranches(projectDir);
|
|
}
|
|
|
|
export function parseTaktBranches(output: string): BranchInfo[] {
|
|
return BranchManager.parseTaktBranches(output);
|
|
}
|
|
|
|
export function getFilesChanged(cwd: string, defaultBranch: string, branch: string): number {
|
|
return defaultManager.getFilesChanged(cwd, defaultBranch, branch);
|
|
}
|
|
|
|
export function extractTaskSlug(branch: string): string {
|
|
return BranchManager.extractTaskSlug(branch);
|
|
}
|
|
|
|
export function getOriginalInstruction(cwd: string, defaultBranch: string, branch: string): string {
|
|
return defaultManager.getOriginalInstruction(cwd, defaultBranch, branch);
|
|
}
|
|
|
|
export function buildListItems(
|
|
projectDir: string,
|
|
branches: BranchInfo[],
|
|
defaultBranch: string,
|
|
): BranchListItem[] {
|
|
return defaultManager.buildListItems(projectDir, branches, defaultBranch);
|
|
}
|