diff --git a/src/lib/__tests__/project-config-loader.test.ts b/src/lib/__tests__/project-config-loader.test.ts new file mode 100644 index 00000000..9415f0b5 --- /dev/null +++ b/src/lib/__tests__/project-config-loader.test.ts @@ -0,0 +1,282 @@ +/** + * Tests for src/lib/project-config-loader.ts + */ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { mkdirSync, writeFileSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { + loadProjectConfig, + validateProjectConfig, + mergeVcsConfig, + ProjectConfigError, + type ProjectConfig, +} from "../project-config-loader.js"; + +// ── Helpers ────────────────────────────────────────────────────────────────── + +function mkTmpDir(): string { + const dir = join( + tmpdir(), + `foreman-pcc-test-${Date.now()}-${Math.random().toString(36).slice(2)}`, + ); + mkdirSync(dir, { recursive: true }); + return dir; +} + +function writeProjectConfig(projectRoot: string, content: string): void { + const dir = join(projectRoot, ".foreman"); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, "config.yaml"), content, "utf-8"); +} + +// ── validateProjectConfig ──────────────────────────────────────────────────── + +describe("validateProjectConfig", () => { + const projectPath = "/tmp/test-project"; + + it("accepts an empty object (no vcs key)", () => { + const config = validateProjectConfig({}, projectPath); + expect(config.vcs).toBeUndefined(); + }); + + it("parses vcs.backend = 'git'", () => { + const config = validateProjectConfig({ vcs: { backend: "git" } }, projectPath); + expect(config.vcs!.backend).toBe("git"); + }); + + it("parses vcs.backend = 'jujutsu'", () => { + const config = validateProjectConfig({ vcs: { backend: "jujutsu" } }, projectPath); + expect(config.vcs!.backend).toBe("jujutsu"); + }); + + it("parses vcs.backend = 'auto'", () => { + const config = validateProjectConfig({ vcs: { backend: "auto" } }, projectPath); + expect(config.vcs!.backend).toBe("auto"); + }); + + it("parses vcs.git.useTown = true", () => { + const config = validateProjectConfig( + { vcs: { backend: "git", git: { useTown: true } } }, + projectPath, + ); + expect(config.vcs!.git!.useTown).toBe(true); + }); + + it("parses vcs.git.useTown = false", () => { + const config = validateProjectConfig( + { vcs: { backend: "git", git: { useTown: false } } }, + projectPath, + ); + expect(config.vcs!.git!.useTown).toBe(false); + }); + + it("parses vcs.jujutsu.minVersion", () => { + const config = validateProjectConfig( + { vcs: { backend: "jujutsu", jujutsu: { minVersion: "0.21.0" } } }, + projectPath, + ); + expect(config.vcs!.jujutsu!.minVersion).toBe("0.21.0"); + }); + + it("throws ProjectConfigError on non-object input", () => { + expect(() => validateProjectConfig("string", projectPath)).toThrow(ProjectConfigError); + expect(() => validateProjectConfig(null, projectPath)).toThrow(ProjectConfigError); + expect(() => validateProjectConfig(42, projectPath)).toThrow(ProjectConfigError); + }); + + it("throws ProjectConfigError when vcs is not an object", () => { + expect(() => + validateProjectConfig({ vcs: "git" }, projectPath), + ).toThrow(ProjectConfigError); + }); + + it("throws ProjectConfigError when vcs.backend is invalid", () => { + expect(() => + validateProjectConfig({ vcs: { backend: "svn" } }, projectPath), + ).toThrow(ProjectConfigError); + expect(() => + validateProjectConfig({ vcs: { backend: "svn" } }, projectPath), + ).toThrow(/vcs.backend must be/); + }); + + it("throws ProjectConfigError when vcs.backend is missing", () => { + expect(() => + validateProjectConfig({ vcs: {} }, projectPath), + ).toThrow(ProjectConfigError); + }); + + it("throws ProjectConfigError when vcs.git is not an object", () => { + expect(() => + validateProjectConfig({ vcs: { backend: "git", git: "yes" } }, projectPath), + ).toThrow(ProjectConfigError); + }); + + it("throws ProjectConfigError when vcs.git.useTown is not a boolean", () => { + expect(() => + validateProjectConfig( + { vcs: { backend: "git", git: { useTown: "yes" } } }, + projectPath, + ), + ).toThrow(ProjectConfigError); + }); + + it("throws ProjectConfigError when vcs.jujutsu is not an object", () => { + expect(() => + validateProjectConfig({ vcs: { backend: "jujutsu", jujutsu: "v0.21" } }, projectPath), + ).toThrow(ProjectConfigError); + }); + + it("throws ProjectConfigError when vcs.jujutsu.minVersion is not a string", () => { + expect(() => + validateProjectConfig( + { vcs: { backend: "jujutsu", jujutsu: { minVersion: 21 } } }, + projectPath, + ), + ).toThrow(ProjectConfigError); + }); + + it("throws ProjectConfigError when vcs.jujutsu.minVersion is empty string", () => { + expect(() => + validateProjectConfig( + { vcs: { backend: "jujutsu", jujutsu: { minVersion: "" } } }, + projectPath, + ), + ).toThrow(ProjectConfigError); + }); +}); + +// ── loadProjectConfig ──────────────────────────────────────────────────────── + +describe("loadProjectConfig", () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = mkTmpDir(); + }); + + afterEach(() => { + rmSync(tmpDir, { recursive: true, force: true }); + }); + + it("returns empty object when .foreman/config.yaml does not exist", () => { + const config = loadProjectConfig(tmpDir); + expect(config).toEqual({}); + expect(config.vcs).toBeUndefined(); + }); + + it("returns empty object when config.yaml is empty", () => { + writeProjectConfig(tmpDir, ""); + const config = loadProjectConfig(tmpDir); + expect(config).toEqual({}); + }); + + it("returns empty object when config.yaml is null YAML", () => { + writeProjectConfig(tmpDir, "~"); + const config = loadProjectConfig(tmpDir); + expect(config).toEqual({}); + }); + + it("loads vcs.backend = 'git' from config.yaml", () => { + writeProjectConfig(tmpDir, "vcs:\n backend: git\n"); + const config = loadProjectConfig(tmpDir); + expect(config.vcs!.backend).toBe("git"); + }); + + it("loads vcs.backend = 'jujutsu' from config.yaml", () => { + writeProjectConfig(tmpDir, "vcs:\n backend: jujutsu\n"); + const config = loadProjectConfig(tmpDir); + expect(config.vcs!.backend).toBe("jujutsu"); + }); + + it("loads vcs.backend = 'auto' from config.yaml", () => { + writeProjectConfig(tmpDir, "vcs:\n backend: auto\n"); + const config = loadProjectConfig(tmpDir); + expect(config.vcs!.backend).toBe("auto"); + }); + + it("loads full vcs config with nested options", () => { + writeProjectConfig( + tmpDir, + `vcs: + backend: git + git: + useTown: true +`, + ); + const config = loadProjectConfig(tmpDir); + expect(config.vcs!.backend).toBe("git"); + expect(config.vcs!.git!.useTown).toBe(true); + }); + + it("loads jujutsu minVersion from config.yaml", () => { + writeProjectConfig( + tmpDir, + `vcs: + backend: jujutsu + jujutsu: + minVersion: "0.21.0" +`, + ); + const config = loadProjectConfig(tmpDir); + expect(config.vcs!.backend).toBe("jujutsu"); + expect(config.vcs!.jujutsu!.minVersion).toBe("0.21.0"); + }); + + it("throws ProjectConfigError when config.yaml has invalid YAML", () => { + writeProjectConfig(tmpDir, "vcs: {\n invalid yaml here\n"); + expect(() => loadProjectConfig(tmpDir)).toThrow(ProjectConfigError); + expect(() => loadProjectConfig(tmpDir)).toThrow(/failed to parse YAML/); + }); + + it("throws ProjectConfigError when vcs.backend is invalid", () => { + writeProjectConfig(tmpDir, "vcs:\n backend: svn\n"); + expect(() => loadProjectConfig(tmpDir)).toThrow(ProjectConfigError); + }); + + it("throws ProjectConfigError when config.yaml is a non-object YAML value", () => { + writeProjectConfig(tmpDir, "42"); + expect(() => loadProjectConfig(tmpDir)).toThrow(ProjectConfigError); + }); +}); + +// ── mergeVcsConfig ──────────────────────────────────────────────────────────── + +describe("mergeVcsConfig", () => { + it("returns workflowVcs when both are defined (workflow wins)", () => { + const workflowVcs = { backend: "git" as const }; + const projectVcs = { backend: "jujutsu" as const }; + const merged = mergeVcsConfig(workflowVcs, projectVcs); + expect(merged.backend).toBe("git"); + }); + + it("returns projectVcs when workflowVcs is undefined", () => { + const projectVcs = { backend: "jujutsu" as const }; + const merged = mergeVcsConfig(undefined, projectVcs); + expect(merged.backend).toBe("jujutsu"); + }); + + it("returns auto default when both are undefined", () => { + const merged = mergeVcsConfig(undefined, undefined); + expect(merged.backend).toBe("auto"); + }); + + it("returns workflowVcs when projectVcs is undefined", () => { + const workflowVcs = { backend: "git" as const }; + const merged = mergeVcsConfig(workflowVcs, undefined); + expect(merged.backend).toBe("git"); + }); + + it("preserves nested git config from workflowVcs", () => { + const workflowVcs = { backend: "git" as const, git: { useTown: true } }; + const projectVcs = { backend: "git" as const, git: { useTown: false } }; + const merged = mergeVcsConfig(workflowVcs, projectVcs); + expect(merged.git!.useTown).toBe(true); // workflow wins + }); + + it("preserves jujutsu.minVersion from projectVcs when workflow has no vcs", () => { + const projectVcs = { backend: "jujutsu" as const, jujutsu: { minVersion: "0.21.0" } }; + const merged = mergeVcsConfig(undefined, projectVcs); + expect(merged.jujutsu!.minVersion).toBe("0.21.0"); + }); +}); diff --git a/src/lib/__tests__/workflow-loader.test.ts b/src/lib/__tests__/workflow-loader.test.ts index 6cc29c35..3f2e54cf 100644 --- a/src/lib/__tests__/workflow-loader.test.ts +++ b/src/lib/__tests__/workflow-loader.test.ts @@ -605,3 +605,126 @@ describe("resolvePhaseModel", () => { expect(resolvePhaseModel(phase, "high", fallback)).toBe("anthropic/claude-sonnet-4-6"); }); }); + +// ── validateWorkflowConfig — vcs block ─────────────────────────────────────── + +describe("validateWorkflowConfig — vcs block", () => { + const minimalPhases = [{ name: "finalize", builtin: true }]; + + it("parses vcs.backend = 'git'", () => { + const raw = { name: "w", vcs: { backend: "git" }, phases: minimalPhases }; + const config = validateWorkflowConfig(raw, "w"); + expect(config.vcs).toBeDefined(); + expect(config.vcs!.backend).toBe("git"); + }); + + it("parses vcs.backend = 'jujutsu'", () => { + const raw = { name: "w", vcs: { backend: "jujutsu" }, phases: minimalPhases }; + const config = validateWorkflowConfig(raw, "w"); + expect(config.vcs!.backend).toBe("jujutsu"); + }); + + it("parses vcs.backend = 'auto'", () => { + const raw = { name: "w", vcs: { backend: "auto" }, phases: minimalPhases }; + const config = validateWorkflowConfig(raw, "w"); + expect(config.vcs!.backend).toBe("auto"); + }); + + it("vcs is optional — no vcs key means config.vcs is undefined", () => { + const raw = { name: "w", phases: minimalPhases }; + const config = validateWorkflowConfig(raw, "w"); + expect(config.vcs).toBeUndefined(); + }); + + it("parses vcs.git.useTown", () => { + const raw = { + name: "w", + vcs: { backend: "git", git: { useTown: true } }, + phases: minimalPhases, + }; + const config = validateWorkflowConfig(raw, "w"); + expect(config.vcs!.git).toBeDefined(); + expect(config.vcs!.git!.useTown).toBe(true); + }); + + it("parses vcs.jujutsu.minVersion", () => { + const raw = { + name: "w", + vcs: { backend: "jujutsu", jujutsu: { minVersion: "0.21.0" } }, + phases: minimalPhases, + }; + const config = validateWorkflowConfig(raw, "w"); + expect(config.vcs!.jujutsu).toBeDefined(); + expect(config.vcs!.jujutsu!.minVersion).toBe("0.21.0"); + }); + + it("throws WorkflowConfigError when vcs.backend is invalid", () => { + const raw = { name: "w", vcs: { backend: "svn" }, phases: minimalPhases }; + expect(() => validateWorkflowConfig(raw, "w")).toThrow(WorkflowConfigError); + expect(() => validateWorkflowConfig(raw, "w")).toThrow(/vcs.backend must be/); + }); + + it("throws WorkflowConfigError when vcs is not an object", () => { + const raw = { name: "w", vcs: "git", phases: minimalPhases }; + expect(() => validateWorkflowConfig(raw, "w")).toThrow(WorkflowConfigError); + }); + + it("throws WorkflowConfigError when vcs.git is not an object", () => { + const raw = { name: "w", vcs: { backend: "git", git: "yes" }, phases: minimalPhases }; + expect(() => validateWorkflowConfig(raw, "w")).toThrow(WorkflowConfigError); + }); + + it("throws WorkflowConfigError when vcs.git.useTown is not a boolean", () => { + const raw = { + name: "w", + vcs: { backend: "git", git: { useTown: "yes" } }, + phases: minimalPhases, + }; + expect(() => validateWorkflowConfig(raw, "w")).toThrow(WorkflowConfigError); + }); + + it("throws WorkflowConfigError when vcs.jujutsu is not an object", () => { + const raw = { name: "w", vcs: { backend: "jujutsu", jujutsu: "v0.21" }, phases: minimalPhases }; + expect(() => validateWorkflowConfig(raw, "w")).toThrow(WorkflowConfigError); + }); + + it("throws WorkflowConfigError when vcs.jujutsu.minVersion is not a string", () => { + const raw = { + name: "w", + vcs: { backend: "jujutsu", jujutsu: { minVersion: 21 } }, + phases: minimalPhases, + }; + expect(() => validateWorkflowConfig(raw, "w")).toThrow(WorkflowConfigError); + }); + + it("throws WorkflowConfigError when vcs.jujutsu.minVersion is empty string", () => { + const raw = { + name: "w", + vcs: { backend: "jujutsu", jujutsu: { minVersion: "" } }, + phases: minimalPhases, + }; + expect(() => validateWorkflowConfig(raw, "w")).toThrow(WorkflowConfigError); + }); + + it("vcs is present in YAML file and loaded correctly", () => { + const tmpDir = mkTmpDir(); + try { + writeWorkflowFile(tmpDir, "custom", ` +name: custom +vcs: + backend: git + git: + useTown: false +phases: + - name: finalize + builtin: true +`); + const config = loadWorkflowConfig("custom", tmpDir); + expect(config.vcs).toBeDefined(); + expect(config.vcs!.backend).toBe("git"); + expect(config.vcs!.git!.useTown).toBe(false); + } finally { + rmSync(tmpDir, { recursive: true, force: true }); + } + }); +}); diff --git a/src/lib/project-config-loader.ts b/src/lib/project-config-loader.ts new file mode 100644 index 00000000..afe12807 --- /dev/null +++ b/src/lib/project-config-loader.ts @@ -0,0 +1,195 @@ +/** + * Project-level configuration loader. + * + * Loads and validates the project-wide Foreman config from: + * /.foreman/config.yaml + * + * The project config provides defaults that apply across all workflows. + * The `vcs` key specifies which VCS backend to use: + * + * @example + * ```yaml + * # .foreman/config.yaml + * vcs: + * backend: git + * git: + * useTown: true + * ``` + * + * Configuration precedence (highest wins): + * 1. Workflow YAML `vcs` key (per-workflow override) + * 2. Project `.foreman/config.yaml` `vcs` key + * 3. Auto-detection (`.jj/` → jujutsu, `.git/` → git) + */ + +import { readFileSync, existsSync } from "node:fs"; +import { join } from "node:path"; +import { load as yamlLoad } from "js-yaml"; +import type { VcsConfig } from "./vcs/types.js"; + +// ── Types ───────────────────────────────────────────────────────────────────── + +/** + * Project-level Foreman configuration loaded from `.foreman/config.yaml`. + */ +export interface ProjectConfig { + /** + * VCS backend configuration. + * When set, applies as the project default for all workflows. + * Individual workflow YAML `vcs` keys override this setting. + * When absent, auto-detection is used. + */ + vcs?: VcsConfig; +} + +// ── Error ───────────────────────────────────────────────────────────────────── + +/** + * Error thrown when a project config file exists but is invalid. + */ +export class ProjectConfigError extends Error { + constructor( + public readonly projectPath: string, + public readonly reason: string, + ) { + super( + `Project config error at ${join(projectPath, ".foreman", "config.yaml")}: ${reason}. ` + + `Check the file syntax or remove it to use defaults.`, + ); + this.name = "ProjectConfigError"; + } +} + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +function isRecord(v: unknown): v is Record { + return typeof v === "object" && v !== null && !Array.isArray(v); +} + +// ── Validator ───────────────────────────────────────────────────────────────── + +/** + * Validate and coerce raw YAML parse output into a ProjectConfig. + * + * @throws ProjectConfigError if the YAML is structurally invalid. + */ +export function validateProjectConfig(raw: unknown, projectPath: string): ProjectConfig { + if (!isRecord(raw)) { + throw new ProjectConfigError(projectPath, "must be a YAML object"); + } + + const config: ProjectConfig = {}; + + // ── Parse optional vcs block ────────────────────────────────────────────── + if (raw["vcs"] !== undefined) { + const rawVcs = raw["vcs"]; + if (!isRecord(rawVcs)) { + throw new ProjectConfigError(projectPath, "'vcs' must be an object"); + } + + const backend = rawVcs["backend"]; + if (backend !== "git" && backend !== "jujutsu" && backend !== "auto") { + throw new ProjectConfigError( + projectPath, + `vcs.backend must be 'git', 'jujutsu', or 'auto'; got '${String(backend)}'`, + ); + } + + const vcs: VcsConfig = { backend }; + + // Parse optional git sub-config + if (rawVcs["git"] !== undefined) { + if (!isRecord(rawVcs["git"])) { + throw new ProjectConfigError(projectPath, "'vcs.git' must be an object"); + } + const rawGit = rawVcs["git"]; + vcs.git = {}; + if (rawGit["useTown"] !== undefined) { + if (typeof rawGit["useTown"] !== "boolean") { + throw new ProjectConfigError(projectPath, "'vcs.git.useTown' must be a boolean"); + } + vcs.git.useTown = rawGit["useTown"]; + } + } + + // Parse optional jujutsu sub-config + if (rawVcs["jujutsu"] !== undefined) { + if (!isRecord(rawVcs["jujutsu"])) { + throw new ProjectConfigError(projectPath, "'vcs.jujutsu' must be an object"); + } + const rawJj = rawVcs["jujutsu"]; + vcs.jujutsu = {}; + if (rawJj["minVersion"] !== undefined) { + if (typeof rawJj["minVersion"] !== "string" || !rawJj["minVersion"]) { + throw new ProjectConfigError( + projectPath, + "'vcs.jujutsu.minVersion' must be a non-empty string", + ); + } + vcs.jujutsu.minVersion = rawJj["minVersion"]; + } + } + + config.vcs = vcs; + } + + return config; +} + +// ── Loader ──────────────────────────────────────────────────────────────────── + +/** + * Load and validate the project-level config from `.foreman/config.yaml`. + * + * Returns an empty object `{}` if the file does not exist (non-fatal). + * Throws `ProjectConfigError` if the file exists but is invalid YAML or + * fails schema validation. + * + * @param projectPath - Absolute path to the project root. + * @throws ProjectConfigError if the file exists but is invalid. + */ +export function loadProjectConfig(projectPath: string): ProjectConfig { + const configPath = join(projectPath, ".foreman", "config.yaml"); + + if (!existsSync(configPath)) { + return {}; + } + + let raw: unknown; + try { + raw = yamlLoad(readFileSync(configPath, "utf-8")); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + throw new ProjectConfigError(projectPath, `failed to parse YAML: ${msg}`); + } + + // An empty file parses as null + if (raw === null || raw === undefined) { + return {}; + } + + return validateProjectConfig(raw, projectPath); +} + +// ── Merge ───────────────────────────────────────────────────────────────────── + +/** + * Merge workflow-level and project-level VCS config with proper precedence. + * + * Precedence (highest to lowest): + * 1. Workflow YAML `vcs` key + * 2. Project `.foreman/config.yaml` `vcs` key + * 3. Default: `{ backend: 'auto' }` + * + * @param workflowVcs - VCS config from the workflow YAML (highest priority). + * @param projectVcs - VCS config from the project config (second priority). + * @returns Resolved VcsConfig to use for this pipeline run. + */ +export function mergeVcsConfig( + workflowVcs: VcsConfig | undefined, + projectVcs: VcsConfig | undefined, +): VcsConfig { + if (workflowVcs !== undefined) return workflowVcs; + if (projectVcs !== undefined) return projectVcs; + return { backend: "auto" }; +} diff --git a/src/lib/vcs/__tests__/factory.test.ts b/src/lib/vcs/__tests__/factory.test.ts new file mode 100644 index 00000000..fc01e8cb --- /dev/null +++ b/src/lib/vcs/__tests__/factory.test.ts @@ -0,0 +1,199 @@ +/** + * TRD-003-TEST: VcsBackendFactory verification. + * + * Tests: + * 1. Explicit 'git' backend selection → returns GitBackend + * 2. Explicit 'jujutsu' backend selection → returns JujutsuBackend + * 3. Auto-detection with .git/ → returns GitBackend + * 4. Auto-detection with .jj/ → returns JujutsuBackend + * 5. Auto-detection with both .jj/ and .git/ → JujutsuBackend wins + * 6. Auto-detection with neither → throws descriptive error + * + * Tests use temporary directories (no actual git/jj CLI calls needed for factory logic). + */ + +import { describe, it, expect, afterEach } from 'vitest'; +import { + mkdtempSync, + mkdirSync, + rmSync, + realpathSync, +} from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { VcsBackendFactory, GitBackend, JujutsuBackend } from '../index.js'; +import type { VcsConfig } from '../types.js'; + +// ── Helpers ────────────────────────────────────────────────────────────────── + +const tempDirs: string[] = []; + +function makeTempDir(): string { + const dir = realpathSync(mkdtempSync(join(tmpdir(), 'foreman-factory-test-'))); + tempDirs.push(dir); + return dir; +} + +function makeDirWithGit(): string { + const dir = makeTempDir(); + mkdirSync(join(dir, '.git')); + return dir; +} + +function makeDirWithJj(): string { + const dir = makeTempDir(); + mkdirSync(join(dir, '.jj')); + return dir; +} + +function makeDirWithBoth(): string { + const dir = makeTempDir(); + mkdirSync(join(dir, '.git')); + mkdirSync(join(dir, '.jj')); + return dir; +} + +afterEach(() => { + for (const dir of tempDirs) { + rmSync(dir, { recursive: true, force: true }); + } + tempDirs.length = 0; +}); + +// ── Explicit backend selection ──────────────────────────────────────────────── + +describe('VcsBackendFactory.create — explicit backend selection', () => { + it('returns GitBackend when backend is "git"', async () => { + const dir = makeTempDir(); + const config: VcsConfig = { backend: 'git' }; + const backend = await VcsBackendFactory.create(config, dir); + expect(backend).toBeInstanceOf(GitBackend); + }); + + it('returns JujutsuBackend when backend is "jujutsu"', async () => { + const dir = makeTempDir(); + const config: VcsConfig = { backend: 'jujutsu' }; + const backend = await VcsBackendFactory.create(config, dir); + expect(backend).toBeInstanceOf(JujutsuBackend); + }); + + it('passes the projectPath to GitBackend constructor', async () => { + const dir = makeTempDir(); + const config: VcsConfig = { backend: 'git' }; + const backend = await VcsBackendFactory.create(config, dir) as GitBackend; + expect(backend.projectPath).toBe(dir); + }); + + it('passes the projectPath to JujutsuBackend constructor', async () => { + const dir = makeTempDir(); + const config: VcsConfig = { backend: 'jujutsu' }; + const backend = await VcsBackendFactory.create(config, dir) as JujutsuBackend; + expect(backend.projectPath).toBe(dir); + }); + + it('explicit git backend ignores absence of .git directory', async () => { + // Factory must NOT do filesystem detection for explicit backends + const dir = makeTempDir(); // no .git or .jj + const config: VcsConfig = { backend: 'git' }; + const backend = await VcsBackendFactory.create(config, dir); + expect(backend).toBeInstanceOf(GitBackend); + }); + + it('explicit jujutsu backend ignores absence of .jj directory', async () => { + const dir = makeTempDir(); // no .git or .jj + const config: VcsConfig = { backend: 'jujutsu' }; + const backend = await VcsBackendFactory.create(config, dir); + expect(backend).toBeInstanceOf(JujutsuBackend); + }); +}); + +// ── Auto-detection ──────────────────────────────────────────────────────────── + +describe('VcsBackendFactory.create — auto-detection', () => { + it('detects GitBackend when only .git/ exists', async () => { + const dir = makeDirWithGit(); + const config: VcsConfig = { backend: 'auto' }; + const backend = await VcsBackendFactory.create(config, dir); + expect(backend).toBeInstanceOf(GitBackend); + }); + + it('detects JujutsuBackend when only .jj/ exists', async () => { + const dir = makeDirWithJj(); + const config: VcsConfig = { backend: 'auto' }; + const backend = await VcsBackendFactory.create(config, dir); + expect(backend).toBeInstanceOf(JujutsuBackend); + }); + + it('.jj/ takes precedence over .git/ in colocated repos', async () => { + const dir = makeDirWithBoth(); // has both .git/ and .jj/ + const config: VcsConfig = { backend: 'auto' }; + const backend = await VcsBackendFactory.create(config, dir); + expect(backend).toBeInstanceOf(JujutsuBackend); + }); + + it('throws descriptive error when neither .git/ nor .jj/ exists', async () => { + const dir = makeTempDir(); // no .git or .jj + const config: VcsConfig = { backend: 'auto' }; + await expect(VcsBackendFactory.create(config, dir)).rejects.toThrow( + /No VCS detected/, + ); + }); + + it('error message includes the project path for debugging', async () => { + const dir = makeTempDir(); + const config: VcsConfig = { backend: 'auto' }; + await expect(VcsBackendFactory.create(config, dir)).rejects.toThrow(dir); + }); + + it('error message mentions both expected directory names', async () => { + const dir = makeTempDir(); + const config: VcsConfig = { backend: 'auto' }; + await expect(VcsBackendFactory.create(config, dir)).rejects.toThrow( + /\.git.*\.jj|\.jj.*\.git/, + ); + }); +}); + +// ── Config sub-options pass-through ────────────────────────────────────────── + +describe('VcsBackendFactory.create — VcsConfig sub-options', () => { + it('accepts git sub-config without error', async () => { + const dir = makeTempDir(); + const config: VcsConfig = { backend: 'git', git: { useTown: true } }; + const backend = await VcsBackendFactory.create(config, dir); + expect(backend).toBeInstanceOf(GitBackend); + }); + + it('accepts jujutsu sub-config without error', async () => { + const dir = makeTempDir(); + const config: VcsConfig = { backend: 'jujutsu', jujutsu: { minVersion: '0.25.0' } }; + const backend = await VcsBackendFactory.create(config, dir); + expect(backend).toBeInstanceOf(JujutsuBackend); + }); + + it('accepts auto with both sub-configs and detects by filesystem', async () => { + const dir = makeDirWithGit(); + const config: VcsConfig = { + backend: 'auto', + git: { useTown: false }, + jujutsu: { minVersion: '0.25.0' }, + }; + const backend = await VcsBackendFactory.create(config, dir); + expect(backend).toBeInstanceOf(GitBackend); + }); +}); + +// ── Return type ─────────────────────────────────────────────────────────────── + +describe('VcsBackendFactory.create — return type', () => { + it('returns a value assignable to VcsBackend (structural typing)', async () => { + const dir = makeTempDir(); + const config: VcsConfig = { backend: 'git' }; + const backend = await VcsBackendFactory.create(config, dir); + // Check that all interface methods exist on the returned object + expect(typeof backend.getRepoRoot).toBe('function'); + expect(typeof backend.createWorkspace).toBe('function'); + expect(typeof backend.merge).toBe('function'); + expect(typeof backend.getFinalizeCommands).toBe('function'); + }); +}); diff --git a/src/lib/vcs/__tests__/git-backend.test.ts b/src/lib/vcs/__tests__/git-backend.test.ts new file mode 100644 index 00000000..0902c376 --- /dev/null +++ b/src/lib/vcs/__tests__/git-backend.test.ts @@ -0,0 +1,230 @@ +/** + * Tests for GitBackend repository introspection methods. + * + * Mirrors the test coverage in src/lib/__tests__/git.test.ts for + * getRepoRoot, getMainRepoRoot, detectDefaultBranch, and getCurrentBranch. + */ + +import { describe, it, expect, afterEach } from "vitest"; +import { + mkdtempSync, + writeFileSync, + realpathSync, + rmSync, +} from "node:fs"; +import { execFileSync } from "node:child_process"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { GitBackend } from "../git-backend.js"; + +// ── Helpers ────────────────────────────────────────────────────────────────── + +function makeTempRepo(branch = "main"): string { + // realpathSync resolves macOS /var → /private/var symlink + const dir = realpathSync( + mkdtempSync(join(tmpdir(), "foreman-git-backend-test-")), + ); + execFileSync("git", ["init", `--initial-branch=${branch}`], { cwd: dir }); + execFileSync("git", ["config", "user.email", "test@test.com"], { cwd: dir }); + execFileSync("git", ["config", "user.name", "Test"], { cwd: dir }); + writeFileSync(join(dir, "README.md"), "# init\n"); + execFileSync("git", ["add", "."], { cwd: dir }); + execFileSync("git", ["commit", "-m", "initial commit"], { cwd: dir }); + return dir; +} + +const tempDirs: string[] = []; + +afterEach(() => { + for (const dir of tempDirs) { + rmSync(dir, { recursive: true, force: true }); + } + tempDirs.length = 0; +}); + +// ── getRepoRoot ─────────────────────────────────────────────────────────────── + +describe("GitBackend.getRepoRoot", () => { + it("returns repo root when called from the root itself", async () => { + const repo = makeTempRepo(); + tempDirs.push(repo); + const backend = new GitBackend(repo); + + const root = await backend.getRepoRoot(repo); + expect(root).toBe(repo); + }); + + it("finds root from a subdirectory", async () => { + const repo = makeTempRepo(); + tempDirs.push(repo); + const subdir = join(repo, "src", "nested"); + execFileSync("mkdir", ["-p", subdir]); + const backend = new GitBackend(repo); + + const root = await backend.getRepoRoot(subdir); + expect(root).toBe(repo); + }); + + it("throws when the path is not inside a git repository", async () => { + const dir = realpathSync( + mkdtempSync(join(tmpdir(), "foreman-no-git-")), + ); + tempDirs.push(dir); + const backend = new GitBackend(dir); + + await expect(backend.getRepoRoot(dir)).rejects.toThrow(/rev-parse failed/); + }); +}); + +// ── getMainRepoRoot ─────────────────────────────────────────────────────────── + +describe("GitBackend.getMainRepoRoot", () => { + it("returns the main repo root when called from the main repo", async () => { + const repo = makeTempRepo(); + tempDirs.push(repo); + const backend = new GitBackend(repo); + + const mainRoot = await backend.getMainRepoRoot(repo); + expect(mainRoot).toBe(repo); + }); + + it("returns the main repo root even when called from a linked worktree", async () => { + const repo = makeTempRepo(); + tempDirs.push(repo); + + // Create a linked worktree + const worktreePath = join(repo, "wt-test"); + execFileSync( + "git", + ["worktree", "add", "-b", "feature/wt", worktreePath], + { cwd: repo }, + ); + + const backend = new GitBackend(repo); + const mainRoot = await backend.getMainRepoRoot(worktreePath); + expect(mainRoot).toBe(repo); + }); +}); + +// ── getCurrentBranch ────────────────────────────────────────────────────────── + +describe("GitBackend.getCurrentBranch", () => { + it("returns the current branch name", async () => { + const repo = makeTempRepo("main"); + tempDirs.push(repo); + const backend = new GitBackend(repo); + + const branch = await backend.getCurrentBranch(repo); + expect(branch).toBe("main"); + }); + + it("returns the custom branch name after checkout", async () => { + const repo = makeTempRepo("main"); + tempDirs.push(repo); + execFileSync("git", ["checkout", "-b", "feature/test"], { cwd: repo }); + const backend = new GitBackend(repo); + + const branch = await backend.getCurrentBranch(repo); + expect(branch).toBe("feature/test"); + }); +}); + +// ── detectDefaultBranch ─────────────────────────────────────────────────────── + +describe("GitBackend.detectDefaultBranch", () => { + it("returns 'main' when the local branch is named 'main'", async () => { + const repo = makeTempRepo("main"); + tempDirs.push(repo); + const backend = new GitBackend(repo); + + const branch = await backend.detectDefaultBranch(repo); + expect(branch).toBe("main"); + }); + + it("returns 'master' when only 'master' exists (no 'main', no remote)", async () => { + const dir = realpathSync( + mkdtempSync(join(tmpdir(), "foreman-git-backend-master-")), + ); + tempDirs.push(dir); + execFileSync("git", ["init", "--initial-branch=master"], { cwd: dir }); + execFileSync("git", ["config", "user.email", "test@test.com"], { cwd: dir }); + execFileSync("git", ["config", "user.name", "Test"], { cwd: dir }); + writeFileSync(join(dir, "README.md"), "# init\n"); + execFileSync("git", ["add", "."], { cwd: dir }); + execFileSync("git", ["commit", "-m", "initial commit"], { cwd: dir }); + + const backend = new GitBackend(dir); + const branch = await backend.detectDefaultBranch(dir); + expect(branch).toBe("master"); + }); + + it("returns custom branch name when origin/HEAD points to it", async () => { + // Create a non-bare 'remote' repo with a commit on 'develop' branch + const remoteDir = realpathSync( + mkdtempSync(join(tmpdir(), "foreman-git-backend-remote-")), + ); + tempDirs.push(remoteDir); + execFileSync("git", ["init", "--initial-branch=develop"], { cwd: remoteDir }); + execFileSync("git", ["config", "user.email", "test@test.com"], { cwd: remoteDir }); + execFileSync("git", ["config", "user.name", "Test"], { cwd: remoteDir }); + writeFileSync(join(remoteDir, "README.md"), "# remote\n"); + execFileSync("git", ["add", "."], { cwd: remoteDir }); + execFileSync("git", ["commit", "-m", "initial commit"], { cwd: remoteDir }); + + // Clone so origin/HEAD is set + const cloneDir = realpathSync( + mkdtempSync(join(tmpdir(), "foreman-git-backend-clone-")), + ); + tempDirs.push(cloneDir); + execFileSync("git", ["clone", remoteDir, cloneDir]); + execFileSync("git", ["config", "user.email", "test@test.com"], { cwd: cloneDir }); + execFileSync("git", ["config", "user.name", "Test"], { cwd: cloneDir }); + + // Confirm symbolic-ref is set by the clone + const symRef = execFileSync( + "git", + ["symbolic-ref", "refs/remotes/origin/HEAD", "--short"], + { cwd: cloneDir }, + ) + .toString() + .trim(); + expect(symRef).toBe("origin/develop"); + + const backend = new GitBackend(cloneDir); + const branch = await backend.detectDefaultBranch(cloneDir); + expect(branch).toBe("develop"); + }); + + it("falls back to current branch when no main/master and no remote", async () => { + const dir = realpathSync( + mkdtempSync(join(tmpdir(), "foreman-git-backend-trunk-")), + ); + tempDirs.push(dir); + execFileSync("git", ["init", "--initial-branch=trunk"], { cwd: dir }); + execFileSync("git", ["config", "user.email", "test@test.com"], { cwd: dir }); + execFileSync("git", ["config", "user.name", "Test"], { cwd: dir }); + writeFileSync(join(dir, "README.md"), "# init\n"); + execFileSync("git", ["add", "."], { cwd: dir }); + execFileSync("git", ["commit", "-m", "initial commit"], { cwd: dir }); + + const backend = new GitBackend(dir); + const branch = await backend.detectDefaultBranch(dir); + expect(branch).toBe("trunk"); + }); + + it("respects git-town.main-branch config above all other detection", async () => { + const repo = makeTempRepo("main"); + tempDirs.push(repo); + + // Set git-town.main-branch to 'develop' + execFileSync( + "git", + ["config", "git-town.main-branch", "develop"], + { cwd: repo }, + ); + + const backend = new GitBackend(repo); + const branch = await backend.detectDefaultBranch(repo); + expect(branch).toBe("develop"); + }); +}); diff --git a/src/lib/vcs/__tests__/interface.test.ts b/src/lib/vcs/__tests__/interface.test.ts new file mode 100644 index 00000000..042a995b --- /dev/null +++ b/src/lib/vcs/__tests__/interface.test.ts @@ -0,0 +1,249 @@ +/** + * TRD-001-TEST: VcsBackend interface verification. + * + * Verifies that: + * 1. A class implementing VcsBackend must provide all required methods. + * 2. The interface correctly groups methods into the 6 functional categories. + * 3. Return types are correct (Promise for async ops, sync for getFinalizeCommands). + * 4. Both GitBackend and JujutsuBackend export from the correct module. + * + * These tests are structural/compilation checks; runtime behaviour is tested in + * git-backend.test.ts and factory.test.ts. + */ + +import { describe, it, expect } from 'vitest'; +import type { VcsBackend } from '../backend.js'; +import type { + Workspace, + WorkspaceResult, + MergeResult, + RebaseResult, + DeleteBranchResult, + FinalizeCommands, + FinalizeTemplateVars, +} from '../types.js'; +import { GitBackend } from '../git-backend.js'; +import { JujutsuBackend } from '../index.js'; + +// ── Compile-time: mock implementation ─────────────────────────────────────── + +/** + * A minimal mock that satisfies the VcsBackend interface. + * If any method is removed from this class, TypeScript will emit a compile error, + * proving that the interface enforces the full method surface. + */ +class MockVcsBackend implements VcsBackend { + // Repository Introspection + async getRepoRoot(_path: string): Promise { return ''; } + async getMainRepoRoot(_path: string): Promise { return ''; } + async detectDefaultBranch(_repoPath: string): Promise { return 'main'; } + async getCurrentBranch(_repoPath: string): Promise { return 'main'; } + + // Branch / Bookmark Operations + async checkoutBranch(_repoPath: string, _branchName: string): Promise { /* stub */ } + async branchExists(_repoPath: string, _branchName: string): Promise { return false; } + async branchExistsOnRemote(_repoPath: string, _branchName: string): Promise { return false; } + async deleteBranch(): Promise { return { deleted: false, wasFullyMerged: false }; } + + // Workspace Management + async createWorkspace(): Promise { return { workspacePath: '', branchName: '' }; } + async removeWorkspace(_repoPath: string, _workspacePath: string): Promise { /* stub */ } + async listWorkspaces(_repoPath: string): Promise { return []; } + + // Commit & Sync + async stageAll(_workspacePath: string): Promise { /* stub */ } + async commit(_workspacePath: string, _message: string): Promise { return ''; } + async getHeadId(_workspacePath: string): Promise { return ''; } + async push(): Promise { /* stub */ } + async pull(_workspacePath: string, _branchName: string): Promise { /* stub */ } + async fetch(_workspacePath: string): Promise { /* stub */ } + async rebase(_workspacePath: string, _onto: string): Promise { + return { success: true, hasConflicts: false }; + } + async abortRebase(_workspacePath: string): Promise { /* stub */ } + + // Merge Operations + async merge(): Promise { return { success: true }; } + + // Diff, Conflict & Status + async getConflictingFiles(_workspacePath: string): Promise { return []; } + async diff(_repoPath: string, _from: string, _to: string): Promise { return ''; } + async getModifiedFiles(_workspacePath: string, _base: string): Promise { return []; } + async cleanWorkingTree(_workspacePath: string): Promise { /* stub */ } + async status(_workspacePath: string): Promise { return ''; } + + // Finalize Command Generation (sync — no Promise) + getFinalizeCommands(_vars: FinalizeTemplateVars): FinalizeCommands { + return { + stageCommand: '', + commitCommand: '', + pushCommand: '', + rebaseCommand: '', + branchVerifyCommand: '', + cleanCommand: '', + }; + } +} + +// ── Tests ──────────────────────────────────────────────────────────────────── + +describe('VcsBackend interface', () => { + it('MockVcsBackend satisfies VcsBackend interface at compile time', () => { + const backend: VcsBackend = new MockVcsBackend(); + expect(backend).toBeDefined(); + }); + + it('has 4 repository introspection methods', () => { + const backend = new MockVcsBackend(); + expect(typeof backend.getRepoRoot).toBe('function'); + expect(typeof backend.getMainRepoRoot).toBe('function'); + expect(typeof backend.detectDefaultBranch).toBe('function'); + expect(typeof backend.getCurrentBranch).toBe('function'); + }); + + it('has 4 branch/bookmark operation methods', () => { + const backend = new MockVcsBackend(); + expect(typeof backend.checkoutBranch).toBe('function'); + expect(typeof backend.branchExists).toBe('function'); + expect(typeof backend.branchExistsOnRemote).toBe('function'); + expect(typeof backend.deleteBranch).toBe('function'); + }); + + it('has 3 workspace management methods', () => { + const backend = new MockVcsBackend(); + expect(typeof backend.createWorkspace).toBe('function'); + expect(typeof backend.removeWorkspace).toBe('function'); + expect(typeof backend.listWorkspaces).toBe('function'); + }); + + it('has 8 commit & sync methods', () => { + const backend = new MockVcsBackend(); + expect(typeof backend.stageAll).toBe('function'); + expect(typeof backend.commit).toBe('function'); + expect(typeof backend.getHeadId).toBe('function'); + expect(typeof backend.push).toBe('function'); + expect(typeof backend.pull).toBe('function'); + expect(typeof backend.fetch).toBe('function'); + expect(typeof backend.rebase).toBe('function'); + expect(typeof backend.abortRebase).toBe('function'); + }); + + it('has 1 merge operation method', () => { + const backend = new MockVcsBackend(); + expect(typeof backend.merge).toBe('function'); + }); + + it('has 5 diff/conflict/status methods', () => { + const backend = new MockVcsBackend(); + expect(typeof backend.getConflictingFiles).toBe('function'); + expect(typeof backend.diff).toBe('function'); + expect(typeof backend.getModifiedFiles).toBe('function'); + expect(typeof backend.cleanWorkingTree).toBe('function'); + expect(typeof backend.status).toBe('function'); + }); + + it('has 1 finalize command generation method', () => { + const backend = new MockVcsBackend(); + expect(typeof backend.getFinalizeCommands).toBe('function'); + }); + + it('async methods return Promises', async () => { + const backend = new MockVcsBackend(); + // Spot-check a few Promise-returning methods + await expect(backend.getRepoRoot('/')).resolves.toBeDefined(); + await expect(backend.branchExists('/', 'main')).resolves.toBe(false); + await expect(backend.listWorkspaces('/')).resolves.toEqual([]); + }); + + it('getFinalizeCommands is synchronous and returns all 6 command fields', () => { + const backend = new MockVcsBackend(); + const vars: FinalizeTemplateVars = { + seedId: 'bd-test', + seedTitle: 'Test task', + baseBranch: 'dev', + worktreePath: '/tmp/worktrees/bd-test', + }; + const cmds = backend.getFinalizeCommands(vars); + // Must not be a Promise + expect(cmds).not.toBeInstanceOf(Promise); + // Must have all 6 fields + expect(typeof cmds.stageCommand).toBe('string'); + expect(typeof cmds.commitCommand).toBe('string'); + expect(typeof cmds.pushCommand).toBe('string'); + expect(typeof cmds.rebaseCommand).toBe('string'); + expect(typeof cmds.branchVerifyCommand).toBe('string'); + expect(typeof cmds.cleanCommand).toBe('string'); + }); +}); + +// ── GitBackend implements VcsBackend ───────────────────────────────────────── + +describe('GitBackend satisfies VcsBackend interface', () => { + it('can be assigned to a VcsBackend variable', () => { + const backend: VcsBackend = new GitBackend('/tmp'); + expect(backend).toBeInstanceOf(GitBackend); + }); + + it('exposes all interface methods', () => { + const backend = new GitBackend('/tmp'); + // Introspection + expect(typeof backend.getRepoRoot).toBe('function'); + expect(typeof backend.getMainRepoRoot).toBe('function'); + expect(typeof backend.detectDefaultBranch).toBe('function'); + expect(typeof backend.getCurrentBranch).toBe('function'); + // Branches + expect(typeof backend.checkoutBranch).toBe('function'); + expect(typeof backend.branchExists).toBe('function'); + expect(typeof backend.branchExistsOnRemote).toBe('function'); + expect(typeof backend.deleteBranch).toBe('function'); + // Workspaces + expect(typeof backend.createWorkspace).toBe('function'); + expect(typeof backend.removeWorkspace).toBe('function'); + expect(typeof backend.listWorkspaces).toBe('function'); + // Commit & Sync + expect(typeof backend.stageAll).toBe('function'); + expect(typeof backend.commit).toBe('function'); + expect(typeof backend.getHeadId).toBe('function'); + expect(typeof backend.push).toBe('function'); + expect(typeof backend.pull).toBe('function'); + expect(typeof backend.fetch).toBe('function'); + expect(typeof backend.rebase).toBe('function'); + expect(typeof backend.abortRebase).toBe('function'); + // Merge + expect(typeof backend.merge).toBe('function'); + // Diff/Status + expect(typeof backend.getConflictingFiles).toBe('function'); + expect(typeof backend.diff).toBe('function'); + expect(typeof backend.getModifiedFiles).toBe('function'); + expect(typeof backend.cleanWorkingTree).toBe('function'); + expect(typeof backend.status).toBe('function'); + // Finalize + expect(typeof backend.getFinalizeCommands).toBe('function'); + }); + + it('Phase-B stub methods throw descriptive errors', async () => { + const backend = new GitBackend('/tmp'); + await expect(backend.checkoutBranch('/tmp', 'main')).rejects.toThrow(/Phase B/); + await expect(backend.stageAll('/tmp')).rejects.toThrow(/Phase B/); + await expect(backend.merge('/tmp', 'feature')).rejects.toThrow(/Phase B/); + expect(() => backend.getFinalizeCommands({ + seedId: 'bd-x', seedTitle: 'X', baseBranch: 'dev', worktreePath: '/tmp', + })).toThrow(/Phase B/); + }); +}); + +// ── JujutsuBackend satisfies VcsBackend ────────────────────────────────────── + +describe('JujutsuBackend satisfies VcsBackend interface', () => { + it('can be assigned to a VcsBackend variable', () => { + const backend: VcsBackend = new JujutsuBackend('/tmp'); + expect(backend).toBeInstanceOf(JujutsuBackend); + }); + + it('all methods throw "not yet implemented" for Phase A', async () => { + const backend = new JujutsuBackend('/tmp'); + await expect(backend.getRepoRoot('/tmp')).rejects.toThrow(/Phase B/); + await expect(backend.getCurrentBranch('/tmp')).rejects.toThrow(/Phase B/); + await expect(backend.merge('/tmp', 'feature')).rejects.toThrow(/Phase B/); + }); +}); diff --git a/src/lib/vcs/backend.ts b/src/lib/vcs/backend.ts new file mode 100644 index 00000000..8f018861 --- /dev/null +++ b/src/lib/vcs/backend.ts @@ -0,0 +1,289 @@ +/** + * VcsBackend interface — the core abstraction for all VCS operations. + * + * This file exists in its own module to avoid circular imports: both + * git-backend.ts and index.ts import from here, but neither imports the other. + * + * @module src/lib/vcs/backend + */ + +import type { + Workspace, + WorkspaceResult, + MergeResult, + RebaseResult, + DeleteBranchOptions, + DeleteBranchResult, + PushOptions, + FinalizeTemplateVars, + FinalizeCommands, +} from './types.js'; + +/** + * Backend-agnostic interface for all VCS operations in Foreman. + * + * Implementations: + * - GitBackend — wraps git CLI calls (mirrors src/lib/git.ts) + * - JujutsuBackend — wraps jj CLI calls + * + * All methods that interact with the file system or shell are async. + * Expected failures (conflicts, missing branches) are encoded in structured + * return types (MergeResult, RebaseResult). Unexpected errors throw exceptions. + */ +export interface VcsBackend { + // ── Repository Introspection ────────────────────────────────────────── + + /** + * Find the root of the VCS repository containing `path`. + * + * For git: runs `git rev-parse --show-toplevel`. + * For jj: runs `jj workspace root`. + * Returns the absolute path to the repository root. + */ + getRepoRoot(path: string): Promise; + + /** + * Find the main (primary) repository root, even when called from a linked + * worktree or a jj workspace. + * + * For git: resolves the common `.git` directory to strip the worktree suffix. + * For jj: same as getRepoRoot (jj workspaces share a single repo root). + */ + getMainRepoRoot(path: string): Promise; + + /** + * Detect the default development branch / bookmark for a repository. + * + * For git: checks (in order) git-town.main-branch config, origin/HEAD, + * local 'main', local 'master', and falls back to current branch. + * For jj: returns the main trunk bookmark (typically 'main' or 'master'). + */ + detectDefaultBranch(repoPath: string): Promise; + + /** + * Get the name of the currently checked-out branch or bookmark. + * + * For git: `git rev-parse --abbrev-ref HEAD`. + * For jj: `jj bookmark list --revisions @`. + */ + getCurrentBranch(repoPath: string): Promise; + + // ── Branch / Bookmark Operations ───────────────────────────────────── + + /** + * Checkout an existing branch or create it if it does not exist locally. + * + * For git: `git checkout ` or `git checkout -b `. + * For jj: `jj bookmark set ` or `jj new -m `. + */ + checkoutBranch(repoPath: string, branchName: string): Promise; + + /** + * Check whether a local branch / bookmark exists. + * + * Returns true if the branch exists locally in the repository. + */ + branchExists(repoPath: string, branchName: string): Promise; + + /** + * Check whether a branch / bookmark exists on the remote. + * + * For git: uses `git ls-remote --heads origin `. + * For jj: uses `jj bookmark list --all` and inspects remote tracking entries. + */ + branchExistsOnRemote(repoPath: string, branchName: string): Promise; + + /** + * Delete a local branch / bookmark. + * + * Options: + * - force: delete even if not fully merged (git -D / jj bookmark delete --allow-non-empty) + * - targetBranch: the branch to check merge status against; defaults to the default branch + * + * Returns whether deletion occurred and whether the branch was fully merged. + */ + deleteBranch( + repoPath: string, + branchName: string, + opts?: DeleteBranchOptions, + ): Promise; + + // ── Workspace Management ────────────────────────────────────────────── + + /** + * Create an isolated workspace (git worktree or jj workspace) for a task. + * + * The workspace is created on a new branch named `foreman/`, + * branching from `baseBranch` (or the default branch if omitted). + * + * If the worktree/workspace already exists it is rebased onto `baseBranch`. + * + * @param repoPath - absolute path to the main repository root + * @param seedId - unique identifier for the task (used as the branch suffix) + * @param baseBranch - the branch to branch from (defaults to default branch) + * @param setupSteps - optional list of setup commands to run after creation + * @param setupCache - optional cache descriptor for reproducible setup caching + */ + createWorkspace( + repoPath: string, + seedId: string, + baseBranch?: string, + setupSteps?: string[], + setupCache?: string, + ): Promise; + + /** + * Remove an existing workspace (git worktree prune or jj workspace forget). + * + * @param repoPath - absolute path to the main repository root + * @param workspacePath - absolute path to the workspace directory to remove + */ + removeWorkspace(repoPath: string, workspacePath: string): Promise; + + /** + * List all workspaces associated with the repository. + * + * For git: returns all linked worktrees (git worktree list --porcelain). + * For jj: returns all workspace entries (jj workspace list). + */ + listWorkspaces(repoPath: string): Promise; + + // ── Commit & Sync ───────────────────────────────────────────────────── + + /** + * Stage all changes in the workspace. + * + * For git: `git add -A`. + * For jj: no-op (jj tracks changes automatically). + */ + stageAll(workspacePath: string): Promise; + + /** + * Commit staged changes with the given message. + * + * Returns the new commit hash (git) or change ID (jj). + */ + commit(workspacePath: string, message: string): Promise; + + /** + * Get the current HEAD commit hash or jj change ID for the workspace. + */ + getHeadId(workspacePath: string): Promise; + + /** + * Push the current branch / bookmark to the remote. + * + * @param opts.force - force-push (overwrite remote history) + * @param opts.allowNew - jj-specific: pass --allow-new for new bookmarks + */ + push( + workspacePath: string, + branchName: string, + opts?: PushOptions, + ): Promise; + + /** + * Pull (fetch + merge) the latest changes for the given branch. + */ + pull(workspacePath: string, branchName: string): Promise; + + /** + * Fetch all refs from the remote without merging. + */ + fetch(workspacePath: string): Promise; + + /** + * Rebase the current workspace branch onto `onto`. + * + * For git: `git rebase origin/` (after fetching). + * For jj: `jj rebase -d `. + * + * Returns a structured result; does NOT throw on conflict — the caller + * must check `result.hasConflicts` and call abortRebase() if needed. + */ + rebase(workspacePath: string, onto: string): Promise; + + /** + * Abort an in-progress rebase and restore the workspace to its pre-rebase state. + * + * For git: `git rebase --abort`. + * For jj: abandons the conflicting commits. + */ + abortRebase(workspacePath: string): Promise; + + // ── Merge Operations ────────────────────────────────────────────────── + + /** + * Merge a branch into `targetBranch` (or the default branch if omitted). + * + * Implements the stash-checkout-merge-restore pattern to handle dirty + * working trees in git. For jj, uses `jj merge`. + * + * Returns a structured result with the list of conflicting files on failure. + * Does NOT throw on conflict — the caller must check `result.success`. + * + * @param repoPath - main repo root (not a worktree path) + * @param branchName - the source branch to merge in + * @param targetBranch - the branch to merge into (defaults to default branch) + */ + merge( + repoPath: string, + branchName: string, + targetBranch?: string, + ): Promise; + + // ── Diff, Conflict & Status ─────────────────────────────────────────── + + /** + * Return the list of files in conflict during an active rebase or merge. + * + * For git: parses `git diff --name-only --diff-filter=U`. + * For jj: parses `jj resolve --list`. + */ + getConflictingFiles(workspacePath: string): Promise; + + /** + * Return the diff output between two refs. + * + * @param from - base ref (commit SHA, branch name, jj change ID) + * @param to - target ref; defaults to the working copy if omitted + */ + diff(repoPath: string, from: string, to: string): Promise; + + /** + * Return the list of files modified relative to `base`. + * + * For git: `git diff --name-only `. + * For jj: `jj diff --summary -r `. + */ + getModifiedFiles(workspacePath: string, base: string): Promise; + + /** + * Discard all uncommitted changes and restore the workspace to HEAD. + * + * For git: `git checkout -- . && git clean -fd`. + * For jj: `jj restore`. + */ + cleanWorkingTree(workspacePath: string): Promise; + + /** + * Return a human-readable status summary of the workspace. + * + * For git: `git status --short`. + * For jj: `jj status`. + */ + status(workspacePath: string): Promise; + + // ── Finalize Command Generation ─────────────────────────────────────── + + /** + * Generate the backend-specific shell commands for the Finalize phase. + * + * The returned commands are injected into the finalize prompt template so + * the Finalize agent can execute them without knowing which VCS is in use. + * All 6 fields are required; use an empty string for no-op commands. + * + * @param vars - template variables (seedId, seedTitle, baseBranch, worktreePath) + */ + getFinalizeCommands(vars: FinalizeTemplateVars): FinalizeCommands; +} diff --git a/src/lib/vcs/git-backend.ts b/src/lib/vcs/git-backend.ts new file mode 100644 index 00000000..9e8b828e --- /dev/null +++ b/src/lib/vcs/git-backend.ts @@ -0,0 +1,301 @@ +/** + * GitBackend — Git-specific VCS backend implementation. + * + * Phase A: Implements the VcsBackend interface. The 4 repository-introspection + * methods are fully implemented; the remaining methods are Phase-B stubs that + * throw descriptive errors. Full implementation follows in Phase B. + * + * @module src/lib/vcs/git-backend + */ + +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; +import type { VcsBackend } from "./backend.js"; +import type { + Workspace, + WorkspaceResult, + MergeResult, + RebaseResult, + DeleteBranchOptions, + DeleteBranchResult, + PushOptions, + FinalizeTemplateVars, + FinalizeCommands, +} from "./types.js"; + +const execFileAsync = promisify(execFile); + +/** + * GitBackend encapsulates git-specific VCS operations for a given project path. + * + * Constructor receives the project root path; all methods operate relative to it + * unless given an explicit path argument (for worktree-aware operations). + */ +export class GitBackend implements VcsBackend { + readonly projectPath: string; + + constructor(projectPath: string) { + this.projectPath = projectPath; + } + + // ── Private helpers ────────────────────────────────────────────────── + + /** + * Execute a git command in the given working directory. + * Returns trimmed stdout on success; throws with a formatted error on failure. + */ + private async git(args: string[], cwd: string): Promise { + try { + const { stdout } = await execFileAsync("git", args, { + cwd, + maxBuffer: 10 * 1024 * 1024, + }); + return stdout.trim(); + } catch (err: unknown) { + const e = err as { stdout?: string; stderr?: string; message?: string }; + const combined = + [e.stdout, e.stderr] + .map((s) => (s ?? "").trim()) + .filter(Boolean) + .join("\n") || e.message || String(err); + throw new Error(`git ${args[0]} failed: ${combined}`); + } + } + + // ── Repository Introspection ───────────────────────────────────────── + + /** + * Find the root of the git repository containing `path`. + * + * Returns the worktree root for linked worktrees. + * Use `getMainRepoRoot()` to always get the primary project root. + */ + async getRepoRoot(path: string): Promise { + return this.git(["rev-parse", "--show-toplevel"], path); + } + + /** + * Find the main (primary) worktree root from any git worktree. + * + * `git rev-parse --show-toplevel` returns the *current* worktree root, + * which for a linked worktree is the worktree directory itself — not the + * main project root. This function resolves the common `.git` directory + * and strips the trailing `/.git` to always return the main project root. + */ + async getMainRepoRoot(path: string): Promise { + const commonDir = await this.git(["rev-parse", "--git-common-dir"], path); + // commonDir is e.g. "/path/to/project/.git" — strip the trailing "/.git" + if (commonDir.endsWith("/.git")) { + return commonDir.slice(0, -5); + } + // Fallback: if not a standard path, use show-toplevel + return this.git(["rev-parse", "--show-toplevel"], path); + } + + /** + * Detect the default/parent branch for a repository. + * + * Resolution order: + * 1. `git config get git-town.main-branch` — respect user's explicit development trunk config + * 2. `git symbolic-ref refs/remotes/origin/HEAD --short` → strips "origin/" prefix + * (e.g. "origin/main" → "main"). Works when the remote has been fetched. + * 3. Check whether "main" exists as a local branch. + * 4. Check whether "master" exists as a local branch. + * 5. Fall back to the current branch (`getCurrentBranch()`). + */ + async detectDefaultBranch(repoPath: string): Promise { + // 1. Respect git-town.main-branch config (user's explicit development trunk) + try { + const gtMain = await this.git( + ["config", "get", "git-town.main-branch"], + repoPath, + ); + if (gtMain) return gtMain; + } catch { + // git-town not configured or command unavailable — fall through + } + + // 2. Try origin/HEAD symbolic ref + try { + const ref = await this.git( + ["symbolic-ref", "refs/remotes/origin/HEAD", "--short"], + repoPath, + ); + // ref is e.g. "origin/main" — strip the "origin/" prefix + if (ref) { + return ref.replace(/^origin\//, ""); + } + } catch { + // origin/HEAD not set or no remote — fall through + } + + // 3. Check if "main" exists locally + try { + await this.git(["rev-parse", "--verify", "main"], repoPath); + return "main"; + } catch { + // "main" does not exist — fall through + } + + // 4. Check if "master" exists locally + try { + await this.git(["rev-parse", "--verify", "master"], repoPath); + return "master"; + } catch { + // "master" does not exist — fall through + } + + // 5. Fall back to the current branch + return this.getCurrentBranch(repoPath); + } + + /** + * Get the current branch name. + */ + async getCurrentBranch(repoPath: string): Promise { + return this.git(["rev-parse", "--abbrev-ref", "HEAD"], repoPath); + } + + // ── Branch / Bookmark Operations — Phase B stubs ──────────────────── + + async checkoutBranch(_repoPath: string, _branchName: string): Promise { + throw new Error("GitBackend.checkoutBranch: not yet implemented (Phase B)"); + } + + async branchExists(_repoPath: string, _branchName: string): Promise { + throw new Error("GitBackend.branchExists: not yet implemented (Phase B)"); + } + + async branchExistsOnRemote( + _repoPath: string, + _branchName: string, + ): Promise { + throw new Error( + "GitBackend.branchExistsOnRemote: not yet implemented (Phase B)", + ); + } + + async deleteBranch( + _repoPath: string, + _branchName: string, + _opts?: DeleteBranchOptions, + ): Promise { + throw new Error("GitBackend.deleteBranch: not yet implemented (Phase B)"); + } + + // ── Workspace Management — Phase B stubs ───────────────────────────── + + async createWorkspace( + _repoPath: string, + _seedId: string, + _baseBranch?: string, + _setupSteps?: string[], + _setupCache?: string, + ): Promise { + throw new Error( + "GitBackend.createWorkspace: not yet implemented (Phase B)", + ); + } + + async removeWorkspace( + _repoPath: string, + _workspacePath: string, + ): Promise { + throw new Error( + "GitBackend.removeWorkspace: not yet implemented (Phase B)", + ); + } + + async listWorkspaces(_repoPath: string): Promise { + throw new Error( + "GitBackend.listWorkspaces: not yet implemented (Phase B)", + ); + } + + // ── Commit & Sync — Phase B stubs ──────────────────────────────────── + + async stageAll(_workspacePath: string): Promise { + throw new Error("GitBackend.stageAll: not yet implemented (Phase B)"); + } + + async commit(_workspacePath: string, _message: string): Promise { + throw new Error("GitBackend.commit: not yet implemented (Phase B)"); + } + + async getHeadId(_workspacePath: string): Promise { + throw new Error("GitBackend.getHeadId: not yet implemented (Phase B)"); + } + + async push( + _workspacePath: string, + _branchName: string, + _opts?: PushOptions, + ): Promise { + throw new Error("GitBackend.push: not yet implemented (Phase B)"); + } + + async pull(_workspacePath: string, _branchName: string): Promise { + throw new Error("GitBackend.pull: not yet implemented (Phase B)"); + } + + async fetch(_workspacePath: string): Promise { + throw new Error("GitBackend.fetch: not yet implemented (Phase B)"); + } + + async rebase(_workspacePath: string, _onto: string): Promise { + throw new Error("GitBackend.rebase: not yet implemented (Phase B)"); + } + + async abortRebase(_workspacePath: string): Promise { + throw new Error("GitBackend.abortRebase: not yet implemented (Phase B)"); + } + + // ── Merge Operations — Phase B stubs ───────────────────────────────── + + async merge( + _repoPath: string, + _branchName: string, + _targetBranch?: string, + ): Promise { + throw new Error("GitBackend.merge: not yet implemented (Phase B)"); + } + + // ── Diff, Conflict & Status — Phase B stubs ────────────────────────── + + async getConflictingFiles(_workspacePath: string): Promise { + throw new Error( + "GitBackend.getConflictingFiles: not yet implemented (Phase B)", + ); + } + + async diff(_repoPath: string, _from: string, _to: string): Promise { + throw new Error("GitBackend.diff: not yet implemented (Phase B)"); + } + + async getModifiedFiles( + _workspacePath: string, + _base: string, + ): Promise { + throw new Error( + "GitBackend.getModifiedFiles: not yet implemented (Phase B)", + ); + } + + async cleanWorkingTree(_workspacePath: string): Promise { + throw new Error( + "GitBackend.cleanWorkingTree: not yet implemented (Phase B)", + ); + } + + async status(_workspacePath: string): Promise { + throw new Error("GitBackend.status: not yet implemented (Phase B)"); + } + + // ── Finalize Command Generation — Phase B stub ──────────────────────── + + getFinalizeCommands(_vars: FinalizeTemplateVars): FinalizeCommands { + throw new Error( + "GitBackend.getFinalizeCommands: not yet implemented (Phase B)", + ); + } +} diff --git a/src/lib/vcs/index.ts b/src/lib/vcs/index.ts new file mode 100644 index 00000000..8566301f --- /dev/null +++ b/src/lib/vcs/index.ts @@ -0,0 +1,216 @@ +/** + * VCS Backend abstraction for Foreman — main entry point. + * + * Exports: + * - VcsBackend — interface that every backend must implement + * - VcsBackendFactory — factory for creating the correct backend instance + * - GitBackend — git implementation (introspection fully implemented; rest Phase B) + * - JujutsuBackend — jj implementation (stub in Phase A, full in Phase B) + * - All shared types — re-exported from ./types.js + * + * @module src/lib/vcs/index + */ + +import { existsSync } from 'node:fs'; +import { join } from 'node:path'; +import type { VcsBackend } from './backend.js'; +import type { VcsConfig } from './types.js'; +import type { + Workspace, + WorkspaceResult, + MergeResult, + RebaseResult, + DeleteBranchOptions, + DeleteBranchResult, + PushOptions, + FinalizeTemplateVars, + FinalizeCommands, +} from './types.js'; + +// Re-export the VcsBackend interface +export type { VcsBackend } from './backend.js'; + +// Re-export all shared types so consumers can import from the single entry point. +export type { + Workspace, + WorkspaceResult, + MergeResult, + RebaseResult, + DeleteBranchOptions, + DeleteBranchResult, + PushOptions, + FinalizeTemplateVars, + FinalizeCommands, + VcsConfig, +} from './types.js'; + +// Re-export the concrete GitBackend. +export { GitBackend } from './git-backend.js'; + +// ── JujutsuBackend stub ────────────────────────────────────────────────────── + +/** + * Phase-A stub for JujutsuBackend. + * Full implementation is deferred to Phase B. + */ +export class JujutsuBackend implements VcsBackend { + readonly projectPath: string; + + constructor(projectPath: string) { + this.projectPath = projectPath; + } + + async getRepoRoot(_path: string): Promise { + throw new Error('JujutsuBackend.getRepoRoot: not yet implemented (Phase B)'); + } + async getMainRepoRoot(_path: string): Promise { + throw new Error('JujutsuBackend.getMainRepoRoot: not yet implemented (Phase B)'); + } + async detectDefaultBranch(_repoPath: string): Promise { + throw new Error('JujutsuBackend.detectDefaultBranch: not yet implemented (Phase B)'); + } + async getCurrentBranch(_repoPath: string): Promise { + throw new Error('JujutsuBackend.getCurrentBranch: not yet implemented (Phase B)'); + } + async checkoutBranch(_repoPath: string, _branchName: string): Promise { + throw new Error('JujutsuBackend.checkoutBranch: not yet implemented (Phase B)'); + } + async branchExists(_repoPath: string, _branchName: string): Promise { + throw new Error('JujutsuBackend.branchExists: not yet implemented (Phase B)'); + } + async branchExistsOnRemote(_repoPath: string, _branchName: string): Promise { + throw new Error('JujutsuBackend.branchExistsOnRemote: not yet implemented (Phase B)'); + } + async deleteBranch( + _repoPath: string, + _branchName: string, + _opts?: DeleteBranchOptions, + ): Promise { + throw new Error('JujutsuBackend.deleteBranch: not yet implemented (Phase B)'); + } + async createWorkspace( + _repoPath: string, + _seedId: string, + _baseBranch?: string, + _setupSteps?: string[], + _setupCache?: string, + ): Promise { + throw new Error('JujutsuBackend.createWorkspace: not yet implemented (Phase B)'); + } + async removeWorkspace(_repoPath: string, _workspacePath: string): Promise { + throw new Error('JujutsuBackend.removeWorkspace: not yet implemented (Phase B)'); + } + async listWorkspaces(_repoPath: string): Promise { + throw new Error('JujutsuBackend.listWorkspaces: not yet implemented (Phase B)'); + } + async stageAll(_workspacePath: string): Promise { + throw new Error('JujutsuBackend.stageAll: not yet implemented (Phase B)'); + } + async commit(_workspacePath: string, _message: string): Promise { + throw new Error('JujutsuBackend.commit: not yet implemented (Phase B)'); + } + async getHeadId(_workspacePath: string): Promise { + throw new Error('JujutsuBackend.getHeadId: not yet implemented (Phase B)'); + } + async push(_workspacePath: string, _branchName: string, _opts?: PushOptions): Promise { + throw new Error('JujutsuBackend.push: not yet implemented (Phase B)'); + } + async pull(_workspacePath: string, _branchName: string): Promise { + throw new Error('JujutsuBackend.pull: not yet implemented (Phase B)'); + } + async fetch(_workspacePath: string): Promise { + throw new Error('JujutsuBackend.fetch: not yet implemented (Phase B)'); + } + async rebase(_workspacePath: string, _onto: string): Promise { + throw new Error('JujutsuBackend.rebase: not yet implemented (Phase B)'); + } + async abortRebase(_workspacePath: string): Promise { + throw new Error('JujutsuBackend.abortRebase: not yet implemented (Phase B)'); + } + async merge( + _repoPath: string, + _branchName: string, + _targetBranch?: string, + ): Promise { + throw new Error('JujutsuBackend.merge: not yet implemented (Phase B)'); + } + async getConflictingFiles(_workspacePath: string): Promise { + throw new Error('JujutsuBackend.getConflictingFiles: not yet implemented (Phase B)'); + } + async diff(_repoPath: string, _from: string, _to: string): Promise { + throw new Error('JujutsuBackend.diff: not yet implemented (Phase B)'); + } + async getModifiedFiles(_workspacePath: string, _base: string): Promise { + throw new Error('JujutsuBackend.getModifiedFiles: not yet implemented (Phase B)'); + } + async cleanWorkingTree(_workspacePath: string): Promise { + throw new Error('JujutsuBackend.cleanWorkingTree: not yet implemented (Phase B)'); + } + async status(_workspacePath: string): Promise { + throw new Error('JujutsuBackend.status: not yet implemented (Phase B)'); + } + getFinalizeCommands(_vars: FinalizeTemplateVars): FinalizeCommands { + throw new Error('JujutsuBackend.getFinalizeCommands: not yet implemented (Phase B)'); + } +} + +// ── VcsBackendFactory ──────────────────────────────────────────────────────── + +/** + * Factory for creating the appropriate VcsBackend instance. + * + * Usage: + * ```ts + * const backend = await VcsBackendFactory.create(config, projectPath); + * ``` + * + * Auto-detection precedence (when backend === 'auto'): + * 1. `.jj/` directory found → JujutsuBackend (handles colocated git+jj repos) + * 2. `.git/` directory found → GitBackend + * 3. Neither found → throws descriptive error + */ +export class VcsBackendFactory { + /** + * Create a VcsBackend instance based on the provided configuration. + * + * @param config - VCS configuration (from .foreman/config.yaml) + * @param projectPath - absolute path to the project root for auto-detection + * @returns - the appropriate VcsBackend implementation + * @throws - when auto-detection fails or an unknown backend is specified + */ + static async create(config: VcsConfig, projectPath: string): Promise { + const { backend } = config; + + switch (backend) { + case 'git': { + const { GitBackend } = await import('./git-backend.js'); + return new GitBackend(projectPath); + } + + case 'jujutsu': + return new JujutsuBackend(projectPath); + + case 'auto': { + // .jj/ takes precedence — handles colocated git+jj repositories + if (existsSync(join(projectPath, '.jj'))) { + return new JujutsuBackend(projectPath); + } + if (existsSync(join(projectPath, '.git'))) { + const { GitBackend } = await import('./git-backend.js'); + return new GitBackend(projectPath); + } + throw new Error( + `No VCS detected in ${projectPath}. Expected .git/ or .jj/ directory.`, + ); + } + + default: { + // TypeScript exhaustiveness guard — should never reach here at runtime + const _exhaustive: never = backend; + throw new Error( + `Unknown VCS backend: "${String(_exhaustive)}". Valid values are: git, jujutsu, auto.`, + ); + } + } + } +} diff --git a/src/lib/workflow-loader.ts b/src/lib/workflow-loader.ts index 817e8e75..44be9fcb 100644 --- a/src/lib/workflow-loader.ts +++ b/src/lib/workflow-loader.ts @@ -45,6 +45,7 @@ import { import { join, dirname } from "node:path"; import { fileURLToPath } from "node:url"; import { load as yamlLoad } from "js-yaml"; +import type { VcsConfig } from "./vcs/types.js"; // ── Types ───────────────────────────────────────────────────────────────────── @@ -166,6 +167,18 @@ export interface WorkflowPhaseConfig { export interface WorkflowConfig { /** Workflow name (e.g. "default", "smoke"). */ name: string; + /** + * Optional VCS backend configuration. + * Overrides project-level .foreman/config.yaml vcs setting. + * When absent, the project config vcs setting is used; falls back to 'auto' detection. + * + * @example + * ```yaml + * vcs: + * backend: git + * ``` + */ + vcs?: VcsConfig; /** * Optional setup steps to run before pipeline phases begin. * When present, these replace the Node.js-specific installDependencies() fallback. @@ -267,6 +280,53 @@ export function validateWorkflowConfig(raw: unknown, workflowName: string): Work setupCache = { key: c["key"], path: c["path"] }; } + // ── Parse optional vcs block ────────────────────────────────────────────── + let vcs: VcsConfig | undefined; + if (raw["vcs"] !== undefined) { + const rawVcs = raw["vcs"]; + if (!isRecord(rawVcs)) { + throw new WorkflowConfigError(workflowName, "'vcs' must be an object"); + } + const backend = rawVcs["backend"]; + if (backend !== "git" && backend !== "jujutsu" && backend !== "auto") { + throw new WorkflowConfigError( + workflowName, + `vcs.backend must be 'git', 'jujutsu', or 'auto'; got '${String(backend)}'`, + ); + } + vcs = { backend }; + + // Parse optional git sub-config + if (rawVcs["git"] !== undefined) { + if (!isRecord(rawVcs["git"])) { + throw new WorkflowConfigError(workflowName, "'vcs.git' must be an object"); + } + const rawGit = rawVcs["git"]; + vcs.git = {}; + if (rawGit["useTown"] !== undefined) { + if (typeof rawGit["useTown"] !== "boolean") { + throw new WorkflowConfigError(workflowName, "'vcs.git.useTown' must be a boolean"); + } + vcs.git.useTown = rawGit["useTown"]; + } + } + + // Parse optional jujutsu sub-config + if (rawVcs["jujutsu"] !== undefined) { + if (!isRecord(rawVcs["jujutsu"])) { + throw new WorkflowConfigError(workflowName, "'vcs.jujutsu' must be an object"); + } + const rawJj = rawVcs["jujutsu"]; + vcs.jujutsu = {}; + if (rawJj["minVersion"] !== undefined) { + if (typeof rawJj["minVersion"] !== "string" || !rawJj["minVersion"]) { + throw new WorkflowConfigError(workflowName, "'vcs.jujutsu.minVersion' must be a non-empty string"); + } + vcs.jujutsu.minVersion = rawJj["minVersion"]; + } + } + } + if (!Array.isArray(raw["phases"])) { throw new WorkflowConfigError(workflowName, "missing required 'phases' array"); } @@ -345,6 +405,7 @@ export function validateWorkflowConfig(raw: unknown, workflowName: string): Work } const config: WorkflowConfig = { name, phases }; + if (vcs !== undefined) config.vcs = vcs; if (setup !== undefined) config.setup = setup; if (setupCache !== undefined) config.setupCache = setupCache; return config; diff --git a/src/orchestrator/dispatcher.ts b/src/orchestrator/dispatcher.ts index a013a0d0..85329323 100644 --- a/src/orchestrator/dispatcher.ts +++ b/src/orchestrator/dispatcher.ts @@ -19,6 +19,7 @@ import { PLAN_STEP_CONFIG } from "./roles.js"; import { isPiAvailable } from "./pi-rpc-spawn-strategy.js"; import { resolveWorkflowType } from "../lib/workflow-config-loader.js"; import { loadWorkflowConfig, resolveWorkflowName } from "../lib/workflow-loader.js"; +import { loadProjectConfig, mergeVcsConfig } from "../lib/project-config-loader.js"; import type { SeedInfo, DispatchResult, @@ -768,6 +769,25 @@ export class Dispatcher { const seedType = resolveWorkflowType(seed.type ?? "feature", seed.labels); + // Resolve VCS backend: merge workflow YAML vcs key > project config vcs key > auto + let vcsBackend: 'git' | 'jujutsu' | 'auto' = 'auto'; + try { + const resolvedWorkflow = resolveWorkflowName(seed.type ?? "feature", seed.labels); + let workflowVcs: import("../lib/vcs/types.js").VcsConfig | undefined; + try { + const wfConfig = loadWorkflowConfig(resolvedWorkflow, this.projectPath); + workflowVcs = wfConfig.vcs; + } catch { + // Non-fatal: workflow may not exist or may not have vcs key + } + const projectConfig = loadProjectConfig(this.projectPath); + const merged = mergeVcsConfig(workflowVcs, projectConfig.vcs); + vcsBackend = merged.backend; + } catch { + // Non-fatal: fall back to auto-detection + log(`[foreman] Could not resolve VCS backend for ${seed.id} — using 'auto'`); + } + await spawnWorkerProcess({ runId, projectId: this.resolveProjectId(), @@ -787,6 +807,7 @@ export class Dispatcher { seedType, seedLabels: seed.labels, seedPriority: seed.priority, + vcsBackend, }); return { sessionKey }; @@ -1107,6 +1128,12 @@ export interface WorkerConfig { * Forwarded to the pipeline executor to resolve per-priority models from YAML. */ seedPriority?: string; + /** + * Resolved VCS backend type ('git' | 'jujutsu' | 'auto'). + * Determined by merging workflow YAML vcs key > project config vcs key > auto-detection default. + * Passed to the worker process so it can reconstruct the correct backend without re-detecting. + */ + vcsBackend?: 'git' | 'jujutsu' | 'auto'; } // ── Spawn Strategy Pattern ────────────────────────────────────────────── diff --git a/src/orchestrator/doctor.ts b/src/orchestrator/doctor.ts index 901b023d..3a0654c6 100644 --- a/src/orchestrator/doctor.ts +++ b/src/orchestrator/doctor.ts @@ -203,17 +203,120 @@ export class Doctor { }; } + /** + * Check if the jj (Jujutsu) binary is available and meets the minimum version. + * Only warns — does not fail — if jj is absent, since it's not required for git-based projects. + * + * @param minVersion - Optional minimum version string (e.g. "0.21.0"). + * When provided, the installed version is compared and + * a warning is emitted if it does not meet the requirement. + */ + async checkJujutsuBinary(minVersion?: string): Promise { + try { + const { stdout } = await execFileAsync("jj", ["--version"]); + const installedVersion = stdout.trim(); + + if (minVersion) { + // Simple semver comparison: split on dots and compare numeric parts + const parseVersion = (v: string): number[] => + v + .replace(/^[^0-9]*/, "") // strip leading non-numeric chars (e.g. "jj 0.21.0" -> "0.21.0") + .split(".") + .slice(0, 3) + .map((n) => parseInt(n, 10) || 0); + + const installed = parseVersion(installedVersion); + const required = parseVersion(minVersion); + + for (let i = 0; i < 3; i++) { + const inst = installed[i] ?? 0; + const req = required[i] ?? 0; + if (inst > req) break; + if (inst < req) { + return { + name: "jj (Jujutsu) binary", + status: "warn", + message: `jj version ${installedVersion} is below required minimum ${minVersion}`, + details: `Upgrade jj to at least ${minVersion}: https://github.com/martinvonz/jj`, + }; + } + } + } + + return { + name: "jj (Jujutsu) binary", + status: "pass", + message: `jj is available: ${installedVersion}`, + }; + } catch { + return { + name: "jj (Jujutsu) binary", + status: "warn", + message: "jj not found in PATH — required for Jujutsu VCS backend", + details: "Install jj from: https://github.com/martinvonz/jj (cargo install --locked jujutsu)", + }; + } + } + + /** + * Check if the project is using the Jujutsu VCS backend and, if so, whether + * the repository is in colocated mode (both .jj/ and .git/ present). + * Colocated mode is required for Foreman's git-based worktree operations to work + * alongside jj. + * + * Returns 'skip' if not a jj project. + */ + async checkJujutsuColocated(): Promise { + const jjDir = join(this.projectPath, ".jj"); + const gitDir = join(this.projectPath, ".git"); + + if (!existsSync(jjDir)) { + return { + name: "jj colocated mode", + status: "skip", + message: "No .jj/ directory — not a Jujutsu project", + }; + } + + if (!existsSync(gitDir)) { + return { + name: "jj colocated mode", + status: "fail", + message: "Jujutsu project detected (.jj/ found) but .git/ is missing — Foreman requires colocated mode", + details: "Reinitialize with colocated mode: jj git init --colocate", + }; + } + + return { + name: "jj colocated mode", + status: "pass", + message: "Jujutsu project is in colocated mode (.jj/ and .git/ both present)", + }; + } + async checkSystem(): Promise { // TRD-024: sd backend removed. Always check br and bv binaries. - const [brResult, bvResult, gitResult, gitTownInstalled, gitTownMainBranch, oldLogsResult] = await Promise.all([ + const [brResult, bvResult, gitResult, gitTownInstalled, gitTownMainBranch, oldLogsResult, jjColocated] = await Promise.all([ this.checkBrBinary(), this.checkBvBinary(), this.checkGitBinary(), this.checkGitTownInstalled(), this.checkGitTownMainBranch(), this.checkOldLogs(), + this.checkJujutsuColocated(), ]); - return [brResult, bvResult, gitResult, gitTownInstalled, gitTownMainBranch, oldLogsResult]; + + const results = [brResult, bvResult, gitResult, gitTownInstalled, gitTownMainBranch, oldLogsResult]; + + // Only include jj binary check if the project appears to use Jujutsu + // (colocated check is non-skip), or always include jj colocated check result + if (jjColocated.status !== "skip") { + results.push(jjColocated); + const jjBinaryResult = await this.checkJujutsuBinary(); + results.push(jjBinaryResult); + } + + return results; } /**