/** * GitFlow Worktree — Create, remove, repair, list, validate. * Self-contained: imports only from local lib + node built-ins. */ import { existsSync, readFileSync, writeFileSync, readdirSync, statSync, mkdirSync } from 'fs'; import { join, dirname, relative, resolve, basename } from 'path'; import type { WorktreeInfo, GitFlowConfig } from './types.js'; import { execGit, execGitOrFail, isInsideWorkTree, getAheadBehind, fetch as gitFetch, fastForwardOnly, remoteRefExists } from './git.js'; import { toForwardSlashes } from './paths.js'; export async function listWorktrees(cwd?: string): Promise { const result = await execGit(['worktree', 'list', '--porcelain'], cwd); if (result.exitCode !== 0) return []; const worktrees: WorktreeInfo[] = []; let current: Partial = {}; for (const line of result.stdout.split('\n')) { if (line.startsWith('worktree ')) { if (current.path) worktrees.push(current as WorktreeInfo); current = { path: line.slice(9), isBare: false, isPrunable: false, branch: '', head: '' }; } else if (line.startsWith('HEAD ')) { current.head = line.slice(5); } else if (line.startsWith('branch ')) { current.branch = line.slice(7).replace('refs/heads/', ''); } else if (line === 'bare') { current.isBare = true; } else if (line === 'prunable') { current.isPrunable = true; } } if (current.path) worktrees.push(current as WorktreeInfo); return worktrees; } export async function createWorktree( branch: string, targetPath: string, baseBranch: string, cwd?: string, ): Promise { mkdirSync(dirname(targetPath), { recursive: true }); await execGitOrFail( ['worktree', 'add', '-b', branch, targetPath, `origin/${baseBranch}`], cwd, ); } export async function removeWorktree(path: string, cwd?: string): Promise { if (existsSync(path)) { await execGit(['worktree', 'remove', path, '--force'], cwd); } await execGit(['worktree', 'prune'], cwd); } export function getWorktreePath(branch: string, config: GitFlowConfig, cwd?: string): string { const { structure } = config.worktrees; // When worktrees.structure is unset (uninitialized template config) the join // below would yield a cwd-RELATIVE path like "5.2.0", creating a parasite // worktree UNDER the current worktree (e.g. 02-Develop/5.2.0) instead of // /releases/5.2.0. Derive the container dirs from the bare repo root // (/.bare) so the path is ABSOLUTE. The walk-up MUST start from the // repo the CLI acts on (`cwd` = --workdir): process.cwd() is the skills // install dir on a --workdir invocation, where no bare root exists — that // exact miss produced 02-Develop/5.14.0. A configured structure value // always wins (back-compat). const bare = findBareDir(cwd); const root = bare ? dirname(bare) : ''; const dir = (configured: string, fallback: string): string => configured || (root ? join(root, fallback) : ''); const entry = branch.startsWith('feature/') ? { container: dir(structure.features, 'features'), name: branch.slice('feature/'.length) } : branch.startsWith('release/') ? { container: dir(structure.releases, 'releases'), name: branch.slice('release/'.length) } : branch.startsWith('hotfix/') ? { container: dir(structure.hotfixes, 'hotfixes'), name: branch.slice('hotfix/'.length) } : null; // No resolvable container (no config, no bare root found) → '' so the caller // falls back to a flat checkout — NEVER a cwd-relative parasite path. if (!entry || !entry.container) return ''; return join(entry.container, entry.name); } export function validateWorktreePath(worktreePath: string): { valid: boolean; error?: string } { const name = basename(worktreePath); if (/^\d{2}-/.test(name) && name !== '01-Main' && name !== '02-Develop') { return { valid: false, error: `Numbered directory "${name}" forbidden. Use config paths: features/{name}, releases/v{version}, hotfixes/{name}`, }; } return { valid: true }; } export function repairWorktreePaths(bareDir: string): { repaired: number; details: string[] } { const worktreesDir = join(bareDir, 'worktrees'); if (!existsSync(worktreesDir)) return { repaired: 0, details: [] }; let repaired = 0; const details: string[] = []; const entries = readdirSync(worktreesDir); for (const wtName of entries) { const wtDir = join(worktreesDir, wtName); try { if (!statSync(wtDir).isDirectory()) continue; const gitdirFile = join(wtDir, 'gitdir'); if (!existsSync(gitdirFile)) continue; const storedPath = readFileSync(gitdirFile, 'utf-8').trim(); let resolvedPath: string; if (/^[A-Za-z]:[/\\]/.test(storedPath) || storedPath.startsWith('/')) { resolvedPath = storedPath.replace(/\\/g, '/'); } else { resolvedPath = resolve(wtDir, storedPath); } const wtRealDir = dirname(resolvedPath); if (!existsSync(wtRealDir)) continue; const relGitdir = toForwardSlashes(relative(resolve(wtDir), join(resolve(wtRealDir), '.git'))); if (storedPath !== relGitdir) { writeFileSync(gitdirFile, relGitdir + '\n'); details.push(`${wtName}/gitdir → ${relGitdir}`); repaired++; } const wtGitFile = join(wtRealDir, '.git'); if (existsSync(wtGitFile) && statSync(wtGitFile).isFile()) { const gitContent = readFileSync(wtGitFile, 'utf-8').trim(); const gitPath = gitContent.replace('gitdir: ', ''); const relBare = toForwardSlashes(relative(resolve(wtRealDir), resolve(wtDir))); if (gitPath !== relBare) { writeFileSync(wtGitFile, `gitdir: ${relBare}\n`); details.push(`${wtName}/.git → ${relBare}`); repaired++; } } } catch (err: unknown) { const code = (err as NodeJS.ErrnoException).code; if (code === 'EPERM' || code === 'EBUSY') { details.push(`${wtName} → skipped (${code}: file locked)`); } else { throw err; } } } return { repaired, details }; } export function findBareDir(startDir?: string): string | null { let dir = startDir || process.cwd(); const root = resolve('/'); while (dir !== root) { // `.bare` (legacy layout) OR a bare `.git` DIRECTORY (current init layout: // `git clone --bare … /.git`). A bare repo holds a `worktrees/` registry // — that distinguishes it from a normal repo's `.git`, and from a linked // worktree's `.git` which is a FILE (skipped by isDirectory()). const dotBare = join(dir, '.bare'); if (existsSync(dotBare) && statSync(dotBare).isDirectory()) return dotBare; const dotGit = join(dir, '.git'); if (existsSync(dotGit) && statSync(dotGit).isDirectory() && existsSync(join(dotGit, 'worktrees'))) return dotGit; const parent = dirname(dir); if (parent === dir) break; dir = parent; } return null; } export async function preflightGitCheck(cwd?: string): Promise<{ ok: boolean; repaired: number }> { const quickTest = await execGit(['rev-parse', '--git-dir'], cwd); if (quickTest.exitCode === 0) return { ok: true, repaired: 0 }; const bareDir = findBareDir(cwd); if (!bareDir) return { ok: false, repaired: 0 }; const { repaired } = repairWorktreePaths(bareDir); const verifyTest = await execGit(['rev-parse', '--git-dir'], cwd); return { ok: verifyTest.exitCode === 0, repaired }; } /** * Make a worktree usable for work-tree operations (status/merge/checkout/ff). * Repairs the inherited-bare misconfig: a LINKED worktree git treats as bare * (core.bare=true via extensions.worktreeConfig with no per-worktree * config.worktree) → set core.bare=false in ITS config.worktree. Local config * only — touches no refs, history or remote; safe and reversible. */ export async function ensureWorkTreeUsable(cwd: string): Promise<{ ok: boolean; repaired: boolean; error?: string }> { if (await isInsideWorkTree(cwd)) return { ok: true, repaired: false }; // Only repair a LINKED worktree (its `.git` is a FILE pointing at the bare // repo). A bare root (`.git`/`.bare` directory) is not meant to be a work tree. const dotGit = join(cwd, '.git'); const isLinkedWorktree = existsSync(dotGit) && statSync(dotGit).isFile(); if (!isLinkedWorktree) { return { ok: false, repaired: false, error: `'${cwd}' is not a git work tree.` }; } const r = await execGit(['config', '--worktree', 'core.bare', 'false'], cwd); if (r.exitCode === 0 && (await isInsideWorkTree(cwd))) { return { ok: true, repaired: true }; } return { ok: false, repaired: false, error: `'${cwd}' is a linked worktree git treats as bare and auto-repair failed ` + `(${r.stderr || 'config --worktree core.bare false'}). Check extensions.worktreeConfig / config.worktree.`, }; } // ─── Align local develop with origin before a feature PR/merge ────────────── export type AlignAction = | { action: 'none' } // aligned, ahead-only, or no remote ref yet | { action: 'ff' } // behind only → fast-forward to origin | { action: 'diverged' }; // ahead AND behind → refuse (needs a human) /** Pure decision for the develop↔origin alignment. */ export function decideAlignment(o: { ahead: number; behind: number; remoteExists: boolean }): AlignAction { if (!o.remoteExists) return { action: 'none' }; if (o.ahead > 0 && o.behind > 0) return { action: 'diverged' }; if (o.behind > 0) return { action: 'ff' }; return { action: 'none' }; } export interface DevelopAlignment { ok: boolean; worktree: string; ahead: number; behind: number; fastForwarded: boolean; diverged: boolean; error?: string; } /** * Ensure LOCAL develop == origin/develop BEFORE integrating a feature (the rule: * never PR/merge a feature onto a stale local develop). Fetches origin, then — * behind only → fast-forward; diverged → refuse; aligned/ahead/no-remote → * nothing to do. Trustworthy because getAheadBehind THROWS on git failure * instead of reporting a fake 0/0. Call again AFTER the merge to re-sync local. */ export async function alignDevelopWithRemote(developWorktree: string, developBranch: string): Promise { const base = { worktree: developWorktree, ahead: 0, behind: 0, fastForwarded: false, diverged: false }; const usable = await ensureWorkTreeUsable(developWorktree); if (!usable.ok) return { ...base, ok: false, error: usable.error }; const fetched = await gitFetch('origin', developWorktree); if (fetched.exitCode !== 0) return { ...base, ok: false, error: `fetch origin failed: ${fetched.stderr || 'unknown error'}` }; const remote = `origin/${developBranch}`; if (!(await remoteRefExists(remote, developWorktree))) return { ...base, ok: true }; try { const { ahead, behind } = await getAheadBehind(developBranch, remote, developWorktree); const decision = decideAlignment({ ahead, behind, remoteExists: true }); if (decision.action === 'diverged') { return { ...base, ahead, behind, diverged: true, ok: false, error: `Local '${developBranch}' has diverged from ${remote} (${ahead} ahead / ${behind} behind). Resolve manually before PR/merge.` }; } if (decision.action === 'ff') { const ff = await fastForwardOnly(remote, developWorktree); if (ff.exitCode !== 0) { return { ...base, ahead, behind, ok: false, error: `fast-forward of '${developBranch}' to ${remote} failed: ${ff.stderr || 'unknown error'}` }; } return { ...base, ahead, behind, fastForwarded: true, ok: true }; } return { ...base, ahead, behind, ok: true }; } catch (err) { return { ...base, ok: false, error: (err as Error).message }; } } export async function cloneBare(url: string, bareDir: string): Promise { mkdirSync(dirname(bareDir), { recursive: true }); await execGitOrFail(['clone', '--bare', url, bareDir]); await execGitOrFail(['config', 'remote.origin.fetch', '+refs/heads/*:refs/remotes/origin/*'], bareDir); await execGit(['fetch', 'origin'], bareDir); } /** * Add a worktree for an EXISTING branch (init-time import of main/develop). * Unlike createWorktree (which always creates a NEW branch via `-b`), this * checks out the branch that already exists in the freshly-cloned bare repo: * 1. local head `refs/heads/` (bare clone copies all heads) → checkout * 2. remote-tracking `refs/remotes/origin/` → create + track * 3. neither, but a fallbackBase is given (e.g. develop from main) → branch off it * Throws if the branch cannot be resolved and no fallback is provided. */ export async function addWorktreeCheckout( bareDir: string, targetPath: string, branch: string, fallbackBase?: string, ): Promise { mkdirSync(dirname(targetPath), { recursive: true }); const localRef = await execGit(['show-ref', '--verify', `refs/heads/${branch}`], bareDir); if (localRef.exitCode === 0) { await execGitOrFail(['worktree', 'add', targetPath, branch], bareDir); return; } const remoteRef = await execGit(['show-ref', '--verify', `refs/remotes/origin/${branch}`], bareDir); if (remoteRef.exitCode === 0) { await execGitOrFail(['worktree', 'add', '-b', branch, targetPath, `origin/${branch}`], bareDir); return; } if (fallbackBase) { await execGitOrFail(['worktree', 'add', '-b', branch, targetPath, fallbackBase], bareDir); return; } throw new Error(`Branch '${branch}' not found in repository and no fallback base provided.`); } /** Default branch of a freshly-cloned bare repo (its HEAD symref), or null. */ export async function detectDefaultBranch(bareDir: string): Promise { const res = await execGit(['symbolic-ref', '--short', 'HEAD'], bareDir); return res.exitCode === 0 && res.stdout ? res.stdout : null; } /** * Pick the MAINLINE branch for the 01-Main worktree: prefer `main`, then * `master`, and only fall back to the repo's HEAD default if neither exists. * Critically NOT the HEAD default by itself — many repos default to `develop`, * which must never be checked out in both the main AND develop worktrees. */ export async function resolveMainBranch(bareDir: string): Promise { for (const candidate of ['main', 'master']) { const ref = await execGit(['show-ref', '--verify', `refs/heads/${candidate}`], bareDir); if (ref.exitCode === 0) return candidate; } return (await detectDefaultBranch(bareDir)) || 'main'; }