Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
f12478c
Add Pi worktree switch selector
lunelson Jun 5, 2026
e516b91
first cards for fe-809
lunelson Jun 5, 2026
e520e41
adjusted seed workflow doc
lunelson Jun 5, 2026
f1a11bb
fix for workspace.json state ownership
lunelson Jun 5, 2026
25588a1
Spec-scope graph LSNs
lunelson Jun 5, 2026
4b67cf9
Enforce graph clock rows
lunelson Jun 5, 2026
f8b0a4f
follow up test fixes re graph clock rows
lunelson Jun 5, 2026
5dcae64
Refine Brunch TUI chrome
lunelson Jun 5, 2026
37431cd
FE-809: Add graph review-set acceptance
lunelson Jun 5, 2026
88343c3
Add Tailwind web styling proof
lunelson Jun 5, 2026
2147778
Add DB fixture export tooling
lunelson Jun 5, 2026
87daece
FE-809: Wire review-set structured exchange
lunelson Jun 5, 2026
02638e2
FE-809: Retire review-cycle core card
lunelson Jun 5, 2026
d9f2fcc
plan and scope collection
lunelson Jun 5, 2026
fd12209
FE-809: Lock structured exchange details schemas
lunelson Jun 5, 2026
af94372
refactor for schema lock
lunelson Jun 5, 2026
bde2257
FE-809: Move structured exchange tools to Zod projectors
lunelson Jun 5, 2026
74351d1
FE-809: Single-source structured exchange schema atoms
lunelson Jun 5, 2026
eb8b8c4
FE-809: Retire legacy structured exchange result path
lunelson Jun 5, 2026
2f9ecd8
FE-809: Harden structured exchange schema boundaries
lunelson Jun 5, 2026
348ecda
FE-809: Reconcile structured exchange schema lock
lunelson Jun 5, 2026
cf2c320
FE-809: Close structured exchange emission boundary
lunelson Jun 5, 2026
a573eec
3 final slice scopes for fe-809
lunelson Jun 5, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
146 changes: 142 additions & 4 deletions .pi/extensions/worktree/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,18 @@ export type CreateSiblingWorktreeResultDetails =
readonly stderr?: string;
};

export interface ListedWorktree {
readonly path: string;
readonly head?: string;
readonly branch?: string;
readonly detached: boolean;
}

export interface SwitchableWorktree {
readonly path: string;
readonly label: string;
}

interface GitProbeResult {
readonly ok: boolean;
readonly stdout: string;
Expand Down Expand Up @@ -163,6 +175,81 @@ export async function validateGitWorktree(targetPath: string): Promise<WorktreeV
return { ok: true, cwd: targetPath };
}

export function parseWorktreePorcelain(output: string): ListedWorktree[] {
const entries: ListedWorktree[] = [];
let currentPath: string | undefined;
let currentHead: string | undefined;
let currentBranch: string | undefined;
let currentDetached = false;

for (const line of output.split(/\r?\n/)) {
if (line.length === 0) continue;

if (line.startsWith('worktree ')) {
if (currentPath !== undefined) {
const entry = {
path: currentPath,
detached: currentDetached,
...(currentHead === undefined ? {} : { head: currentHead }),
...(currentBranch === undefined ? {} : { branch: currentBranch }),
};
entries.push(entry);
}
currentPath = line.slice('worktree '.length);
currentHead = undefined;
currentBranch = undefined;
currentDetached = false;
continue;
}

if (line.startsWith('HEAD ')) {
currentHead = line.slice('HEAD '.length);
continue;
}

if (line.startsWith('branch ')) {
const branch = line.slice('branch '.length);
currentBranch = branch.startsWith('refs/heads/') ? branch.slice('refs/heads/'.length) : branch;
continue;
}

if (line === 'detached') {
currentDetached = true;
}
}

if (currentPath !== undefined) {
const entry = {
path: currentPath,
detached: currentDetached,
...(currentHead === undefined ? {} : { head: currentHead }),
...(currentBranch === undefined ? {} : { branch: currentBranch }),
};
entries.push(entry);
}

return entries;
}

