singularity-forge/src/worktree-cli.ts

539 lines
17 KiB
TypeScript
Raw Normal View History

/**
* SF Worktree CLI standalone subcommand and -w flag handling.
*
* Manages the full worktree lifecycle from the command line:
* sf -w Create auto-named worktree, start interactive session
* sf -w my-feature Create/resume named worktree
* sf worktree list List worktrees with status
* sf worktree merge [name] Squash-merge a worktree into main
* sf worktree clean Remove all merged/empty worktrees
* sf worktree remove <n> Remove a specific worktree
*
* On session exit (via session_shutdown event), auto-commits dirty work
* so nothing is lost. The SF extension reads SF_CLI_WORKTREE to know
* when a session was launched via -w.
*
* Note: Extension modules are .ts files loaded via jiti (not compiled to .js).
* We use createJiti() here because this module is compiled by tsc but imports
* from resources/extensions/sf/ which are shipped as raw .ts (#1283).
*/
import { existsSync } from "node:fs";
import { createJiti } from "@mariozechner/jiti";
import chalk from "chalk";
import { resolveBundledSourceResource } from "./bundled-resource-path.js";
import { generateWorktreeName } from "./worktree-name-gen.js";
const jiti = createJiti(import.meta.filename, {
interopDefault: true,
debug: false,
});
const sfExtensionPath = (...segments: string[]) =>
resolveBundledSourceResource(
import.meta.url,
"extensions",
"sf",
...segments,
);
// Lazily-loaded extension modules (loaded once on first use via jiti)
let _ext: ExtensionModules | null = null;
interface ExtensionModules {
createWorktree: (
basePath: string,
name: string,
) => { path: string; branch: string };
listWorktrees: (
basePath: string,
) => Array<{ name: string; path: string; branch: string }>;
removeWorktree: (
basePath: string,
name: string,
opts?: { deleteBranch?: boolean },
) => void;
mergeWorktreeToMain: (
basePath: string,
name: string,
commitMessage: string,
) => void;
diffWorktreeAll: (
basePath: string,
name: string,
) => { added: any[]; modified: any[]; removed: any[] };
diffWorktreeNumstat: (
basePath: string,
name: string,
) => Array<{ added: number; removed: number }>;
worktreeBranchName: (name: string) => string;
worktreePath: (basePath: string, name: string) => string;
runWorktreePostCreateHook: (
basePath: string,
wtPath: string,
) => string | null;
nativeHasChanges: (path: string) => boolean;
nativeDetectMainBranch: (basePath: string) => string;
nativeCommitCountBetween: (
basePath: string,
from: string,
to: string,
) => number;
inferCommitType: (name: string) => string;
autoCommitCurrentBranch: (
wtPath: string,
reason: string,
name: string,
) => void;
}
async function loadExtensionModules(): Promise<ExtensionModules> {
if (_ext) return _ext;
const [wtMgr, autoWt, gitBridge, gitSvc, wt] = await Promise.all([
jiti.import(sfExtensionPath("worktree-manager.ts"), {}) as Promise<any>,
jiti.import(sfExtensionPath("auto-worktree.ts"), {}) as Promise<any>,
jiti.import(sfExtensionPath("native-git-bridge.ts"), {}) as Promise<any>,
jiti.import(sfExtensionPath("git-service.ts"), {}) as Promise<any>,
jiti.import(sfExtensionPath("worktree.ts"), {}) as Promise<any>,
]);
_ext = {
createWorktree: wtMgr.createWorktree,
listWorktrees: wtMgr.listWorktrees,
removeWorktree: wtMgr.removeWorktree,
mergeWorktreeToMain: wtMgr.mergeWorktreeToMain,
diffWorktreeAll: wtMgr.diffWorktreeAll,
diffWorktreeNumstat: wtMgr.diffWorktreeNumstat,
worktreeBranchName: wtMgr.worktreeBranchName,
worktreePath: wtMgr.worktreePath,
runWorktreePostCreateHook: autoWt.runWorktreePostCreateHook,
nativeHasChanges: gitBridge.nativeHasChanges,
nativeDetectMainBranch: gitBridge.nativeDetectMainBranch,
nativeCommitCountBetween: gitBridge.nativeCommitCountBetween,
inferCommitType: gitSvc.inferCommitType,
autoCommitCurrentBranch: wt.autoCommitCurrentBranch,
};
return _ext;
}
// ─── Types ──────────────────────────────────────────────────────────────────
interface WorktreeStatus {
name: string;
path: string;
branch: string;
exists: boolean;
filesChanged: number;
linesAdded: number;
linesRemoved: number;
uncommitted: boolean;
commits: number;
}
// ─── Status Helpers ─────────────────────────────────────────────────────────
function getWorktreeStatus(
ext: ExtensionModules,
basePath: string,
name: string,
wtPath: string,
): WorktreeStatus {
const diff = ext.diffWorktreeAll(basePath, name);
const numstat = ext.diffWorktreeNumstat(basePath, name);
const filesChanged =
diff.added.length + diff.modified.length + diff.removed.length;
let linesAdded = 0;
let linesRemoved = 0;
for (const s of numstat) {
linesAdded += s.added;
linesRemoved += s.removed;
}
let uncommitted = false;
try {
uncommitted = existsSync(wtPath) && ext.nativeHasChanges(wtPath);
} catch {
/* */
}
let commits = 0;
try {
const mainBranch = ext.nativeDetectMainBranch(basePath);
commits = ext.nativeCommitCountBetween(
basePath,
mainBranch,
ext.worktreeBranchName(name),
);
} catch {
/* */
}
return {
name,
path: wtPath,
branch: ext.worktreeBranchName(name),
exists: existsSync(wtPath),
filesChanged,
linesAdded,
linesRemoved,
uncommitted,
commits,
};
}
// ─── Formatters ─────────────────────────────────────────────────────────────
function formatStatus(s: WorktreeStatus): string {
const lines: string[] = [];
const badge = s.uncommitted
? chalk.yellow(" (uncommitted)")
: s.filesChanged > 0
? chalk.cyan(" (unmerged)")
: chalk.green(" (clean)");
lines.push(` ${chalk.bold.cyan(s.name)}${badge}`);
lines.push(` ${chalk.dim("branch")} ${chalk.magenta(s.branch)}`);
lines.push(` ${chalk.dim("path")} ${chalk.dim(s.path)}`);
if (s.filesChanged > 0) {
lines.push(
` ${chalk.dim("diff")} ${s.filesChanged} files, ${chalk.green(`+${s.linesAdded}`)} ${chalk.red(`-${s.linesRemoved}`)}, ${s.commits} commit${s.commits === 1 ? "" : "s"}`,
);
}
return lines.join("\n");
}
// ─── Subcommand: list ───────────────────────────────────────────────────────
async function handleList(basePath: string): Promise<void> {
const ext = await loadExtensionModules();
const worktrees = ext.listWorktrees(basePath);
if (worktrees.length === 0) {
process.stderr.write(
chalk.dim("No worktrees. Create one with: sf -w <name>\n"),
);
return;
}
process.stderr.write(chalk.bold("\nWorktrees\n\n"));
for (const wt of worktrees) {
const status = getWorktreeStatus(ext, basePath, wt.name, wt.path);
process.stderr.write(formatStatus(status) + "\n\n");
}
}
// ─── Subcommand: merge ──────────────────────────────────────────────────────
async function handleMerge(basePath: string, args: string[]): Promise<void> {
const ext = await loadExtensionModules();
const name = args[0];
if (!name) {
// If only one worktree exists, merge it
const worktrees = ext.listWorktrees(basePath);
if (worktrees.length === 1) {
await doMerge(ext, basePath, worktrees[0].name);
return;
}
process.stderr.write(chalk.red("Usage: sf worktree merge <name>\n"));
process.stderr.write(chalk.dim("Run sf worktree list to see worktrees.\n"));
process.exit(1);
}
await doMerge(ext, basePath, name);
}
async function doMerge(
ext: ExtensionModules,
basePath: string,
name: string,
): Promise<void> {
const worktrees = ext.listWorktrees(basePath);
const wt = worktrees.find((w) => w.name === name);
if (!wt) {
process.stderr.write(chalk.red(`Worktree "${name}" not found.\n`));
process.exit(1);
}
const status = getWorktreeStatus(ext, basePath, name, wt.path);
if (status.filesChanged === 0 && !status.uncommitted) {
process.stderr.write(
chalk.dim(`Worktree "${name}" has no changes to merge.\n`),
);
// Clean up empty worktree
ext.removeWorktree(basePath, name, { deleteBranch: true });
process.stderr.write(
chalk.green(`Removed empty worktree ${chalk.bold(name)}.\n`),
);
return;
}
// Auto-commit dirty work before merge
if (status.uncommitted) {
try {
ext.autoCommitCurrentBranch(wt.path, "worktree-merge", name);
process.stderr.write(
chalk.dim(" Auto-committed dirty work before merge.\n"),
);
} catch {
/* best-effort */
}
}
const commitType = ext.inferCommitType(name);
const commitMessage = `${commitType}: merge worktree ${name}\n\nSF-Worktree: ${name}`;
process.stderr.write(
`\nMerging ${chalk.bold.cyan(name)}${chalk.magenta(ext.nativeDetectMainBranch(basePath))}\n`,
);
process.stderr.write(
chalk.dim(
` ${status.filesChanged} files, ${chalk.green(`+${status.linesAdded}`)} ${chalk.red(`-${status.linesRemoved}`)}\n\n`,
),
);
try {
ext.mergeWorktreeToMain(basePath, name, commitMessage);
ext.removeWorktree(basePath, name, { deleteBranch: true });
process.stderr.write(
chalk.green(`✓ Merged and cleaned up ${chalk.bold(name)}\n`),
);
process.stderr.write(chalk.dim(` commit: ${commitMessage}\n`));
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
process.stderr.write(chalk.red(`✗ Merge failed: ${msg}\n`));
process.stderr.write(
chalk.dim(
" Resolve conflicts manually, then run sf worktree merge again.\n",
),
);
process.exit(1);
}
}
// ─── Subcommand: clean ──────────────────────────────────────────────────────
async function handleClean(basePath: string): Promise<void> {
const ext = await loadExtensionModules();
const worktrees = ext.listWorktrees(basePath);
if (worktrees.length === 0) {
process.stderr.write(chalk.dim("No worktrees to clean.\n"));
return;
}
let cleaned = 0;
for (const wt of worktrees) {
const status = getWorktreeStatus(ext, basePath, wt.name, wt.path);
if (status.filesChanged === 0 && !status.uncommitted) {
try {
ext.removeWorktree(basePath, wt.name, { deleteBranch: true });
process.stderr.write(
chalk.green(` ✓ Removed ${chalk.bold(wt.name)} (clean)\n`),
);
cleaned++;
} catch {
process.stderr.write(chalk.yellow(` ✗ Failed to remove ${wt.name}\n`));
}
} else {
process.stderr.write(
chalk.dim(
` ─ Kept ${chalk.bold(wt.name)} (${status.filesChanged} changed files)\n`,
),
);
}
}
process.stderr.write(
chalk.dim(`\nCleaned ${cleaned} worktree${cleaned === 1 ? "" : "s"}.\n`),
);
}
// ─── Subcommand: remove ─────────────────────────────────────────────────────
async function handleRemove(basePath: string, args: string[]): Promise<void> {
const ext = await loadExtensionModules();
const name = args[0];
if (!name) {
process.stderr.write(chalk.red("Usage: sf worktree remove <name>\n"));
process.exit(1);
}
const worktrees = ext.listWorktrees(basePath);
const wt = worktrees.find((w) => w.name === name);
if (!wt) {
process.stderr.write(chalk.red(`Worktree "${name}" not found.\n`));
process.exit(1);
}
const status = getWorktreeStatus(ext, basePath, name, wt.path);
if (status.filesChanged > 0 || status.uncommitted) {
process.stderr.write(
chalk.yellow(
`⚠ Worktree "${name}" has unmerged changes (${status.filesChanged} files).\n`,
),
);
process.stderr.write(
chalk.yellow(
" Use --force to remove anyway, or merge first: sf worktree merge " +
name +
"\n",
),
);
if (!process.argv.includes("--force")) {
process.exit(1);
}
}
ext.removeWorktree(basePath, name, { deleteBranch: true });
process.stderr.write(chalk.green(`✓ Removed worktree ${chalk.bold(name)}\n`));
}
// ─── Subcommand: status (default when no args) ─────────────────────────────
async function handleStatusBanner(basePath: string): Promise<void> {
const ext = await loadExtensionModules();
const worktrees = ext.listWorktrees(basePath);
if (worktrees.length === 0) return;
const withChanges = worktrees.filter((wt) => {
try {
const diff = ext.diffWorktreeAll(basePath, wt.name);
return diff.added.length + diff.modified.length + diff.removed.length > 0;
} catch {
return false;
}
});
if (withChanges.length === 0) return;
const names = withChanges.map((w) => chalk.cyan(w.name)).join(", ");
process.stderr.write(
chalk.dim("[forge] ") +
chalk.yellow(
`${withChanges.length} worktree${withChanges.length === 1 ? "" : "s"} with unmerged changes: `,
) +
names +
"\n" +
chalk.dim("[forge] ") +
chalk.dim(
"Resume: sf -w <name> | Merge: sf worktree merge <name> | List: sf worktree list\n\n",
),
);
}
// ─── -w flag: create/resume worktree for interactive session ────────────────
async function handleWorktreeFlag(
worktreeFlag: boolean | string,
): Promise<void> {
const ext = await loadExtensionModules();
const basePath = process.cwd();
// sf -w (no name) — resume most recent worktree with changes, or create new
if (worktreeFlag === true) {
const existing = ext.listWorktrees(basePath);
const withChanges = existing.filter((wt) => {
try {
const diff = ext.diffWorktreeAll(basePath, wt.name);
return (
diff.added.length + diff.modified.length + diff.removed.length > 0
);
} catch {
return false;
}
});
if (withChanges.length === 1) {
// Single active worktree — resume it
const wt = withChanges[0];
process.chdir(wt.path);
process.env.SF_CLI_WORKTREE = wt.name;
process.env.SF_CLI_WORKTREE_BASE = basePath;
process.stderr.write(
chalk.green(`✓ Resumed worktree ${chalk.bold(wt.name)}\n`),
);
process.stderr.write(chalk.dim(` path ${wt.path}\n`));
process.stderr.write(chalk.dim(` branch ${wt.branch}\n\n`));
return;
}
if (withChanges.length > 1) {
// Multiple active worktrees — show them and ask user to pick
process.stderr.write(
chalk.yellow(
`${withChanges.length} worktrees have unmerged changes:\n\n`,
),
);
for (const wt of withChanges) {
const status = getWorktreeStatus(ext, basePath, wt.name, wt.path);
process.stderr.write(formatStatus(status) + "\n\n");
}
process.stderr.write(chalk.dim("Specify which one: sf -w <name>\n"));
process.exit(0);
}
// No active worktrees — create a new one
const name = generateWorktreeName();
await createAndEnter(ext, basePath, name);
return;
}
// sf -w <name> — create or resume named worktree
const name = worktreeFlag as string;
const existing = ext.listWorktrees(basePath);
const found = existing.find((wt) => wt.name === name);
if (found) {
process.chdir(found.path);
process.env.SF_CLI_WORKTREE = name;
process.env.SF_CLI_WORKTREE_BASE = basePath;
process.stderr.write(
chalk.green(`✓ Resumed worktree ${chalk.bold(name)}\n`),
);
process.stderr.write(chalk.dim(` path ${found.path}\n`));
process.stderr.write(chalk.dim(` branch ${found.branch}\n\n`));
} else {
await createAndEnter(ext, basePath, name);
}
}
async function createAndEnter(
ext: ExtensionModules,
basePath: string,
name: string,
): Promise<void> {
try {
const info = ext.createWorktree(basePath, name);
const hookError = ext.runWorktreePostCreateHook(basePath, info.path);
if (hookError) {
process.stderr.write(chalk.yellow(`[forge] ${hookError}\n`));
}
process.chdir(info.path);
process.env.SF_CLI_WORKTREE = name;
process.env.SF_CLI_WORKTREE_BASE = basePath;
process.stderr.write(
chalk.green(`✓ Created worktree ${chalk.bold(name)}\n`),
);
process.stderr.write(chalk.dim(` path ${info.path}\n`));
process.stderr.write(chalk.dim(` branch ${info.branch}\n\n`));
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
process.stderr.write(
chalk.red(`[forge] Failed to create worktree: ${msg}\n`),
);
process.exit(1);
}
}
// ─── Exports ────────────────────────────────────────────────────────────────
export {
getWorktreeStatus,
handleClean,
handleList,
handleMerge,
handleRemove,
handleStatusBanner,
handleWorktreeFlag,
};