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:
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,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' })
|
||||
);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user