Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions src/lib/git.ts
Original file line number Diff line number Diff line change
Expand Up @@ -196,18 +196,30 @@ 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;
head: string;
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;
Expand Down Expand Up @@ -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<string> {
return git(["rev-parse", "--show-toplevel"], path);
Expand All @@ -250,6 +270,7 @@ export async function getRepoRoot(path: string): Promise<string> {
* 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<string> {
const commonDir = await git(["rev-parse", "--git-common-dir"], path);
Expand All @@ -270,6 +291,7 @@ export async function getMainRepoRoot(path: string): Promise<string> {
* 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<string> {
// 1. Respect git-town.main-branch config (user's explicit development trunk)
Expand Down Expand Up @@ -319,6 +341,7 @@ export async function detectDefaultBranch(repoPath: string): Promise<string> {

/**
* Get the current branch name.
* @deprecated Use `GitBackend.getCurrentBranch()` from `src/lib/vcs/git-backend.ts` instead.
*/
export async function getCurrentBranch(repoPath: string): Promise<string> {
return git(["rev-parse", "--abbrev-ref", "HEAD"], repoPath);
Expand All @@ -327,6 +350,7 @@ export async function getCurrentBranch(repoPath: string): Promise<string> {
/**
* 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<void> {
await git(["checkout", branchName], repoPath);
Expand All @@ -338,6 +362,7 @@ export async function checkoutBranch(repoPath: string, branchName: string): Prom
* - Branch: foreman/<seedId>
* - Location: <repoPath>/.foreman-worktrees/<seedId>
* - 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,
Expand Down Expand Up @@ -426,6 +451,7 @@ export async function createWorktree(
* After removing the worktree, runs `git worktree prune` to delete any stale
* `.git/worktrees/<name>` 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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -557,6 +585,7 @@ export async function deleteBranch(
*
* Uses `git show-ref --verify --quiet refs/heads/<branchName>`.
* 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,
Expand All @@ -576,6 +605,7 @@ export async function gitBranchExists(
* Uses `git rev-parse origin/<branchName>` 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,
Expand All @@ -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,
Expand Down
199 changes: 199 additions & 0 deletions src/lib/vcs/__tests__/factory.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
Loading
Loading