diff --git a/src/lib/vcs/__tests__/types.test.ts b/src/lib/vcs/__tests__/types.test.ts new file mode 100644 index 00000000..0bc55b4a --- /dev/null +++ b/src/lib/vcs/__tests__/types.test.ts @@ -0,0 +1,295 @@ +import { describe, it, expect } from 'vitest'; +import type { + Workspace, + WorkspaceResult, + MergeResult, + RebaseResult, + DeleteBranchOptions, + DeleteBranchResult, + PushOptions, + FinalizeTemplateVars, + FinalizeCommands, + VcsConfig, +} from '../types.js'; + +// ── AC-I-002-1: Workspace can replace Worktree from git.ts ────────────── + +describe('Workspace type', () => { + it('has all fields required by Worktree (backward compat)', () => { + const ws: Workspace = { + path: '/tmp/worktrees/bd-deoi', + branch: 'foreman/bd-deoi', + head: 'abc123def456', + bare: false, + }; + expect(ws.path).toBe('/tmp/worktrees/bd-deoi'); + expect(ws.branch).toBe('foreman/bd-deoi'); + expect(ws.head).toBe('abc123def456'); + expect(ws.bare).toBe(false); + }); + + it('accepts a jj change ID as head', () => { + const ws: Workspace = { + path: '/tmp/jj-workspace', + branch: 'trunk()', + head: 'yklonqvs', // jj short change ID + bare: false, + }; + expect(ws.head).toBe('yklonqvs'); + }); + + it('accepts a jj bookmark name as branch', () => { + const ws: Workspace = { + path: '/tmp/jj-workspace', + branch: 'my-bookmark', + head: 'abc', + bare: false, + }; + expect(ws.branch).toBe('my-bookmark'); + }); + + it('bare field is required and boolean', () => { + const ws: Workspace = { path: '/', branch: 'main', head: 'a1b2c3', bare: false }; + expect(typeof ws.bare).toBe('boolean'); + }); +}); + +// ── WorkspaceResult ───────────────────────────────────────────────────── + +describe('WorkspaceResult type', () => { + it('has workspacePath and branchName', () => { + const result: WorkspaceResult = { + workspacePath: '/tmp/worktrees/bd-deoi', + branchName: 'foreman/bd-deoi', + }; + expect(result.workspacePath).toContain('bd-deoi'); + expect(result.branchName).toBe('foreman/bd-deoi'); + }); +}); + +// ── MergeResult ───────────────────────────────────────────────────────── + +describe('MergeResult type', () => { + it('success:true has no conflicts', () => { + const result: MergeResult = { success: true }; + expect(result.success).toBe(true); + expect(result.conflicts).toBeUndefined(); + }); + + it('success:false can have conflicts array', () => { + const result: MergeResult = { + success: false, + conflicts: ['src/lib/git.ts', 'package.json'], + }; + expect(result.conflicts).toHaveLength(2); + }); + + it('conflicts field is optional', () => { + const minimal: MergeResult = { success: false }; + expect(minimal.conflicts).toBeUndefined(); + }); +}); + +// ── RebaseResult ──────────────────────────────────────────────────────── + +describe('RebaseResult type', () => { + it('clean rebase has no conflicting files', () => { + const result: RebaseResult = { success: true, hasConflicts: false }; + expect(result.conflictingFiles).toBeUndefined(); + }); + + it('conflict rebase includes conflictingFiles', () => { + const result: RebaseResult = { + success: false, + hasConflicts: true, + conflictingFiles: ['src/lib/git.ts'], + }; + expect(result.hasConflicts).toBe(true); + expect(result.conflictingFiles).toContain('src/lib/git.ts'); + }); + + it('conflictingFiles is optional', () => { + const result: RebaseResult = { success: false, hasConflicts: true }; + expect(result.conflictingFiles).toBeUndefined(); + }); +}); + +// ── DeleteBranchOptions ───────────────────────────────────────────────── + +describe('DeleteBranchOptions type', () => { + it('both fields are optional', () => { + const opts: DeleteBranchOptions = {}; + expect(opts.force).toBeUndefined(); + expect(opts.targetBranch).toBeUndefined(); + }); + + it('accepts force and targetBranch', () => { + const opts: DeleteBranchOptions = { force: true, targetBranch: 'dev' }; + expect(opts.force).toBe(true); + expect(opts.targetBranch).toBe('dev'); + }); +}); + +// ── DeleteBranchResult ────────────────────────────────────────────────── + +describe('DeleteBranchResult type', () => { + it('has required boolean fields', () => { + const result: DeleteBranchResult = { deleted: true, wasFullyMerged: true }; + expect(typeof result.deleted).toBe('boolean'); + expect(typeof result.wasFullyMerged).toBe('boolean'); + }); + + it('can represent a force-deleted unmerged branch', () => { + const result: DeleteBranchResult = { deleted: true, wasFullyMerged: false }; + expect(result.deleted).toBe(true); + expect(result.wasFullyMerged).toBe(false); + }); +}); + +// ── PushOptions ───────────────────────────────────────────────────────── + +describe('PushOptions type', () => { + it('all fields are optional', () => { + const opts: PushOptions = {}; + expect(opts.force).toBeUndefined(); + expect(opts.allowNew).toBeUndefined(); + }); + + it('accepts jj-specific allowNew', () => { + const opts: PushOptions = { allowNew: true }; + expect(opts.allowNew).toBe(true); + }); + + it('accepts force push', () => { + const opts: PushOptions = { force: true }; + expect(opts.force).toBe(true); + }); +}); + +// ── FinalizeTemplateVars ──────────────────────────────────────────────── + +describe('FinalizeTemplateVars type', () => { + it('has all required fields', () => { + const vars: FinalizeTemplateVars = { + seedId: 'bd-deoi', + seedTitle: 'Define Shared VCS Types', + baseBranch: 'dev', + worktreePath: '/tmp/worktrees/bd-deoi', + }; + expect(vars.seedId).toBe('bd-deoi'); + expect(vars.baseBranch).toBe('dev'); + }); +}); + +// ── AC-I-002-2: FinalizeCommands has all 6 required fields ────────────── + +describe('FinalizeCommands type', () => { + it('has all 6 required fields', () => { + const cmds: FinalizeCommands = { + stageCommand: 'git add -A', + commitCommand: 'git commit -m "feat: implement task"', + pushCommand: 'git push origin foreman/bd-deoi', + rebaseCommand: 'git rebase origin/dev', + branchVerifyCommand: 'git ls-remote --heads origin foreman/bd-deoi', + cleanCommand: 'git worktree remove /tmp/worktrees/bd-deoi', + }; + // Verify all 6 are present and string-typed + 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'); + }); + + it('accepts empty strings for no-op commands (e.g. jj auto-staging)', () => { + const cmds: FinalizeCommands = { + stageCommand: '', // jj auto-stages + commitCommand: 'jj commit -m "feat: implement task"', + pushCommand: 'jj git push --allow-new', + rebaseCommand: 'jj rebase -d main', + branchVerifyCommand: 'jj bookmark list', + cleanCommand: 'jj workspace forget foreman-bd-deoi', + }; + expect(cmds.stageCommand).toBe(''); + }); +}); + +// ── VcsConfig ─────────────────────────────────────────────────────────── + +describe('VcsConfig type', () => { + it('backend is required and must be git | jujutsu | auto', () => { + const cfgGit: VcsConfig = { backend: 'git' }; + const cfgJj: VcsConfig = { backend: 'jujutsu' }; + const cfgAuto: VcsConfig = { backend: 'auto' }; + expect(cfgGit.backend).toBe('git'); + expect(cfgJj.backend).toBe('jujutsu'); + expect(cfgAuto.backend).toBe('auto'); + }); + + it('git and jujutsu sub-configs are optional', () => { + const cfg: VcsConfig = { backend: 'auto' }; + expect(cfg.git).toBeUndefined(); + expect(cfg.jujutsu).toBeUndefined(); + }); + + it('accepts git useTown option', () => { + const cfg: VcsConfig = { backend: 'git', git: { useTown: true } }; + expect(cfg.git?.useTown).toBe(true); + }); + + it('accepts jujutsu minVersion option', () => { + const cfg: VcsConfig = { backend: 'jujutsu', jujutsu: { minVersion: '0.25.0' } }; + expect(cfg.jujutsu?.minVersion).toBe('0.25.0'); + }); + + it('useTown and minVersion are optional within sub-configs', () => { + const cfg: VcsConfig = { backend: 'git', git: {} }; + expect(cfg.git?.useTown).toBeUndefined(); + }); +}); + +// ── AC-I-002-3: Both GitBackend and JujutsuBackend can use all types ───── + +describe('Type compatibility across backends', () => { + it('Workspace works for a git backend scenario', () => { + // Simulating what GitBackend.createWorkspace() would return + const gitWorkspace: Workspace = { + path: '/tmp/worktrees/bd-deoi', + branch: 'foreman/bd-deoi', + head: '7c3d2e1f4a8b', // git SHA + bare: false, + }; + const result: WorkspaceResult = { + workspacePath: gitWorkspace.path, + branchName: gitWorkspace.branch, + }; + expect(result.branchName).toBe('foreman/bd-deoi'); + }); + + it('Workspace works for a jujutsu backend scenario', () => { + // Simulating what JujutsuBackend.createWorkspace() would return + const jjWorkspace: Workspace = { + path: '/tmp/jj-workspace/bd-deoi', + branch: 'foreman/bd-deoi', // jj bookmark name + head: 'yklonqvs', // jj short change ID + bare: false, + }; + expect(jjWorkspace.head.length).toBeLessThan(40); // jj IDs are shorter than git SHAs + }); + + it('MergeResult is usable by both backends', () => { + const gitMerge: MergeResult = { success: true }; + const jjMerge: MergeResult = { success: false, conflicts: ['src/lib/git.ts'] }; + expect(gitMerge.success).toBe(true); + expect(jjMerge.conflicts).toBeDefined(); + }); + + it('PushOptions allowNew is usable by jj, ignorable by git', () => { + // Both backends accept the same PushOptions shape + const gitPushOpts: PushOptions = { force: false }; + const jjPushOpts: PushOptions = { force: false, allowNew: true }; + expect(gitPushOpts.allowNew).toBeUndefined(); // git ignores it + expect(jjPushOpts.allowNew).toBe(true); + }); +}); diff --git a/src/lib/vcs/types.ts b/src/lib/vcs/types.ts new file mode 100644 index 00000000..54190f33 --- /dev/null +++ b/src/lib/vcs/types.ts @@ -0,0 +1,124 @@ +/** + * Shared VCS types for backend-agnostic workspace and operation representations. + * These types serve as the data contract for all VCS backend implementations + * (GitBackend, JujutsuBackend) and their consumers throughout Foreman. + * + * @module src/lib/vcs/types + */ + +/** Replaces Worktree from git.ts. Backend-agnostic workspace representation. */ +export interface Workspace { + /** Absolute filesystem path to the workspace/worktree directory. */ + path: string; + /** git branch name or jj bookmark name */ + branch: string; + /** git commit hash or jj change ID */ + head: string; + /** Always false for jj workspaces; may be true for git bare worktrees. */ + bare: boolean; +} + +/** Result of a createWorkspace() operation. */ +export interface WorkspaceResult { + /** Absolute path to the created workspace directory. */ + workspacePath: string; + /** Branch/bookmark name created for this workspace; format: 'foreman/' for both backends. */ + branchName: string; +} + +/** Result of a merge operation. */ +export interface MergeResult { + /** True if the merge completed without conflicts. */ + success: boolean; + /** List of files with conflicts, if any. Omitted when merge succeeds cleanly. */ + conflicts?: string[]; +} + +/** Result of a rebase operation. */ +export interface RebaseResult { + /** True if the rebase completed successfully. */ + success: boolean; + /** True if the rebase encountered conflicts that require resolution. */ + hasConflicts: boolean; + /** List of files with conflicts. Present only when hasConflicts is true. */ + conflictingFiles?: string[]; +} + +/** Options for delete branch/bookmark operations. */ +export interface DeleteBranchOptions { + /** If true, force-delete even if not fully merged (equivalent to git branch -D). */ + force?: boolean; + /** + * The target/base branch to check merge status against. + * Defaults to the repository's default branch if omitted. + */ + targetBranch?: string; +} + +/** Result of a delete branch/bookmark operation. */ +export interface DeleteBranchResult { + /** True if the branch/bookmark was successfully deleted. */ + deleted: boolean; + /** True if the branch had been fully merged into the target before deletion. */ + wasFullyMerged: boolean; +} + +/** Options for push operations. */ +export interface PushOptions { + /** If true, force-push (overwrite remote history). Use with caution. */ + force?: boolean; + /** + * Jujutsu-specific: passes --allow-new flag to allow pushing new bookmarks. + * GitBackend ignores this field. + */ + allowNew?: boolean; +} + +/** Template variables for backend-specific finalize command generation. */ +export interface FinalizeTemplateVars { + /** The seed/bead ID for this task (e.g. 'bd-deoi'). */ + seedId: string; + /** Human-readable title of the seed/task. */ + seedTitle: string; + /** The base branch to rebase onto (e.g. 'dev' or 'main'). */ + baseBranch: string; + /** Absolute path to the worktree/workspace directory. */ + worktreePath: string; +} + +/** + * Backend-specific finalize commands for prompt rendering. + * The Finalize agent uses these pre-computed commands in its prompt so it + * doesn't need to know which VCS backend is in use. All fields are required; + * use an empty string for commands that are no-ops on a given backend. + */ +export interface FinalizeCommands { + /** Command to stage all changes (e.g. 'git add -A'). Empty string for backends with auto-staging (jj). */ + stageCommand: string; + /** Command to commit staged changes with an appropriate message. */ + commitCommand: string; + /** Command to push the branch/bookmark to the remote. */ + pushCommand: string; + /** Command to rebase the workspace branch onto the base branch. */ + rebaseCommand: string; + /** Command to verify the branch/bookmark exists on the remote after push. */ + branchVerifyCommand: string; + /** Command to clean up the workspace after finalization. */ + cleanCommand: string; +} + +/** VCS configuration read from project config YAML (.foreman/config.yaml). */ +export interface VcsConfig { + /** Which VCS backend to use. 'auto' detects based on repository contents. */ + backend: 'git' | 'jujutsu' | 'auto'; + /** Git-specific configuration options. */ + git?: { + /** If true, use git-town for branch management operations. Default: true. */ + useTown?: boolean; + }; + /** Jujutsu-specific configuration options. */ + jujutsu?: { + /** Minimum jj version required; validated by 'foreman doctor'. */ + minVersion?: string; + }; +}