feat: migrate to OpenCode SDK worktrees with per-project config
Add SDK-based worktree management that lists and starts SDK worktrees Migrate per-project setup to ~/.config/openchamber/<projectId>.json Deprecate .openchamber legacy paths and adapt UI to new config
This commit is contained in:
@@ -4,7 +4,8 @@ import type { Session } from "@opencode-ai/sdk/v2";
|
||||
import { opencodeClient } from "@/lib/opencode/client";
|
||||
import { getSafeStorage } from "./utils/safeStorage";
|
||||
import type { WorktreeMetadata } from "@/types/worktree";
|
||||
import { archiveWorktree, getWorktreeStatus, listWorktrees, mapWorktreeToMetadata } from "@/lib/git/worktreeService";
|
||||
import { getWorktreeStatus, listWorktrees, mapWorktreeToMetadata } from "@/lib/git/worktreeService";
|
||||
import { listProjectWorktrees, removeProjectWorktree } from "@/lib/worktrees/worktreeManager";
|
||||
import { useDirectoryStore } from "./useDirectoryStore";
|
||||
import { useProjectsStore } from "./useProjectsStore";
|
||||
import type { ProjectEntry } from "@/lib/api/types";
|
||||
@@ -135,14 +136,25 @@ const archiveSessionWorktree = async (
|
||||
options?: { deleteRemoteBranch?: boolean; remoteName?: string }
|
||||
) => {
|
||||
const status = metadata.status ?? (await getWorktreeStatus(metadata.path).catch(() => undefined));
|
||||
await archiveWorktree({
|
||||
projectDirectory: metadata.projectDirectory,
|
||||
path: metadata.path,
|
||||
branch: metadata.branch,
|
||||
force: Boolean(status?.isDirty),
|
||||
deleteRemote: Boolean(options?.deleteRemoteBranch),
|
||||
remote: options?.remoteName,
|
||||
});
|
||||
|
||||
const projects = useProjectsStore.getState().projects;
|
||||
const normalizedProject = normalizePath(metadata.projectDirectory) ?? metadata.projectDirectory;
|
||||
const projectEntry = projects.find((project) => normalizePath(project.path) === normalizedProject);
|
||||
|
||||
const projectRef = {
|
||||
id: projectEntry?.id ?? `path:${normalizedProject}`,
|
||||
path: normalizedProject,
|
||||
};
|
||||
|
||||
await removeProjectWorktree(
|
||||
projectRef,
|
||||
status ? ({ ...metadata, status } as WorktreeMetadata) : metadata,
|
||||
{
|
||||
deleteRemoteBranch: options?.deleteRemoteBranch,
|
||||
remoteName: options?.remoteName,
|
||||
force: Boolean(status?.isDirty),
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const normalizePath = (value?: string | null): string | null => {
|
||||
@@ -551,7 +563,19 @@ export const useSessionStore = create<SessionStore>()(
|
||||
try {
|
||||
const candidates = new Set<string>();
|
||||
|
||||
const managedWorktrees = await listProjectWorktrees({
|
||||
id: project.id,
|
||||
path: normalizedProject,
|
||||
}).catch(() => []);
|
||||
discoveredWorktrees = managedWorktrees;
|
||||
managedWorktrees.forEach((meta) => {
|
||||
if (meta?.path) {
|
||||
candidates.add(normalizePath(meta.path) ?? meta.path);
|
||||
}
|
||||
});
|
||||
|
||||
// Check if .openchamber directory exists before trying to list it
|
||||
// LEGACY_WORKTREES: filesystem scan fallback for legacy <project>/.openchamber/*
|
||||
const projectEntriesList = await opencodeClient.listLocalDirectory(normalizedProject);
|
||||
const worktreeDirExists = projectEntriesList.some(
|
||||
(entry) => entry.isDirectory && entry.name === WORKTREE_ROOT
|
||||
@@ -569,14 +593,6 @@ export const useSessionStore = create<SessionStore>()(
|
||||
});
|
||||
}
|
||||
|
||||
const listedWorktrees = await listWorktrees(normalizedProject);
|
||||
if (Array.isArray(listedWorktrees)) {
|
||||
discoveredWorktrees = listedWorktrees
|
||||
.map((info) => mapWorktreeToMetadata(normalizedProject, info))
|
||||
.filter((meta) => meta.path.includes(`/${WORKTREE_ROOT}/`));
|
||||
discoveredWorktrees.forEach((meta) => candidates.add(meta.path));
|
||||
}
|
||||
|
||||
candidates.forEach((candidate) => {
|
||||
const normalizedCandidate = normalizePath(candidate) ?? candidate;
|
||||
validPaths.add(normalizedCandidate);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { create } from 'zustand';
|
||||
import { devtools } from 'zustand/middleware';
|
||||
import { opencodeClient } from '@/lib/opencode/client';
|
||||
import { listProjectWorktrees } from '@/lib/worktrees/worktreeManager';
|
||||
import { useDirectoryStore } from './useDirectoryStore';
|
||||
import { useProjectsStore } from './useProjectsStore';
|
||||
import { useSessionStore } from './useSessionStore';
|
||||
@@ -21,14 +22,6 @@ const resolveProjectDirectory = (currentDirectory: string | null | undefined): s
|
||||
return activeProjectPath;
|
||||
}
|
||||
|
||||
const normalizedCurrent = typeof currentDirectory === 'string' ? normalize(currentDirectory) : '';
|
||||
const marker = `/${OPENCHAMBER_DIR}/`;
|
||||
const markerIndex = normalizedCurrent.indexOf(marker);
|
||||
|
||||
if (markerIndex > 0) {
|
||||
return normalizedCurrent.slice(0, markerIndex);
|
||||
}
|
||||
|
||||
return currentDirectory ? normalize(currentDirectory) : null;
|
||||
};
|
||||
|
||||
@@ -401,7 +394,13 @@ export const useAgentGroupsStore = create<AgentGroupsStore>()(
|
||||
}
|
||||
|
||||
const normalizedProject = normalize(projectDirectory);
|
||||
const openChamberRoot = buildOpenChamberRoot(normalizedProject);
|
||||
|
||||
const projectsState = useProjectsStore.getState();
|
||||
const projectEntry = projectsState.projects.find((p) => normalize(p.path) === normalizedProject);
|
||||
const projectRef = {
|
||||
id: projectEntry?.id ?? `path:${normalizedProject}`,
|
||||
path: normalizedProject,
|
||||
};
|
||||
|
||||
const previousGroups = get().groups;
|
||||
set({ isLoading: true, error: null });
|
||||
@@ -409,7 +408,21 @@ export const useAgentGroupsStore = create<AgentGroupsStore>()(
|
||||
try {
|
||||
const apiClient = opencodeClient.getApiClient();
|
||||
const canonicalProject = await resolveCanonicalDirectory(apiClient, normalizedProject);
|
||||
const openChamberRootCanonical = buildOpenChamberRoot(canonicalProject);
|
||||
const canonicalRef = canonicalProject && canonicalProject !== normalizedProject
|
||||
? { ...projectRef, path: canonicalProject }
|
||||
: null;
|
||||
|
||||
const managedWorktrees = await listProjectWorktrees(projectRef).catch(() => []);
|
||||
const managedWorktreesCanonical = canonicalRef
|
||||
? await listProjectWorktrees(canonicalRef).catch(() => [])
|
||||
: [];
|
||||
|
||||
const worktreeDirectorySet = new Set<string>();
|
||||
[...managedWorktrees, ...managedWorktreesCanonical].forEach((meta) => {
|
||||
if (meta?.path) {
|
||||
worktreeDirectorySet.add(normalize(meta.path));
|
||||
}
|
||||
});
|
||||
|
||||
// Get git worktree info first - we need to query each worktree separately
|
||||
let worktreeInfoMap = new Map<string, Awaited<ReturnType<typeof listWorktrees>>[number]>();
|
||||
@@ -429,7 +442,7 @@ export const useAgentGroupsStore = create<AgentGroupsStore>()(
|
||||
const list = Array.isArray(scoped.data) ? scoped.data : [];
|
||||
if (list.some((session) => {
|
||||
const dir = normalize((session as { directory?: string | null }).directory ?? '');
|
||||
return startsWithDirectory(dir, openChamberRoot) || startsWithDirectory(dir, openChamberRootCanonical);
|
||||
return dir ? worktreeDirectorySet.has(dir) : false;
|
||||
})) {
|
||||
return list;
|
||||
}
|
||||
@@ -472,26 +485,29 @@ export const useAgentGroupsStore = create<AgentGroupsStore>()(
|
||||
if (!dir) {
|
||||
return false;
|
||||
}
|
||||
return startsWithDirectory(dir, openChamberRoot) || startsWithDirectory(dir, openChamberRootCanonical);
|
||||
return worktreeDirectorySet.has(dir);
|
||||
});
|
||||
|
||||
// Some OpenCode builds do not return sessions across directories in the global list.
|
||||
// If we didn't discover any group sessions, fall back to querying each `.openchamber` worktree directory directly.
|
||||
// If we didn't discover any group sessions, fall back to querying each worktree directory directly.
|
||||
if (allSessions.length === 0) {
|
||||
const candidates = new Set<string>();
|
||||
|
||||
// 1) Git worktree list
|
||||
// 1) Known worktree directories for this project
|
||||
worktreeDirectorySet.forEach((dir) => candidates.add(dir));
|
||||
|
||||
// 2) Git worktree list (covers SDK + legacy)
|
||||
worktreeInfoList
|
||||
.map((info) => normalize(info.worktree))
|
||||
.filter((worktreePath) =>
|
||||
startsWithDirectory(worktreePath, openChamberRoot) || startsWithDirectory(worktreePath, openChamberRootCanonical)
|
||||
)
|
||||
.filter(Boolean)
|
||||
.forEach((worktreePath) => candidates.add(worktreePath));
|
||||
|
||||
// 2) Filesystem scan (handles cases where git worktree listing breaks or isn't available)
|
||||
const roots = Array.from(new Set([openChamberRoot, openChamberRootCanonical].map((p) => normalize(p)).filter(Boolean)));
|
||||
// LEGACY_WORKTREES: optional filesystem scan for legacy <project>/.openchamber/*
|
||||
const roots = [buildOpenChamberRoot(normalizedProject), buildOpenChamberRoot(canonicalProject)]
|
||||
.map((p) => normalize(p))
|
||||
.filter(Boolean);
|
||||
await Promise.all(
|
||||
roots.map(async (root) => {
|
||||
Array.from(new Set(roots)).map(async (root) => {
|
||||
const dirs = await listOpenChamberDirectories(root);
|
||||
dirs.forEach((dir) => candidates.add(dir));
|
||||
})
|
||||
|
||||
@@ -2,8 +2,8 @@ import { create } from 'zustand';
|
||||
import { devtools } from 'zustand/middleware';
|
||||
import type { CreateMultiRunParams, CreateMultiRunResult } from '@/types/multirun';
|
||||
import { opencodeClient } from '@/lib/opencode/client';
|
||||
import { createWorktree, runWorktreeSetupCommands } from '@/lib/git/worktreeService';
|
||||
import { saveWorktreeSetupCommands } from '@/lib/openchamberConfig';
|
||||
import { createSdkWorktree, type ProjectRef } from '@/lib/worktrees/worktreeManager';
|
||||
import { checkIsGitRepository } from '@/lib/gitApi';
|
||||
import { useSessionStore } from './sessionStore';
|
||||
import { useDirectoryStore } from './useDirectoryStore';
|
||||
@@ -30,53 +30,33 @@ const toModelSlug = (providerID: string, modelID: string): string => {
|
||||
};
|
||||
|
||||
/**
|
||||
* Generate branch name for a run.
|
||||
* Format: <groupSlug>/<modelSlug>
|
||||
* Seed name for SDK worktree creation.
|
||||
* Uses slashes for readability; SDK will slugify.
|
||||
*/
|
||||
const generateBranchName = (groupSlug: string, modelSlug: string): string => {
|
||||
const generateWorktreeNameSeed = (groupSlug: string, modelSlug: string): string => {
|
||||
return `${groupSlug}/${modelSlug}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Generate a stable worktree slug for a branch name.
|
||||
* Keeps `.openchamber/<slug>` branch-aligned.
|
||||
*/
|
||||
const sanitizeWorktreeSlug = (value: string): string => {
|
||||
return value
|
||||
.trim()
|
||||
.replace(/[^A-Za-z0-9._-]+/g, '-')
|
||||
.replace(/^[-_]+|[-_]+$/g, '')
|
||||
.slice(0, 120);
|
||||
};
|
||||
|
||||
|
||||
const resolveProjectDirectory = (): string | null => {
|
||||
const resolveActiveProject = (): ProjectRef | null => {
|
||||
const projectsState = useProjectsStore.getState();
|
||||
const activeProjectId = projectsState.activeProjectId;
|
||||
const activeProjectPath = activeProjectId
|
||||
? projectsState.projects.find((project) => project.id === activeProjectId)?.path
|
||||
: undefined;
|
||||
|
||||
if (typeof activeProjectPath === 'string' && activeProjectPath.trim().length > 0) {
|
||||
return activeProjectPath;
|
||||
}
|
||||
|
||||
const currentDirectory = useDirectoryStore.getState().currentDirectory ?? null;
|
||||
if (!currentDirectory) {
|
||||
if (!activeProjectId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalized = currentDirectory.replace(/\\/g, '/').replace(/\/+$/, '') || currentDirectory;
|
||||
const marker = '/.openchamber/';
|
||||
const markerIndex = normalized.indexOf(marker);
|
||||
if (markerIndex > 0) {
|
||||
return normalized.slice(0, markerIndex);
|
||||
}
|
||||
if (normalized.endsWith('/.openchamber')) {
|
||||
return normalized.slice(0, normalized.length - '/.openchamber'.length);
|
||||
const project = projectsState.projects.find((entry) => entry.id === activeProjectId);
|
||||
if (project?.path) {
|
||||
return { id: project.id, path: project.path };
|
||||
}
|
||||
|
||||
return normalized;
|
||||
// Fall back to current directory only when active project is missing.
|
||||
const currentDirectory = useDirectoryStore.getState().currentDirectory ?? null;
|
||||
if (currentDirectory && currentDirectory.trim().length > 0) {
|
||||
const normalized = currentDirectory.replace(/\\/g, '/').replace(/\/+$/, '') || currentDirectory;
|
||||
return { id: `path:${normalized}`, path: normalized };
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
interface MultiRunState {
|
||||
@@ -126,12 +106,14 @@ export const useMultiRunStore = create<MultiRunStore>()(
|
||||
set({ isLoading: true, error: null });
|
||||
|
||||
try {
|
||||
const directory = resolveProjectDirectory();
|
||||
if (!directory) {
|
||||
set({ error: 'No directory selected', isLoading: false });
|
||||
const project = resolveActiveProject();
|
||||
if (!project) {
|
||||
set({ error: 'Select a project', isLoading: false });
|
||||
return null;
|
||||
}
|
||||
|
||||
const directory = project.path;
|
||||
|
||||
const isGit = await checkIsGitRepository(directory);
|
||||
if (!isGit) {
|
||||
set({ error: 'Not in a git repository', isLoading: false });
|
||||
@@ -174,28 +156,15 @@ export const useMultiRunStore = create<MultiRunStore>()(
|
||||
|
||||
const modelSlug = toModelSlug(model.providerID, model.modelID);
|
||||
// Append index only when same model is selected multiple times
|
||||
const branch = count > 1
|
||||
? generateBranchName(groupSlug, `${modelSlug}/${index}`)
|
||||
: generateBranchName(groupSlug, modelSlug);
|
||||
|
||||
if (!branch) {
|
||||
set({ error: 'Branch name is required for worktree creation', isLoading: false });
|
||||
return null;
|
||||
}
|
||||
|
||||
const worktreeSlug = sanitizeWorktreeSlug(branch);
|
||||
if (!worktreeSlug) {
|
||||
set({ error: `Invalid branch name: ${branch}`, isLoading: false });
|
||||
return null;
|
||||
}
|
||||
const preferredName = count > 1
|
||||
? generateWorktreeNameSeed(groupSlug, `${modelSlug}/${index}`)
|
||||
: generateWorktreeNameSeed(groupSlug, modelSlug);
|
||||
|
||||
try {
|
||||
const worktreeMetadata = await createWorktree({
|
||||
projectDirectory: directory,
|
||||
worktreeSlug,
|
||||
branch,
|
||||
createBranch: true,
|
||||
startPoint,
|
||||
const worktreeMetadata = await createSdkWorktree(project, {
|
||||
preferredName,
|
||||
setupCommands: commandsToRun,
|
||||
startPoint: startPoint ?? null,
|
||||
});
|
||||
|
||||
// Session title format: groupSlug/provider/model (or groupSlug/provider/model/index for duplicates)
|
||||
@@ -227,7 +196,7 @@ export const useMultiRunStore = create<MultiRunStore>()(
|
||||
// Save setup commands to config if any were provided (for future worktree creation)
|
||||
const commandsToSave = setupCommands?.filter(cmd => cmd.trim().length > 0) ?? [];
|
||||
if (commandsToSave.length > 0) {
|
||||
saveWorktreeSetupCommands(directory, commandsToSave).catch(() => {
|
||||
saveWorktreeSetupCommands(project, commandsToSave).catch(() => {
|
||||
console.warn('[MultiRun] Failed to save worktree setup commands');
|
||||
});
|
||||
}
|
||||
@@ -257,21 +226,7 @@ export const useMultiRunStore = create<MultiRunStore>()(
|
||||
// Ignore refresh errors
|
||||
}
|
||||
|
||||
// Kick off setup commands after sessions are visible.
|
||||
if (commandsToRun.length > 0) {
|
||||
for (const run of createdRuns) {
|
||||
void runWorktreeSetupCommands(run.worktreePath, directory, commandsToRun)
|
||||
.then((result) => {
|
||||
if (!result.success) {
|
||||
const failed = result.results.filter((r) => !r.success);
|
||||
console.warn(`[MultiRun] Setup commands failed for ${run.worktreePath}:`, failed);
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.warn(`[MultiRun] Setup commands error for ${run.worktreePath}:`, err);
|
||||
});
|
||||
}
|
||||
}
|
||||
// Setup commands run via SDK worktree startCommand.
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
|
||||
@@ -124,9 +124,6 @@ const sanitizeProjects = (value: unknown): ProjectEntry[] => {
|
||||
if (candidate.worktreeDefaults && typeof candidate.worktreeDefaults === 'object') {
|
||||
const wt = candidate.worktreeDefaults as Record<string, unknown>;
|
||||
const defaults: WorktreeDefaults = {};
|
||||
if (typeof wt.branchPrefix === 'string') {
|
||||
defaults.branchPrefix = wt.branchPrefix;
|
||||
}
|
||||
if (typeof wt.baseBranch === 'string') {
|
||||
defaults.baseBranch = wt.baseBranch;
|
||||
}
|
||||
@@ -463,13 +460,6 @@ export const useProjectsStore = create<ProjectsStore>()(
|
||||
}
|
||||
|
||||
const merged: WorktreeDefaults = { ...target.worktreeDefaults };
|
||||
if (defaults.branchPrefix !== undefined) {
|
||||
if (defaults.branchPrefix.trim()) {
|
||||
merged.branchPrefix = defaults.branchPrefix.trim();
|
||||
} else {
|
||||
delete merged.branchPrefix;
|
||||
}
|
||||
}
|
||||
if (defaults.baseBranch !== undefined) {
|
||||
if (defaults.baseBranch.trim()) {
|
||||
merged.baseBranch = defaults.baseBranch.trim();
|
||||
|
||||
Reference in New Issue
Block a user