feat: better branch titles

This commit is contained in:
2026-07-22 11:17:24 -04:00
parent c46f8f1783
commit 0034272c54
3 changed files with 50 additions and 14 deletions

View File

@@ -260,7 +260,7 @@ async function executePlanBatches(
// Clean up stale worktrees from interrupted runs before starting. // Clean up stale worktrees from interrupted runs before starting.
if (config.execution.worktrees !== "never" && projectDir) { if (config.execution.worktrees !== "never" && projectDir) {
const removed = cleanupStaleWorktrees(projectDir, progress.getKey()); const removed = cleanupStaleWorktrees(projectDir, config.paths.stateDir);
if (removed.length > 0) { if (removed.length > 0) {
ctx.ui.notify( ctx.ui.notify(
`Cleaned up ${removed.length} stale worktree(s) from previous run.`, `Cleaned up ${removed.length} stale worktree(s) from previous run.`,

View File

@@ -778,6 +778,8 @@ async function executeTask(
config.paths.stateDir, config.paths.stateDir,
task.id, task.id,
progress.getKey(), progress.getKey(),
undefined,
task.title,
) )
: null; : null;
const worktreeDir = wt?.dir ?? projectDir; const worktreeDir = wt?.dir ?? projectDir;

View File

@@ -6,7 +6,7 @@ import { ensureDir } from "./utils";
export interface WorktreeHandle { export interface WorktreeHandle {
/** Absolute path to the worktree working directory. */ /** Absolute path to the worktree working directory. */
dir: string; dir: string;
/** Branch name: `ralpi/<prdKey>/<taskId>`. */ /** Branch name: slugified task title, or `ralpi/<prdKey>/<taskId>` as a fallback. */
branch: string; branch: string;
/** Main repo directory (where the primary working tree lives). */ /** Main repo directory (where the primary working tree lives). */
mainDir: string; mainDir: string;
@@ -108,12 +108,36 @@ function safeBranchSuffix(taskId: string): string {
return taskId.replace(/[^a-zA-Z0-9_-]/g, "-"); return taskId.replace(/[^a-zA-Z0-9_-]/g, "-");
} }
/**
* Sanitise a free-form task title into a git-branch-safe slug.
*
* Lowercases, replaces runs of non-alphanumeric characters with single
* hyphens, trims leading/trailing hyphens, and caps the length so the
* branch name stays readable and within reasonable git limits.
*
* Returns an empty string when the title produces no usable slug.
*/
function slugifyTitle(title: string): string {
return title
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 60);
}
/** /**
* Create a git worktree for a task. * Create a git worktree for a task.
* *
* The worktree is created at `<mainDir>/.ralpi/worktrees/<taskId>` on a new * The worktree is created at `<mainDir>/.ralpi/worktrees/<taskId>` on a new
* branch `ralpi/<prdKey>/<taskId>`, based at `baseRef` (defaults to the * branch. When `taskTitle` is provided the branch name is the slugified title
* current HEAD of `mainDir`). * alone (e.g. `fix-plans-tab-grammar-casing-icons`); otherwise it falls back
* to `ralpi/<prdKey>/<taskId>`. Based at `baseRef` (defaults to the current
* HEAD of `mainDir`).
*
* The worktree directory always uses the bare `taskId` for a stable path;
* stale-worktree cleanup identifies ralpi worktrees by that path, not by
* branch name, so descriptive branch names are safe.
* *
* Returns null if `mainDir` is not a git repo or the worktree creation fails. * Returns null if `mainDir` is not a git repo or the worktree creation fails.
*/ */
@@ -123,6 +147,7 @@ export function createWorktree(
taskId: string, taskId: string,
prdKey: string, prdKey: string,
baseRef?: string, baseRef?: string,
taskTitle?: string,
): WorktreeHandle | null { ): WorktreeHandle | null {
if (!isGitRepo(mainDir)) return null; if (!isGitRepo(mainDir)) return null;
@@ -130,7 +155,8 @@ export function createWorktree(
if (!ref) return null; if (!ref) return null;
const safeId = safeBranchSuffix(taskId); const safeId = safeBranchSuffix(taskId);
const branch = `ralpi/${prdKey}/${safeId}`; const slug = taskTitle ? slugifyTitle(taskTitle) : "";
const branch = slug || `ralpi/${prdKey}/${safeId}`;
const wtDir = worktreePath(mainDir, stateDir, taskId); const wtDir = worktreePath(mainDir, stateDir, taskId);
// Ensure the parent directory exists so `git worktree add` can create // Ensure the parent directory exists so `git worktree add` can create
@@ -271,13 +297,14 @@ export function removeWorktree(mainDir: string, wt: WorktreeHandle): void {
/** /**
* Clean up stale worktrees from interrupted runs. * Clean up stale worktrees from interrupted runs.
* *
* Lists all worktrees whose branches start with `ralpi/<prdKey>/` and * Identifies ralpi-owned worktrees by their path living under
* removes them. Called at the start of a loop to ensure a clean slate. * `<mainDir>/<stateDir>/worktrees/` and removes them. Called at the start
* Returns the list of removed worktree directories. * of a loop to ensure a clean slate. Returns the list of removed worktree
* directories.
*/ */
export function cleanupStaleWorktrees( export function cleanupStaleWorktrees(
mainDir: string, mainDir: string,
prdKey: string, stateDir: string,
): string[] { ): string[] {
const removed: string[] = []; const removed: string[] = [];
@@ -287,6 +314,9 @@ export function cleanupStaleWorktrees(
const list = git("worktree list --porcelain", mainDir); const list = git("worktree list --porcelain", mainDir);
if (!list) return removed; if (!list) return removed;
// Worktrees we manage live under <mainDir>/<stateDir>/worktrees/.
const managedRoot = path.resolve(mainDir, stateDir, "worktrees");
// Parse worktree list: each entry is `worktree <path>` followed by metadata. // Parse worktree list: each entry is `worktree <path>` followed by metadata.
const wtLines = list const wtLines = list
.split("\n") .split("\n")
@@ -297,14 +327,18 @@ export function cleanupStaleWorktrees(
// Skip the main working tree (always first in the list). // Skip the main working tree (always first in the list).
if (path.resolve(wtDir) === path.resolve(mainDir)) continue; if (path.resolve(wtDir) === path.resolve(mainDir)) continue;
// Check if this worktree is on a ralpi branch for this PRD. // Only touch worktrees that live under the ralpi worktrees directory.
const branch = git(`rev-parse --abbrev-ref HEAD`, wtDir); const resolved = path.resolve(wtDir);
if (!branch) continue; if (
if (!branch.startsWith(`ralpi/${prdKey}/`)) continue; resolved !== managedRoot &&
!resolved.startsWith(managedRoot + path.sep)
)
continue;
// Remove the worktree and its branch. // Remove the worktree and its branch.
git(`worktree remove --force "${wtDir}"`, mainDir); git(`worktree remove --force "${wtDir}"`, mainDir);
if (branch !== "HEAD" && branch !== "detached") { const branch = git(`rev-parse --abbrev-ref HEAD`, wtDir);
if (branch && branch !== "HEAD" && branch !== "detached") {
git(`branch -D "${branch}"`, mainDir); git(`branch -D "${branch}"`, mainDir);
} }
removed.push(wtDir); removed.push(wtDir);