Skip to content
Open
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
645 changes: 645 additions & 0 deletions package-lock.json

Large diffs are not rendered by default.

4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,9 @@
"nanoid": "^5.1.5",
"proper-lockfile": "^4.1.2",
"write-file-atomic": "^7.0.0",
"yaml": "^2.7.1"
"yaml": "^2.7.1",
"fastify": "^5.7.4",
"@fastify/cors": "^11.2.0"
},
"devDependencies": {
"@types/node": "^22.13.4",
Expand Down
117 changes: 28 additions & 89 deletions src/commands/merge.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,11 @@
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 { getRepoRoot } from '../core/worktree.js';
import { mergeWorktree } from '../core/merge.js';
import { getCurrentPaneId } from '../core/self.js';
import { listSessionPanes, type PaneInfo } from '../core/tmux.js';
import { PpgError, WorktreeNotFoundError, MergeFailedError } from '../lib/errors.js';
import { listSessionPanes } from '../core/tmux.js';
import { WorktreeNotFoundError } from '../lib/errors.js';
import { output, success, info, warn } from '../lib/output.js';
import { execaEnv } from '../lib/env.js';

export interface MergeOptions {
strategy?: 'squash' | 'no-ff';
Expand All @@ -29,18 +27,6 @@ export async function mergeCommand(worktreeId: string, options: MergeOptions): P

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',
);
}

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`);
Expand All @@ -50,89 +36,42 @@ export async function mergeCommand(worktreeId: string, options: MergeOptions): P
return;
}

// Set worktree status to merging
await updateManifest(projectRoot, (m) => {
if (m.worktrees[wt.id]) {
m.worktrees[wt.id].status = 'merging';
}
return m;
});

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})`);
const cleanupEnabled = options.cleanup !== false;

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,
});
}

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}`,
);
// Build self-protection context for cleanup
const selfPaneId = cleanupEnabled ? getCurrentPaneId() : null;
let paneMap;
if (cleanupEnabled && selfPaneId) {
paneMap = await listSessionPanes(manifest.sessionName);
}

// 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...');
info(`Merging ${wt.branch} into ${wt.baseBranch} (${options.strategy ?? 'squash'})`);

const selfPaneId = getCurrentPaneId();
let paneMap: Map<string, PaneInfo> | undefined;
if (selfPaneId) {
paneMap = await listSessionPanes(manifest.sessionName);
}
const result = await mergeWorktree(projectRoot, wt, {
strategy: options.strategy,
cleanup: cleanupEnabled,
force: options.force,
cleanupOptions: cleanupEnabled ? { selfPaneId, paneMap } : undefined,
});

const cleanupResult = await cleanupWorktree(projectRoot, wt, { selfPaneId, paneMap });
selfProtected = cleanupResult.selfProtected;
success(`Merged ${wt.branch} into ${wt.baseBranch}`);

if (selfProtected) {
warn(`Some tmux targets skipped during cleanup — contains current ppg process`);
}
if (result.selfProtected) {
warn(`Some tmux targets skipped during cleanup — contains current ppg process`);
}
if (result.cleaned) {
success(`Cleaned up worktree ${wt.id}`);
}

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);
}
}
89 changes: 11 additions & 78 deletions src/commands/pr.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,12 @@
import { execa } from 'execa';
import { updateManifest, resolveWorktree } from '../core/manifest.js';
import { refreshAllAgentStatuses } from '../core/agent.js';
import { getRepoRoot } from '../core/worktree.js';
import { PpgError, NotInitializedError, WorktreeNotFoundError, GhNotFoundError } from '../lib/errors.js';
import { createWorktreePr } from '../core/pr.js';
import { NotInitializedError, WorktreeNotFoundError } from '../lib/errors.js';
import { output, success, info } from '../lib/output.js';
import { execaEnv } from '../lib/env.js';

// GitHub PR body limit is 65536 chars; leave room for truncation notice
const MAX_BODY_LENGTH = 60_000;
// Re-export for backwards compatibility with existing tests/consumers
export { buildBodyFromResults, truncateBody } from '../core/pr.js';

export interface PrOptions {
title?: string;
Expand All @@ -31,82 +30,16 @@ export async function prCommand(worktreeRef: string, options: PrOptions): Promis
const wt = resolveWorktree(manifest, worktreeRef);
if (!wt) throw new WorktreeNotFoundError(worktreeRef);

// Verify gh is available
try {
await execa('gh', ['--version'], execaEnv);
} catch {
throw new GhNotFoundError();
}

// Push the worktree branch
info(`Pushing branch ${wt.branch} to origin`);
try {
await execa('git', ['push', '-u', 'origin', wt.branch], { ...execaEnv, cwd: projectRoot });
} catch (err) {
throw new PpgError(
`Failed to push branch ${wt.branch}: ${err instanceof Error ? err.message : err}`,
'INVALID_ARGS',
);
}

// Build PR title and body
const title = options.title ?? wt.name;
const body = options.body ?? await buildBodyFromResults(Object.values(wt.agents));

// Build gh pr create args
const ghArgs = [
'pr', 'create',
'--head', wt.branch,
'--base', wt.baseBranch,
'--title', title,
'--body', body,
];
if (options.draft) {
ghArgs.push('--draft');
}

info(`Creating PR: ${title}`);
let prUrl: string;
try {
const result = await execa('gh', ghArgs, { ...execaEnv, cwd: projectRoot });
prUrl = result.stdout.trim();
} catch (err) {
throw new PpgError(
`Failed to create PR: ${err instanceof Error ? err.message : err}`,
'INVALID_ARGS',
);
}

// Store PR URL in manifest
await updateManifest(projectRoot, (m) => {
if (m.worktrees[wt.id]) {
m.worktrees[wt.id].prUrl = prUrl;
}
return m;
info(`Creating PR for ${wt.branch}`);
const result = await createWorktreePr(projectRoot, wt, {
title: options.title,
body: options.body,
draft: options.draft,
});

if (options.json) {
output({
success: true,
worktreeId: wt.id,
branch: wt.branch,
baseBranch: wt.baseBranch,
prUrl,
}, true);
output({ success: true, ...result }, true);
} else {
success(`PR created: ${prUrl}`);
success(`PR created: ${result.prUrl}`);
}
}

/** Build PR body from agent prompts, with truncation. */
export async function buildBodyFromResults(agents: { id: string; prompt: string }[]): Promise<string> {
if (agents.length === 0) return '';
const sections = agents.map((a) => `## Agent: ${a.id}\n\n${a.prompt}`);
return truncateBody(sections.join('\n\n---\n\n'));
}

