Add worktree history merge command

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
dotta
2026-03-20 15:02:24 -05:00
parent a46dc4634b
commit 0ec79d4295
3 changed files with 986 additions and 1 deletions

View File

@@ -0,0 +1,182 @@
import { describe, expect, it } from "vitest";
import { buildWorktreeMergePlan, parseWorktreeMergeScopes } from "../commands/worktree-merge-history-lib.js";
function makeIssue(overrides: Record<string, unknown> = {}) {
return {
id: "issue-1",
companyId: "company-1",
projectId: null,
projectWorkspaceId: null,
goalId: "goal-1",
parentId: null,
title: "Issue",
description: null,
status: "todo",
priority: "medium",
assigneeAgentId: null,
assigneeUserId: null,
checkoutRunId: null,
executionRunId: null,
executionAgentNameKey: null,
executionLockedAt: null,
createdByAgentId: null,
createdByUserId: "local-board",
issueNumber: 1,
identifier: "PAP-1",
requestDepth: 0,
billingCode: null,
assigneeAdapterOverrides: null,
executionWorkspaceId: null,
executionWorkspacePreference: null,
executionWorkspaceSettings: null,
startedAt: null,
completedAt: null,
cancelledAt: null,
hiddenAt: null,
createdAt: new Date("2026-03-20T00:00:00.000Z"),
updatedAt: new Date("2026-03-20T00:00:00.000Z"),
...overrides,
} as any;
}
function makeComment(overrides: Record<string, unknown> = {}) {
return {
id: "comment-1",
companyId: "company-1",
issueId: "issue-1",
authorAgentId: null,
authorUserId: "local-board",
body: "hello",
createdAt: new Date("2026-03-20T00:00:00.000Z"),
updatedAt: new Date("2026-03-20T00:00:00.000Z"),
...overrides,
} as any;
}
describe("worktree merge history planner", () => {
it("parses default scopes", () => {
expect(parseWorktreeMergeScopes(undefined)).toEqual(["issues", "comments"]);
expect(parseWorktreeMergeScopes("issues")).toEqual(["issues"]);
});
it("dedupes nested worktree issues by preserved source uuid", () => {
const sharedIssue = makeIssue({ id: "issue-a", identifier: "PAP-10", title: "Shared" });
const branchOneIssue = makeIssue({
id: "issue-b",
identifier: "PAP-22",
title: "Branch one issue",
createdAt: new Date("2026-03-20T01:00:00.000Z"),
});
const branchTwoIssue = makeIssue({
id: "issue-c",
identifier: "PAP-23",
title: "Branch two issue",
createdAt: new Date("2026-03-20T02:00:00.000Z"),
});
const plan = buildWorktreeMergePlan({
companyId: "company-1",
companyName: "Paperclip",
issuePrefix: "PAP",
previewIssueCounterStart: 500,
scopes: ["issues", "comments"],
sourceIssues: [sharedIssue, branchOneIssue, branchTwoIssue],
targetIssues: [sharedIssue, branchOneIssue],
sourceComments: [],
targetComments: [],
targetAgents: [],
targetProjects: [],
targetProjectWorkspaces: [],
targetGoals: [{ id: "goal-1" }] as any,
});
expect(plan.counts.issuesToInsert).toBe(1);
expect(plan.issuePlans.filter((item) => item.action === "insert").map((item) => item.source.id)).toEqual(["issue-c"]);
expect(plan.issuePlans.find((item) => item.source.id === "issue-c" && item.action === "insert")).toMatchObject({
previewIdentifier: "PAP-501",
});
});
it("clears missing references and coerces in_progress without an assignee", () => {
const plan = buildWorktreeMergePlan({
companyId: "company-1",
companyName: "Paperclip",
issuePrefix: "PAP",
previewIssueCounterStart: 10,
scopes: ["issues"],
sourceIssues: [
makeIssue({
id: "issue-x",
identifier: "PAP-99",
status: "in_progress",
assigneeAgentId: "agent-missing",
projectId: "project-missing",
projectWorkspaceId: "workspace-missing",
goalId: "goal-missing",
}),
],
targetIssues: [],
sourceComments: [],
targetComments: [],
targetAgents: [],
targetProjects: [],
targetProjectWorkspaces: [],
targetGoals: [],
});
const insert = plan.issuePlans[0] as any;
expect(insert.targetStatus).toBe("todo");
expect(insert.targetAssigneeAgentId).toBeNull();
expect(insert.targetProjectId).toBeNull();
expect(insert.targetProjectWorkspaceId).toBeNull();
expect(insert.targetGoalId).toBeNull();
expect(insert.adjustments).toEqual([
"clear_assignee_agent",
"clear_project",
"clear_project_workspace",
"clear_goal",
"coerce_in_progress_to_todo",
]);
});
it("imports comments onto shared or newly imported issues while skipping existing comments", () => {
const sharedIssue = makeIssue({ id: "issue-a", identifier: "PAP-10" });
const newIssue = makeIssue({
id: "issue-b",
identifier: "PAP-11",
createdAt: new Date("2026-03-20T01:00:00.000Z"),
});
const existingComment = makeComment({ id: "comment-existing", issueId: "issue-a" });
const sharedIssueComment = makeComment({ id: "comment-shared", issueId: "issue-a" });
const newIssueComment = makeComment({
id: "comment-new-issue",
issueId: "issue-b",
authorAgentId: "missing-agent",
createdAt: new Date("2026-03-20T01:05:00.000Z"),
});
const plan = buildWorktreeMergePlan({
companyId: "company-1",
companyName: "Paperclip",
issuePrefix: "PAP",
previewIssueCounterStart: 10,
scopes: ["issues", "comments"],
sourceIssues: [sharedIssue, newIssue],
targetIssues: [sharedIssue],
sourceComments: [existingComment, sharedIssueComment, newIssueComment],
targetComments: [existingComment],
targetAgents: [],
targetProjects: [],
targetProjectWorkspaces: [],
targetGoals: [{ id: "goal-1" }] as any,
});
expect(plan.counts.commentsToInsert).toBe(2);
expect(plan.counts.commentsExisting).toBe(1);
expect(plan.commentPlans.filter((item) => item.action === "insert").map((item) => item.source.id)).toEqual([
"comment-shared",
"comment-new-issue",
]);
expect(plan.adjustments.clear_author_agent).toBe(1);
});
});

