feat(worktrees): ship upstream-first worktree flow across web + vscode (#418)
* feat: add worktree validation and deleteLocalBranch option Add API to validate and create worktrees with new payload types Allow deleting local branches when removing worktrees via UI and API Introduce OpenCode style random names for worktrees when not provided * feat: enable SSH/HTTPS transport detection for PR picker Load remotes for the current project directory to inform PR picker options. Determine preferred push transport from remotes and apply it. Expose sshUrl in API for frontend to build SSH clone URLs * feat: extend head repo with sshUrl and improve push error messages Add sshUrl field to head repo mapping Enhance push failure handling to display stderr or stdout details Return push details on success * fix: worktree path * feat: worktree set upstream on creation Enable pushing to upstream by default when no remote is specified Remove per-remote dropdown for push actions and auto-use first/upstream remote Update server and VSCode git services to support push without explicit remote and set upstream * fix: worktree-name sanitization * feat: rename worktree path field and branch prefix * feat(worktrees): add git.worktree facade, validation endpoint, upstream/remote-aware creation, and non-blocking setup execution * refactor(git): use git.worktree namespace in branch picker * feat(worktrees): sync OpenCode sandbox metadata on create/remove * fix(worktrees): accept new path key in workspace guard and validate remote startRef * chore(docs): remove temporary worktree testing plan * feat: add git worktree management API (list/create/delete/validate) for vscode * feat: wire root tracking remote and defaults for new worktrees Add resolveRootTrackingRemote to detect upstream remote for root branch Apply upstream defaults when creating new worktrees to auto-set upstream Replace validation and creation flow to use new worktreeCreate APIs * feat(worktrees): enable root tracking remote handling
This commit is contained in:
@@ -29,8 +29,8 @@ interface SessionState {
|
||||
interface SessionActions {
|
||||
loadSessions: () => Promise<void>;
|
||||
createSession: (title?: string, directoryOverride?: string | null, parentID?: string | null) => Promise<Session | null>;
|
||||
deleteSession: (id: string, options?: { archiveWorktree?: boolean; deleteRemoteBranch?: boolean; remoteName?: string }) => Promise<boolean>;
|
||||
deleteSessions: (ids: string[], options?: { archiveWorktree?: boolean; deleteRemoteBranch?: boolean; remoteName?: string; silent?: boolean }) => Promise<{ deletedIds: string[]; failedIds: string[] }>;
|
||||
deleteSession: (id: string, options?: { archiveWorktree?: boolean; deleteRemoteBranch?: boolean; deleteLocalBranch?: boolean; remoteName?: string }) => Promise<boolean>;
|
||||
deleteSessions: (ids: string[], options?: { archiveWorktree?: boolean; deleteRemoteBranch?: boolean; deleteLocalBranch?: boolean; remoteName?: string; silent?: boolean }) => Promise<{ deletedIds: string[]; failedIds: string[] }>;
|
||||
updateSessionTitle: (id: string, title: string) => Promise<void>;
|
||||
shareSession: (id: string) => Promise<Session | null>;
|
||||
unshareSession: (id: string) => Promise<Session | null>;
|
||||
@@ -132,7 +132,7 @@ const clearInvalidSessionSelection = (directory: string | null | undefined, vali
|
||||
|
||||
const archiveSessionWorktree = async (
|
||||
metadata: WorktreeMetadata,
|
||||
options?: { deleteRemoteBranch?: boolean; remoteName?: string }
|
||||
options?: { deleteRemoteBranch?: boolean; deleteLocalBranch?: boolean; remoteName?: string }
|
||||
) => {
|
||||
const status = metadata.status ?? (await getWorktreeStatus(metadata.path).catch(() => undefined));
|
||||
|
||||
@@ -150,8 +150,8 @@ const archiveSessionWorktree = async (
|
||||
status ? ({ ...metadata, status } as WorktreeMetadata) : metadata,
|
||||
{
|
||||
deleteRemoteBranch: options?.deleteRemoteBranch,
|
||||
deleteLocalBranch: options?.deleteLocalBranch,
|
||||
remoteName: options?.remoteName,
|
||||
force: Boolean(status?.isDirty),
|
||||
}
|
||||
);
|
||||
};
|
||||
@@ -955,6 +955,7 @@ export const useSessionStore = create<SessionStore>()(
|
||||
try {
|
||||
await archiveSessionWorktree(metadata, {
|
||||
deleteRemoteBranch: options?.deleteRemoteBranch,
|
||||
deleteLocalBranch: options?.deleteLocalBranch,
|
||||
remoteName: options?.remoteName,
|
||||
});
|
||||
archiveSucceeded = true;
|
||||
@@ -1012,7 +1013,7 @@ export const useSessionStore = create<SessionStore>()(
|
||||
|
||||
deleteSessions: async (
|
||||
ids: string[],
|
||||
options?: { archiveWorktree?: boolean; deleteRemoteBranch?: boolean; remoteName?: string; silent?: boolean }
|
||||
options?: { archiveWorktree?: boolean; deleteRemoteBranch?: boolean; deleteLocalBranch?: boolean; remoteName?: string; silent?: boolean }
|
||||
) => {
|
||||
const uniqueIds = Array.from(new Set(ids.filter((id): id is string => typeof id === "string" && id.length > 0)));
|
||||
if (uniqueIds.length === 0) {
|
||||
@@ -1064,6 +1065,7 @@ export const useSessionStore = create<SessionStore>()(
|
||||
try {
|
||||
await archiveSessionWorktree(metadata, {
|
||||
deleteRemoteBranch: options?.deleteRemoteBranch,
|
||||
deleteLocalBranch: options?.deleteLocalBranch,
|
||||
remoteName: options?.remoteName,
|
||||
});
|
||||
archivedWorktrees.push({ path: metadata.path, projectDirectory: metadata.projectDirectory });
|
||||
|
||||
@@ -215,8 +215,8 @@ export interface SessionStore {
|
||||
createSession: (title?: string, directoryOverride?: string | null, parentID?: string | null) => Promise<Session | null>;
|
||||
createSessionFromAssistantMessage: (sourceMessageId: string) => Promise<void>;
|
||||
|
||||
deleteSession: (id: string, options?: { archiveWorktree?: boolean; deleteRemoteBranch?: boolean; remoteName?: string }) => Promise<boolean>;
|
||||
deleteSessions: (ids: string[], options?: { archiveWorktree?: boolean; deleteRemoteBranch?: boolean; remoteName?: string; silent?: boolean }) => Promise<{ deletedIds: string[]; failedIds: string[] }>;
|
||||
deleteSession: (id: string, options?: { archiveWorktree?: boolean; deleteRemoteBranch?: boolean; deleteLocalBranch?: boolean; remoteName?: string }) => Promise<boolean>;
|
||||
deleteSessions: (ids: string[], options?: { archiveWorktree?: boolean; deleteRemoteBranch?: boolean; deleteLocalBranch?: boolean; remoteName?: string; silent?: boolean }) => Promise<{ deletedIds: string[]; failedIds: string[] }>;
|
||||
updateSessionTitle: (id: string, title: string) => Promise<void>;
|
||||
shareSession: (id: string) => Promise<Session | null>;
|
||||
unshareSession: (id: string) => Promise<Session | null>;
|
||||
|
||||
@@ -272,6 +272,39 @@ const collectDeleteCandidates = async (params: {
|
||||
return results;
|
||||
};
|
||||
|
||||
const deleteGroupWorktreeSessions = async (params: {
|
||||
group: AgentGroup;
|
||||
projectDirectory: string;
|
||||
worktreePaths: string[];
|
||||
}) => {
|
||||
const apiClient = opencodeClient.getApiClient();
|
||||
const candidates = await collectDeleteCandidates({
|
||||
apiClient,
|
||||
group: params.group,
|
||||
projectDirectory: params.projectDirectory,
|
||||
worktreePaths: params.worktreePaths,
|
||||
});
|
||||
|
||||
const sessionStore = useSessionStore.getState();
|
||||
const ids = new Set<string>();
|
||||
|
||||
candidates.forEach(({ worktreePath, sessionIds, metadata }) => {
|
||||
sessionIds.forEach((id) => {
|
||||
ids.add(id);
|
||||
if (metadata) {
|
||||
sessionStore.setWorktreeMetadata(id, metadata);
|
||||
sessionStore.setSessionDirectory(id, worktreePath);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
if (ids.size === 0) {
|
||||
return { failedIds: [] as string[] };
|
||||
}
|
||||
|
||||
return sessionStore.deleteSessions(Array.from(ids), { archiveWorktree: true, silent: true });
|
||||
};
|
||||
|
||||
/**
|
||||
* Parse a session title to extract group, provider, model, and index.
|
||||
* Title format: groupSlug/provider/model[/index]
|
||||
@@ -555,27 +588,11 @@ export const useAgentGroupsStore = create<AgentGroupsStore>()(
|
||||
|
||||
set({ isLoading: true, error: null });
|
||||
try {
|
||||
const apiClient = opencodeClient.getApiClient();
|
||||
const candidates = await collectDeleteCandidates({
|
||||
apiClient,
|
||||
const { failedIds } = await deleteGroupWorktreeSessions({
|
||||
group,
|
||||
projectDirectory: normalize(projectDirectory),
|
||||
worktreePaths: group.sessions.map((s) => s.path),
|
||||
});
|
||||
|
||||
const sessionStore = useSessionStore.getState();
|
||||
const ids = new Set<string>();
|
||||
candidates.forEach(({ worktreePath, sessionIds, metadata }) => {
|
||||
sessionIds.forEach((id) => {
|
||||
ids.add(id);
|
||||
if (metadata) {
|
||||
sessionStore.setWorktreeMetadata(id, metadata);
|
||||
sessionStore.setSessionDirectory(id, worktreePath);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const { failedIds } = await sessionStore.deleteSessions(Array.from(ids), { archiveWorktree: true, silent: true });
|
||||
if (failedIds.length > 0) {
|
||||
set({ error: 'Failed to delete some sessions' });
|
||||
}
|
||||
@@ -613,27 +630,11 @@ export const useAgentGroupsStore = create<AgentGroupsStore>()(
|
||||
|
||||
set({ isLoading: true, error: null });
|
||||
try {
|
||||
const apiClient = opencodeClient.getApiClient();
|
||||
const candidates = await collectDeleteCandidates({
|
||||
apiClient,
|
||||
const { failedIds } = await deleteGroupWorktreeSessions({
|
||||
group,
|
||||
projectDirectory: normalize(projectDirectory),
|
||||
worktreePaths: [normalizedWorktreePath],
|
||||
});
|
||||
|
||||
const sessionStore = useSessionStore.getState();
|
||||
const ids = new Set<string>();
|
||||
candidates.forEach(({ worktreePath: resolvedPath, sessionIds, metadata }) => {
|
||||
sessionIds.forEach((id) => {
|
||||
ids.add(id);
|
||||
if (metadata) {
|
||||
sessionStore.setWorktreeMetadata(id, metadata);
|
||||
sessionStore.setSessionDirectory(id, resolvedPath);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const { failedIds } = await sessionStore.deleteSessions(Array.from(ids), { archiveWorktree: true, silent: true });
|
||||
if (failedIds.length > 0) {
|
||||
set({ error: 'Failed to delete some sessions' });
|
||||
}
|
||||
@@ -690,27 +691,11 @@ export const useAgentGroupsStore = create<AgentGroupsStore>()(
|
||||
|
||||
set({ isLoading: true, error: null });
|
||||
try {
|
||||
const apiClient = opencodeClient.getApiClient();
|
||||
const candidates = await collectDeleteCandidates({
|
||||
apiClient,
|
||||
const { failedIds } = await deleteGroupWorktreeSessions({
|
||||
group,
|
||||
projectDirectory: normalize(projectDirectory),
|
||||
worktreePaths: toDelete,
|
||||
});
|
||||
|
||||
const sessionStore = useSessionStore.getState();
|
||||
const ids = new Set<string>();
|
||||
candidates.forEach(({ worktreePath, sessionIds, metadata }) => {
|
||||
sessionIds.forEach((id) => {
|
||||
ids.add(id);
|
||||
if (metadata) {
|
||||
sessionStore.setWorktreeMetadata(id, metadata);
|
||||
sessionStore.setSessionDirectory(id, worktreePath);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const { failedIds } = await sessionStore.deleteSessions(Array.from(ids), { archiveWorktree: true, silent: true });
|
||||
if (failedIds.length > 0) {
|
||||
set({ error: 'Failed to delete some sessions' });
|
||||
}
|
||||
|
||||
@@ -3,7 +3,8 @@ import { devtools } from 'zustand/middleware';
|
||||
import type { CreateMultiRunParams, CreateMultiRunResult } from '@/types/multirun';
|
||||
import { opencodeClient } from '@/lib/opencode/client';
|
||||
import { saveWorktreeSetupCommands } from '@/lib/openchamberConfig';
|
||||
import { createSdkWorktree, type ProjectRef } from '@/lib/worktrees/worktreeManager';
|
||||
import type { ProjectRef } from '@/lib/worktrees/worktreeManager';
|
||||
import { createWorktreeWithDefaults, resolveRootTrackingRemote } from '@/lib/worktrees/worktreeCreate';
|
||||
import { getRootBranch } from '@/lib/worktrees/worktreeStatus';
|
||||
import { checkIsGitRepository } from '@/lib/gitApi';
|
||||
import { useSessionStore } from './sessionStore';
|
||||
@@ -31,8 +32,8 @@ const toModelSlug = (providerID: string, modelID: string): string => {
|
||||
};
|
||||
|
||||
/**
|
||||
* Seed name for SDK worktree creation.
|
||||
* Uses slashes for readability; SDK will slugify.
|
||||
* Seed name for worktree creation.
|
||||
* Uses slashes for readability; create payload will slugify.
|
||||
*/
|
||||
const generateWorktreeNameSeed = (groupSlug: string, modelSlug: string): string => {
|
||||
return `${groupSlug}/${modelSlug}`;
|
||||
@@ -123,6 +124,7 @@ export const useMultiRunStore = create<MultiRunStore>()(
|
||||
|
||||
const groupSlug = toGitSafeSlug(groupName);
|
||||
const rootBranch = await getRootBranch(directory);
|
||||
const rootTrackingRemote = await resolveRootTrackingRemote(directory);
|
||||
|
||||
const createdRuns: Array<{
|
||||
sessionId: string;
|
||||
@@ -156,11 +158,16 @@ export const useMultiRunStore = create<MultiRunStore>()(
|
||||
const preferredName = count > 1
|
||||
? generateWorktreeNameSeed(groupSlug, `${modelSlug}/${index}`)
|
||||
: generateWorktreeNameSeed(groupSlug, modelSlug);
|
||||
|
||||
try {
|
||||
const worktreeMetadata = await createSdkWorktree(project, {
|
||||
const worktreeMetadata = await createWorktreeWithDefaults(project, {
|
||||
preferredName,
|
||||
mode: 'new',
|
||||
branchName: preferredName,
|
||||
worktreeName: preferredName,
|
||||
startRef: params.worktreeBaseBranch || 'HEAD',
|
||||
setupCommands: commandsToRun,
|
||||
}, {
|
||||
resolvedRootTrackingRemote: rootTrackingRemote,
|
||||
});
|
||||
|
||||
const enrichedMetadata = {
|
||||
|
||||
Reference in New Issue
Block a user