From cf8f933a399899df6c9c665e2e6d11c53dc727cd Mon Sep 17 00:00:00 2001 From: 2witstudios <2witstudios@gmail.com> Date: Fri, 27 Feb 2026 00:00:56 -0600 Subject: [PATCH 1/3] feat: extract merge operation to core/operations/merge.ts Move merge orchestration (state transitions, git merge, cleanup) from commands/merge.ts into a reusable performMerge() function. The CLI command becomes a thin wrapper for arg parsing and output formatting. This enables both CLI and future REST routes to share the same merge logic with typed options and structured results. Closes #59 --- src/commands/merge.ts | 136 +++--------- src/core/operations/merge.test.ts | 345 ++++++++++++++++++++++++++++++ src/core/operations/merge.ts | 154 +++++++++++++ 3 files changed, 527 insertions(+), 108 deletions(-) create mode 100644 src/core/operations/merge.test.ts create mode 100644 src/core/operations/merge.ts diff --git a/src/commands/merge.ts b/src/commands/merge.ts index 5dca227..16b11b5 100644 --- a/src/commands/merge.ts +++ b/src/commands/merge.ts @@ -1,15 +1,8 @@ -import { execa } from 'execa'; -import { requireManifest, updateManifest, resolveWorktree } from '../core/manifest.js'; -import { refreshAllAgentStatuses } from '../core/agent.js'; -import { getRepoRoot, getCurrentBranch } from '../core/worktree.js'; -import { cleanupWorktree } from '../core/cleanup.js'; -import { getCurrentPaneId } from '../core/self.js'; -import { listSessionPanes, type PaneInfo } from '../core/tmux.js'; -import { PpgError, WorktreeNotFoundError, MergeFailedError } from '../lib/errors.js'; +import { performMerge, type MergeResult } from '../core/operations/merge.js'; +import { getRepoRoot } from '../core/worktree.js'; import { output, success, info, warn } from '../lib/output.js'; -import { execaEnv } from '../lib/env.js'; -export interface MergeOptions { +export interface MergeCommandOptions { strategy?: 'squash' | 'no-ff'; cleanup?: boolean; dryRun?: boolean; @@ -17,122 +10,49 @@ export interface MergeOptions { json?: boolean; } -export async function mergeCommand(worktreeId: string, options: MergeOptions): Promise { +export async function mergeCommand(worktreeId: string, options: MergeCommandOptions): Promise { const projectRoot = await getRepoRoot(); - - await requireManifest(projectRoot); - const manifest = await updateManifest(projectRoot, async (m) => { - return refreshAllAgentStatuses(m, projectRoot); - }); - - const wt = resolveWorktree(manifest, worktreeId); - - if (!wt) throw new WorktreeNotFoundError(worktreeId); - - // Check all agents finished - const agents = Object.values(wt.agents); - const incomplete = agents.filter((a) => a.status === 'running'); - - if (incomplete.length > 0 && !options.force) { - const ids = incomplete.map((a) => a.id).join(', '); - throw new PpgError( - `${incomplete.length} agent(s) still running: ${ids}. Use --force to merge anyway.`, - 'AGENTS_RUNNING', - ); - } + const strategy = options.strategy ?? 'squash'; if (options.dryRun) { info('Dry run — no changes will be made'); - info(`Would merge branch ${wt.branch} into ${wt.baseBranch} using ${options.strategy ?? 'squash'} strategy`); - if (options.cleanup !== false) { - info(`Would remove worktree ${wt.id} and delete branch ${wt.branch}`); - } - return; } - // Set worktree status to merging - await updateManifest(projectRoot, (m) => { - if (m.worktrees[wt.id]) { - m.worktrees[wt.id].status = 'merging'; - } - return m; + const result = await performMerge({ + projectRoot, + worktreeRef: worktreeId, + strategy: options.strategy, + cleanup: options.cleanup, + dryRun: options.dryRun, + force: options.force, }); - const strategy = options.strategy ?? 'squash'; - - try { - const currentBranch = await getCurrentBranch(projectRoot); - if (currentBranch !== wt.baseBranch) { - info(`Switching to base branch ${wt.baseBranch}`); - await execa('git', ['checkout', wt.baseBranch], { ...execaEnv, cwd: projectRoot }); - } - - info(`Merging ${wt.branch} into ${wt.baseBranch} (${strategy})`); - - if (strategy === 'squash') { - await execa('git', ['merge', '--squash', wt.branch], { ...execaEnv, cwd: projectRoot }); - await execa('git', ['commit', '-m', `ppg: merge ${wt.name} (${wt.branch})`], { - ...execaEnv, - cwd: projectRoot, - }); - } else { - await execa('git', ['merge', '--no-ff', wt.branch, '-m', `ppg: merge ${wt.name} (${wt.branch})`], { - ...execaEnv, - cwd: projectRoot, - }); + if (result.dryRun) { + info(`Would merge branch ${result.branch} into ${result.baseBranch} using ${result.strategy} strategy`); + if (options.cleanup !== false) { + info(`Would remove worktree ${result.worktreeId} and delete branch ${result.branch}`); } - - success(`Merged ${wt.branch} into ${wt.baseBranch}`); - } catch (err) { - await updateManifest(projectRoot, (m) => { - if (m.worktrees[wt.id]) { - m.worktrees[wt.id].status = 'failed'; - } - return m; - }); - throw new MergeFailedError( - `Merge failed: ${err instanceof Error ? err.message : err}`, - ); + return; } - // Mark as merged - await updateManifest(projectRoot, (m) => { - if (m.worktrees[wt.id]) { - m.worktrees[wt.id].status = 'merged'; - m.worktrees[wt.id].mergedAt = new Date().toISOString(); - } - return m; - }); - - // Cleanup with self-protection - let selfProtected = false; - if (options.cleanup !== false) { - info('Cleaning up...'); - - const selfPaneId = getCurrentPaneId(); - let paneMap: Map | undefined; - if (selfPaneId) { - paneMap = await listSessionPanes(manifest.sessionName); - } - - const cleanupResult = await cleanupWorktree(projectRoot, wt, { selfPaneId, paneMap }); - selfProtected = cleanupResult.selfProtected; + success(`Merged ${result.branch} into ${result.baseBranch}`); - if (selfProtected) { - warn(`Some tmux targets skipped during cleanup — contains current ppg process`); + if (result.cleaned) { + if (result.selfProtected) { + warn('Some tmux targets skipped during cleanup — contains current ppg process'); } - success(`Cleaned up worktree ${wt.id}`); + success(`Cleaned up worktree ${result.worktreeId}`); } if (options.json) { output({ success: true, - worktreeId: wt.id, - branch: wt.branch, - baseBranch: wt.baseBranch, - strategy, - cleaned: options.cleanup !== false, - selfProtected: selfProtected || undefined, + worktreeId: result.worktreeId, + branch: result.branch, + baseBranch: result.baseBranch, + strategy: result.strategy, + cleaned: result.cleaned, + selfProtected: result.selfProtected || undefined, }, true); } } diff --git a/src/core/operations/merge.test.ts b/src/core/operations/merge.test.ts new file mode 100644 index 0000000..08a8f55 --- /dev/null +++ b/src/core/operations/merge.test.ts @@ -0,0 +1,345 @@ +import { describe, test, expect, vi, beforeEach } from 'vitest'; +import type { Manifest, WorktreeEntry } from '../../types/manifest.js'; + +// --- Mocks --- + +const mockExeca = vi.fn(async () => ({ stdout: 'main', stderr: '' })); +vi.mock('execa', () => ({ + execa: (...args: unknown[]) => (mockExeca as Function)(...args), +})); + +const mockManifest = (): Manifest => ({ + version: 1, + projectRoot: '/project', + sessionName: 'ppg', + worktrees: { + 'wt-abc123': makeWorktree(), + }, + createdAt: '2025-01-01T00:00:00.000Z', + updatedAt: '2025-01-01T00:00:00.000Z', +}); + +let latestManifest: Manifest; + +vi.mock('../manifest.js', () => ({ + requireManifest: vi.fn(async () => latestManifest), + updateManifest: vi.fn(async (_root: string, updater: (m: Manifest) => Manifest | Promise) => { + latestManifest = await updater(latestManifest); + return latestManifest; + }), + resolveWorktree: vi.fn((manifest: Manifest, ref: string) => { + return manifest.worktrees[ref] ?? + Object.values(manifest.worktrees).find((wt) => wt.name === ref || wt.branch === ref); + }), +})); + +vi.mock('../agent.js', () => ({ + refreshAllAgentStatuses: vi.fn(async (m: Manifest) => m), +})); + +vi.mock('../worktree.js', () => ({ + getCurrentBranch: vi.fn(async () => 'main'), +})); + +vi.mock('../cleanup.js', () => ({ + cleanupWorktree: vi.fn(async () => ({ + worktreeId: 'wt-abc123', + manifestUpdated: true, + tmuxKilled: 1, + tmuxSkipped: 0, + tmuxFailed: 0, + selfProtected: false, + selfProtectedTargets: [], + })), +})); + +vi.mock('../self.js', () => ({ + getCurrentPaneId: vi.fn(() => null), +})); + +vi.mock('../tmux.js', () => ({ + listSessionPanes: vi.fn(async () => new Map()), +})); + +vi.mock('../../lib/errors.js', async () => { + const actual = await vi.importActual('../../lib/errors.js'); + return actual; +}); + +vi.mock('../../lib/env.js', () => ({ + execaEnv: { env: { PATH: '/usr/bin' } }, +})); + +import { performMerge } from './merge.js'; +import { updateManifest } from '../manifest.js'; +import { getCurrentBranch } from '../worktree.js'; +import { cleanupWorktree } from '../cleanup.js'; +import { getCurrentPaneId } from '../self.js'; +import { listSessionPanes } from '../tmux.js'; +import { PpgError, MergeFailedError, WorktreeNotFoundError } from '../../lib/errors.js'; + +function makeWorktree(overrides: Partial = {}): WorktreeEntry { + return { + id: 'wt-abc123', + name: 'test-feature', + path: '/project/.worktrees/wt-abc123', + branch: 'ppg/test-feature', + baseBranch: 'main', + status: 'active', + tmuxWindow: 'ppg:1', + agents: { + 'ag-00000001': { + id: 'ag-00000001', + name: 'claude-1', + agentType: 'claude', + status: 'exited', + tmuxTarget: 'ppg:1.0', + prompt: 'do stuff', + startedAt: '2025-01-01T00:00:00.000Z', + }, + }, + createdAt: '2025-01-01T00:00:00.000Z', + ...overrides, + }; +} + +describe('performMerge', () => { + beforeEach(() => { + vi.clearAllMocks(); + latestManifest = mockManifest(); + mockExeca.mockResolvedValue({ stdout: 'main', stderr: '' }); + }); + + test('performs squash merge and returns result', async () => { + const result = await performMerge({ + projectRoot: '/project', + worktreeRef: 'wt-abc123', + strategy: 'squash', + }); + + expect(result.merged).toBe(true); + expect(result.strategy).toBe('squash'); + expect(result.worktreeId).toBe('wt-abc123'); + expect(result.branch).toBe('ppg/test-feature'); + expect(result.baseBranch).toBe('main'); + expect(result.cleaned).toBe(true); + expect(result.dryRun).toBe(false); + }); + + test('defaults to squash strategy', async () => { + const result = await performMerge({ + projectRoot: '/project', + worktreeRef: 'wt-abc123', + }); + + expect(result.strategy).toBe('squash'); + // Verify git merge --squash was called + expect(mockExeca).toHaveBeenCalledWith( + 'git', ['merge', '--squash', 'ppg/test-feature'], + expect.objectContaining({ cwd: '/project' }), + ); + }); + + test('supports no-ff merge strategy', async () => { + await performMerge({ + projectRoot: '/project', + worktreeRef: 'wt-abc123', + strategy: 'no-ff', + }); + + expect(mockExeca).toHaveBeenCalledWith( + 'git', ['merge', '--no-ff', 'ppg/test-feature', '-m', 'ppg: merge test-feature (ppg/test-feature)'], + expect.objectContaining({ cwd: '/project' }), + ); + }); + + test('state transitions: active → merging → merged → cleaned', async () => { + const statusLog: string[] = []; + vi.mocked(updateManifest).mockImplementation(async (_root, updater) => { + latestManifest = await updater(latestManifest); + const wt = latestManifest.worktrees['wt-abc123']; + if (wt) statusLog.push(wt.status); + return latestManifest; + }); + + await performMerge({ + projectRoot: '/project', + worktreeRef: 'wt-abc123', + }); + + // First call: refreshAllAgentStatuses (status stays active) + // Second call: set merging + // Third call: set merged + // Fourth call (inside cleanupWorktree): set cleaned + expect(statusLog).toContain('merging'); + expect(statusLog).toContain('merged'); + expect(statusLog.indexOf('merging')).toBeLessThan(statusLog.indexOf('merged')); + }); + + test('sets status to failed on git merge error', async () => { + mockExeca.mockImplementation(async (...args: unknown[]) => { + const cmdArgs = args[1] as string[]; + if (cmdArgs[0] === 'merge') { + throw new Error('CONFLICT (content): Merge conflict in file.ts'); + } + return { stdout: 'main', stderr: '' }; + }); + + await expect( + performMerge({ + projectRoot: '/project', + worktreeRef: 'wt-abc123', + }), + ).rejects.toThrow(MergeFailedError); + + expect(latestManifest.worktrees['wt-abc123'].status).toBe('failed'); + }); + + test('throws AGENTS_RUNNING when agents still running', async () => { + latestManifest.worktrees['wt-abc123'].agents['ag-00000001'].status = 'running'; + + await expect( + performMerge({ + projectRoot: '/project', + worktreeRef: 'wt-abc123', + }), + ).rejects.toThrow(PpgError); + + try { + await performMerge({ + projectRoot: '/project', + worktreeRef: 'wt-abc123', + }); + } catch (err) { + expect((err as PpgError).code).toBe('AGENTS_RUNNING'); + } + }); + + test('force bypasses running agent check', async () => { + latestManifest.worktrees['wt-abc123'].agents['ag-00000001'].status = 'running'; + + const result = await performMerge({ + projectRoot: '/project', + worktreeRef: 'wt-abc123', + force: true, + }); + + expect(result.merged).toBe(true); + }); + + test('throws WorktreeNotFoundError for invalid ref', async () => { + await expect( + performMerge({ + projectRoot: '/project', + worktreeRef: 'wt-nonexistent', + }), + ).rejects.toThrow(WorktreeNotFoundError); + }); + + test('dry run returns early without modifying state', async () => { + const result = await performMerge({ + projectRoot: '/project', + worktreeRef: 'wt-abc123', + dryRun: true, + }); + + expect(result.dryRun).toBe(true); + expect(result.merged).toBe(false); + expect(result.cleaned).toBe(false); + // Should not have called git merge + expect(mockExeca).not.toHaveBeenCalledWith( + 'git', expect.arrayContaining(['merge']), + expect.anything(), + ); + // Worktree status unchanged + expect(latestManifest.worktrees['wt-abc123'].status).toBe('active'); + }); + + test('skips cleanup when cleanup=false', async () => { + const result = await performMerge({ + projectRoot: '/project', + worktreeRef: 'wt-abc123', + cleanup: false, + }); + + expect(result.merged).toBe(true); + expect(result.cleaned).toBe(false); + expect(cleanupWorktree).not.toHaveBeenCalled(); + }); + + test('switches to base branch if not already on it', async () => { + vi.mocked(getCurrentBranch).mockResolvedValueOnce('some-other-branch'); + + await performMerge({ + projectRoot: '/project', + worktreeRef: 'wt-abc123', + }); + + expect(mockExeca).toHaveBeenCalledWith( + 'git', ['checkout', 'main'], + expect.objectContaining({ cwd: '/project' }), + ); + }); + + test('skips checkout when already on base branch', async () => { + vi.mocked(getCurrentBranch).mockResolvedValueOnce('main'); + + await performMerge({ + projectRoot: '/project', + worktreeRef: 'wt-abc123', + }); + + expect(mockExeca).not.toHaveBeenCalledWith( + 'git', ['checkout', 'main'], + expect.anything(), + ); + }); + + test('passes self-protection context to cleanup', async () => { + vi.mocked(getCurrentPaneId).mockReturnValueOnce('%5'); + const paneMap = new Map(); + vi.mocked(listSessionPanes).mockResolvedValueOnce(paneMap); + + await performMerge({ + projectRoot: '/project', + worktreeRef: 'wt-abc123', + }); + + expect(listSessionPanes).toHaveBeenCalledWith('ppg'); + expect(cleanupWorktree).toHaveBeenCalledWith( + '/project', + expect.objectContaining({ id: 'wt-abc123' }), + { selfPaneId: '%5', paneMap }, + ); + }); + + test('reports selfProtected when cleanup skips targets', async () => { + vi.mocked(cleanupWorktree).mockResolvedValueOnce({ + worktreeId: 'wt-abc123', + manifestUpdated: true, + tmuxKilled: 0, + tmuxSkipped: 0, + tmuxFailed: 0, + selfProtected: true, + selfProtectedTargets: ['ppg:1'], + }); + + const result = await performMerge({ + projectRoot: '/project', + worktreeRef: 'wt-abc123', + }); + + expect(result.selfProtected).toBe(true); + }); + + test('sets mergedAt timestamp on successful merge', async () => { + await performMerge({ + projectRoot: '/project', + worktreeRef: 'wt-abc123', + }); + + expect(latestManifest.worktrees['wt-abc123'].mergedAt).toBeDefined(); + // Should be a valid ISO date + expect(() => new Date(latestManifest.worktrees['wt-abc123'].mergedAt!)).not.toThrow(); + }); +}); diff --git a/src/core/operations/merge.ts b/src/core/operations/merge.ts new file mode 100644 index 0000000..ae3d374 --- /dev/null +++ b/src/core/operations/merge.ts @@ -0,0 +1,154 @@ +import { execa } from 'execa'; +import { requireManifest, updateManifest, resolveWorktree } from '../manifest.js'; +import { refreshAllAgentStatuses } from '../agent.js'; +import { getCurrentBranch } from '../worktree.js'; +import { cleanupWorktree } from '../cleanup.js'; +import { getCurrentPaneId } from '../self.js'; +import { listSessionPanes, type PaneInfo } from '../tmux.js'; +import { PpgError, WorktreeNotFoundError, MergeFailedError } from '../../lib/errors.js'; +import { execaEnv } from '../../lib/env.js'; +import type { WorktreeEntry, Manifest } from '../../types/manifest.js'; +import type { CleanupResult } from '../cleanup.js'; + +export type MergeStrategy = 'squash' | 'no-ff'; + +export interface MergeOptions { + projectRoot: string; + worktreeRef: string; + strategy?: MergeStrategy; + cleanup?: boolean; + dryRun?: boolean; + force?: boolean; +} + +export interface MergeResult { + worktreeId: string; + branch: string; + baseBranch: string; + strategy: MergeStrategy; + dryRun: boolean; + merged: boolean; + cleaned: boolean; + selfProtected: boolean; +} + +/** + * Perform a merge operation: resolve worktree, validate agents, run git merge, + * and optionally clean up. + * + * State machine: active → merging → merged → cleaned + * On failure: active → merging → failed + */ +export async function performMerge(options: MergeOptions): Promise { + const { projectRoot, worktreeRef, force = false, dryRun = false } = options; + const strategy = options.strategy ?? 'squash'; + const shouldCleanup = options.cleanup !== false; + + // Load and refresh manifest + await requireManifest(projectRoot); + const manifest = await updateManifest(projectRoot, async (m) => { + return refreshAllAgentStatuses(m, projectRoot); + }); + + const wt = resolveWorktree(manifest, worktreeRef); + if (!wt) throw new WorktreeNotFoundError(worktreeRef); + + // Validate: no running agents unless forced + const agents = Object.values(wt.agents); + const incomplete = agents.filter((a) => a.status === 'running'); + + if (incomplete.length > 0 && !force) { + const ids = incomplete.map((a) => a.id).join(', '); + throw new PpgError( + `${incomplete.length} agent(s) still running: ${ids}. Use --force to merge anyway.`, + 'AGENTS_RUNNING', + ); + } + + // Dry run: return early without changes + if (dryRun) { + return { + worktreeId: wt.id, + branch: wt.branch, + baseBranch: wt.baseBranch, + strategy, + dryRun: true, + merged: false, + cleaned: false, + selfProtected: false, + }; + } + + // Transition: active → merging + await updateManifest(projectRoot, (m) => { + if (m.worktrees[wt.id]) { + m.worktrees[wt.id].status = 'merging'; + } + return m; + }); + + // Perform git merge + try { + const currentBranch = await getCurrentBranch(projectRoot); + if (currentBranch !== wt.baseBranch) { + await execa('git', ['checkout', wt.baseBranch], { ...execaEnv, cwd: projectRoot }); + } + + if (strategy === 'squash') { + await execa('git', ['merge', '--squash', wt.branch], { ...execaEnv, cwd: projectRoot }); + await execa('git', ['commit', '-m', `ppg: merge ${wt.name} (${wt.branch})`], { + ...execaEnv, + cwd: projectRoot, + }); + } else { + await execa('git', ['merge', '--no-ff', wt.branch, '-m', `ppg: merge ${wt.name} (${wt.branch})`], { + ...execaEnv, + cwd: projectRoot, + }); + } + } catch (err) { + // Transition: merging → failed + await updateManifest(projectRoot, (m) => { + if (m.worktrees[wt.id]) { + m.worktrees[wt.id].status = 'failed'; + } + return m; + }); + throw new MergeFailedError( + `Merge failed: ${err instanceof Error ? err.message : err}`, + ); + } + + // Transition: merging → merged + await updateManifest(projectRoot, (m) => { + if (m.worktrees[wt.id]) { + m.worktrees[wt.id].status = 'merged'; + m.worktrees[wt.id].mergedAt = new Date().toISOString(); + } + return m; + }); + + // Cleanup (merged → cleaned) + let selfProtected = false; + if (shouldCleanup) { + const selfPaneId = getCurrentPaneId(); + let paneMap: Map | undefined; + if (selfPaneId) { + paneMap = await listSessionPanes(manifest.sessionName); + } + + const cleanupResult = await cleanupWorktree(projectRoot, wt, { selfPaneId, paneMap }); + selfProtected = cleanupResult.selfProtected; + } + + return { + worktreeId: wt.id, + branch: wt.branch, + baseBranch: wt.baseBranch, + strategy, + dryRun: false, + merged: true, + cleaned: shouldCleanup, + selfProtected, + }; +} From 10c8b83b8674c8b147b9f6479546e7bb2780579e Mon Sep 17 00:00:00 2001 From: 2witstudios <2witstudios@gmail.com> Date: Fri, 27 Feb 2026 07:51:30 -0600 Subject: [PATCH 2/3] =?UTF-8?q?fix:=20address=20review=20findings=20?= =?UTF-8?q?=E2=80=94=20remove=20dead=20code=20and=20fix=20test=20issues?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove unused imports (type MergeResult, WorktreeEntry, Manifest, CleanupResult) - Remove unused `strategy` variable in commands/merge.ts - Fix AGENTS_RUNNING test: single invocation instead of double call - Fix misleading comment about cleanup state transition in mocked test - Remove unnecessary vi.mock passthrough for errors.js --- src/commands/merge.ts | 3 +-- src/core/operations/merge.test.ts | 31 ++++++++----------------------- src/core/operations/merge.ts | 2 -- 3 files changed, 9 insertions(+), 27 deletions(-) diff --git a/src/commands/merge.ts b/src/commands/merge.ts index 16b11b5..53694dc 100644 --- a/src/commands/merge.ts +++ b/src/commands/merge.ts @@ -1,4 +1,4 @@ -import { performMerge, type MergeResult } from '../core/operations/merge.js'; +import { performMerge } from '../core/operations/merge.js'; import { getRepoRoot } from '../core/worktree.js'; import { output, success, info, warn } from '../lib/output.js'; @@ -12,7 +12,6 @@ export interface MergeCommandOptions { export async function mergeCommand(worktreeId: string, options: MergeCommandOptions): Promise { const projectRoot = await getRepoRoot(); - const strategy = options.strategy ?? 'squash'; if (options.dryRun) { info('Dry run — no changes will be made'); diff --git a/src/core/operations/merge.test.ts b/src/core/operations/merge.test.ts index 08a8f55..d3c21e8 100644 --- a/src/core/operations/merge.test.ts +++ b/src/core/operations/merge.test.ts @@ -61,11 +61,6 @@ vi.mock('../tmux.js', () => ({ listSessionPanes: vi.fn(async () => new Map()), })); -vi.mock('../../lib/errors.js', async () => { - const actual = await vi.importActual('../../lib/errors.js'); - return actual; -}); - vi.mock('../../lib/env.js', () => ({ execaEnv: { env: { PATH: '/usr/bin' } }, })); @@ -167,10 +162,8 @@ describe('performMerge', () => { worktreeRef: 'wt-abc123', }); - // First call: refreshAllAgentStatuses (status stays active) - // Second call: set merging - // Third call: set merged - // Fourth call (inside cleanupWorktree): set cleaned + // Call order: refreshAllAgentStatuses (active) → set merging → set merged + // Note: cleanup's manifest update is mocked, so 'cleaned' is not tracked here expect(statusLog).toContain('merging'); expect(statusLog).toContain('merged'); expect(statusLog.indexOf('merging')).toBeLessThan(statusLog.indexOf('merged')); @@ -198,21 +191,13 @@ describe('performMerge', () => { test('throws AGENTS_RUNNING when agents still running', async () => { latestManifest.worktrees['wt-abc123'].agents['ag-00000001'].status = 'running'; - await expect( - performMerge({ - projectRoot: '/project', - worktreeRef: 'wt-abc123', - }), - ).rejects.toThrow(PpgError); + const err = await performMerge({ + projectRoot: '/project', + worktreeRef: 'wt-abc123', + }).catch((e) => e); - try { - await performMerge({ - projectRoot: '/project', - worktreeRef: 'wt-abc123', - }); - } catch (err) { - expect((err as PpgError).code).toBe('AGENTS_RUNNING'); - } + expect(err).toBeInstanceOf(PpgError); + expect(err.code).toBe('AGENTS_RUNNING'); }); test('force bypasses running agent check', async () => { diff --git a/src/core/operations/merge.ts b/src/core/operations/merge.ts index ae3d374..4997833 100644 --- a/src/core/operations/merge.ts +++ b/src/core/operations/merge.ts @@ -7,8 +7,6 @@ import { getCurrentPaneId } from '../self.js'; import { listSessionPanes, type PaneInfo } from '../tmux.js'; import { PpgError, WorktreeNotFoundError, MergeFailedError } from '../../lib/errors.js'; import { execaEnv } from '../../lib/env.js'; -import type { WorktreeEntry, Manifest } from '../../types/manifest.js'; -import type { CleanupResult } from '../cleanup.js'; export type MergeStrategy = 'squash' | 'no-ff'; From b7de0169c02a18b9dbd918413e1a5a5cda3d031b Mon Sep 17 00:00:00 2001 From: 2witstudios <2witstudios@gmail.com> Date: Fri, 27 Feb 2026 08:27:12 -0600 Subject: [PATCH 3/3] test: fix manifest typing in spawn mock --- src/commands/spawn.test.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/commands/spawn.test.ts b/src/commands/spawn.test.ts index ee642c7..12ecbc8 100644 --- a/src/commands/spawn.test.ts +++ b/src/commands/spawn.test.ts @@ -7,6 +7,7 @@ import { spawnAgent } from '../core/agent.js'; import { getRepoRoot } from '../core/worktree.js'; import { agentId, sessionId } from '../lib/id.js'; import * as tmux from '../core/tmux.js'; +import type { Manifest } from '../types/manifest.js'; vi.mock('node:fs/promises', async () => { const actual = await vi.importActual('node:fs/promises'); @@ -79,7 +80,7 @@ const mockedEnsureSession = vi.mocked(tmux.ensureSession); const mockedCreateWindow = vi.mocked(tmux.createWindow); const mockedSplitPane = vi.mocked(tmux.splitPane); -function createManifest(tmuxWindow = '') { +function createManifest(tmuxWindow = ''): Manifest { return { version: 1 as const, projectRoot: '/tmp/repo', @@ -103,7 +104,7 @@ function createManifest(tmuxWindow = '') { } describe('spawnCommand', () => { - let manifestState = createManifest(); + let manifestState: Manifest = createManifest(); let nextAgent = 1; let nextSession = 1;