Add merge-history project import option

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
dotta 2026-03-21 17:09:46 -05:00
parent 8b4850aaea
commit 5dfdbe91bb
3 changed files with 289 additions and 12 deletions

View file

@ -115,6 +115,52 @@ function makeAttachment(overrides: Record<string, unknown> = {}) {
} as any; } as any;
} }
function makeProject(overrides: Record<string, unknown> = {}) {
return {
id: "project-1",
companyId: "company-1",
goalId: null,
name: "Project",
description: null,
status: "in_progress",
leadAgentId: null,
targetDate: null,
color: "#22c55e",
pauseReason: null,
pausedAt: null,
executionWorkspacePolicy: null,
archivedAt: null,
createdAt: new Date("2026-03-20T00:00:00.000Z"),
updatedAt: new Date("2026-03-20T00:00:00.000Z"),
...overrides,
} as any;
}
function makeProjectWorkspace(overrides: Record<string, unknown> = {}) {
return {
id: "workspace-1",
companyId: "company-1",
projectId: "project-1",
name: "Workspace",
sourceType: "local_path",
cwd: "/tmp/project",
repoUrl: "https://github.com/example/project.git",
repoRef: "main",
defaultRef: "main",
visibility: "default",
setupCommand: null,
cleanupCommand: null,
remoteProvider: null,
remoteWorkspaceRef: null,
sharedWorkspaceKey: null,
metadata: null,
isPrimary: true,
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", () => { describe("worktree merge history planner", () => {
it("parses default scopes", () => { it("parses default scopes", () => {
expect(parseWorktreeMergeScopes(undefined)).toEqual(["issues", "comments"]); expect(parseWorktreeMergeScopes(undefined)).toEqual(["issues", "comments"]);
@ -236,6 +282,60 @@ describe("worktree merge history planner", () => {
expect(insert.adjustments).toEqual(["clear_project_workspace"]); expect(insert.adjustments).toEqual(["clear_project_workspace"]);
}); });
it("plans selected project imports and preserves project workspace links", () => {
const sourceProject = makeProject({
id: "source-project-1",
name: "Paperclip Evals",
goalId: "goal-1",
});
const sourceWorkspace = makeProjectWorkspace({
id: "source-workspace-1",
projectId: "source-project-1",
cwd: "/Users/dotta/paperclip-evals",
repoUrl: "https://github.com/paperclipai/paperclip-evals.git",
});
const plan = buildWorktreeMergePlan({
companyId: "company-1",
companyName: "Paperclip",
issuePrefix: "PAP",
previewIssueCounterStart: 10,
scopes: ["issues"],
sourceIssues: [
makeIssue({
id: "issue-project-import",
identifier: "PAP-88",
projectId: "source-project-1",
projectWorkspaceId: "source-workspace-1",
}),
],
targetIssues: [],
sourceComments: [],
targetComments: [],
sourceProjects: [sourceProject],
sourceProjectWorkspaces: [sourceWorkspace],
targetAgents: [],
targetProjects: [],
targetProjectWorkspaces: [],
targetGoals: [{ id: "goal-1" }] as any,
importProjectIds: ["source-project-1"],
});
expect(plan.counts.projectsToImport).toBe(1);
expect(plan.projectImports[0]).toMatchObject({
source: { id: "source-project-1", name: "Paperclip Evals" },
targetGoalId: "goal-1",
workspaces: [{ id: "source-workspace-1" }],
});
const insert = plan.issuePlans[0] as any;
expect(insert.targetProjectId).toBe("source-project-1");
expect(insert.targetProjectWorkspaceId).toBe("source-workspace-1");
expect(insert.projectResolution).toBe("imported");
expect(insert.mappedProjectName).toBe("Paperclip Evals");
expect(insert.adjustments).toEqual([]);
});
it("imports comments onto shared or newly imported issues while skipping existing comments", () => { it("imports comments onto shared or newly imported issues while skipping existing comments", () => {
const sharedIssue = makeIssue({ id: "issue-a", identifier: "PAP-10" }); const sharedIssue = makeIssue({ id: "issue-a", identifier: "PAP-10" });
const newIssue = makeIssue({ const newIssue = makeIssue({

View file

@ -50,7 +50,7 @@ export type PlannedIssueInsert = {
targetProjectId: string | null; targetProjectId: string | null;
targetProjectWorkspaceId: string | null; targetProjectWorkspaceId: string | null;
targetGoalId: string | null; targetGoalId: string | null;
projectResolution: "preserved" | "cleared" | "mapped"; projectResolution: "preserved" | "cleared" | "mapped" | "imported";
mappedProjectName: string | null; mappedProjectName: string | null;
adjustments: ImportAdjustment[]; adjustments: ImportAdjustment[];
}; };
@ -173,17 +173,26 @@ export type PlannedAttachmentSkip = {
action: "skip_existing" | "skip_missing_parent"; action: "skip_existing" | "skip_missing_parent";
}; };
export type PlannedProjectImport = {
source: ProjectRow;
targetLeadAgentId: string | null;
targetGoalId: string | null;
workspaces: ProjectWorkspaceRow[];
};
export type WorktreeMergePlan = { export type WorktreeMergePlan = {
companyId: string; companyId: string;
companyName: string; companyName: string;
issuePrefix: string; issuePrefix: string;
previewIssueCounterStart: number; previewIssueCounterStart: number;
scopes: WorktreeMergeScope[]; scopes: WorktreeMergeScope[];
projectImports: PlannedProjectImport[];
issuePlans: Array<PlannedIssueInsert | PlannedIssueSkip>; issuePlans: Array<PlannedIssueInsert | PlannedIssueSkip>;
commentPlans: Array<PlannedCommentInsert | PlannedCommentSkip>; commentPlans: Array<PlannedCommentInsert | PlannedCommentSkip>;
documentPlans: Array<PlannedIssueDocumentInsert | PlannedIssueDocumentMerge | PlannedIssueDocumentSkip>; documentPlans: Array<PlannedIssueDocumentInsert | PlannedIssueDocumentMerge | PlannedIssueDocumentSkip>;
attachmentPlans: Array<PlannedAttachmentInsert | PlannedAttachmentSkip>; attachmentPlans: Array<PlannedAttachmentInsert | PlannedAttachmentSkip>;
counts: { counts: {
projectsToImport: number;
issuesToInsert: number; issuesToInsert: number;
issuesExisting: number; issuesExisting: number;
issueDrift: number; issueDrift: number;
@ -338,6 +347,8 @@ export function buildWorktreeMergePlan(input: {
targetIssues: IssueRow[]; targetIssues: IssueRow[];
sourceComments: CommentRow[]; sourceComments: CommentRow[];
targetComments: CommentRow[]; targetComments: CommentRow[];
sourceProjects?: ProjectRow[];
sourceProjectWorkspaces?: ProjectWorkspaceRow[];
sourceDocuments?: IssueDocumentRow[]; sourceDocuments?: IssueDocumentRow[];
targetDocuments?: IssueDocumentRow[]; targetDocuments?: IssueDocumentRow[];
sourceDocumentRevisions?: DocumentRevisionRow[]; sourceDocumentRevisions?: DocumentRevisionRow[];
@ -348,6 +359,7 @@ export function buildWorktreeMergePlan(input: {
targetProjects: ProjectRow[]; targetProjects: ProjectRow[];
targetProjectWorkspaces: ProjectWorkspaceRow[]; targetProjectWorkspaces: ProjectWorkspaceRow[];
targetGoals: GoalRow[]; targetGoals: GoalRow[];
importProjectIds?: Iterable<string>;
projectIdOverrides?: Record<string, string | null | undefined>; projectIdOverrides?: Record<string, string | null | undefined>;
}): WorktreeMergePlan { }): WorktreeMergePlan {
const targetIssuesById = new Map(input.targetIssues.map((issue) => [issue.id, issue])); const targetIssuesById = new Map(input.targetIssues.map((issue) => [issue.id, issue]));
@ -357,6 +369,10 @@ export function buildWorktreeMergePlan(input: {
const targetProjectsById = new Map(input.targetProjects.map((project) => [project.id, project])); const targetProjectsById = new Map(input.targetProjects.map((project) => [project.id, project]));
const targetProjectWorkspaceIds = new Set(input.targetProjectWorkspaces.map((workspace) => workspace.id)); const targetProjectWorkspaceIds = new Set(input.targetProjectWorkspaces.map((workspace) => workspace.id));
const targetGoalIds = new Set(input.targetGoals.map((goal) => goal.id)); const targetGoalIds = new Set(input.targetGoals.map((goal) => goal.id));
const sourceProjectsById = new Map((input.sourceProjects ?? []).map((project) => [project.id, project]));
const sourceProjectWorkspaces = input.sourceProjectWorkspaces ?? [];
const sourceProjectWorkspacesByProjectId = groupBy(sourceProjectWorkspaces, (workspace) => workspace.projectId);
const importProjectIds = new Set(input.importProjectIds ?? []);
const scopes = new Set(input.scopes); const scopes = new Set(input.scopes);
const adjustmentCounts: Record<ImportAdjustment, number> = { const adjustmentCounts: Record<ImportAdjustment, number> = {
@ -371,6 +387,34 @@ export function buildWorktreeMergePlan(input: {
clear_attachment_agent: 0, clear_attachment_agent: 0,
}; };
const projectImports: PlannedProjectImport[] = [];
for (const projectId of importProjectIds) {
if (targetProjectIds.has(projectId)) continue;
const sourceProject = sourceProjectsById.get(projectId);
if (!sourceProject) continue;
projectImports.push({
source: sourceProject,
targetLeadAgentId:
sourceProject.leadAgentId && targetAgentIds.has(sourceProject.leadAgentId)
? sourceProject.leadAgentId
: null,
targetGoalId:
sourceProject.goalId && targetGoalIds.has(sourceProject.goalId)
? sourceProject.goalId
: null,
workspaces: [...(sourceProjectWorkspacesByProjectId.get(projectId) ?? [])].sort((left, right) => {
const primaryDelta = Number(right.isPrimary) - Number(left.isPrimary);
if (primaryDelta !== 0) return primaryDelta;
const createdDelta = left.createdAt.getTime() - right.createdAt.getTime();
if (createdDelta !== 0) return createdDelta;
return left.id.localeCompare(right.id);
}),
});
}
const importedProjectWorkspaceIds = new Set(
projectImports.flatMap((project) => project.workspaces.map((workspace) => workspace.id)),
);
const issuePlans: Array<PlannedIssueInsert | PlannedIssueSkip> = []; const issuePlans: Array<PlannedIssueInsert | PlannedIssueSkip> = [];
let nextPreviewIssueNumber = input.previewIssueCounterStart; let nextPreviewIssueNumber = input.previewIssueCounterStart;
for (const issue of sortIssuesForImport(input.sourceIssues)) { for (const issue of sortIssuesForImport(input.sourceIssues)) {
@ -409,6 +453,14 @@ export function buildWorktreeMergePlan(input: {
projectResolution = "mapped"; projectResolution = "mapped";
mappedProjectName = targetProjectsById.get(overrideProjectId)?.name ?? null; mappedProjectName = targetProjectsById.get(overrideProjectId)?.name ?? null;
} }
if (!targetProjectId && issue.projectId && importProjectIds.has(issue.projectId)) {
const sourceProject = sourceProjectsById.get(issue.projectId);
if (sourceProject) {
targetProjectId = sourceProject.id;
projectResolution = "imported";
mappedProjectName = sourceProject.name;
}
}
if (issue.projectId && !targetProjectId) { if (issue.projectId && !targetProjectId) {
adjustments.push("clear_project"); adjustments.push("clear_project");
incrementAdjustment(adjustmentCounts, "clear_project"); incrementAdjustment(adjustmentCounts, "clear_project");
@ -418,7 +470,8 @@ export function buildWorktreeMergePlan(input: {
targetProjectId targetProjectId
&& targetProjectId === issue.projectId && targetProjectId === issue.projectId
&& issue.projectWorkspaceId && issue.projectWorkspaceId
&& targetProjectWorkspaceIds.has(issue.projectWorkspaceId) && (targetProjectWorkspaceIds.has(issue.projectWorkspaceId)
|| importedProjectWorkspaceIds.has(issue.projectWorkspaceId))
? issue.projectWorkspaceId ? issue.projectWorkspaceId
: null; : null;
if (issue.projectWorkspaceId && !targetProjectWorkspaceId) { if (issue.projectWorkspaceId && !targetProjectWorkspaceId) {
@ -672,6 +725,7 @@ export function buildWorktreeMergePlan(input: {
} }
const counts = { const counts = {
projectsToImport: projectImports.length,
issuesToInsert: issuePlans.filter((plan) => plan.action === "insert").length, issuesToInsert: issuePlans.filter((plan) => plan.action === "insert").length,
issuesExisting: issuePlans.filter((plan) => plan.action === "skip_existing").length, issuesExisting: issuePlans.filter((plan) => plan.action === "skip_existing").length,
issueDrift: issuePlans.filter((plan) => plan.action === "skip_existing" && plan.driftKeys.length > 0).length, issueDrift: issuePlans.filter((plan) => plan.action === "skip_existing" && plan.driftKeys.length > 0).length,
@ -699,6 +753,7 @@ export function buildWorktreeMergePlan(input: {
issuePrefix: input.issuePrefix, issuePrefix: input.issuePrefix,
previewIssueCounterStart: input.previewIssueCounterStart, previewIssueCounterStart: input.previewIssueCounterStart,
scopes: input.scopes, scopes: input.scopes,
projectImports,
issuePlans, issuePlans,
commentPlans, commentPlans,
documentPlans, documentPlans,

View file

@ -1488,20 +1488,34 @@ function renderMergePlan(plan: Awaited<ReturnType<typeof collectMergePlan>>["pla
`Target: ${extras.targetPath}`, `Target: ${extras.targetPath}`,
`Company: ${plan.companyName} (${plan.issuePrefix})`, `Company: ${plan.companyName} (${plan.issuePrefix})`,
"", "",
"Projects",
`- import: ${plan.counts.projectsToImport}`,
"",
"Issues", "Issues",
`- insert: ${plan.counts.issuesToInsert}`, `- insert: ${plan.counts.issuesToInsert}`,
`- already present: ${plan.counts.issuesExisting}`, `- already present: ${plan.counts.issuesExisting}`,
`- shared/imported issues with drift: ${plan.counts.issueDrift}`, `- shared/imported issues with drift: ${plan.counts.issueDrift}`,
]; ];
if (plan.projectImports.length > 0) {
lines.push("");
lines.push("Planned project imports");
for (const project of plan.projectImports) {
lines.push(
`- ${project.source.name} (${project.workspaces.length} workspace${project.workspaces.length === 1 ? "" : "s"})`,
);
}
}
const issueInserts = plan.issuePlans.filter((item): item is PlannedIssueInsert => item.action === "insert"); const issueInserts = plan.issuePlans.filter((item): item is PlannedIssueInsert => item.action === "insert");
if (issueInserts.length > 0) { if (issueInserts.length > 0) {
lines.push(""); lines.push("");
lines.push("Planned issue imports"); lines.push("Planned issue imports");
for (const issue of issueInserts) { for (const issue of issueInserts) {
const projectNote = const projectNote =
issue.projectResolution === "mapped" && issue.mappedProjectName (issue.projectResolution === "mapped" || issue.projectResolution === "imported")
? ` project->${issue.mappedProjectName}` && issue.mappedProjectName
? ` project->${issue.projectResolution === "imported" ? "import:" : ""}${issue.mappedProjectName}`
: ""; : "";
const adjustments = issue.adjustments.length > 0 ? ` [${issue.adjustments.join(", ")}]` : ""; const adjustments = issue.adjustments.length > 0 ? ` [${issue.adjustments.join(", ")}]` : "";
const prefix = `- ${issue.source.identifier ?? issue.source.id} -> ${issue.previewIdentifier} (${issue.targetStatus}${projectNote})`; const prefix = `- ${issue.source.identifier ?? issue.source.id} -> ${issue.previewIdentifier} (${issue.targetStatus}${projectNote})`;
@ -1562,6 +1576,7 @@ async function collectMergePlan(input: {
targetDb: ClosableDb; targetDb: ClosableDb;
company: ResolvedMergeCompany; company: ResolvedMergeCompany;
scopes: ReturnType<typeof parseWorktreeMergeScopes>; scopes: ReturnType<typeof parseWorktreeMergeScopes>;
importProjectIds?: Iterable<string>;
projectIdOverrides?: Record<string, string | null | undefined>; projectIdOverrides?: Record<string, string | null | undefined>;
}) { }) {
const companyId = input.company.id; const companyId = input.company.id;
@ -1578,6 +1593,7 @@ async function collectMergePlan(input: {
sourceAttachmentRows, sourceAttachmentRows,
targetAttachmentRows, targetAttachmentRows,
sourceProjectsRows, sourceProjectsRows,
sourceProjectWorkspaceRows,
targetProjectsRows, targetProjectsRows,
targetAgentsRows, targetAgentsRows,
targetProjectWorkspaceRows, targetProjectWorkspaceRows,
@ -1743,6 +1759,10 @@ async function collectMergePlan(input: {
.select() .select()
.from(projects) .from(projects)
.where(eq(projects.companyId, companyId)), .where(eq(projects.companyId, companyId)),
input.sourceDb
.select()
.from(projectWorkspaces)
.where(eq(projectWorkspaces.companyId, companyId)),
input.targetDb input.targetDb
.select() .select()
.from(projects) .from(projects)
@ -1779,6 +1799,8 @@ async function collectMergePlan(input: {
targetIssues: targetIssuesRows, targetIssues: targetIssuesRows,
sourceComments: sourceCommentsRows, sourceComments: sourceCommentsRows,
targetComments: targetCommentsRows, targetComments: targetCommentsRows,
sourceProjects: sourceProjectsRows,
sourceProjectWorkspaces: sourceProjectWorkspaceRows,
sourceDocuments: sourceIssueDocumentsRows as IssueDocumentRow[], sourceDocuments: sourceIssueDocumentsRows as IssueDocumentRow[],
targetDocuments: targetIssueDocumentsRows as IssueDocumentRow[], targetDocuments: targetIssueDocumentsRows as IssueDocumentRow[],
sourceDocumentRevisions: sourceDocumentRevisionRows as DocumentRevisionRow[], sourceDocumentRevisions: sourceDocumentRevisionRows as DocumentRevisionRow[],
@ -1789,6 +1811,7 @@ async function collectMergePlan(input: {
targetProjects: targetProjectsRows, targetProjects: targetProjectsRows,
targetProjectWorkspaces: targetProjectWorkspaceRows, targetProjectWorkspaces: targetProjectWorkspaceRows,
targetGoals: targetGoalsRows, targetGoals: targetGoalsRows,
importProjectIds: input.importProjectIds,
projectIdOverrides: input.projectIdOverrides, projectIdOverrides: input.projectIdOverrides,
}); });
@ -1800,11 +1823,16 @@ async function collectMergePlan(input: {
}; };
} }
type ProjectMappingSelections = {
importProjectIds: string[];
projectIdOverrides: Record<string, string | null>;
};
async function promptForProjectMappings(input: { async function promptForProjectMappings(input: {
plan: Awaited<ReturnType<typeof collectMergePlan>>["plan"]; plan: Awaited<ReturnType<typeof collectMergePlan>>["plan"];
sourceProjects: Awaited<ReturnType<typeof collectMergePlan>>["sourceProjects"]; sourceProjects: Awaited<ReturnType<typeof collectMergePlan>>["sourceProjects"];
targetProjects: Awaited<ReturnType<typeof collectMergePlan>>["targetProjects"]; targetProjects: Awaited<ReturnType<typeof collectMergePlan>>["targetProjects"];
}): Promise<Record<string, string | null>> { }): Promise<ProjectMappingSelections> {
const missingProjectIds = [ const missingProjectIds = [
...new Set( ...new Set(
input.plan.issuePlans input.plan.issuePlans
@ -1813,8 +1841,11 @@ async function promptForProjectMappings(input: {
.map((plan) => plan.source.projectId as string), .map((plan) => plan.source.projectId as string),
), ),
]; ];
if (missingProjectIds.length === 0 || input.targetProjects.length === 0) { if (missingProjectIds.length === 0) {
return {}; return {
importProjectIds: [],
projectIdOverrides: {},
};
} }
const sourceProjectsById = new Map(input.sourceProjects.map((project) => [project.id, project])); const sourceProjectsById = new Map(input.sourceProjects.map((project) => [project.id, project]));
@ -1827,15 +1858,22 @@ async function promptForProjectMappings(input: {
})); }));
const mappings: Record<string, string | null> = {}; const mappings: Record<string, string | null> = {};
const importProjectIds = new Set<string>();
for (const sourceProjectId of missingProjectIds) { for (const sourceProjectId of missingProjectIds) {
const sourceProject = sourceProjectsById.get(sourceProjectId); const sourceProject = sourceProjectsById.get(sourceProjectId);
if (!sourceProject) continue; if (!sourceProject) continue;
const nameMatch = input.targetProjects.find( const nameMatch = input.targetProjects.find(
(project) => project.name.trim().toLowerCase() === sourceProject.name.trim().toLowerCase(), (project) => project.name.trim().toLowerCase() === sourceProject.name.trim().toLowerCase(),
); );
const importSelectionValue = `__import__:${sourceProjectId}`;
const selection = await p.select<string | null>({ const selection = await p.select<string | null>({
message: `Project "${sourceProject.name}" is missing in target. How should ${input.plan.issuePrefix} imports handle it?`, message: `Project "${sourceProject.name}" is missing in target. How should ${input.plan.issuePrefix} imports handle it?`,
options: [ options: [
{
value: importSelectionValue,
label: `Import ${sourceProject.name}`,
hint: "Create the project and copy its workspace settings",
},
...(nameMatch ...(nameMatch
? [{ ? [{
value: nameMatch.id, value: nameMatch.id,
@ -1855,10 +1893,17 @@ async function promptForProjectMappings(input: {
if (p.isCancel(selection)) { if (p.isCancel(selection)) {
throw new Error("Project mapping cancelled."); throw new Error("Project mapping cancelled.");
} }
if (selection === importSelectionValue) {
importProjectIds.add(sourceProjectId);
continue;
}
mappings[sourceProjectId] = selection; mappings[sourceProjectId] = selection;
} }
return mappings; return {
importProjectIds: [...importProjectIds],
projectIdOverrides: mappings,
};
} }
export async function worktreeListCommand(opts: WorktreeListOptions): Promise<void> { export async function worktreeListCommand(opts: WorktreeListOptions): Promise<void> {
@ -1976,6 +2021,77 @@ async function applyMergePlan(input: {
const companyId = input.company.id; const companyId = input.company.id;
return await input.targetDb.transaction(async (tx) => { return await input.targetDb.transaction(async (tx) => {
const importedProjectIds = input.plan.projectImports.map((project) => project.source.id);
const existingImportedProjectIds = importedProjectIds.length > 0
? new Set(
(await tx
.select({ id: projects.id })
.from(projects)
.where(inArray(projects.id, importedProjectIds)))
.map((row) => row.id),
)
: new Set<string>();
const projectImports = input.plan.projectImports.filter((project) => !existingImportedProjectIds.has(project.source.id));
const importedWorkspaceIds = projectImports.flatMap((project) => project.workspaces.map((workspace) => workspace.id));
const existingImportedWorkspaceIds = importedWorkspaceIds.length > 0
? new Set(
(await tx
.select({ id: projectWorkspaces.id })
.from(projectWorkspaces)
.where(inArray(projectWorkspaces.id, importedWorkspaceIds)))
.map((row) => row.id),
)
: new Set<string>();
let insertedProjects = 0;
let insertedProjectWorkspaces = 0;
for (const project of projectImports) {
await tx.insert(projects).values({
id: project.source.id,
companyId,
goalId: project.targetGoalId,
name: project.source.name,
description: project.source.description,
status: project.source.status,
leadAgentId: project.targetLeadAgentId,
targetDate: project.source.targetDate,
color: project.source.color,
pauseReason: project.source.pauseReason,
pausedAt: project.source.pausedAt,
executionWorkspacePolicy: project.source.executionWorkspacePolicy,
archivedAt: project.source.archivedAt,
createdAt: project.source.createdAt,
updatedAt: project.source.updatedAt,
});
insertedProjects += 1;
for (const workspace of project.workspaces) {
if (existingImportedWorkspaceIds.has(workspace.id)) continue;
await tx.insert(projectWorkspaces).values({
id: workspace.id,
companyId,
projectId: project.source.id,
name: workspace.name,
sourceType: workspace.sourceType,
cwd: workspace.cwd,
repoUrl: workspace.repoUrl,
repoRef: workspace.repoRef,
defaultRef: workspace.defaultRef,
visibility: workspace.visibility,
setupCommand: workspace.setupCommand,
cleanupCommand: workspace.cleanupCommand,
remoteProvider: workspace.remoteProvider,
remoteWorkspaceRef: workspace.remoteWorkspaceRef,
sharedWorkspaceKey: workspace.sharedWorkspaceKey,
metadata: workspace.metadata,
isPrimary: workspace.isPrimary,
createdAt: workspace.createdAt,
updatedAt: workspace.updatedAt,
});
insertedProjectWorkspaces += 1;
}
}
const issueCandidates = input.plan.issuePlans.filter( const issueCandidates = input.plan.issuePlans.filter(
(plan): plan is PlannedIssueInsert => plan.action === "insert", (plan): plan is PlannedIssueInsert => plan.action === "insert",
); );
@ -2274,6 +2390,8 @@ async function applyMergePlan(input: {
} }
return { return {
insertedProjects,
insertedProjectWorkspaces,
insertedIssues, insertedIssues,
insertedComments, insertedComments,
insertedDocuments, insertedDocuments,
@ -2330,18 +2448,22 @@ export async function worktreeMergeHistoryCommand(sourceArg: string | undefined,
scopes, scopes,
}); });
if (!opts.yes) { if (!opts.yes) {
const projectIdOverrides = await promptForProjectMappings({ const projectSelections = await promptForProjectMappings({
plan: collected.plan, plan: collected.plan,
sourceProjects: collected.sourceProjects, sourceProjects: collected.sourceProjects,
targetProjects: collected.targetProjects, targetProjects: collected.targetProjects,
}); });
if (Object.keys(projectIdOverrides).length > 0) { if (
projectSelections.importProjectIds.length > 0
|| Object.keys(projectSelections.projectIdOverrides).length > 0
) {
collected = await collectMergePlan({ collected = await collectMergePlan({
sourceDb: sourceHandle.db, sourceDb: sourceHandle.db,
targetDb: targetHandle.db, targetDb: targetHandle.db,
company, company,
scopes, scopes,
projectIdOverrides, importProjectIds: projectSelections.importProjectIds,
projectIdOverrides: projectSelections.projectIdOverrides,
}); });
} }
} }
@ -2381,7 +2503,7 @@ export async function worktreeMergeHistoryCommand(sourceArg: string | undefined,
} }
p.outro( p.outro(
pc.green( pc.green(
`Imported ${applied.insertedIssues} issues, ${applied.insertedComments} comments, ${applied.insertedDocuments} documents (${applied.insertedDocumentRevisions} revisions, ${applied.mergedDocuments} merged), and ${applied.insertedAttachments} attachments into ${company.issuePrefix}.`, `Imported ${applied.insertedProjects} projects (${applied.insertedProjectWorkspaces} workspaces), ${applied.insertedIssues} issues, ${applied.insertedComments} comments, ${applied.insertedDocuments} documents (${applied.insertedDocumentRevisions} revisions, ${applied.mergedDocuments} merged), and ${applied.insertedAttachments} attachments into ${company.issuePrefix}.`,
), ),
); );
} finally { } finally {