/** * execute.ts — Deterministic `gitflow init `. * * Builds the reference worktree architecture in one shot (no LLM in the loop): * 1. bare-clone into /.git * 2. import main → /01-Main (or main if !numbered) * 3. import develop → /02-Develop (or develop if !numbered) — created from main if absent * 4. create /{features,releases,hotfixes} + /.gitflow/cache * 5. write the canonical config to /.gitflow/config.json (shared by all worktrees) * * Imports from ../lib/. No validation here (done in validate.ts). */ import { join, dirname, basename } from 'path'; import { existsSync, mkdirSync, rmSync } from 'fs'; import type { InitSpec, InitResult } from './types.js'; import type { GitFlowConfig, NameVariants } from '../lib/types.js'; import { detectPlatform } from '../lib/platform.js'; import { detectProvider } from '../lib/provider.js'; import { cloneBare, addWorktreeCheckout, resolveMainBranch } from '../lib/worktree.js'; import { detectVersion } from '../lib/version.js'; import { createDefaultConfig, writeConfig } from '../lib/config.js'; import { normalizeForStorage } from '../lib/paths.js'; export async function execute(spec: InitSpec, workDir: string = process.cwd()): Promise { let cleanupRoot: string | null = null; try { const platformInfo = detectPlatform(); // ── A repository URL is mandatory ─────────────────────── if (!spec.url) { return { success: true, needsInput: true, detected: { platform: platformInfo.platform, provider: 'unknown' }, validation: { blockers: [], warnings: ['A repository URL is required: gitflow init '], }, }; } const url = spec.url; const provider = detectProvider(url); const name = spec.name || deriveName(url); const source = normalizeForStorage(spec.source || normalizeForStorage(workDir)); // `root` lets the project folder differ from the project name (e.g. clone // FribikeSharing INTO an existing client folder D:/.../TPF). Default: source/name. const root = spec.root ? normalizeForStorage(spec.root) : join(source, name).replace(/\\/g, '/'); const alreadyInitialized = existsSync(join(root, '.git')) || existsSync(join(root, '.bare')); // ── Proposal mode — derive name + check target, touch nothing on disk ── // Lets the skill confirm name + layout + source before the (network) clone. if (spec.propose || !spec.name) { return { success: true, needsInput: true, detected: { platform: platformInfo.platform, provider }, proposal: { name, source, root, numbered: spec.numbered, exists: alreadyInitialized, alternates: proposeAlternates(name, source), }, validation: { blockers: [], warnings: alreadyInitialized ? [`${root} already contains a git repo`] : [], }, }; } // Guard: never clobber an existing repo unless explicitly forced. if (alreadyInitialized && !spec.force) { return { success: false, needsInput: true, detected: { platform: platformInfo.platform, provider }, proposal: { name, source, root, numbered: spec.numbered, exists: true, alternates: proposeAlternates(name, source), }, error: `${root} already contains a git repo. Pass "force": true to reuse it, or choose another name/root.`, validation: { blockers: [`${root} already contains a git repo (.git/.bare present)`], warnings: [], }, }; } // ── 1. Bare clone into /.git ────────────────────── const bareDir = join(root, '.git').replace(/\\/g, '/'); const rootPreexisted = existsSync(root); if (alreadyInitialized && spec.force) { // Forced re-init: drop the previous (possibly partial) repo first. rmSync(join(root, '.git'), { recursive: true, force: true }); rmSync(join(root, '.bare'), { recursive: true, force: true }); } mkdirSync(root, { recursive: true }); if (!rootPreexisted) cleanupRoot = root; // roll back our fresh dir if a later step fails await cloneBare(url, bareDir); // ── 2/3. Import main + develop as worktrees ───────────── // mainBranch is the MAINLINE (main/master) — never the repo's HEAD default, // since repos that default to develop (e.g. FribikeSharing) must not check // develop out in BOTH worktrees. const mainBranch = spec.mainBranch || (await resolveMainBranch(bareDir)); const developBranch = spec.developBranch || 'develop'; // Folder naming is the user's only layout choice. Numbered (01-Main/02-Develop, // the "organized" mode) or plain (main/develop, the "simple" mode). The config // `mode` is DERIVED from it so the two can never disagree. const mode = spec.mode ?? (spec.numbered ? 'organized' : 'simple'); const mainDir = join(root, spec.numbered ? '01-Main' : 'main').replace(/\\/g, '/'); const developDir = join(root, spec.numbered ? '02-Develop' : 'develop').replace(/\\/g, '/'); await addWorktreeCheckout(bareDir, mainDir, mainBranch); // Create the develop worktree only when develop is a DISTINCT branch (it is // branched off main when the remote has none). Skip it for the degenerate // single-branch repo where develop would collide with main. const warnings: string[] = []; const developCreated = developBranch !== mainBranch; if (developCreated) { await addWorktreeCheckout(bareDir, developDir, developBranch, mainBranch); } else { warnings.push(`No '${developBranch}' branch distinct from the main branch '${mainBranch}' — develop worktree skipped.`); } // Version detection needs a checked-out worktree (the bare root has no working // files): prefer develop, fall back to main. The config itself lives at the root. const configHome = developCreated ? developDir : mainDir; // ── 4. Ephemeral worktree folders + gitflow cache ─────── const featuresDir = join(root, 'features').replace(/\\/g, '/'); const releasesDir = join(root, 'releases').replace(/\\/g, '/'); const hotfixesDir = join(root, 'hotfixes').replace(/\\/g, '/'); for (const d of [featuresDir, releasesDir, hotfixesDir]) mkdirSync(d, { recursive: true }); mkdirSync(join(root, '.gitflow', 'cache'), { recursive: true }); // ── 5. Canonical config at the shared repo root (.gitflow/) ── const versionInfo = await detectVersion(configHome); const config: GitFlowConfig = createDefaultConfig({ platform: { detected: platformInfo.platform, shell: platformInfo.shell, detectedAt: new Date().toISOString(), }, workspace: { path: source, name }, repository: { name, rootFolder: root, nameVariants: nameVariants(name), defaultBranch: mainBranch, remoteUrl: url, }, git: { provider, branches: { main: mainBranch, develop: developBranch }, prefixes: { feature: 'feature/', release: 'release/', hotfix: 'hotfix/' }, }, worktrees: { enabled: mode !== 'disabled', mode, structure: { main: mainDir, develop: developCreated ? developDir : mainDir, features: featuresDir, releases: releasesDir, hotfixes: hotfixesDir, }, }, versioning: { strategy: 'semver', current: versionInfo.current, tagPrefix: 'v', sources: ['csproj', 'package.json', 'VERSION'], }, }); const configPath = join(root, '.gitflow', 'config.json').replace(/\\/g, '/'); await writeConfig(config, configPath); return { success: true, detected: { platform: platformInfo.platform, provider, defaultBranch: mainBranch }, structure: { root, bare: bareDir, main: mainDir, develop: developCreated ? developDir : mainDir, features: featuresDir, releases: releasesDir, hotfixes: hotfixesDir, configPath, }, config, validation: { blockers: [], warnings }, }; } catch (err) { const message = (err as Error).message; if (cleanupRoot) { // Roll back a partially-created project so a retry starts clean. try { rmSync(cleanupRoot, { recursive: true, force: true }); } catch { /* best effort */ } } return { success: false, error: message, validation: { blockers: [message], warnings: [] }, }; } } // ── Name derivation ─────────────────────────────────────── /** Last path segment of a repo URL, minus a trailing `.git`. */ function deriveName(url: string): string { const clean = url.replace(/[/\\]+$/, ''); const seg = clean.split(/[/\\:]/).filter(Boolean).pop() || 'project'; return seg.replace(/\.git$/i, ''); } /** Suggest free alternate names when the first choice is taken. */ function proposeAlternates(name: string, source: string): string[] { const candidates = [`${name}-v5`, `${name}-new`, `${name}-2`]; return candidates.filter((c) => !existsSync(join(source, c))); } function tokenize(name: string): string[] { return name.split(/[.\-_\s]+/).filter(Boolean); } function pascalWord(w: string): string { return w.charAt(0).toUpperCase() + w.slice(1); } function nameVariants(name: string): NameVariants { const tokens = tokenize(name); return { pascalCase: tokens.map(pascalWord).join(''), pascalCaseDot: tokens.map(pascalWord).join('.'), kebabCase: tokens.map((t) => t.toLowerCase()).join('-'), snakeCase: tokens.map((t) => t.toLowerCase()).join('_'), // short all-lowercase tokens (cli, api, ui, sdk…) read better uppercased displayName: tokens .map((t) => (t === t.toLowerCase() && t.length <= 3 ? t.toUpperCase() : pascalWord(t))) .join(' '), }; } // basename is imported for parity with other CLIs / future use. void basename;