View File

@@ -0,0 +1,329 @@
import {
agents,
goals,
issueComments,
issues,
projects,
projectWorkspaces,
} from "@paperclipai/db";
type IssueRow = typeof issues.$inferSelect;
type CommentRow = typeof issueComments.$inferSelect;
type AgentRow = typeof agents.$inferSelect;
type ProjectRow = typeof projects.$inferSelect;
type ProjectWorkspaceRow = typeof projectWorkspaces.$inferSelect;
type GoalRow = typeof goals.$inferSelect;
export const WORKTREE_MERGE_SCOPES = ["issues", "comments"] as const;
export type WorktreeMergeScope = (typeof WORKTREE_MERGE_SCOPES)[number];
export type ImportAdjustment =
| "clear_assignee_agent"
| "clear_project"
| "clear_project_workspace"
| "clear_goal"
| "clear_author_agent"
| "coerce_in_progress_to_todo";
export type IssueMergeAction = "skip_existing" | "insert";
export type CommentMergeAction = "skip_existing" | "skip_missing_parent" | "insert";
export type PlannedIssueInsert = {
source: IssueRow;
action: "insert";
previewIssueNumber: number;
previewIdentifier: string;
targetStatus: string;
targetAssigneeAgentId: string | null;
targetCreatedByAgentId: string | null;
targetProjectId: string | null;
targetProjectWorkspaceId: string | null;
targetGoalId: string | null;
adjustments: ImportAdjustment[];
};
export type PlannedIssueSkip = {
source: IssueRow;
action: "skip_existing";
driftKeys: string[];
};
export type PlannedCommentInsert = {
source: CommentRow;
action: "insert";
targetAuthorAgentId: string | null;
adjustments: ImportAdjustment[];
};
export type PlannedCommentSkip = {
source: CommentRow;
action: "skip_existing" | "skip_missing_parent";
};
export type WorktreeMergePlan = {
companyId: string;
companyName: string;
issuePrefix: string;
previewIssueCounterStart: number;
scopes: WorktreeMergeScope[];
issuePlans: Array<PlannedIssueInsert | PlannedIssueSkip>;
commentPlans: Array<PlannedCommentInsert | PlannedCommentSkip>;
counts: {
issuesToInsert: number;
issuesExisting: number;
issueDrift: number;
commentsToInsert: number;
commentsExisting: number;
commentsMissingParent: number;
};
adjustments: Record<ImportAdjustment, number>;
};
function compareIssueCoreFields(source: IssueRow, target: IssueRow): string[] {
const driftKeys: string[] = [];
if (source.title !== target.title) driftKeys.push("title");
if ((source.description ?? null) !== (target.description ?? null)) driftKeys.push("description");
if (source.status !== target.status) driftKeys.push("status");
if (source.priority !== target.priority) driftKeys.push("priority");
if ((source.parentId ?? null) !== (target.parentId ?? null)) driftKeys.push("parentId");
if ((source.projectId ?? null) !== (target.projectId ?? null)) driftKeys.push("projectId");
if ((source.projectWorkspaceId ?? null) !== (target.projectWorkspaceId ?? null)) driftKeys.push("projectWorkspaceId");
if ((source.goalId ?? null) !== (target.goalId ?? null)) driftKeys.push("goalId");
if ((source.assigneeAgentId ?? null) !== (target.assigneeAgentId ?? null)) driftKeys.push("assigneeAgentId");
if ((source.assigneeUserId ?? null) !== (target.assigneeUserId ?? null)) driftKeys.push("assigneeUserId");
return driftKeys;
}
function incrementAdjustment(
counts: Record<ImportAdjustment, number>,
adjustment: ImportAdjustment,
): void {
counts[adjustment] += 1;
}
function sortIssuesForImport(sourceIssues: IssueRow[]): IssueRow[] {
const byId = new Map(sourceIssues.map((issue) => [issue.id, issue]));
const memoDepth = new Map<string, number>();
const depthFor = (issue: IssueRow, stack = new Set<string>()): number => {
const memoized = memoDepth.get(issue.id);
if (memoized !== undefined) return memoized;
if (!issue.parentId) {
memoDepth.set(issue.id, 0);
return 0;
}
if (stack.has(issue.id)) {
memoDepth.set(issue.id, 0);
return 0;
}
const parent = byId.get(issue.parentId);
if (!parent) {
memoDepth.set(issue.id, 0);
return 0;
}
stack.add(issue.id);
const depth = depthFor(parent, stack) + 1;
stack.delete(issue.id);
memoDepth.set(issue.id, depth);
return depth;
};
return [...sourceIssues].sort((left, right) => {
const depthDelta = depthFor(left) - depthFor(right);
if (depthDelta !== 0) return depthDelta;
const createdDelta = left.createdAt.getTime() - right.createdAt.getTime();
if (createdDelta !== 0) return createdDelta;
return left.id.localeCompare(right.id);
});
}
export function parseWorktreeMergeScopes(rawValue: string | undefined): WorktreeMergeScope[] {
if (!rawValue || rawValue.trim().length === 0) {
return ["issues", "comments"];
}
const parsed = rawValue
.split(",")
.map((value) => value.trim().toLowerCase())
.filter((value): value is WorktreeMergeScope =>
(WORKTREE_MERGE_SCOPES as readonly string[]).includes(value),
);
if (parsed.length === 0) {
throw new Error(
`Invalid scope "${rawValue}". Expected a comma-separated list of: ${WORKTREE_MERGE_SCOPES.join(", ")}.`,
);
}
return [...new Set(parsed)];
}
export function buildWorktreeMergePlan(input: {
companyId: string;
companyName: string;
issuePrefix: string;
previewIssueCounterStart: number;
scopes: WorktreeMergeScope[];
sourceIssues: IssueRow[];
targetIssues: IssueRow[];
sourceComments: CommentRow[];
targetComments: CommentRow[];
targetAgents: AgentRow[];
targetProjects: ProjectRow[];
targetProjectWorkspaces: ProjectWorkspaceRow[];
targetGoals: GoalRow[];
}): WorktreeMergePlan {
const targetIssuesById = new Map(input.targetIssues.map((issue) => [issue.id, issue]));
const targetCommentIds = new Set(input.targetComments.map((comment) => comment.id));
const targetAgentIds = new Set(input.targetAgents.map((agent) => agent.id));
const targetProjectIds = new Set(input.targetProjects.map((project) => project.id));
const targetProjectWorkspaceIds = new Set(input.targetProjectWorkspaces.map((workspace) => workspace.id));
const targetGoalIds = new Set(input.targetGoals.map((goal) => goal.id));
const scopes = new Set(input.scopes);
const adjustmentCounts: Record<ImportAdjustment, number> = {
clear_assignee_agent: 0,
clear_project: 0,
clear_project_workspace: 0,
clear_goal: 0,
clear_author_agent: 0,
coerce_in_progress_to_todo: 0,
};
const issuePlans: Array<PlannedIssueInsert | PlannedIssueSkip> = [];
let nextPreviewIssueNumber = input.previewIssueCounterStart;
for (const issue of sortIssuesForImport(input.sourceIssues)) {
const existing = targetIssuesById.get(issue.id);
if (existing) {
issuePlans.push({
source: issue,
action: "skip_existing",
driftKeys: compareIssueCoreFields(issue, existing),
});
continue;
}
nextPreviewIssueNumber += 1;
const adjustments: ImportAdjustment[] = [];
const targetAssigneeAgentId =
issue.assigneeAgentId && targetAgentIds.has(issue.assigneeAgentId) ? issue.assigneeAgentId : null;
if (issue.assigneeAgentId && !targetAssigneeAgentId) {
adjustments.push("clear_assignee_agent");
incrementAdjustment(adjustmentCounts, "clear_assignee_agent");
}
const targetCreatedByAgentId =
issue.createdByAgentId && targetAgentIds.has(issue.createdByAgentId) ? issue.createdByAgentId : null;
const targetProjectId =
issue.projectId && targetProjectIds.has(issue.projectId) ? issue.projectId : null;
if (issue.projectId && !targetProjectId) {
adjustments.push("clear_project");
incrementAdjustment(adjustmentCounts, "clear_project");
}
const targetProjectWorkspaceId =
targetProjectId
&& issue.projectWorkspaceId
&& targetProjectWorkspaceIds.has(issue.projectWorkspaceId)
? issue.projectWorkspaceId
: null;
if (issue.projectWorkspaceId && !targetProjectWorkspaceId) {
adjustments.push("clear_project_workspace");
incrementAdjustment(adjustmentCounts, "clear_project_workspace");
}
const targetGoalId =
issue.goalId && targetGoalIds.has(issue.goalId) ? issue.goalId : null;
if (issue.goalId && !targetGoalId) {
adjustments.push("clear_goal");
incrementAdjustment(adjustmentCounts, "clear_goal");
}
let targetStatus = issue.status;
if (
targetStatus === "in_progress"
&& !targetAssigneeAgentId
&& !(issue.assigneeUserId && issue.assigneeUserId.trim().length > 0)
) {
targetStatus = "todo";
adjustments.push("coerce_in_progress_to_todo");
incrementAdjustment(adjustmentCounts, "coerce_in_progress_to_todo");
}
issuePlans.push({
source: issue,
action: "insert",
previewIssueNumber: nextPreviewIssueNumber,
previewIdentifier: `${input.issuePrefix}-${nextPreviewIssueNumber}`,
targetStatus,
targetAssigneeAgentId,
targetCreatedByAgentId,
targetProjectId,
targetProjectWorkspaceId,
targetGoalId,
adjustments,
});
}
const issueIdsAvailableAfterImport = new Set<string>([
...input.targetIssues.map((issue) => issue.id),
...issuePlans.filter((plan): plan is PlannedIssueInsert => plan.action === "insert").map((plan) => plan.source.id),
]);
const commentPlans: Array<PlannedCommentInsert | PlannedCommentSkip> = [];
if (scopes.has("comments")) {
const sortedComments = [...input.sourceComments].sort((left, right) => {
const createdDelta = left.createdAt.getTime() - right.createdAt.getTime();
if (createdDelta !== 0) return createdDelta;
return left.id.localeCompare(right.id);
});
for (const comment of sortedComments) {
if (targetCommentIds.has(comment.id)) {
commentPlans.push({ source: comment, action: "skip_existing" });
continue;
}
if (!issueIdsAvailableAfterImport.has(comment.issueId)) {
commentPlans.push({ source: comment, action: "skip_missing_parent" });
continue;
}
const adjustments: ImportAdjustment[] = [];
const targetAuthorAgentId =
comment.authorAgentId && targetAgentIds.has(comment.authorAgentId) ? comment.authorAgentId : null;
if (comment.authorAgentId && !targetAuthorAgentId) {
adjustments.push("clear_author_agent");
incrementAdjustment(adjustmentCounts, "clear_author_agent");
}
commentPlans.push({
source: comment,
action: "insert",
targetAuthorAgentId,
adjustments,
});
}
}
const counts = {
issuesToInsert: issuePlans.filter((plan) => plan.action === "insert").length,
issuesExisting: issuePlans.filter((plan) => plan.action === "skip_existing").length,
issueDrift: issuePlans.filter((plan) => plan.action === "skip_existing" && plan.driftKeys.length > 0).length,
commentsToInsert: commentPlans.filter((plan) => plan.action === "insert").length,
commentsExisting: commentPlans.filter((plan) => plan.action === "skip_existing").length,
commentsMissingParent: commentPlans.filter((plan) => plan.action === "skip_missing_parent").length,
};
return {
companyId: input.companyId,
companyName: input.companyName,
issuePrefix: input.issuePrefix,
previewIssueCounterStart: input.previewIssueCounterStart,
scopes: input.scopes,
issuePlans,
commentPlans,
counts,
adjustments: adjustmentCounts,
};
}