export function selectableSwitchWorktrees(
entries: readonly ListedWorktree[],
callerRoot: string,
): SwitchableWorktree[] {
return entries
.filter((entry) => entry.path !== callerRoot)
.map((entry) => {
let labelSuffix: string;
if (entry.detached) {
labelSuffix = `detached ${entry.head ?? 'unknown HEAD'}`;
} else if (entry.branch !== undefined) {
labelSuffix = `branch ${entry.branch}`;
} else {
labelSuffix = `HEAD ${entry.head ?? 'unknown'}`;
}
return { path: entry.path, label: `${entry.path} (${labelSuffix})` };
});
}

export async function planSiblingWorktree(options: SiblingWorktreePlanOptions): Promise<SiblingWorktreePlan> {
const words = options.greekWords ?? DEFAULT_GREEK_WORDS;
if (words.length === 0) throw new Error('No Greek suffix words configured.');
Expand Down Expand Up @@ -320,12 +407,13 @@ export async function cleanForkedSessionHeader(sessionFile: string): Promise<voi
export async function runSwitchWorktree(
targetPath: string,
ctx: ExtensionCommandContext,
options: SwitchWorktreeOptions = {},
switchOptions: SwitchWorktreeOptions = {},
): Promise<SwitchWorktreeResultDetails> {
const resolvedTarget = resolveSwitchTarget(targetPath, ctx.cwd);
if (resolvedTarget.length === 0) {
ctx.ui.notify('Usage: /worktree:switch <path>', 'error');
return { status: 'failed', targetPath: resolvedTarget, reason: 'missing target path' };
const selectedTarget = await selectSwitchWorktreeTarget(ctx);
if (selectedTarget.status !== 'selected') return selectedTarget;
return runSwitchWorktree(selectedTarget.targetPath, ctx, switchOptions);
}

const validation = await validateGitWorktree(resolvedTarget);
Expand Down Expand Up @@ -356,7 +444,7 @@ export async function runSwitchWorktree(
const relocatedSessionFile = await createRelocatedSession(
sourceSessionFile,
validation.cwd,
options.sessionDir,
switchOptions.sessionDir,
);
const continuation = continuationPrompt(validation.cwd);
const result = await ctx.switchSession(relocatedSessionFile, {
Expand Down Expand Up @@ -476,6 +564,56 @@ export default function registerWorktreeExtension(pi: ExtensionAPI): void {
});
}

interface SelectedSwitchTarget {
readonly status: 'selected';
readonly targetPath: string;
}

async function selectSwitchWorktreeTarget(
ctx: ExtensionCommandContext,
): Promise<SelectedSwitchTarget | SwitchWorktreeResultDetails> {
const rootProbe = await gitProbe(ctx.cwd, 'rev-parse', '--show-toplevel');
if (!rootProbe.ok) {
const reason = gitFailureReason('Could not list git worktrees.', rootProbe);
ctx.ui.notify(reason, 'error');
return { status: 'failed', targetPath: '', reason };
}

const listProbe = await gitProbe(ctx.cwd, 'worktree', 'list', '--porcelain');
if (!listProbe.ok) {
const reason = gitFailureReason('Could not list git worktrees.', listProbe);
ctx.ui.notify(reason, 'error');
return { status: 'failed', targetPath: '', reason };
}

const options = selectableSwitchWorktrees(
parseWorktreePorcelain(listProbe.stdout),
rootProbe.stdout.trim(),
);
if (options.length === 0) {
const reason = 'no other git worktrees available';
ctx.ui.notify('No other git worktrees are available for this repository.', 'info');
return { status: 'failed', targetPath: '', reason };
}

const selectedLabel = await ctx.ui.select(
'Switch Pi worktree',
options.map((option) => option.label),
);
if (selectedLabel === undefined) {
return { status: 'cancelled', targetPath: '', reason: 'worktree selection cancelled' };
}

const selected = options.find((option) => option.label === selectedLabel);
if (selected === undefined) {
const reason = 'selected worktree is no longer available';
ctx.ui.notify(reason, 'error');
return { status: 'failed', targetPath: '', reason };
}

return { status: 'selected', targetPath: selected.path };
}

function continuationPrompt(targetCwd: string): string {
return `Continue in the relocated Pi session from cwd: ${targetCwd}`;
}
Expand Down
Loading
Loading