/** Truncate body to stay within GitHub's PR body size limit. */
export function truncateBody(body: string): string {
if (body.length <= MAX_BODY_LENGTH) return body;
return body.slice(0, MAX_BODY_LENGTH) + '\n\n---\n\n*[Truncated — full results available in `.ppg/results/`]*';
}
3 changes: 2 additions & 1 deletion src/commands/spawn.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { readManifest, resolveWorktree, updateManifest } from '../core/manifest.
import { spawnAgent } from '../core/agent.js';
import { getRepoRoot } from '../core/worktree.js';
import { agentId, sessionId } from '../lib/id.js';
import type { Manifest } from '../types/manifest.js';
import * as tmux from '../core/tmux.js';

vi.mock('node:fs/promises', async () => {
Expand Down Expand Up @@ -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',
Expand Down
74 changes: 74 additions & 0 deletions src/core/kill.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { describe, test, expect, vi, beforeEach } from 'vitest';
import { makeWorktree, makeAgent } from '../test-fixtures.js';
import type { Manifest } from '../types/manifest.js';

// ---- Mocks ----

let manifestState: Manifest;

vi.mock('./manifest.js', () => ({
updateManifest: vi.fn(async (_root: string, updater: (m: Manifest) => Manifest | Promise<Manifest>) => {
manifestState = await updater(structuredClone(manifestState));
return manifestState;
}),
}));

vi.mock('./agent.js', () => ({
killAgents: vi.fn(),
}));

// ---- Imports (after mocks) ----

import { killWorktreeAgents } from './kill.js';
import { killAgents } from './agent.js';

describe('killWorktreeAgents', () => {
beforeEach(() => {
vi.clearAllMocks();
});

test('given worktree with running agents, should kill running agents and set status to gone', async () => {
const agent1 = makeAgent({ id: 'ag-run00001', status: 'running' });
const agent2 = makeAgent({ id: 'ag-idle0001', status: 'idle' });
const wt = makeWorktree({
id: 'wt-abc123',
agents: { 'ag-run00001': agent1, 'ag-idle0001': agent2 },
});
manifestState = {
version: 1,
projectRoot: '/tmp/project',
sessionName: 'ppg',
worktrees: { 'wt-abc123': structuredClone(wt) },
createdAt: '2026-01-01T00:00:00.000Z',
updatedAt: '2026-01-01T00:00:00.000Z',
};

const result = await killWorktreeAgents('/tmp/project', wt);

expect(result.killed).toEqual(['ag-run00001']);
expect(vi.mocked(killAgents)).toHaveBeenCalledWith([agent1]);
expect(manifestState.worktrees['wt-abc123'].agents['ag-run00001'].status).toBe('gone');
expect(manifestState.worktrees['wt-abc123'].agents['ag-idle0001'].status).toBe('idle');
});

test('given worktree with no running agents, should return empty killed list', async () => {
const agent = makeAgent({ id: 'ag-done0001', status: 'exited' });
const wt = makeWorktree({
id: 'wt-abc123',
agents: { 'ag-done0001': agent },
});
manifestState = {
version: 1,
projectRoot: '/tmp/project',
sessionName: 'ppg',
worktrees: { 'wt-abc123': structuredClone(wt) },
createdAt: '2026-01-01T00:00:00.000Z',
updatedAt: '2026-01-01T00:00:00.000Z',
};

const result = await killWorktreeAgents('/tmp/project', wt);

expect(result.killed).toEqual([]);
expect(vi.mocked(killAgents)).toHaveBeenCalledWith([]);
});
});
36 changes: 36 additions & 0 deletions src/core/kill.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { updateManifest } from './manifest.js';
import { killAgents } from './agent.js';
import type { WorktreeEntry } from '../types/manifest.js';

export interface KillWorktreeResult {
worktreeId: string;
killed: string[];
}

/** Kill all running agents in a worktree and set their status to 'gone'. */
export async function killWorktreeAgents(
projectRoot: string,
wt: WorktreeEntry,
): Promise<KillWorktreeResult> {
const toKill = Object.values(wt.agents).filter((a) => a.status === 'running');
const killedIds = toKill.map((a) => a.id);

await killAgents(toKill);

await updateManifest(projectRoot, (m) => {
const mWt = m.worktrees[wt.id];
if (mWt) {
for (const agent of Object.values(mWt.agents)) {
if (killedIds.includes(agent.id)) {
agent.status = 'gone';
}
}
}
return m;
});

return {
worktreeId: wt.id,
killed: killedIds,
};
}
Loading