View File

@@ -17,13 +17,21 @@ import { execFileSync } from "node:child_process";
import { createServer } from "node:net";
import * as p from "@clack/prompts";
import pc from "picocolors";
import { eq } from "drizzle-orm";
import { and, eq, inArray, sql } from "drizzle-orm";
import {
applyPendingMigrations,
agents,
companies,
createDb,
ensurePostgresDatabase,
formatDatabaseBackupResult,
goals,
heartbeatRuns,
issueComments,
issueDocuments,
issues,
projectWorkspaces,
projects,
runDatabaseBackup,
runDatabaseRestore,
} from "@paperclipai/db";
@@ -48,6 +56,12 @@ import {
type WorktreeSeedMode,
type WorktreeLocalPaths,
} from "./worktree-lib.js";
import {
buildWorktreeMergePlan,
parseWorktreeMergeScopes,
type PlannedCommentInsert,
type PlannedIssueInsert,
} from "./worktree-merge-history-lib.js";
type WorktreeInitOptions = {
name?: string;
@@ -73,6 +87,14 @@ type WorktreeEnvOptions = {
json?: boolean;
};
type WorktreeMergeHistoryOptions = {
company?: string;
scope?: string;
apply?: boolean;
dry?: boolean;
yes?: boolean;
};
type EmbeddedPostgresInstance = {
initialise(): Promise<void>;
start(): Promise<void>;
@@ -1071,6 +1093,447 @@ export async function worktreeEnvCommand(opts: WorktreeEnvOptions): Promise<void
console.log(formatShellExports(out));
}
type ClosableDb = ReturnType<typeof createDb> & {
$client?: { end?: (opts?: { timeout?: number }) => Promise<void> };
};
type OpenDbHandle = {
db: ClosableDb;
stop: () => Promise<void>;
};
type ResolvedMergeCompany = {
id: string;
name: string;
issuePrefix: string;
};
function requirePathArgument(name: string, value: string | undefined): string {
const trimmed = nonEmpty(value);
if (!trimmed) {
throw new Error(`${name} is required.`);
}
return path.resolve(trimmed);
}
async function closeDb(db: ClosableDb): Promise<void> {
await db.$client?.end?.({ timeout: 5 }).catch(() => undefined);
}
async function openConfiguredDb(configPath: string): Promise<OpenDbHandle> {
const config = readConfig(configPath);
if (!config) {
throw new Error(`Config not found at ${configPath}.`);
}
const envEntries = readPaperclipEnvEntries(resolvePaperclipEnvFile(configPath));
let embeddedHandle: EmbeddedPostgresHandle | null = null;
try {
if (config.database.mode === "embedded-postgres") {
embeddedHandle = await ensureEmbeddedPostgres(
config.database.embeddedPostgresDataDir,
config.database.embeddedPostgresPort,
);
}
const connectionString = resolveSourceConnectionString(config, envEntries, embeddedHandle?.port);
const db = createDb(connectionString) as ClosableDb;
return {
db,
stop: async () => {
await closeDb(db);
if (embeddedHandle?.startedByThisProcess) {
await embeddedHandle.stop();
}
},
};
} catch (error) {
if (embeddedHandle?.startedByThisProcess) {
await embeddedHandle.stop().catch(() => undefined);
}
throw error;
}
}
async function resolveMergeCompany(input: {
sourceDb: ClosableDb;
targetDb: ClosableDb;
selector?: string;
}): Promise<ResolvedMergeCompany> {
const [sourceCompanies, targetCompanies] = await Promise.all([
input.sourceDb
.select({
id: companies.id,
name: companies.name,
issuePrefix: companies.issuePrefix,
})
.from(companies),
input.targetDb
.select({
id: companies.id,
name: companies.name,
issuePrefix: companies.issuePrefix,
})
.from(companies),
]);
const targetById = new Map(targetCompanies.map((company) => [company.id, company]));
const shared = sourceCompanies.filter((company) => targetById.has(company.id));
const selector = nonEmpty(input.selector);
if (selector) {
const matched = shared.find(
(company) => company.id === selector || company.issuePrefix.toLowerCase() === selector.toLowerCase(),
);
if (!matched) {
throw new Error(`Could not resolve company "${selector}" in both source and target databases.`);
}
return matched;
}
if (shared.length === 1) {
return shared[0];
}
if (shared.length === 0) {
throw new Error("Source and target databases do not share a company id. Pass --company explicitly once both sides match.");
}
const options = shared
.map((company) => `${company.issuePrefix} (${company.name})`)
.join(", ");
throw new Error(`Multiple shared companies found. Re-run with --company <id-or-prefix>. Options: ${options}`);
}
function renderMergePlan(plan: Awaited<ReturnType<typeof collectMergePlan>>["plan"], extras: {
sourcePath: string;
unsupportedRunCount: number;
unsupportedDocumentCount: number;
}): string {
const lines = [
`Mode: preview`,
`Source: ${extras.sourcePath}`,
`Company: ${plan.companyName} (${plan.issuePrefix})`,
"",
"Issues",
`- insert: ${plan.counts.issuesToInsert}`,
`- already present: ${plan.counts.issuesExisting}`,
`- shared/imported issues with drift: ${plan.counts.issueDrift}`,
];
const issueInserts = plan.issuePlans.filter((item): item is PlannedIssueInsert => item.action === "insert");
if (issueInserts.length > 0) {
lines.push("");
lines.push("Planned issue imports");
for (const issue of issueInserts) {
const adjustments = issue.adjustments.length > 0 ? ` [${issue.adjustments.join(", ")}]` : "";
lines.push(
`- ${issue.source.identifier ?? issue.source.id} -> ${issue.previewIdentifier} (${issue.targetStatus})${adjustments}`,
);
}
}
if (plan.scopes.includes("comments")) {
lines.push("");
lines.push("Comments");
lines.push(`- insert: ${plan.counts.commentsToInsert}`);
lines.push(`- already present: ${plan.counts.commentsExisting}`);
lines.push(`- skipped (missing parent): ${plan.counts.commentsMissingParent}`);
}
lines.push("");
lines.push("Adjustments");
lines.push(`- cleared assignee agents: ${plan.adjustments.clear_assignee_agent}`);
lines.push(`- cleared projects: ${plan.adjustments.clear_project}`);
lines.push(`- cleared project workspaces: ${plan.adjustments.clear_project_workspace}`);
lines.push(`- cleared goals: ${plan.adjustments.clear_goal}`);
lines.push(`- cleared comment author agents: ${plan.adjustments.clear_author_agent}`);
lines.push(`- coerced in_progress to todo: ${plan.adjustments.coerce_in_progress_to_todo}`);
lines.push("");
lines.push("Not imported in this phase");
lines.push(`- heartbeat runs: ${extras.unsupportedRunCount}`);
lines.push(`- issue documents: ${extras.unsupportedDocumentCount}`);
lines.push("");
lines.push("Identifiers shown above are provisional preview values. `--apply` reserves fresh issue numbers at write time.");
return lines.join("\n");
}
async function collectMergePlan(input: {
sourceDb: ClosableDb;
targetDb: ClosableDb;
company: ResolvedMergeCompany;
scopes: ReturnType<typeof parseWorktreeMergeScopes>;
}) {
const companyId = input.company.id;
const [targetCompanyRow, sourceIssuesRows, targetIssuesRows, sourceCommentsRows, targetCommentsRows, targetAgentsRows, targetProjectsRows, targetProjectWorkspaceRows, targetGoalsRows, runCountRows, documentCountRows] = await Promise.all([
input.targetDb
.select({
issueCounter: companies.issueCounter,
})
.from(companies)
.where(eq(companies.id, companyId))
.then((rows) => rows[0] ?? null),
input.sourceDb
.select()
.from(issues)
.where(eq(issues.companyId, companyId)),
input.targetDb
.select()
.from(issues)
.where(eq(issues.companyId, companyId)),
input.scopes.includes("comments")
? input.sourceDb
.select()
.from(issueComments)
.where(eq(issueComments.companyId, companyId))
: Promise.resolve([]),
input.scopes.includes("comments")
? input.targetDb
.select()
.from(issueComments)
.where(eq(issueComments.companyId, companyId))
: Promise.resolve([]),
input.targetDb
.select()
.from(agents)
.where(eq(agents.companyId, companyId)),
input.targetDb
.select()
.from(projects)
.where(eq(projects.companyId, companyId)),
input.targetDb
.select()
.from(projectWorkspaces)
.where(eq(projectWorkspaces.companyId, companyId)),
input.targetDb
.select()
.from(goals)
.where(eq(goals.companyId, companyId)),
input.sourceDb
.select({ count: sql<number>`count(*)::int` })
.from(heartbeatRuns)
.where(eq(heartbeatRuns.companyId, companyId)),
input.sourceDb
.select({ count: sql<number>`count(*)::int` })
.from(issueDocuments)
.innerJoin(issues, eq(issueDocuments.issueId, issues.id))
.where(eq(issues.companyId, companyId)),
]);
if (!targetCompanyRow) {
throw new Error(`Target company ${companyId} was not found.`);
}
const plan = buildWorktreeMergePlan({
companyId,
companyName: input.company.name,
issuePrefix: input.company.issuePrefix,
previewIssueCounterStart: targetCompanyRow.issueCounter,
scopes: input.scopes,
sourceIssues: sourceIssuesRows,
targetIssues: targetIssuesRows,
sourceComments: sourceCommentsRows,
targetComments: targetCommentsRows,
targetAgents: targetAgentsRows,
targetProjects: targetProjectsRows,
targetProjectWorkspaces: targetProjectWorkspaceRows,
targetGoals: targetGoalsRows,
});
return {
plan,
unsupportedRunCount: runCountRows[0]?.count ?? 0,
unsupportedDocumentCount: documentCountRows[0]?.count ?? 0,
};
}
async function applyMergePlan(input: {
targetDb: ClosableDb;
company: ResolvedMergeCompany;
plan: Awaited<ReturnType<typeof collectMergePlan>>["plan"];
}) {
const companyId = input.company.id;
return await input.targetDb.transaction(async (tx) => {
const issueCandidates = input.plan.issuePlans.filter(
(plan): plan is PlannedIssueInsert => plan.action === "insert",
);
const issueCandidateIds = issueCandidates.map((issue) => issue.source.id);
const existingIssueIds = issueCandidateIds.length > 0
? new Set(
(await tx
.select({ id: issues.id })
.from(issues)
.where(inArray(issues.id, issueCandidateIds)))
.map((row) => row.id),
)
: new Set<string>();
const issueInserts = issueCandidates.filter((issue) => !existingIssueIds.has(issue.source.id));
let nextIssueNumber = 0;
if (issueInserts.length > 0) {
const [companyRow] = await tx
.update(companies)
.set({ issueCounter: sql`${companies.issueCounter} + ${issueInserts.length}` })
.where(eq(companies.id, companyId))
.returning({ issueCounter: companies.issueCounter });
nextIssueNumber = companyRow.issueCounter - issueInserts.length + 1;
}
const insertedIssueIdentifiers = new Map<string, string>();
for (const issue of issueInserts) {
const issueNumber = nextIssueNumber;
nextIssueNumber += 1;
const identifier = `${input.company.issuePrefix}-${issueNumber}`;
insertedIssueIdentifiers.set(issue.source.id, identifier);
await tx.insert(issues).values({
id: issue.source.id,
companyId,
projectId: issue.targetProjectId,
projectWorkspaceId: issue.targetProjectWorkspaceId,
goalId: issue.targetGoalId,
parentId: issue.source.parentId,
title: issue.source.title,
description: issue.source.description,
status: issue.targetStatus,
priority: issue.source.priority,
assigneeAgentId: issue.targetAssigneeAgentId,
assigneeUserId: issue.source.assigneeUserId,
checkoutRunId: null,
executionRunId: null,
executionAgentNameKey: null,
executionLockedAt: null,
createdByAgentId: issue.targetCreatedByAgentId,
createdByUserId: issue.source.createdByUserId,
issueNumber,
identifier,
requestDepth: issue.source.requestDepth,
billingCode: issue.source.billingCode,
assigneeAdapterOverrides: issue.targetAssigneeAgentId ? issue.source.assigneeAdapterOverrides : null,
executionWorkspaceId: null,
executionWorkspacePreference: null,
executionWorkspaceSettings: null,
startedAt: issue.source.startedAt,
completedAt: issue.source.completedAt,
cancelledAt: issue.source.cancelledAt,
hiddenAt: issue.source.hiddenAt,
createdAt: issue.source.createdAt,
updatedAt: issue.source.updatedAt,
});
}
const commentCandidates = input.plan.commentPlans.filter(
(plan): plan is PlannedCommentInsert => plan.action === "insert",
);
const commentCandidateIds = commentCandidates.map((comment) => comment.source.id);
const existingCommentIds = commentCandidateIds.length > 0
? new Set(
(await tx
.select({ id: issueComments.id })
.from(issueComments)
.where(inArray(issueComments.id, commentCandidateIds)))
.map((row) => row.id),
)
: new Set<string>();
for (const comment of commentCandidates) {
if (existingCommentIds.has(comment.source.id)) continue;
const parentExists = await tx
.select({ id: issues.id })
.from(issues)
.where(and(eq(issues.id, comment.source.issueId), eq(issues.companyId, companyId)))
.then((rows) => rows[0] ?? null);
if (!parentExists) continue;
await tx.insert(issueComments).values({
id: comment.source.id,
companyId,
issueId: comment.source.issueId,
authorAgentId: comment.targetAuthorAgentId,
authorUserId: comment.source.authorUserId,
body: comment.source.body,
createdAt: comment.source.createdAt,
updatedAt: comment.source.updatedAt,
});
}
return {
insertedIssues: issueInserts.length,
insertedComments: commentCandidates.filter((comment) => !existingCommentIds.has(comment.source.id)).length,
insertedIssueIdentifiers,
};
});
}
export async function worktreeMergeHistoryCommand(sourceArg: string, opts: WorktreeMergeHistoryOptions): Promise<void> {
if (opts.apply && opts.dry) {
throw new Error("Use either --apply or --dry, not both.");
}
const sourceRoot = requirePathArgument("Source worktree path", sourceArg);
const sourceConfigPath = path.resolve(sourceRoot, ".paperclip", "config.json");
if (!existsSync(sourceConfigPath)) {
throw new Error(`Source worktree config not found at ${sourceConfigPath}.`);
}
const targetConfigPath = resolveConfigPath();
if (path.resolve(sourceConfigPath) === path.resolve(targetConfigPath)) {
throw new Error("Source and target Paperclip configs are the same. Point --source at a different worktree.");
}
const scopes = parseWorktreeMergeScopes(opts.scope);
const sourceHandle = await openConfiguredDb(sourceConfigPath);
const targetHandle = await openConfiguredDb(targetConfigPath);
try {
const company = await resolveMergeCompany({
sourceDb: sourceHandle.db,
targetDb: targetHandle.db,
selector: opts.company,
});
const collected = await collectMergePlan({
sourceDb: sourceHandle.db,
targetDb: targetHandle.db,
company,
scopes,
});
console.log(renderMergePlan(collected.plan, {
sourcePath: sourceRoot,
unsupportedRunCount: collected.unsupportedRunCount,
unsupportedDocumentCount: collected.unsupportedDocumentCount,
}));
if (!opts.apply) {
return;
}
const confirmed = opts.yes
? true
: await p.confirm({
message: `Import ${collected.plan.counts.issuesToInsert} issues and ${collected.plan.counts.commentsToInsert} comments from ${path.basename(sourceRoot)}?`,
initialValue: false,
});
if (p.isCancel(confirmed) || !confirmed) {
p.log.warn("Import cancelled.");
return;
}
const applied = await applyMergePlan({
targetDb: targetHandle.db,
company,
plan: collected.plan,
});
p.outro(
pc.green(
`Imported ${applied.insertedIssues} issues and ${applied.insertedComments} comments into ${company.issuePrefix}.`,
),
);
} finally {
await targetHandle.stop();
await sourceHandle.stop();
}
}
export function registerWorktreeCommands(program: Command): void {
const worktree = program.command("worktree").description("Worktree-local Paperclip instance helpers");
@@ -1114,6 +1577,17 @@ export function registerWorktreeCommands(program: Command): void {
.option("--json", "Print JSON instead of shell exports")
.action(worktreeEnvCommand);
program
.command("worktree:merge-history")
.description("Preview or import issue/comment history from another worktree into the current instance")
.argument("<source>", "Path to the source worktree root")
.option("--company <id-or-prefix>", "Company id or issue prefix to import")
.option("--scope <items>", "Comma-separated scopes to import (issues, comments)", "issues,comments")
.option("--apply", "Apply the import after previewing the plan", false)
.option("--dry", "Preview only and do not import anything", false)
.option("--yes", "Skip the interactive confirmation prompt when applying", false)
.action(worktreeMergeHistoryCommand);
program
.command("worktree:cleanup")
.description("Safely remove a worktree, its branch, and its isolated instance data")