diff --git a/src/lib/git.ts b/src/lib/git.ts index 61dc700b..7a466fb1 100644 --- a/src/lib/git.ts +++ b/src/lib/git.ts @@ -196,6 +196,10 @@ export async function runSetupWithCache( // ── Interfaces ────────────────────────────────────────────────────────── +/** + * @deprecated Use `Workspace` from `src/lib/vcs/types.ts` instead. + * Kept for backward compatibility; structurally identical to Workspace. + */ export interface Worktree { path: string; branch: string; @@ -203,11 +207,19 @@ export interface Worktree { bare: boolean; } +/** + * @deprecated Use `MergeResult` from `src/lib/vcs/types.ts` instead. + * Kept for backward compatibility; structurally identical to VCS MergeResult. + */ export interface MergeResult { success: boolean; conflicts?: string[]; } +/** + * @deprecated Use `DeleteBranchResult` from `src/lib/vcs/types.ts` instead. + * Kept for backward compatibility; structurally identical to VCS DeleteBranchResult. + */ export interface DeleteBranchResult { deleted: boolean; wasFullyMerged: boolean; @@ -235,9 +247,17 @@ async function git( } // ── Public API ────────────────────────────────────────────────────────── +// +// NOTE (TRD-011): These functions are backward-compatibility shims. +// They retain their original implementations to avoid a circular import +// (git-backend.ts imports utilities from this module). A full refactor to +// delegate to a GitBackend singleton would require moving the utility +// functions (installDependencies, runSetupWithCache, etc.) to a separate +// `src/lib/setup.ts` module — deferred to Phase C. /** * Find the root of the git repository containing `path`. + * @deprecated Use `GitBackend.getRepoRoot()` from `src/lib/vcs/git-backend.ts` instead. */ export async function getRepoRoot(path: string): Promise { return git(["rev-parse", "--show-toplevel"], path); @@ -250,6 +270,7 @@ export async function getRepoRoot(path: string): Promise { * 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. + * @deprecated Use `GitBackend.getMainRepoRoot()` from `src/lib/vcs/git-backend.ts` instead. */ export async function getMainRepoRoot(path: string): Promise { const commonDir = await git(["rev-parse", "--git-common-dir"], path); @@ -270,6 +291,7 @@ export async function getMainRepoRoot(path: string): Promise { * 2. Check whether "main" exists as a local branch. * 3. Check whether "master" exists as a local branch. * 4. Fall back to the current branch. + * @deprecated Use `GitBackend.detectDefaultBranch()` from `src/lib/vcs/git-backend.ts` instead. */ export async function detectDefaultBranch(repoPath: string): Promise { // 1. Respect git-town.main-branch config (user's explicit development trunk) @@ -319,6 +341,7 @@ export async function detectDefaultBranch(repoPath: string): Promise { /** * Get the current branch name. + * @deprecated Use `GitBackend.getCurrentBranch()` from `src/lib/vcs/git-backend.ts` instead. */ export async function getCurrentBranch(repoPath: string): Promise { return git(["rev-parse", "--abbrev-ref", "HEAD"], repoPath); @@ -327,6 +350,7 @@ export async function getCurrentBranch(repoPath: string): Promise { /** * Checkout a branch by name. * Throws if the branch does not exist or the checkout fails. + * @deprecated Use `GitBackend.checkoutBranch()` from `src/lib/vcs/git-backend.ts` instead. */ export async function checkoutBranch(repoPath: string, branchName: string): Promise { await git(["checkout", branchName], repoPath); @@ -338,6 +362,7 @@ export async function checkoutBranch(repoPath: string, branchName: string): Prom * - Branch: foreman/ * - Location: /.foreman-worktrees/ * - Base: current branch (auto-detected if not specified) + * @deprecated Use `GitBackend.createWorkspace()` from `src/lib/vcs/git-backend.ts` instead. */ export async function createWorktree( repoPath: string, @@ -426,6 +451,7 @@ export async function createWorktree( * After removing the worktree, runs `git worktree prune` to delete any stale * `.git/worktrees/` metadata left behind. The prune step is non-fatal — * if it fails, a warning is logged but the function still resolves successfully. + * @deprecated Use `GitBackend.removeWorkspace()` from `src/lib/vcs/git-backend.ts` instead. */ export async function removeWorktree( repoPath: string, @@ -464,6 +490,7 @@ export async function removeWorktree( /** * List all worktrees for the repo. + * @deprecated Use `GitBackend.listWorkspaces()` from `src/lib/vcs/git-backend.ts` instead. */ export async function listWorktrees( repoPath: string, @@ -508,6 +535,7 @@ export async function listWorktrees( * - If NOT merged and `force: true`, uses `git branch -D` (force delete). * - If NOT merged and `force: false` (default), skips deletion and returns `{ deleted: false, wasFullyMerged: false }`. * - If the branch does not exist, returns `{ deleted: false, wasFullyMerged: true }` (already gone). + * @deprecated Use `GitBackend.deleteBranch()` from `src/lib/vcs/git-backend.ts` instead. */ export async function deleteBranch( repoPath: string, @@ -557,6 +585,7 @@ export async function deleteBranch( * * Uses `git show-ref --verify --quiet refs/heads/`. * Returns `false` if the branch does not exist or any error occurs. + * @deprecated Use `GitBackend.branchExists()` from `src/lib/vcs/git-backend.ts` instead. */ export async function gitBranchExists( repoPath: string, @@ -576,6 +605,7 @@ export async function gitBranchExists( * Uses `git rev-parse origin/` against local remote-tracking refs. * Returns `false` if there is no remote, the branch doesn't exist on origin, * or any other error occurs (fail-safe: unknown → don't delete). + * @deprecated Use `GitBackend.branchExistsOnRemote()` from `src/lib/vcs/git-backend.ts` instead. */ export async function branchExistsOnOrigin( repoPath: string, @@ -592,6 +622,7 @@ export async function branchExistsOnOrigin( /** * Merge a branch into the target branch. * Returns success status and any conflicting file paths. + * @deprecated Use `GitBackend.merge()` from `src/lib/vcs/git-backend.ts` instead. */ export async function mergeWorktree( repoPath: string, 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..f18dbf03 --- /dev/null +++ b/src/lib/vcs/__tests__/git-backend.test.ts @@ -0,0 +1,814 @@ +/** + * 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"); + }); +}); + +// ── Phase B Tests ───────────────────────────────────────────────────────────── + +// ── checkoutBranch ──────────────────────────────────────────────────────────── + +describe("GitBackend.checkoutBranch", () => { + it("checks out an existing branch", async () => { + const repo = makeTempRepo(); + tempDirs.push(repo); + execFileSync("git", ["checkout", "-b", "feature/test"], { cwd: repo }); + execFileSync("git", ["checkout", "main"], { cwd: repo }); + const backend = new GitBackend(repo); + + await backend.checkoutBranch(repo, "feature/test"); + const branch = await backend.getCurrentBranch(repo); + expect(branch).toBe("feature/test"); + }); + + it("throws when the branch does not exist", async () => { + const repo = makeTempRepo(); + tempDirs.push(repo); + const backend = new GitBackend(repo); + + await expect(backend.checkoutBranch(repo, "nonexistent")).rejects.toThrow(); + }); +}); + +// ── branchExists ───────────────────────────────────────────────────────────── + +describe("GitBackend.branchExists", () => { + it("returns true for an existing branch", async () => { + const repo = makeTempRepo(); + tempDirs.push(repo); + const backend = new GitBackend(repo); + + const exists = await backend.branchExists(repo, "main"); + expect(exists).toBe(true); + }); + + it("returns false for a non-existent branch", async () => { + const repo = makeTempRepo(); + tempDirs.push(repo); + const backend = new GitBackend(repo); + + const exists = await backend.branchExists(repo, "nonexistent-branch"); + expect(exists).toBe(false); + }); + + it("returns true for a newly created branch", async () => { + const repo = makeTempRepo(); + tempDirs.push(repo); + execFileSync("git", ["checkout", "-b", "feature/new"], { cwd: repo }); + const backend = new GitBackend(repo); + + const exists = await backend.branchExists(repo, "feature/new"); + expect(exists).toBe(true); + }); +}); + +// ── branchExistsOnRemote ────────────────────────────────────────────────────── + +describe("GitBackend.branchExistsOnRemote", () => { + it("returns false when there is no remote", async () => { + const repo = makeTempRepo(); + tempDirs.push(repo); + const backend = new GitBackend(repo); + + const exists = await backend.branchExistsOnRemote(repo, "main"); + expect(exists).toBe(false); + }); + + it("returns true when the branch exists on the remote", async () => { + // Create a 'remote' repo + const remoteDir = realpathSync( + mkdtempSync(join(tmpdir(), "foreman-git-backend-remote-branchexists-")), + ); + tempDirs.push(remoteDir); + execFileSync("git", ["init", "--initial-branch=main"], { 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"], { cwd: remoteDir }); + + const cloneDir = realpathSync( + mkdtempSync(join(tmpdir(), "foreman-git-backend-clone-branchexists-")), + ); + 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 }); + + const backend = new GitBackend(cloneDir); + const exists = await backend.branchExistsOnRemote(cloneDir, "main"); + expect(exists).toBe(true); + }); + + it("returns false when the branch does not exist on the remote", async () => { + const remoteDir = realpathSync( + mkdtempSync(join(tmpdir(), "foreman-git-backend-remote-noexists-")), + ); + tempDirs.push(remoteDir); + execFileSync("git", ["init", "--initial-branch=main"], { 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"], { cwd: remoteDir }); + + const cloneDir = realpathSync( + mkdtempSync(join(tmpdir(), "foreman-git-backend-clone-noexists-")), + ); + tempDirs.push(cloneDir); + execFileSync("git", ["clone", remoteDir, cloneDir]); + + const backend = new GitBackend(cloneDir); + const exists = await backend.branchExistsOnRemote(cloneDir, "feature/does-not-exist"); + expect(exists).toBe(false); + }); +}); + +// ── deleteBranch ───────────────────────────────────────────────────────────── + +describe("GitBackend.deleteBranch", () => { + it("deletes a fully merged branch and returns wasFullyMerged=true", async () => { + const repo = makeTempRepo(); + tempDirs.push(repo); + // Create and merge a feature branch + execFileSync("git", ["checkout", "-b", "feature/merged"], { cwd: repo }); + writeFileSync(join(repo, "feature.txt"), "feature\n"); + execFileSync("git", ["add", "."], { cwd: repo }); + execFileSync("git", ["commit", "-m", "add feature"], { cwd: repo }); + execFileSync("git", ["checkout", "main"], { cwd: repo }); + execFileSync("git", ["merge", "feature/merged", "--no-ff"], { cwd: repo }); + + const backend = new GitBackend(repo); + const result = await backend.deleteBranch(repo, "feature/merged", { targetBranch: "main" }); + expect(result.deleted).toBe(true); + expect(result.wasFullyMerged).toBe(true); + const exists = await backend.branchExists(repo, "feature/merged"); + expect(exists).toBe(false); + }); + + it("does not delete unmerged branch without force", async () => { + const repo = makeTempRepo(); + tempDirs.push(repo); + execFileSync("git", ["checkout", "-b", "feature/unmerged"], { cwd: repo }); + writeFileSync(join(repo, "unmerged.txt"), "content\n"); + execFileSync("git", ["add", "."], { cwd: repo }); + execFileSync("git", ["commit", "-m", "unmerged"], { cwd: repo }); + execFileSync("git", ["checkout", "main"], { cwd: repo }); + + const backend = new GitBackend(repo); + const result = await backend.deleteBranch(repo, "feature/unmerged", { targetBranch: "main" }); + expect(result.deleted).toBe(false); + expect(result.wasFullyMerged).toBe(false); + const exists = await backend.branchExists(repo, "feature/unmerged"); + expect(exists).toBe(true); + }); + + it("force-deletes an unmerged branch when force=true", async () => { + const repo = makeTempRepo(); + tempDirs.push(repo); + execFileSync("git", ["checkout", "-b", "feature/force-delete"], { cwd: repo }); + writeFileSync(join(repo, "forcedel.txt"), "content\n"); + execFileSync("git", ["add", "."], { cwd: repo }); + execFileSync("git", ["commit", "-m", "force delete"], { cwd: repo }); + execFileSync("git", ["checkout", "main"], { cwd: repo }); + + const backend = new GitBackend(repo); + const result = await backend.deleteBranch(repo, "feature/force-delete", { force: true, targetBranch: "main" }); + expect(result.deleted).toBe(true); + expect(result.wasFullyMerged).toBe(false); + }); + + it("returns deleted=false, wasFullyMerged=true when branch does not exist", async () => { + const repo = makeTempRepo(); + tempDirs.push(repo); + const backend = new GitBackend(repo); + + const result = await backend.deleteBranch(repo, "nonexistent-branch"); + expect(result.deleted).toBe(false); + expect(result.wasFullyMerged).toBe(true); + }); +}); + +// ── createWorkspace / removeWorkspace / listWorkspaces ──────────────────────── + +describe("GitBackend.createWorkspace", () => { + it("creates a workspace with the correct branch and path", async () => { + const repo = makeTempRepo(); + tempDirs.push(repo); + const backend = new GitBackend(repo); + + const result = await backend.createWorkspace(repo, "test-seed-001"); + expect(result.branchName).toBe("foreman/test-seed-001"); + expect(result.workspacePath).toBe(join(repo, ".foreman-worktrees", "test-seed-001")); + + const { existsSync } = await import("node:fs"); + expect(existsSync(result.workspacePath)).toBe(true); + + const branches = execFileSync("git", ["branch", "--list"], { cwd: repo }).toString().trim(); + expect(branches).toContain("foreman/test-seed-001"); + }); + + it("reuses an existing workspace by rebasing", async () => { + const repo = makeTempRepo(); + tempDirs.push(repo); + const backend = new GitBackend(repo); + + // Create the workspace the first time + const result1 = await backend.createWorkspace(repo, "test-reuse-001"); + expect(result1.workspacePath).toBe(join(repo, ".foreman-worktrees", "test-reuse-001")); + + // Call again — should reuse existing worktree + const result2 = await backend.createWorkspace(repo, "test-reuse-001"); + expect(result2.workspacePath).toBe(result1.workspacePath); + expect(result2.branchName).toBe(result1.branchName); + }); +}); + +describe("GitBackend.removeWorkspace", () => { + it("removes the workspace directory", async () => { + const repo = makeTempRepo(); + tempDirs.push(repo); + const backend = new GitBackend(repo); + + const { workspacePath } = await backend.createWorkspace(repo, "test-remove-001"); + const { existsSync } = await import("node:fs"); + expect(existsSync(workspacePath)).toBe(true); + + await backend.removeWorkspace(repo, workspacePath); + expect(existsSync(workspacePath)).toBe(false); + }); + + it("prunes stale .git/worktrees metadata", async () => { + const repo = makeTempRepo(); + tempDirs.push(repo); + const backend = new GitBackend(repo); + + const { workspacePath } = await backend.createWorkspace(repo, "test-prune-001"); + const metaDir = join(repo, ".git", "worktrees", "test-prune-001"); + const { existsSync } = await import("node:fs"); + expect(existsSync(metaDir)).toBe(true); + + await backend.removeWorkspace(repo, workspacePath); + expect(existsSync(workspacePath)).toBe(false); + expect(existsSync(metaDir)).toBe(false); + }); +}); + +describe("GitBackend.listWorkspaces", () => { + it("returns all worktrees including the main one", async () => { + const repo = makeTempRepo(); + tempDirs.push(repo); + const backend = new GitBackend(repo); + + await backend.createWorkspace(repo, "test-list-a"); + await backend.createWorkspace(repo, "test-list-b"); + + const workspaces = await backend.listWorkspaces(repo); + const branches = workspaces.map((w) => w.branch); + + expect(branches).toContain("foreman/test-list-a"); + expect(branches).toContain("foreman/test-list-b"); + expect(workspaces.length).toBeGreaterThanOrEqual(3); + }); +}); + +// ── stageAll / commit / getHeadId ───────────────────────────────────────────── + +describe("GitBackend.stageAll + commit + getHeadId", () => { + it("stages all changes, commits, and returns the commit hash", async () => { + const repo = makeTempRepo(); + tempDirs.push(repo); + const backend = new GitBackend(repo); + + writeFileSync(join(repo, "new-file.txt"), "hello\n"); + await backend.stageAll(repo); + const hash = await backend.commit(repo, "test commit"); + + expect(hash).toBeTruthy(); + expect(hash.length).toBeLessThanOrEqual(10); // short hash + + const headId = await backend.getHeadId(repo); + expect(headId).toBe(hash); + }); + + it("getHeadId returns the current HEAD short hash", async () => { + const repo = makeTempRepo(); + tempDirs.push(repo); + const backend = new GitBackend(repo); + + const headId = await backend.getHeadId(repo); + // Verify it looks like a short git hash (hex chars) + expect(headId).toMatch(/^[0-9a-f]{4,40}$/); + }); +}); + +// ── fetch / rebase / abortRebase ───────────────────────────────────────────── + +describe("GitBackend.fetch", () => { + it("does not throw when no remote is configured", async () => { + const repo = makeTempRepo(); + tempDirs.push(repo); + const backend = new GitBackend(repo); + + // fetch fails non-fatally for no remote — but our impl catches that + // It should either succeed or the method should handle the error gracefully + // Since there's no remote, we just check it doesn't throw fatally + try { + await backend.fetch(repo); + } catch { + // Acceptable: fetch may throw when there's no remote configured + } + }); +}); + +describe("GitBackend.rebase", () => { + it("returns success when already up-to-date", async () => { + // Create a remote-like setup + const remoteDir = realpathSync( + mkdtempSync(join(tmpdir(), "foreman-git-backend-remote-rebase-")), + ); + tempDirs.push(remoteDir); + execFileSync("git", ["init", "--initial-branch=main"], { 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"], { cwd: remoteDir }); + + const cloneDir = realpathSync( + mkdtempSync(join(tmpdir(), "foreman-git-backend-clone-rebase-")), + ); + 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 }); + + const backend = new GitBackend(cloneDir); + const result = await backend.rebase(cloneDir, "main"); + expect(result.success).toBe(true); + expect(result.hasConflicts).toBe(false); + }); +}); + +describe("GitBackend.abortRebase", () => { + it("throws when no rebase is in progress", async () => { + const repo = makeTempRepo(); + tempDirs.push(repo); + const backend = new GitBackend(repo); + + await expect(backend.abortRebase(repo)).rejects.toThrow(); + }); +}); + +// ── merge ───────────────────────────────────────────────────────────────────── + +describe("GitBackend.merge", () => { + it("merges a feature branch cleanly", async () => { + const repo = makeTempRepo(); + tempDirs.push(repo); + const backend = new GitBackend(repo); + + // Create a feature branch with a new file + execFileSync("git", ["checkout", "-b", "feature/merge-test"], { cwd: repo }); + writeFileSync(join(repo, "feature.txt"), "feature content\n"); + execFileSync("git", ["add", "."], { cwd: repo }); + execFileSync("git", ["commit", "-m", "add feature"], { cwd: repo }); + execFileSync("git", ["checkout", "main"], { cwd: repo }); + + const result = await backend.merge(repo, "feature/merge-test", "main"); + expect(result.success).toBe(true); + + const { existsSync } = await import("node:fs"); + expect(existsSync(join(repo, "feature.txt"))).toBe(true); + }); + + it("detects merge conflicts and returns conflicting files", async () => { + const repo = makeTempRepo(); + tempDirs.push(repo); + const backend = new GitBackend(repo); + + // Create a feature branch that modifies README + execFileSync("git", ["checkout", "-b", "feature/conflict-test"], { cwd: repo }); + writeFileSync(join(repo, "README.md"), "# feature branch version\n"); + execFileSync("git", ["add", "."], { cwd: repo }); + execFileSync("git", ["commit", "-m", "feature edit"], { cwd: repo }); + execFileSync("git", ["checkout", "main"], { cwd: repo }); + + // Also modify README on main + writeFileSync(join(repo, "README.md"), "# main branch version\n"); + execFileSync("git", ["add", "."], { cwd: repo }); + execFileSync("git", ["commit", "-m", "main edit"], { cwd: repo }); + + const result = await backend.merge(repo, "feature/conflict-test", "main"); + expect(result.success).toBe(false); + expect(result.conflicts).toBeDefined(); + expect(result.conflicts!.length).toBeGreaterThan(0); + expect(result.conflicts).toContain("README.md"); + + // Clean up the failed merge state + execFileSync("git", ["merge", "--abort"], { cwd: repo }); + }); +}); + +// ── getConflictingFiles / diff / getModifiedFiles / cleanWorkingTree / status ── + +describe("GitBackend.getConflictingFiles", () => { + it("returns empty array when no conflicts", async () => { + const repo = makeTempRepo(); + tempDirs.push(repo); + const backend = new GitBackend(repo); + + const files = await backend.getConflictingFiles(repo); + expect(files).toEqual([]); + }); +}); + +describe("GitBackend.diff", () => { + it("returns diff between two commits", async () => { + const repo = makeTempRepo(); + tempDirs.push(repo); + const backend = new GitBackend(repo); + + const head1 = await backend.getHeadId(repo); + + writeFileSync(join(repo, "diff-test.txt"), "new content\n"); + await backend.stageAll(repo); + const head2 = await backend.commit(repo, "add diff-test.txt"); + + const diffOutput = await backend.diff(repo, head1, head2); + expect(diffOutput).toContain("diff-test.txt"); + }); + + it("returns empty string when refs are identical", async () => { + const repo = makeTempRepo(); + tempDirs.push(repo); + const backend = new GitBackend(repo); + + const head = await backend.getHeadId(repo); + const diffOutput = await backend.diff(repo, head, head); + expect(diffOutput).toBe(""); + }); +}); + +describe("GitBackend.getModifiedFiles", () => { + it("returns list of files modified since base", async () => { + const repo = makeTempRepo(); + tempDirs.push(repo); + const backend = new GitBackend(repo); + + const base = await backend.getHeadId(repo); + writeFileSync(join(repo, "modified.txt"), "modified\n"); + await backend.stageAll(repo); + await backend.commit(repo, "add modified.txt"); + + const files = await backend.getModifiedFiles(repo, base); + expect(files).toContain("modified.txt"); + }); + + it("returns empty array when no modifications since base", async () => { + const repo = makeTempRepo(); + tempDirs.push(repo); + const backend = new GitBackend(repo); + + const head = await backend.getHeadId(repo); + const files = await backend.getModifiedFiles(repo, head); + expect(files).toEqual([]); + }); +}); + +describe("GitBackend.cleanWorkingTree", () => { + it("discards untracked files", async () => { + const repo = makeTempRepo(); + tempDirs.push(repo); + const backend = new GitBackend(repo); + const { existsSync } = await import("node:fs"); + + writeFileSync(join(repo, "untracked.txt"), "untracked\n"); + expect(existsSync(join(repo, "untracked.txt"))).toBe(true); + + await backend.cleanWorkingTree(repo); + expect(existsSync(join(repo, "untracked.txt"))).toBe(false); + }); + + it("discards modified tracked files", async () => { + const repo = makeTempRepo(); + tempDirs.push(repo); + const backend = new GitBackend(repo); + const { readFileSync } = await import("node:fs"); + + writeFileSync(join(repo, "README.md"), "# modified\n"); + const content = readFileSync(join(repo, "README.md"), "utf-8"); + expect(content).toBe("# modified\n"); + + await backend.cleanWorkingTree(repo); + const restored = readFileSync(join(repo, "README.md"), "utf-8"); + expect(restored).toBe("# init\n"); + }); +}); + +describe("GitBackend.status", () => { + it("returns empty string for a clean repo", async () => { + const repo = makeTempRepo(); + tempDirs.push(repo); + const backend = new GitBackend(repo); + + const statusOutput = await backend.status(repo); + expect(statusOutput).toBe(""); + }); + + it("returns non-empty string when there are uncommitted changes", async () => { + const repo = makeTempRepo(); + tempDirs.push(repo); + const backend = new GitBackend(repo); + + writeFileSync(join(repo, "untracked.txt"), "untracked\n"); + const statusOutput = await backend.status(repo); + expect(statusOutput).toContain("untracked.txt"); + }); +}); + +// ── getFinalizeCommands ──────────────────────────────────────────────────────── + +describe("GitBackend.getFinalizeCommands", () => { + it("returns all 6 required command fields", () => { + const backend = new GitBackend("/tmp/repo"); + const cmds = backend.getFinalizeCommands({ + seedId: "bd-abc1", + seedTitle: "Add new feature", + baseBranch: "dev", + worktreePath: "/tmp/repo/.foreman-worktrees/bd-abc1", + }); + + expect(cmds.stageCommand).toBe("git add -A"); + expect(cmds.commitCommand).toContain("bd-abc1"); + expect(cmds.commitCommand).toContain("Add new feature"); + expect(cmds.pushCommand).toContain("foreman/bd-abc1"); + expect(cmds.pushCommand).toContain("origin"); + expect(cmds.rebaseCommand).toContain("dev"); + expect(cmds.rebaseCommand).toContain("fetch"); + expect(cmds.branchVerifyCommand).toContain("foreman/bd-abc1"); + expect(cmds.cleanCommand).toContain("prune"); + }); + + it("escapes double quotes in seedTitle", () => { + const backend = new GitBackend("/tmp/repo"); + const cmds = backend.getFinalizeCommands({ + seedId: "bd-xyz9", + seedTitle: 'Fix "the bug"', + baseBranch: "main", + worktreePath: "/tmp/repo/.foreman-worktrees/bd-xyz9", + }); + + // Should not have unescaped double quotes that would break the shell command + expect(cmds.commitCommand).toContain('\\"the bug\\"'); + }); + + it("generates correct push command format", () => { + const backend = new GitBackend("/tmp/repo"); + const cmds = backend.getFinalizeCommands({ + seedId: "bd-test", + seedTitle: "Test task", + baseBranch: "main", + worktreePath: "/tmp/worktree", + }); + + expect(cmds.pushCommand).toBe("git push -u origin foreman/bd-test"); + }); + + it("generates correct rebase command format", () => { + const backend = new GitBackend("/tmp/repo"); + const cmds = backend.getFinalizeCommands({ + seedId: "bd-test", + seedTitle: "Test task", + baseBranch: "dev", + worktreePath: "/tmp/worktree", + }); + + expect(cmds.rebaseCommand).toBe("git fetch origin && git rebase origin/dev"); + }); +}); diff --git a/src/lib/vcs/__tests__/interface.test.ts b/src/lib/vcs/__tests__/interface.test.ts new file mode 100644 index 00000000..ff9f72c7 --- /dev/null +++ b/src/lib/vcs/__tests__/interface.test.ts @@ -0,0 +1,254 @@ +/** + * 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 methods are fully implemented (no longer stub errors)', async () => { + // Phase B is complete — methods now throw real errors from git operations, + // not "Phase B" stub errors. Verify getFinalizeCommands returns a value. + const backend = new GitBackend('/tmp'); + // getFinalizeCommands is a pure function — should not throw + const cmds = backend.getFinalizeCommands({ + seedId: 'bd-x', seedTitle: 'X', baseBranch: 'dev', worktreePath: '/tmp', + }); + expect(cmds.stageCommand).toBe('git add -A'); + expect(cmds.pushCommand).toContain('foreman/bd-x'); + // Other methods that call git will throw real git errors (not Phase B stubs) + await expect(backend.checkoutBranch('/tmp', 'main')).rejects.toThrow(/git checkout failed/); + await expect(backend.stageAll('/tmp')).rejects.toThrow(/git add failed/); + }); +}); + +// ── 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..e6c7d42f --- /dev/null +++ b/src/lib/vcs/backend.ts @@ -0,0 +1,290 @@ +/** + * 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'; +import type { WorkflowSetupStep, WorkflowSetupCache } from '../workflow-loader.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 steps to run after creation + * @param setupCache - optional cache configuration for reproducible setup caching + */ + createWorkspace( + repoPath: string, + seedId: string, + baseBranch?: string, + setupSteps?: WorkflowSetupStep[], + setupCache?: WorkflowSetupCache, + ): 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..4d9535e4 --- /dev/null +++ b/src/lib/vcs/git-backend.ts @@ -0,0 +1,711 @@ +/** + * GitBackend — Git-specific VCS backend implementation. + * + * Phase A: Implements the VcsBackend interface. The 4 repository-introspection + * methods are fully implemented. + * + * Phase B: All remaining methods implemented (TRD-005 through TRD-010). + * + * @module src/lib/vcs/git-backend + */ + +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; +import { existsSync } from "node:fs"; +import fs from "node:fs/promises"; +import { join } from "node:path"; +import type { VcsBackend } from "./backend.js"; +import type { + Workspace, + WorkspaceResult, + MergeResult, + RebaseResult, + DeleteBranchOptions, + DeleteBranchResult, + PushOptions, + FinalizeTemplateVars, + FinalizeCommands, +} from "./types.js"; +import { + runSetupWithCache, + installDependencies, +} from "../git.js"; +import type { WorkflowSetupStep, WorkflowSetupCache } from "../workflow-loader.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 — TRD-005 ────────────────────────── + + /** + * Checkout an existing branch. Throws if the branch does not exist. + */ + async checkoutBranch(repoPath: string, branchName: string): Promise { + await this.git(["checkout", branchName], repoPath); + } + + /** + * Check whether a local branch exists. + * Uses `git show-ref --verify --quiet refs/heads/`. + */ + async branchExists(repoPath: string, branchName: string): Promise { + try { + await this.git( + ["show-ref", "--verify", "--quiet", `refs/heads/${branchName}`], + repoPath, + ); + return true; + } catch { + return false; + } + } + + /** + * Check whether a branch exists on the origin remote. + * Uses `git rev-parse --verify origin/` against local remote-tracking refs. + */ + async branchExistsOnRemote( + repoPath: string, + branchName: string, + ): Promise { + try { + await this.git( + ["rev-parse", "--verify", `origin/${branchName}`], + repoPath, + ); + return true; + } catch { + return false; + } + } + + /** + * Delete a local branch with merge-safety checks. + * + * - If the branch is fully merged into targetBranch, uses `git branch -D` (safe delete after verify). + * - If NOT merged and `force: true`, uses `git branch -D` (force delete). + * - If NOT merged and `force: false` (default), skips deletion. + * - If the branch does not exist, returns `{ deleted: false, wasFullyMerged: true }`. + */ + async deleteBranch( + repoPath: string, + branchName: string, + opts?: DeleteBranchOptions, + ): Promise { + const force = opts?.force ?? false; + const targetBranch = + opts?.targetBranch ?? (await this.detectDefaultBranch(repoPath)); + + // Check if branch exists + try { + await this.git(["rev-parse", "--verify", branchName], repoPath); + } catch { + // Branch not found — already gone + return { deleted: false, wasFullyMerged: true }; + } + + // Check merge status: is branchName an ancestor of targetBranch? + let isFullyMerged = false; + try { + await this.git( + ["merge-base", "--is-ancestor", branchName, targetBranch], + repoPath, + ); + isFullyMerged = true; + } catch { + // merge-base --is-ancestor exits non-zero when branch is NOT an ancestor + isFullyMerged = false; + } + + if (isFullyMerged) { + // We verified merge status via merge-base --is-ancestor against targetBranch. + // Use -D because git branch -d checks against HEAD, which may differ from targetBranch. + await this.git(["branch", "-D", branchName], repoPath); + return { deleted: true, wasFullyMerged: true }; + } + + if (force) { + // Force delete — caller explicitly asked for it + await this.git(["branch", "-D", branchName], repoPath); + return { deleted: true, wasFullyMerged: false }; + } + + // Not merged and not forced — skip deletion + return { deleted: false, wasFullyMerged: false }; + } + + // ── Workspace Management — TRD-006 ─────────────────────────────────── + + /** + * Create an isolated workspace (git worktree) for a task. + * + * - Branch: foreman/ + * - Location: /.foreman-worktrees/ + * - Base: baseBranch (or current branch if not specified) + * + * If the worktree already exists (retry case), rebases onto baseBranch + * with auto-cleanup of unstaged changes if needed. + */ + async createWorkspace( + repoPath: string, + seedId: string, + baseBranch?: string, + setupSteps?: WorkflowSetupStep[], + setupCache?: WorkflowSetupCache, + ): Promise { + const base = baseBranch ?? (await this.getCurrentBranch(repoPath)); + const branchName = `foreman/${seedId}`; + const worktreePath = join(repoPath, ".foreman-worktrees", seedId); + + // If worktree already exists (e.g. from a failed previous run), reuse it + if (existsSync(worktreePath)) { + // Update the branch to the latest base so it picks up new code. + // Rebase may fail when there are unstaged changes in the worktree — + // attempt a `git checkout -- .` to discard them before retrying. + try { + await this.git(["rebase", base], worktreePath); + } catch (rebaseErr) { + const rebaseMsg = + rebaseErr instanceof Error ? rebaseErr.message : String(rebaseErr); + const hasUnstagedChanges = + rebaseMsg.includes("unstaged changes") || + rebaseMsg.includes("uncommitted changes") || + rebaseMsg.includes("please stash"); + + if (hasUnstagedChanges) { + console.error( + `[git] Rebase failed due to unstaged changes in ${worktreePath} — cleaning and retrying`, + ); + try { + // Discard all unstaged changes and untracked files so rebase can proceed + await this.git(["checkout", "--", "."], worktreePath); + await this.git(["clean", "-fd"], worktreePath); + // Retry the rebase after cleaning + await this.git(["rebase", base], worktreePath); + } catch (retryErr) { + const retryMsg = + retryErr instanceof Error ? retryErr.message : String(retryErr); + // Abort any partial rebase to leave the worktree in a usable state + try { + await this.git(["rebase", "--abort"], worktreePath); + } catch { + /* already clean */ + } + throw new Error( + `Rebase failed even after cleaning unstaged changes: ${retryMsg}`, + ); + } + } else { + // Non-unstaged-changes rebase failure (e.g. real conflicts): throw so + // the dispatcher does not spawn an agent into a broken worktree. + try { + await this.git(["rebase", "--abort"], worktreePath); + } catch { + /* already clean */ + } + throw new Error( + `Rebase failed in ${worktreePath}: ${rebaseMsg.slice(0, 300)}`, + ); + } + } + // Reinstall in case dependencies changed after rebase + if (setupSteps && setupSteps.length > 0) { + await runSetupWithCache(worktreePath, repoPath, setupSteps, setupCache); + } else { + await installDependencies(worktreePath); + } + return { workspacePath: worktreePath, branchName }; + } + + // Branch may exist without a worktree (worktree was cleaned up but branch wasn't) + try { + await this.git( + ["worktree", "add", "-b", branchName, worktreePath, base], + repoPath, + ); + } catch (err: unknown) { + const msg = (err as Error).message ?? ""; + if (msg.includes("already exists")) { + // Branch exists — create worktree using existing branch + await this.git(["worktree", "add", worktreePath, branchName], repoPath); + } else { + throw err; + } + } + + // Run setup steps with caching (or fallback to Node.js dependency install) + if (setupSteps && setupSteps.length > 0) { + await runSetupWithCache(worktreePath, repoPath, setupSteps, setupCache); + } else { + await installDependencies(worktreePath); + } + + return { workspacePath: worktreePath, branchName }; + } + + /** + * Remove an existing workspace (git worktree) and prune stale metadata. + * + * Tries `git worktree remove --force`, falls back to `fs.rm` for untracked + * files, then runs `git worktree prune` non-fatally. + */ + async removeWorkspace( + repoPath: string, + workspacePath: string, + ): Promise { + // Try the standard git removal first. + try { + await this.git( + ["worktree", "remove", workspacePath, "--force"], + repoPath, + ); + } catch (removeErr) { + const removeMsg = + removeErr instanceof Error ? removeErr.message : String(removeErr); + console.error( + `[git] Warning: git worktree remove --force failed for ${workspacePath}: ${removeMsg}`, + ); + console.error(`[git] Falling back to fs.rm for ${workspacePath}`); + try { + await fs.rm(workspacePath, { recursive: true, force: true }); + } catch (rmErr) { + const rmMsg = rmErr instanceof Error ? rmErr.message : String(rmErr); + console.error( + `[git] Warning: fs.rm fallback also failed for ${workspacePath}: ${rmMsg}`, + ); + } + } + + // Prune stale .git/worktrees/ metadata so the next dispatch does not + // fail with "fatal: not a git repository: .git/worktrees/". + try { + await this.git(["worktree", "prune"], repoPath); + } catch (pruneErr) { + // Non-fatal: log a warning and continue. + const msg = + pruneErr instanceof Error ? pruneErr.message : String(pruneErr); + console.error( + `[git] Warning: worktree prune failed after removing ${workspacePath}: ${msg}`, + ); + } + } + + /** + * List all workspaces (worktrees) for the repo. + * Parses `git worktree list --porcelain` output into Workspace[]. + */ + async listWorkspaces(repoPath: string): Promise { + const raw = await this.git( + ["worktree", "list", "--porcelain"], + repoPath, + ); + + if (!raw) return []; + + const workspaces: Workspace[] = []; + let current: Partial = {}; + + for (const line of raw.split("\n")) { + if (line.startsWith("worktree ")) { + if (current.path) workspaces.push(current as Workspace); + current = { path: line.slice("worktree ".length), bare: false }; + } else if (line.startsWith("HEAD ")) { + current.head = line.slice("HEAD ".length); + } else if (line.startsWith("branch ")) { + // refs/heads/foreman/abc → foreman/abc + current.branch = line.slice("branch refs/heads/".length); + } else if (line === "bare") { + current.bare = true; + } else if (line === "detached") { + current.branch = "(detached)"; + } else if (line === "" && current.path) { + workspaces.push(current as Workspace); + current = {}; + } + } + if (current.path) workspaces.push(current as Workspace); + + return workspaces; + } + + // ── Commit & Sync — TRD-007 ────────────────────────────────────────── + + /** + * Stage all changes in the workspace. + */ + async stageAll(workspacePath: string): Promise { + await this.git(["add", "-A"], workspacePath); + } + + /** + * Commit staged changes with the given message. + * Returns the short commit hash. + */ + async commit(workspacePath: string, message: string): Promise { + await this.git(["commit", "-m", message], workspacePath); + return this.git(["rev-parse", "--short", "HEAD"], workspacePath); + } + + /** + * Get the current HEAD commit hash (short form). + */ + async getHeadId(workspacePath: string): Promise { + return this.git(["rev-parse", "--short", "HEAD"], workspacePath); + } + + /** + * Push the current branch to the remote. + * Uses `-u origin ` with optional force flag. + */ + async push( + workspacePath: string, + branchName: string, + opts?: PushOptions, + ): Promise { + const args = ["push", "-u", "origin", branchName]; + if (opts?.force) args.push("--force"); + await this.git(args, workspacePath); + } + + /** + * Pull (fetch + merge) the latest changes for the given branch. + */ + async pull(workspacePath: string, branchName: string): Promise { + await this.git(["fetch", "origin"], workspacePath); + await this.git(["merge", `origin/${branchName}`], workspacePath); + } + + /** + * Fetch all refs from the remote without merging. + */ + async fetch(workspacePath: string): Promise { + await this.git(["fetch", "origin"], workspacePath); + } + + /** + * Rebase the current workspace branch onto `onto` (after fetching). + * Returns a structured result; does NOT throw on conflict. + */ + async rebase(workspacePath: string, onto: string): Promise { + try { + await this.git(["fetch", "origin"], workspacePath); + } catch { + // Fetch failure is non-fatal (e.g. no remote) — try rebase anyway + } + + try { + await this.git(["rebase", `origin/${onto}`], workspacePath); + return { success: true, hasConflicts: false }; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + + // Check if there are conflicting files + let conflictingFiles: string[] = []; + try { + const conflictOut = await this.git( + ["diff", "--name-only", "--diff-filter=U"], + workspacePath, + ); + conflictingFiles = conflictOut + .split("\n") + .map((f) => f.trim()) + .filter(Boolean); + } catch { + // Can't determine conflicting files — return empty list + } + + if (conflictingFiles.length > 0 || msg.includes("CONFLICT") || msg.includes("conflict")) { + return { + success: false, + hasConflicts: true, + conflictingFiles, + }; + } + + // Unexpected error — re-throw + throw err; + } + } + + /** + * Abort an in-progress rebase and restore the workspace to its pre-rebase state. + */ + async abortRebase(workspacePath: string): Promise { + await this.git(["rebase", "--abort"], workspacePath); + } + + // ── Merge Operations — TRD-008 ──────────────────────────────────────── + + /** + * Merge a branch into targetBranch (or the current branch if omitted). + * + * Implements the stash-checkout-merge-restore pattern to handle dirty + * working trees. + * + * Returns a structured result with the list of conflicting files on failure. + * Does NOT throw on conflict — the caller must check `result.success`. + */ + async merge( + repoPath: string, + branchName: string, + targetBranch?: string, + ): Promise { + const target = targetBranch ?? (await this.getCurrentBranch(repoPath)); + + // Stash any local changes so checkout doesn't fail on a dirty tree + let stashed = false; + try { + const stashOut = await this.git( + ["stash", "push", "-m", "foreman-merge-auto-stash"], + repoPath, + ); + stashed = !stashOut.includes("No local changes"); + } catch { + // stash may fail if there's nothing to stash — that's fine + } + + try { + // Checkout target branch + await this.git(["checkout", target], repoPath); + + try { + await this.git(["merge", branchName, "--no-ff"], repoPath); + return { success: true }; + } catch (err: unknown) { + const message = err instanceof Error ? err.message : String(err); + if ( + message.includes("CONFLICT") || + message.includes("Merge conflict") + ) { + // Gather conflicting files + const statusOut = await this.git( + ["diff", "--name-only", "--diff-filter=U"], + repoPath, + ); + const conflicts = statusOut + .split("\n") + .map((f) => f.trim()) + .filter(Boolean); + return { success: false, conflicts }; + } + // Re-throw for unexpected errors + throw err; + } + } finally { + // Restore stashed changes + if (stashed) { + try { + await this.git(["stash", "pop"], repoPath); + } catch { + // Pop may conflict — leave in stash, user can recover with `git stash pop` + } + } + } + } + + // ── Diff, Conflict & Status — TRD-009 ──────────────────────────────── + + /** + * Return the list of files in conflict during an active rebase or merge. + */ + async getConflictingFiles(workspacePath: string): Promise { + const out = await this.git( + ["diff", "--name-only", "--diff-filter=U"], + workspacePath, + ); + return out + .split("\n") + .map((f) => f.trim()) + .filter(Boolean); + } + + /** + * Return the diff output between two refs. + */ + async diff(repoPath: string, from: string, to: string): Promise { + return this.git(["diff", `${from}..${to}`], repoPath); + } + + /** + * Return the list of files modified relative to `base`. + */ + async getModifiedFiles( + workspacePath: string, + base: string, + ): Promise { + const out = await this.git( + ["diff", "--name-only", base], + workspacePath, + ); + return out + .split("\n") + .map((f) => f.trim()) + .filter(Boolean); + } + + /** + * Discard all uncommitted changes and restore the workspace to HEAD. + */ + async cleanWorkingTree(workspacePath: string): Promise { + await this.git(["checkout", "--", "."], workspacePath); + await this.git(["clean", "-fd"], workspacePath); + } + + /** + * Return a human-readable status summary of the workspace. + */ + async status(workspacePath: string): Promise { + return this.git(["status", "--short"], workspacePath); + } + + // ── Finalize Command Generation — TRD-010 ───────────────────────────── + + /** + * Generate the git-specific shell commands for the Finalize phase. + * + * All 6 fields are required. Special characters in seedTitle are escaped + * to prevent shell injection when commands are interpolated into prompts. + */ + getFinalizeCommands(vars: FinalizeTemplateVars): FinalizeCommands { + // Escape double quotes in seedTitle to prevent shell injection + const escapedTitle = vars.seedTitle.replace(/"/g, '\\"'); + return { + stageCommand: "git add -A", + commitCommand: `git commit -m "${escapedTitle} (${vars.seedId})"`, + pushCommand: `git push -u origin foreman/${vars.seedId}`, + rebaseCommand: `git fetch origin && git rebase origin/${vars.baseBranch}`, + branchVerifyCommand: `git rev-parse --verify origin/foreman/${vars.seedId}`, + cleanCommand: "git worktree prune", + }; + } +} diff --git a/src/lib/vcs/index.ts b/src/lib/vcs/index.ts new file mode 100644 index 00000000..046b11fa --- /dev/null +++ b/src/lib/vcs/index.ts @@ -0,0 +1,217 @@ +/** + * 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'; +import type { WorkflowSetupStep, WorkflowSetupCache } from '../workflow-loader.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?: WorkflowSetupStep[], + _setupCache?: WorkflowSetupCache, + ): 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.`, + ); + } + } + } +}