refactor: simplify worktree management by removing legacy API

- Remove legacy worktree API usage and related state
- Add Manage Branches button in the Git header for quick access
- Introduce worktree status utilities to derive root branch hints
This commit is contained in:
Bohdan Triapitsyn
2026-02-06 12:38:09 +02:00
parent 2f336a0716
commit 0503f51357
26 changed files with 504 additions and 1450 deletions
+38 -30
View File
@@ -4,7 +4,7 @@ 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 { getWorktreeStatus, listWorktrees, mapWorktreeToMetadata } from "@/lib/git/worktreeService";
import { getWorktreeStatus } from "@/lib/worktrees/worktreeStatus";
import { listProjectWorktrees, removeProjectWorktree } from "@/lib/worktrees/worktreeManager";
import { useDirectoryStore } from "./useDirectoryStore";
import { useProjectsStore } from "./useProjectsStore";
@@ -52,8 +52,6 @@ type SessionStore = SessionState & SessionActions;
const safeStorage = getSafeStorage();
const SESSION_SELECTION_STORAGE_KEY = "oc.sessionSelectionByDirectory";
const WORKTREE_ROOT = ".openchamber";
type SessionSelectionMap = Record<string, string>;
const readSessionSelectionMap = (): SessionSelectionMap => {
@@ -278,11 +276,11 @@ const hydrateSessionWorktreeMetadata = async (
return null;
}
let worktreeEntries;
let worktreeEntries: WorktreeMetadata[];
try {
worktreeEntries = await listWorktrees(normalizedProject);
worktreeEntries = await listProjectWorktrees({ id: `path:${normalizedProject}`, path: normalizedProject });
} catch (error) {
console.debug("Failed to hydrate worktree metadata from git worktree list:", error);
console.debug("Failed to hydrate worktree metadata from worktree list:", error);
return null;
}
@@ -298,8 +296,7 @@ const hydrateSessionWorktreeMetadata = async (
}
const worktreeMapByPath = new Map<string, WorktreeMetadata>();
worktreeEntries.forEach((info) => {
const metadata = mapWorktreeToMetadata(normalizedProject, info);
worktreeEntries.forEach((metadata) => {
const normalizedPath = normalizePath(metadata.path) ?? metadata.path;
if (normalizedPath === normalizedProject) {
@@ -312,6 +309,26 @@ const hydrateSessionWorktreeMetadata = async (
let mutated = false;
const next = new Map(existingMetadata);
const mergeHydratedMetadata = (
hydrated: WorktreeMetadata,
previous?: WorktreeMetadata
): WorktreeMetadata => {
if (!previous) {
return hydrated;
}
return {
...previous,
...hydrated,
branch: hydrated.branch || previous.branch,
label: hydrated.label || previous.label,
name: hydrated.name || previous.name,
projectDirectory: hydrated.projectDirectory || previous.projectDirectory,
createdFromBranch: hydrated.createdFromBranch || previous.createdFromBranch,
kind: hydrated.kind || previous.kind,
status: hydrated.status || previous.status,
};
};
sessionsWithDirectory.forEach(({ id, directory }) => {
const metadata = worktreeMapByPath.get(directory);
if (!metadata) {
@@ -322,8 +339,19 @@ const hydrateSessionWorktreeMetadata = async (
}
const previous = next.get(id);
if (!previous || previous.path !== metadata.path || previous.branch !== metadata.branch || previous.label !== metadata.label) {
next.set(id, metadata);
const merged = mergeHydratedMetadata(metadata, previous);
if (
!previous ||
previous.path !== merged.path ||
previous.branch !== merged.branch ||
previous.label !== merged.label ||
previous.name !== merged.name ||
previous.projectDirectory !== merged.projectDirectory ||
previous.createdFromBranch !== merged.createdFromBranch ||
previous.kind !== merged.kind ||
previous.source !== merged.source
) {
next.set(id, merged);
mutated = true;
}
});
@@ -569,7 +597,6 @@ export const useSessionStore = create<SessionStore>()(
validPaths.add(normalizedProject);
if (isGitRepo) {
const worktreeRoot = `${normalizedProject}/${WORKTREE_ROOT}`;
try {
const candidates = new Set<string>();
@@ -584,25 +611,6 @@ export const useSessionStore = create<SessionStore>()(
}
});
// LEGACY_WORKTREES: check if .openchamber directory exists before listing 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
);
if (worktreeDirExists) {
const entries = await opencodeClient.listLocalDirectory(worktreeRoot);
entries
.filter((entry) => entry.isDirectory)
.forEach((entry) => {
const isAbsolutePath = /^([A-Za-z]:)?\//.test(entry.path);
const resolvedPath = isAbsolutePath ? entry.path : `${worktreeRoot}/${entry.name}`;
const normalizedPath = normalizePath(resolvedPath) ?? resolvedPath;
candidates.add(normalizedPath);
});
}
candidates.forEach((candidate) => {
const normalizedCandidate = normalizePath(candidate) ?? candidate;
validPaths.add(normalizedCandidate);
+11 -82
View File
@@ -6,11 +6,8 @@ import { useDirectoryStore } from './useDirectoryStore';
import { useProjectsStore } from './useProjectsStore';
import { useSessionStore } from './useSessionStore';
import type { WorktreeMetadata } from '@/types/worktree';
import { listWorktrees, mapWorktreeToMetadata } from '@/lib/git/worktreeService';
import type { Session } from '@opencode-ai/sdk/v2';
// LEGACY_WORKTREES: legacy worktree root inside project.
const OPENCHAMBER_DIR = '.openchamber';
const resolveProjectDirectory = (currentDirectory: string | null | undefined): string | null => {
const projectsState = useProjectsStore.getState();
@@ -109,48 +106,6 @@ const normalize = (value: string): string => {
return replaced.replace(/\/+$/, '');
};
const buildOpenChamberRoot = (projectDirectory: string): string => {
const normalizedProject = normalize(projectDirectory);
if (!normalizedProject || normalizedProject === '/') {
return `/${OPENCHAMBER_DIR}`;
}
return `${normalizedProject}/${OPENCHAMBER_DIR}`;
};
const resolveDirectoryListingPaths = (root: string, entries: Array<{ name?: string; path?: string }>): string[] => {
const normalizedRoot = normalize(root);
return entries
.map((entry) => {
const entryPath = typeof entry.path === 'string' && entry.path.trim().length > 0 ? entry.path : null;
if (entryPath) {
const normalizedEntry = normalize(entryPath);
if (normalizedEntry) {
return normalizedEntry;
}
}
const name = typeof entry.name === 'string' ? entry.name.trim() : '';
if (!name || !normalizedRoot) {
return null;
}
return `${normalizedRoot}/${name}`;
})
.filter((value): value is string => Boolean(value));
};
const listOpenChamberDirectories = async (root: string): Promise<string[]> => {
const normalizedRoot = normalize(root);
if (!normalizedRoot) {
return [];
}
try {
const entries = await opencodeClient.listLocalDirectory(normalizedRoot);
const directories = entries.filter((entry) => entry.isDirectory);
return resolveDirectoryListingPaths(normalizedRoot, directories);
} catch {
return [];
}
};
const startsWithDirectory = (candidate: string, root: string): boolean => {
const normalizedCandidate = normalize(candidate);
@@ -253,12 +208,12 @@ const buildWorktreeMetadataByPath = async (group: AgentGroup, projectDirectory:
}
try {
const infos = await listWorktrees(projectDirectory);
const infoByPath = new Map(infos.map((info) => [normalize(info.worktree), info]));
const worktrees = await listProjectWorktrees({ id: `path:${projectDirectory}`, path: projectDirectory });
const infoByPath = new Map(worktrees.map((meta) => [normalize(meta.path), meta]));
missingPaths.forEach((path) => {
const info = infoByPath.get(path);
if (info) {
map.set(path, mapWorktreeToMetadata(projectDirectory, info));
map.set(path, info);
}
});
} catch {
@@ -419,24 +374,17 @@ export const useAgentGroupsStore = create<AgentGroupsStore>()(
: [];
const worktreeDirectorySet = new Set<string>();
const worktreeMetadataMap = new Map<string, WorktreeMetadata>();
[...managedWorktrees, ...managedWorktreesCanonical].forEach((meta) => {
if (meta?.path) {
worktreeDirectorySet.add(normalize(meta.path));
const key = normalize(meta.path);
worktreeDirectorySet.add(key);
if (!worktreeMetadataMap.has(key)) {
worktreeMetadataMap.set(key, meta);
}
}
});
// Get git worktree info first - we need to query each worktree separately
let worktreeInfoMap = new Map<string, Awaited<ReturnType<typeof listWorktrees>>[number]>();
let worktreeInfoList: Awaited<ReturnType<typeof listWorktrees>> = [];
try {
worktreeInfoList = await listWorktrees(normalizedProject);
worktreeInfoMap = new Map(
worktreeInfoList.map((info) => [normalize(info.worktree), info])
);
} catch {
console.debug('Failed to list git worktrees');
}
const fetchCandidateSessions = async (): Promise<Session[]> => {
try {
const scoped = await apiClient.session.list({ directory: normalizedProject });
@@ -497,23 +445,6 @@ export const useAgentGroupsStore = create<AgentGroupsStore>()(
// 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(Boolean)
.forEach((worktreePath) => candidates.add(worktreePath));
// LEGACY_WORKTREES: optional filesystem scan for legacy <project>/.openchamber/*
const roots = [buildOpenChamberRoot(normalizedProject), buildOpenChamberRoot(canonicalProject)]
.map((p) => normalize(p))
.filter(Boolean);
await Promise.all(
Array.from(new Set(roots)).map(async (root) => {
const dirs = await listOpenChamberDirectories(root);
dirs.forEach((dir) => candidates.add(dir));
})
);
if (candidates.size > 0) {
allSessions = await fetchSessionsByWorktreeDirectories(Array.from(candidates));
}
@@ -533,7 +464,7 @@ export const useAgentGroupsStore = create<AgentGroupsStore>()(
if (!parsed) continue; // Skip sessions without valid agent group title
const sessionPath = normalize(session.directory);
const worktreeInfo = worktreeInfoMap.get(sessionPath);
const worktreeInfo = worktreeMetadataMap.get(sessionPath);
const agentSession: AgentGroupSession = {
id: session.id,
@@ -543,9 +474,7 @@ export const useAgentGroupsStore = create<AgentGroupsStore>()(
instanceNumber: parsed.index,
branch: worktreeInfo?.branch ?? '',
displayLabel: `${parsed.provider}/${parsed.model}`,
worktreeMetadata: worktreeInfo
? mapWorktreeToMetadata(normalizedProject, worktreeInfo)
: undefined,
worktreeMetadata: worktreeInfo,
};
const existing = groupsMap.get(parsed.groupSlug);
+3 -7
View File
@@ -4,6 +4,7 @@ import type { CreateMultiRunParams, CreateMultiRunResult } from '@/types/multiru
import { opencodeClient } from '@/lib/opencode/client';
import { saveWorktreeSetupCommands } from '@/lib/openchamberConfig';
import { createSdkWorktree, type ProjectRef } from '@/lib/worktrees/worktreeManager';
import { getRootBranch } from '@/lib/worktrees/worktreeStatus';
import { checkIsGitRepository } from '@/lib/gitApi';
import { useSessionStore } from './sessionStore';
import { useDirectoryStore } from './useDirectoryStore';
@@ -121,11 +122,7 @@ export const useMultiRunStore = create<MultiRunStore>()(
}
const groupSlug = toGitSafeSlug(groupName);
const worktreeBaseBranch =
typeof params.worktreeBaseBranch === 'string' && params.worktreeBaseBranch.trim().length > 0
? params.worktreeBaseBranch.trim()
: 'HEAD';
const startPoint = worktreeBaseBranch !== 'HEAD' ? worktreeBaseBranch : undefined;
const rootBranch = await getRootBranch(directory);
const createdRuns: Array<{
sessionId: string;
@@ -164,12 +161,11 @@ export const useMultiRunStore = create<MultiRunStore>()(
const worktreeMetadata = await createSdkWorktree(project, {
preferredName,
setupCommands: commandsToRun,
startPoint: startPoint ?? null,
});
const enrichedMetadata = {
...worktreeMetadata,
createdFromBranch: startPoint ?? 'HEAD',
createdFromBranch: rootBranch,
kind: 'standard' as const,
};
+1 -47
View File
@@ -1,7 +1,7 @@
import { create } from 'zustand';
import { devtools } from 'zustand/middleware';
import { opencodeClient } from '@/lib/opencode/client';
import type { ProjectEntry, WorktreeDefaults } from '@/lib/api/types';
import type { ProjectEntry } from '@/lib/api/types';
import type { DesktopSettings } from '@/lib/desktop';
import { updateDesktopSettings } from '@/lib/persistence';
import { getSafeStorage } from './utils/safeStorage';
@@ -27,7 +27,6 @@ interface ProjectsStore {
validateProjectPath: (path: string) => ProjectPathValidationResult;
synchronizeFromSettings: (settings: DesktopSettings) => void;
getActiveProject: () => ProjectEntry | null;
updateWorktreeDefaults: (projectId: string, defaults: Partial<WorktreeDefaults>) => void;
}
const safeStorage = getSafeStorage();
@@ -124,20 +123,6 @@ const sanitizeProjects = (value: unknown): ProjectEntry[] => {
if (typeof candidate.sidebarCollapsed === 'boolean') {
project.sidebarCollapsed = candidate.sidebarCollapsed;
}
if (candidate.worktreeDefaults && typeof candidate.worktreeDefaults === 'object') {
const wt = candidate.worktreeDefaults as Record<string, unknown>;
const defaults: WorktreeDefaults = {};
if (typeof wt.baseBranch === 'string') {
defaults.baseBranch = wt.baseBranch;
}
if (typeof wt.autoCreateWorktree === 'boolean') {
defaults.autoCreateWorktree = wt.autoCreateWorktree;
}
if (Object.keys(defaults).length > 0) {
project.worktreeDefaults = defaults;
}
}
result.push(project);
}
@@ -452,37 +437,6 @@ export const useProjectsStore = create<ProjectsStore>()(
return projects.find((project) => project.id === activeProjectId) ?? null;
},
updateWorktreeDefaults: (projectId: string, defaults: Partial<WorktreeDefaults>) => {
if (vscodeWorkspace) {
return;
}
const { projects, activeProjectId } = get();
const target = projects.find((project) => project.id === projectId);
if (!target) {
return;
}
const merged: WorktreeDefaults = { ...target.worktreeDefaults };
if (defaults.baseBranch !== undefined) {
if (defaults.baseBranch.trim()) {
merged.baseBranch = defaults.baseBranch.trim();
} else {
delete merged.baseBranch;
}
}
if (defaults.autoCreateWorktree !== undefined) {
merged.autoCreateWorktree = defaults.autoCreateWorktree;
}
const nextProjects = projects.map((project) =>
project.id === projectId
? { ...project, worktreeDefaults: Object.keys(merged).length > 0 ? merged : undefined }
: project
);
set({ projects: nextProjects });
persistProjects(nextProjects, activeProjectId);
},
}), { name: 'projects-store' })
);