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:
@@ -285,9 +285,59 @@ export interface GitCommitFilesResponse {
|
||||
}
|
||||
|
||||
export interface GitWorktreeInfo {
|
||||
worktree: string;
|
||||
head?: string;
|
||||
branch?: string;
|
||||
head: string;
|
||||
name: string;
|
||||
branch: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
export interface GitWorktreeValidationError {
|
||||
code: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface GitWorktreeValidationResult {
|
||||
ok: boolean;
|
||||
errors: GitWorktreeValidationError[];
|
||||
resolved?: {
|
||||
mode?: 'new' | 'existing';
|
||||
localBranch?: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
export interface CreateGitWorktreePayload {
|
||||
mode?: 'new' | 'existing';
|
||||
/** Worktree folder name (falls back to OpenCode name generation when omitted). */
|
||||
worktreeName?: string;
|
||||
/** Backward-compatible alias for worktreeName. */
|
||||
name?: string;
|
||||
/** New local branch name for mode=new. */
|
||||
branchName?: string;
|
||||
/** Existing local/remote branch for mode=existing. */
|
||||
existingBranch?: string;
|
||||
/** Start ref for mode=new (local/remote branch or commit SHA). */
|
||||
startRef?: string;
|
||||
/** Additional startup script to run after project startup script. */
|
||||
startCommand?: string;
|
||||
/** Configure upstream tracking for the created/attached local branch. */
|
||||
setUpstream?: boolean;
|
||||
upstreamRemote?: string;
|
||||
upstreamBranch?: string;
|
||||
/** Optional remote provisioning (used for fork PR workflows). */
|
||||
ensureRemoteName?: string;
|
||||
ensureRemoteUrl?: string;
|
||||
}
|
||||
|
||||
export interface GitWorktreeCreateResult {
|
||||
head: string;
|
||||
name: string;
|
||||
branch: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
export interface RemoveGitWorktreePayload {
|
||||
directory: string;
|
||||
deleteLocalBranch?: boolean;
|
||||
}
|
||||
|
||||
export interface GitDeleteBranchPayload {
|
||||
@@ -322,6 +372,13 @@ export interface GeneratedPullRequestDescription {
|
||||
body: string;
|
||||
}
|
||||
|
||||
export interface GitWorktreeAPI {
|
||||
list(directory: string): Promise<GitWorktreeInfo[]>;
|
||||
validate?(directory: string, payload: CreateGitWorktreePayload): Promise<GitWorktreeValidationResult>;
|
||||
create?(directory: string, payload: CreateGitWorktreePayload): Promise<GitWorktreeCreateResult>;
|
||||
remove?(directory: string, payload: RemoveGitWorktreePayload): Promise<{ success: boolean }>;
|
||||
}
|
||||
|
||||
export interface GitAPI {
|
||||
checkIsGitRepository(directory: string): Promise<boolean>;
|
||||
getGitStatus(directory: string): Promise<GitStatus>;
|
||||
@@ -338,6 +395,9 @@ export interface GitAPI {
|
||||
payload: { base: string; head: string; context?: string; zenModel?: string }
|
||||
): Promise<GeneratedPullRequestDescription>;
|
||||
listGitWorktrees(directory: string): Promise<GitWorktreeInfo[]>;
|
||||
validateGitWorktree?(directory: string, payload: CreateGitWorktreePayload): Promise<GitWorktreeValidationResult>;
|
||||
createGitWorktree?(directory: string, payload: CreateGitWorktreePayload): Promise<GitWorktreeCreateResult>;
|
||||
deleteGitWorktree?(directory: string, payload: RemoveGitWorktreePayload): Promise<{ success: boolean }>;
|
||||
createGitCommit(directory: string, message: string, options?: CreateGitCommitOptions): Promise<GitCommitResult>;
|
||||
gitPush(directory: string, options?: { remote?: string; branch?: string; options?: string[] | Record<string, unknown> }): Promise<GitPushResult>;
|
||||
gitPull(directory: string, options?: { remote?: string; branch?: string }): Promise<GitPullResult>;
|
||||
@@ -367,6 +427,7 @@ export interface GitAPI {
|
||||
stash(directory: string, options?: { message?: string; includeUntracked?: boolean }): Promise<{ success: boolean }>;
|
||||
stashPop(directory: string): Promise<{ success: boolean }>;
|
||||
getConflictDetails(directory: string): Promise<MergeConflictDetails>;
|
||||
worktree?: GitWorktreeAPI;
|
||||
}
|
||||
|
||||
export interface FileListEntry {
|
||||
@@ -633,6 +694,7 @@ export type GitHubPullRequestHeadRepo = {
|
||||
repo: string;
|
||||
url: string;
|
||||
cloneUrl?: string;
|
||||
sshUrl?: string;
|
||||
};
|
||||
|
||||
export type GitHubPullRequestSummary = GitHubPullRequest & {
|
||||
|
||||
@@ -18,6 +18,11 @@ export type {
|
||||
GitLogEntry,
|
||||
GitLogResponse,
|
||||
GitWorktreeInfo,
|
||||
CreateGitWorktreePayload,
|
||||
GitWorktreeCreateResult,
|
||||
RemoveGitWorktreePayload,
|
||||
GitWorktreeValidationError,
|
||||
GitWorktreeValidationResult,
|
||||
GitDeleteBranchPayload,
|
||||
GitDeleteRemoteBranchPayload,
|
||||
DiscoveredGitCredential,
|
||||
@@ -120,10 +125,64 @@ export async function generatePullRequestDescription(
|
||||
|
||||
export async function listGitWorktrees(directory: string): Promise<import('./api/types').GitWorktreeInfo[]> {
|
||||
const runtime = getRuntimeGit();
|
||||
if (runtime?.worktree?.list) {
|
||||
return runtime.worktree.list(directory);
|
||||
}
|
||||
if (runtime) return runtime.listGitWorktrees(directory);
|
||||
return gitHttp.listGitWorktrees(directory);
|
||||
}
|
||||
|
||||
export async function validateGitWorktree(
|
||||
directory: string,
|
||||
payload: import('./api/types').CreateGitWorktreePayload
|
||||
): Promise<import('./api/types').GitWorktreeValidationResult> {
|
||||
const runtime = getRuntimeGit();
|
||||
if (runtime?.worktree?.validate) {
|
||||
return runtime.worktree.validate(directory, payload);
|
||||
}
|
||||
if (runtime?.validateGitWorktree) {
|
||||
return runtime.validateGitWorktree(directory, payload);
|
||||
}
|
||||
return gitHttp.validateGitWorktree(directory, payload);
|
||||
}
|
||||
|
||||
export async function createGitWorktree(
|
||||
directory: string,
|
||||
payload: import('./api/types').CreateGitWorktreePayload
|
||||
): Promise<import('./api/types').GitWorktreeCreateResult> {
|
||||
const runtime = getRuntimeGit();
|
||||
if (runtime?.worktree?.create) {
|
||||
return runtime.worktree.create(directory, payload);
|
||||
}
|
||||
if (runtime?.createGitWorktree) {
|
||||
return runtime.createGitWorktree(directory, payload);
|
||||
}
|
||||
return gitHttp.createGitWorktree(directory, payload);
|
||||
}
|
||||
|
||||
export async function deleteGitWorktree(
|
||||
directory: string,
|
||||
payload: import('./api/types').RemoveGitWorktreePayload
|
||||
): Promise<{ success: boolean }> {
|
||||
const runtime = getRuntimeGit();
|
||||
if (runtime?.worktree?.remove) {
|
||||
return runtime.worktree.remove(directory, payload);
|
||||
}
|
||||
if (runtime?.deleteGitWorktree) {
|
||||
return runtime.deleteGitWorktree(directory, payload);
|
||||
}
|
||||
return gitHttp.deleteGitWorktree(directory, payload);
|
||||
}
|
||||
|
||||
export const git = {
|
||||
worktree: {
|
||||
list: listGitWorktrees,
|
||||
validate: validateGitWorktree,
|
||||
create: createGitWorktree,
|
||||
remove: deleteGitWorktree,
|
||||
},
|
||||
};
|
||||
|
||||
export async function createGitCommit(
|
||||
directory: string,
|
||||
message: string,
|
||||
|
||||
@@ -11,6 +11,10 @@ import type {
|
||||
GitDeleteRemoteBranchPayload,
|
||||
GeneratedCommitMessage,
|
||||
GitWorktreeInfo,
|
||||
CreateGitWorktreePayload,
|
||||
GitWorktreeCreateResult,
|
||||
RemoveGitWorktreePayload,
|
||||
GitWorktreeValidationResult,
|
||||
CreateGitCommitOptions,
|
||||
GitCommitResult,
|
||||
GitPushResult,
|
||||
@@ -299,6 +303,51 @@ export async function listGitWorktrees(directory: string): Promise<GitWorktreeIn
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function validateGitWorktree(directory: string, payload: CreateGitWorktreePayload): Promise<GitWorktreeValidationResult> {
|
||||
const response = await fetch(buildUrl(`${API_BASE}/worktrees/validate`, directory), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload ?? {}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ error: response.statusText }));
|
||||
throw new Error(error.error || 'Failed to validate worktree');
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function createGitWorktree(directory: string, payload: CreateGitWorktreePayload): Promise<GitWorktreeCreateResult> {
|
||||
const response = await fetch(buildUrl(`${API_BASE}/worktrees`, directory), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload ?? {}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ error: response.statusText }));
|
||||
throw new Error(error.error || 'Failed to create worktree');
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function deleteGitWorktree(directory: string, payload: RemoveGitWorktreePayload): Promise<{ success: boolean }> {
|
||||
const response = await fetch(buildUrl(`${API_BASE}/worktrees`, directory), {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload ?? {}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ error: response.statusText }));
|
||||
throw new Error(error.error || 'Failed to delete worktree');
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function createGitCommit(
|
||||
directory: string,
|
||||
message: string,
|
||||
|
||||
@@ -15,10 +15,10 @@ import { generateBranchName } from '@/lib/git/branchNameGenerator';
|
||||
import { getRootBranch, getWorktreeStatus } from '@/lib/worktrees/worktreeStatus';
|
||||
import { getWorktreeSetupCommands } from '@/lib/openchamberConfig';
|
||||
import {
|
||||
createSdkWorktree,
|
||||
removeProjectWorktree,
|
||||
type ProjectRef,
|
||||
} from '@/lib/worktrees/worktreeManager';
|
||||
import { createWorktreeWithDefaults } from '@/lib/worktrees/worktreeCreate';
|
||||
import { startConfigUpdate, finishConfigUpdate } from '@/lib/configUpdate';
|
||||
|
||||
const normalizePath = (value: string): string => value.replace(/\\/g, '/').replace(/\/+$/, '') || value;
|
||||
@@ -98,8 +98,11 @@ export async function createWorktreeSession(): Promise<{ id: string } | null> {
|
||||
|
||||
const setupCommands = await getWorktreeSetupCommands(projectRef);
|
||||
const rootBranch = await getRootBranch(projectRef.path);
|
||||
const metadata = await createSdkWorktree(projectRef, {
|
||||
const metadata = await createWorktreeWithDefaults(projectRef, {
|
||||
preferredName,
|
||||
mode: 'new',
|
||||
branchName: preferredName,
|
||||
worktreeName: preferredName,
|
||||
setupCommands,
|
||||
});
|
||||
|
||||
@@ -118,7 +121,7 @@ export async function createWorktreeSession(): Promise<{ id: string } | null> {
|
||||
const session = await sessionStore.createSession(undefined, metadata.path);
|
||||
if (!session) {
|
||||
// Clean up the worktree if session creation failed
|
||||
await removeProjectWorktree(projectRef, metadata).catch(() => undefined);
|
||||
await removeProjectWorktree(projectRef, metadata, { deleteLocalBranch: true }).catch(() => undefined);
|
||||
toast.error('Failed to create session', {
|
||||
description: 'Could not create a session for the worktree.',
|
||||
});
|
||||
@@ -264,8 +267,11 @@ export async function createWorktreeOnly(): Promise<string | null> {
|
||||
const projectRef: ProjectRef = { id: activeProject.id, path: projectDirectory };
|
||||
const preferredName = generateBranchName();
|
||||
const setupCommands = await getWorktreeSetupCommands(projectRef);
|
||||
const metadata = await createSdkWorktree(projectRef, {
|
||||
const metadata = await createWorktreeWithDefaults(projectRef, {
|
||||
preferredName,
|
||||
mode: 'new',
|
||||
branchName: preferredName,
|
||||
worktreeName: preferredName,
|
||||
setupCommands,
|
||||
});
|
||||
|
||||
@@ -303,7 +309,18 @@ export async function createWorktreeOnly(): Promise<string | null> {
|
||||
*/
|
||||
export async function createWorktreeSessionForBranch(
|
||||
projectDirectory: string,
|
||||
branchName: string
|
||||
branchName: string,
|
||||
options?: {
|
||||
kind?: 'pr' | 'standard';
|
||||
existingBranch?: string;
|
||||
worktreeName?: string;
|
||||
setUpstream?: boolean;
|
||||
upstreamRemote?: string;
|
||||
upstreamBranch?: string;
|
||||
ensureRemoteName?: string;
|
||||
ensureRemoteUrl?: string;
|
||||
createdFromBranch?: string;
|
||||
}
|
||||
): Promise<{ id: string } | null> {
|
||||
if (isCreatingWorktreeSession) {
|
||||
return null;
|
||||
@@ -335,15 +352,25 @@ export async function createWorktreeSessionForBranch(
|
||||
|
||||
const setupCommands = await getWorktreeSetupCommands(projectRef);
|
||||
const rootBranch = await getRootBranch(projectRef.path);
|
||||
const metadata = await createSdkWorktree(projectRef, {
|
||||
const metadata = await createWorktreeWithDefaults(projectRef, {
|
||||
preferredName: branchName,
|
||||
mode: 'existing',
|
||||
existingBranch: options?.existingBranch || branchName,
|
||||
branchName,
|
||||
worktreeName: options?.worktreeName || branchName,
|
||||
setUpstream: options?.setUpstream,
|
||||
upstreamRemote: options?.upstreamRemote,
|
||||
upstreamBranch: options?.upstreamBranch,
|
||||
ensureRemoteName: options?.ensureRemoteName,
|
||||
ensureRemoteUrl: options?.ensureRemoteUrl,
|
||||
setupCommands,
|
||||
});
|
||||
|
||||
const kind = options?.kind ?? 'standard';
|
||||
const createdMetadata = {
|
||||
...metadata,
|
||||
createdFromBranch: rootBranch,
|
||||
kind: 'standard' as const,
|
||||
createdFromBranch: options?.createdFromBranch || rootBranch,
|
||||
kind,
|
||||
};
|
||||
|
||||
// Get worktree status
|
||||
@@ -355,7 +382,7 @@ export async function createWorktreeSessionForBranch(
|
||||
const session = await sessionStore.createSession(undefined, metadata.path);
|
||||
if (!session) {
|
||||
// Clean up the worktree if session creation failed
|
||||
await removeProjectWorktree(projectRef, metadata).catch(() => undefined);
|
||||
await removeProjectWorktree(projectRef, metadata, { deleteLocalBranch: true }).catch(() => undefined);
|
||||
toast.error('Failed to create session', {
|
||||
description: 'Could not create a session for the worktree.',
|
||||
});
|
||||
@@ -467,7 +494,16 @@ export async function createWorktreeSessionForNewBranch(
|
||||
projectDirectory: string,
|
||||
preferredBranchName: string,
|
||||
startPoint?: string,
|
||||
options?: { kind?: 'pr' | 'standard' }
|
||||
options?: {
|
||||
kind?: 'pr' | 'standard';
|
||||
worktreeName?: string;
|
||||
setUpstream?: boolean;
|
||||
upstreamRemote?: string;
|
||||
upstreamBranch?: string;
|
||||
ensureRemoteName?: string;
|
||||
ensureRemoteUrl?: string;
|
||||
createdFromBranch?: string;
|
||||
}
|
||||
): Promise<{ id: string; branch: string } | null> {
|
||||
if (isCreatingWorktreeSession) {
|
||||
return null;
|
||||
@@ -506,15 +542,23 @@ export async function createWorktreeSessionForNewBranch(
|
||||
|
||||
const setupCommands = await getWorktreeSetupCommands(projectRef);
|
||||
const rootBranch = await getRootBranch(projectRef.path);
|
||||
|
||||
try {
|
||||
const metadata = await createSdkWorktree(projectRef, {
|
||||
const metadata = await createWorktreeWithDefaults(projectRef, {
|
||||
preferredName: base,
|
||||
mode: 'new',
|
||||
branchName: base,
|
||||
worktreeName: options?.worktreeName || base,
|
||||
startRef: start,
|
||||
setUpstream: options?.setUpstream,
|
||||
upstreamRemote: options?.upstreamRemote,
|
||||
upstreamBranch: options?.upstreamBranch,
|
||||
ensureRemoteName: options?.ensureRemoteName,
|
||||
ensureRemoteUrl: options?.ensureRemoteUrl,
|
||||
setupCommands,
|
||||
});
|
||||
const createdMetadata = {
|
||||
...metadata,
|
||||
createdFromBranch: rootBranch || start,
|
||||
createdFromBranch: options?.createdFromBranch || rootBranch || start,
|
||||
kind,
|
||||
};
|
||||
|
||||
@@ -524,7 +568,7 @@ export async function createWorktreeSessionForNewBranch(
|
||||
const sessionStore = useSessionStore.getState();
|
||||
const session = await sessionStore.createSession(undefined, metadata.path);
|
||||
if (!session) {
|
||||
await removeProjectWorktree(projectRef, metadata).catch(() => undefined);
|
||||
await removeProjectWorktree(projectRef, metadata, { deleteLocalBranch: true }).catch(() => undefined);
|
||||
throw new Error('Could not create a session for the worktree.');
|
||||
}
|
||||
|
||||
@@ -616,9 +660,25 @@ export async function createWorktreeSessionForNewBranchExact(
|
||||
projectDirectory: string,
|
||||
branchName: string,
|
||||
startPoint: string,
|
||||
options?: { kind?: 'pr' | 'standard' }
|
||||
options?: {
|
||||
kind?: 'pr' | 'standard';
|
||||
worktreeName?: string;
|
||||
setUpstream?: boolean;
|
||||
upstreamRemote?: string;
|
||||
upstreamBranch?: string;
|
||||
ensureRemoteName?: string;
|
||||
ensureRemoteUrl?: string;
|
||||
createdFromBranch?: string;
|
||||
}
|
||||
): Promise<{ id: string; branch: string } | null> {
|
||||
return createWorktreeSessionForNewBranch(projectDirectory, branchName, startPoint, {
|
||||
kind: options?.kind,
|
||||
worktreeName: options?.worktreeName,
|
||||
setUpstream: options?.setUpstream,
|
||||
upstreamRemote: options?.upstreamRemote,
|
||||
upstreamBranch: options?.upstreamBranch,
|
||||
ensureRemoteName: options?.ensureRemoteName,
|
||||
ensureRemoteUrl: options?.ensureRemoteUrl,
|
||||
createdFromBranch: options?.createdFromBranch,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
import { getGitBranches, getGitStatus } from '@/lib/gitApi';
|
||||
import type { CreateWorktreeArgs, ProjectRef } from '@/lib/worktrees/worktreeManager';
|
||||
import { createWorktree } from '@/lib/worktrees/worktreeManager';
|
||||
import { getRootBranch } from '@/lib/worktrees/worktreeStatus';
|
||||
|
||||
const parseTrackingRef = (tracking: string | null | undefined): { remote: string; branch: string } | null => {
|
||||
const value = String(tracking || '').trim().replace(/^remotes\//, '');
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const separatorIndex = value.indexOf('/');
|
||||
if (separatorIndex <= 0 || separatorIndex >= value.length - 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
remote: value.slice(0, separatorIndex),
|
||||
branch: value.slice(separatorIndex + 1),
|
||||
};
|
||||
};
|
||||
|
||||
const normalizeBranchName = (value: string): string => {
|
||||
return String(value || '')
|
||||
.trim()
|
||||
.replace(/^refs\/heads\//, '')
|
||||
.replace(/^heads\//, '')
|
||||
.replace(/^remotes\//, '');
|
||||
};
|
||||
|
||||
const resolveLocalBranchName = (args: CreateWorktreeArgs): string => {
|
||||
if (args.branchName) {
|
||||
return normalizeBranchName(args.branchName);
|
||||
}
|
||||
if (args.mode === 'existing') {
|
||||
return normalizeBranchName(args.existingBranch || args.preferredName || '');
|
||||
}
|
||||
return normalizeBranchName(args.preferredName || '');
|
||||
};
|
||||
|
||||
export const resolveRootTrackingRemote = async (projectDirectory: string): Promise<string | null> => {
|
||||
const rootBranch = await getRootBranch(projectDirectory);
|
||||
|
||||
try {
|
||||
const branchState = await getGitBranches(projectDirectory);
|
||||
const tracking = branchState.branches?.[rootBranch]?.tracking || null;
|
||||
const parsed = parseTrackingRef(tracking);
|
||||
if (parsed?.remote) {
|
||||
return parsed.remote;
|
||||
}
|
||||
} catch {
|
||||
// ignore and fallback to status tracking
|
||||
}
|
||||
|
||||
try {
|
||||
const status = await getGitStatus(projectDirectory);
|
||||
const parsed = parseTrackingRef(status.tracking);
|
||||
if (parsed?.remote) {
|
||||
return parsed.remote;
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export const resolveWorktreeUpstreamDefaults = async (
|
||||
projectDirectory: string,
|
||||
localBranch: string
|
||||
): Promise<{ setUpstream: true; upstreamRemote: string; upstreamBranch: string } | null> => {
|
||||
const remote = await resolveRootTrackingRemote(projectDirectory);
|
||||
const normalizedBranch = normalizeBranchName(localBranch);
|
||||
if (!remote || !normalizedBranch) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
setUpstream: true,
|
||||
upstreamRemote: remote,
|
||||
upstreamBranch: normalizedBranch,
|
||||
};
|
||||
};
|
||||
|
||||
export const withWorktreeUpstreamDefaults = async (
|
||||
projectDirectory: string,
|
||||
args: CreateWorktreeArgs,
|
||||
options?: { resolvedRootTrackingRemote?: string | null }
|
||||
): Promise<CreateWorktreeArgs> => {
|
||||
const localBranch = resolveLocalBranchName(args);
|
||||
const resolvedRemote = options?.resolvedRootTrackingRemote;
|
||||
const defaults = resolvedRemote === undefined
|
||||
? await resolveWorktreeUpstreamDefaults(projectDirectory, localBranch)
|
||||
: (resolvedRemote && normalizeBranchName(localBranch)
|
||||
? {
|
||||
setUpstream: true as const,
|
||||
upstreamRemote: resolvedRemote,
|
||||
upstreamBranch: normalizeBranchName(localBranch),
|
||||
}
|
||||
: null);
|
||||
if (!defaults) {
|
||||
return args;
|
||||
}
|
||||
|
||||
return {
|
||||
...args,
|
||||
setUpstream: args.setUpstream ?? defaults.setUpstream,
|
||||
upstreamRemote: args.upstreamRemote || defaults.upstreamRemote,
|
||||
upstreamBranch: args.upstreamBranch || defaults.upstreamBranch,
|
||||
};
|
||||
};
|
||||
|
||||
export const createWorktreeWithDefaults = async (
|
||||
project: ProjectRef,
|
||||
args: CreateWorktreeArgs,
|
||||
options?: { resolvedRootTrackingRemote?: string | null }
|
||||
) => {
|
||||
const resolvedArgs = await withWorktreeUpstreamDefaults(project.path, args, options);
|
||||
return createWorktree(project, resolvedArgs);
|
||||
};
|
||||
@@ -1,7 +1,13 @@
|
||||
import { opencodeClient } from '@/lib/opencode/client';
|
||||
import { substituteCommandVariables } from '@/lib/openchamberConfig';
|
||||
import type { WorktreeMetadata } from '@/types/worktree';
|
||||
import { deleteRemoteBranch, getGitStatus } from '@/lib/gitApi';
|
||||
import {
|
||||
deleteRemoteBranch,
|
||||
git,
|
||||
} from '@/lib/gitApi';
|
||||
import type {
|
||||
CreateGitWorktreePayload,
|
||||
GitWorktreeValidationResult,
|
||||
} from '@/lib/api/types';
|
||||
|
||||
export type ProjectRef = { id: string; path: string };
|
||||
|
||||
@@ -16,21 +22,24 @@ const normalizePath = (value: string): string => {
|
||||
const slugifyWorktreeName = (value: string): string => {
|
||||
return value
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^refs\/heads\//, '')
|
||||
.replace(/^heads\//, '')
|
||||
.replace(/\s+/g, '-')
|
||||
.replace(/^\/+|\/+$/g, '')
|
||||
.split('/').join('-')
|
||||
.replace(/[^A-Za-z0-9._-]+/g, '-')
|
||||
.replace(/-+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
.slice(0, 80);
|
||||
};
|
||||
|
||||
const unwrapSdkData = (value: unknown): unknown => {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return value;
|
||||
}
|
||||
const record = value as Record<string, unknown>;
|
||||
if ('data' in record) {
|
||||
return record.data;
|
||||
}
|
||||
return value;
|
||||
const normalizeBranchName = (value: string): string => {
|
||||
return value
|
||||
.trim()
|
||||
.replace(/^refs\/heads\//, '')
|
||||
.replace(/^heads\//, '')
|
||||
.replace(/\s+/g, '-')
|
||||
.replace(/^\/+|\/+$/g, '');
|
||||
};
|
||||
|
||||
const deriveSdkWorktreeNameFromDirectory = (directory: string): string => {
|
||||
@@ -57,106 +66,73 @@ export const buildSdkStartCommand = (args: {
|
||||
return joined.trim().length > 0 ? joined : undefined;
|
||||
};
|
||||
|
||||
const waitForSdkWorktreeReady = async (directory: string, timeoutMs = 60_000): Promise<void> => {
|
||||
const target = normalizePath(directory);
|
||||
if (!target) {
|
||||
return;
|
||||
}
|
||||
const toCreatePayload = (args: {
|
||||
preferredName?: string;
|
||||
setupCommands?: string[];
|
||||
mode?: 'new' | 'existing';
|
||||
worktreeName?: string;
|
||||
branchName?: string;
|
||||
existingBranch?: string;
|
||||
startRef?: string;
|
||||
setUpstream?: boolean;
|
||||
upstreamRemote?: string;
|
||||
upstreamBranch?: string;
|
||||
ensureRemoteName?: string;
|
||||
ensureRemoteUrl?: string;
|
||||
}, projectDirectory: string): CreateGitWorktreePayload => {
|
||||
const mode = args.mode === 'existing' ? 'existing' : 'new';
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
let done = false;
|
||||
let unsubscribe = () => {};
|
||||
let timeout: ReturnType<typeof setTimeout> | null = null;
|
||||
const cleanup = () => {
|
||||
if (timeout) {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
try {
|
||||
unsubscribe();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
const finish = (result?: { error?: string }) => {
|
||||
if (done) return;
|
||||
done = true;
|
||||
cleanup();
|
||||
if (result?.error) {
|
||||
reject(new Error(result.error));
|
||||
} else {
|
||||
resolve();
|
||||
}
|
||||
};
|
||||
const worktreeNameSeed = args.worktreeName ?? args.preferredName ?? '';
|
||||
const worktreeName = slugifyWorktreeName(worktreeNameSeed);
|
||||
|
||||
timeout = setTimeout(() => {
|
||||
finish({ error: 'Worktree startup timed out' });
|
||||
}, timeoutMs);
|
||||
const branchNameSeed = args.branchName ?? (mode === 'new' ? args.preferredName : undefined) ?? '';
|
||||
const branchName = normalizeBranchName(branchNameSeed);
|
||||
|
||||
unsubscribe = opencodeClient.subscribeToGlobalEvents(
|
||||
(event) => {
|
||||
const payload = event.payload as { type?: string; properties?: Record<string, unknown> };
|
||||
if (payload?.type === 'worktree.ready') {
|
||||
finish();
|
||||
return;
|
||||
}
|
||||
if (payload?.type === 'worktree.failed') {
|
||||
const message = typeof payload.properties?.message === 'string'
|
||||
? payload.properties.message
|
||||
: 'Worktree failed to start';
|
||||
finish({ error: message });
|
||||
}
|
||||
},
|
||||
undefined,
|
||||
undefined,
|
||||
{ directory: target }
|
||||
);
|
||||
const existingBranch = normalizeBranchName(args.existingBranch ?? args.branchName ?? '');
|
||||
const startRef = (args.startRef || '').trim();
|
||||
|
||||
const commands = Array.isArray(args.setupCommands) ? args.setupCommands : [];
|
||||
const startCommand = buildSdkStartCommand({
|
||||
projectDirectory,
|
||||
setupCommands: commands,
|
||||
});
|
||||
|
||||
return {
|
||||
mode,
|
||||
...(worktreeName ? { worktreeName } : {}),
|
||||
...(branchName ? { branchName } : {}),
|
||||
...(existingBranch ? { existingBranch } : {}),
|
||||
...(startRef ? { startRef } : {}),
|
||||
...(startCommand ? { startCommand } : {}),
|
||||
...(args.setUpstream ? { setUpstream: true } : {}),
|
||||
...(args.upstreamRemote ? { upstreamRemote: args.upstreamRemote } : {}),
|
||||
...(args.upstreamBranch ? { upstreamBranch: args.upstreamBranch } : {}),
|
||||
...(args.ensureRemoteName ? { ensureRemoteName: args.ensureRemoteName } : {}),
|
||||
...(args.ensureRemoteUrl ? { ensureRemoteUrl: args.ensureRemoteUrl } : {}),
|
||||
};
|
||||
};
|
||||
|
||||
export async function listProjectWorktrees(project: ProjectRef): Promise<WorktreeMetadata[]> {
|
||||
const projectDirectory = project.path;
|
||||
const scoped = opencodeClient.getScopedApiClient(projectDirectory);
|
||||
const normalizedProjectDirectory = normalizePath(projectDirectory);
|
||||
|
||||
const results: WorktreeMetadata[] = [];
|
||||
|
||||
// SDK worktrees
|
||||
try {
|
||||
const raw = await scoped.worktree.list();
|
||||
const data = unwrapSdkData(raw);
|
||||
const directories = Array.isArray(data) ? data : [];
|
||||
|
||||
for (const entry of directories) {
|
||||
if (typeof entry !== 'string' || entry.trim().length === 0) {
|
||||
continue;
|
||||
}
|
||||
const directory = normalizePath(entry);
|
||||
const name = deriveSdkWorktreeNameFromDirectory(directory);
|
||||
results.push({
|
||||
source: 'sdk',
|
||||
name,
|
||||
path: directory,
|
||||
const worktrees = await git.worktree.list(projectDirectory).catch(() => []);
|
||||
const results: WorktreeMetadata[] = worktrees
|
||||
.filter((entry) => typeof entry.path === 'string' && entry.path.trim().length > 0)
|
||||
.map((entry) => {
|
||||
const worktreePath = normalizePath(entry.path);
|
||||
const branch = (entry.branch || '').replace(/^refs\/heads\//, '').trim();
|
||||
const name = (entry.name || '').trim();
|
||||
return {
|
||||
source: 'sdk' as const,
|
||||
name: name || deriveSdkWorktreeNameFromDirectory(worktreePath),
|
||||
path: worktreePath,
|
||||
projectDirectory,
|
||||
branch: '',
|
||||
label: name,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
// Enrich worktrees with branch information from git status
|
||||
await Promise.all(
|
||||
results.map(async (worktree) => {
|
||||
try {
|
||||
const status = await getGitStatus(worktree.path);
|
||||
if (status?.current) {
|
||||
worktree.branch = status.current;
|
||||
}
|
||||
} catch {
|
||||
// ignore - branch will remain empty
|
||||
}
|
||||
branch,
|
||||
label: branch || name || deriveSdkWorktreeNameFromDirectory(worktreePath),
|
||||
};
|
||||
})
|
||||
);
|
||||
.filter((entry) => normalizePath(entry.path) !== normalizedProjectDirectory);
|
||||
|
||||
return results.sort((a, b) => {
|
||||
const aLabel = (a.label || a.branch || a.path).toLowerCase();
|
||||
@@ -165,71 +141,67 @@ export async function listProjectWorktrees(project: ProjectRef): Promise<Worktre
|
||||
});
|
||||
}
|
||||
|
||||
export async function createSdkWorktree(project: ProjectRef, args: {
|
||||
export type CreateWorktreeArgs = {
|
||||
preferredName?: string;
|
||||
setupCommands?: string[];
|
||||
}): Promise<WorktreeMetadata> {
|
||||
mode?: 'new' | 'existing';
|
||||
worktreeName?: string;
|
||||
branchName?: string;
|
||||
existingBranch?: string;
|
||||
startRef?: string;
|
||||
setUpstream?: boolean;
|
||||
upstreamRemote?: string;
|
||||
upstreamBranch?: string;
|
||||
ensureRemoteName?: string;
|
||||
ensureRemoteUrl?: string;
|
||||
};
|
||||
|
||||
export async function createWorktree(project: ProjectRef, args: CreateWorktreeArgs): Promise<WorktreeMetadata> {
|
||||
const projectDirectory = project.path;
|
||||
const scoped = opencodeClient.getScopedApiClient(projectDirectory);
|
||||
const payload = toCreatePayload(args, projectDirectory);
|
||||
|
||||
const baseName = typeof args.preferredName === 'string' ? slugifyWorktreeName(args.preferredName) : '';
|
||||
const seed = baseName || undefined;
|
||||
const created = await git.worktree.create(projectDirectory, payload);
|
||||
const returnedName = typeof created?.name === 'string' ? created.name : '';
|
||||
const returnedBranch = typeof created?.branch === 'string' ? created.branch : '';
|
||||
const returnedPath = typeof created?.path === 'string' ? created.path : '';
|
||||
|
||||
const commands = Array.isArray(args.setupCommands) ? args.setupCommands : [];
|
||||
const startCommand = buildSdkStartCommand({
|
||||
projectDirectory,
|
||||
setupCommands: commands,
|
||||
});
|
||||
|
||||
const name = seed || undefined;
|
||||
const raw = await scoped.worktree.create({
|
||||
worktreeCreateInput: {
|
||||
...(name ? { name } : {}),
|
||||
...(startCommand ? { startCommand } : {}),
|
||||
},
|
||||
});
|
||||
|
||||
const data = unwrapSdkData(raw);
|
||||
if (!data || typeof data !== 'object') {
|
||||
throw new Error('Invalid worktree.create response');
|
||||
}
|
||||
|
||||
const record = data as Record<string, unknown>;
|
||||
const returnedName = typeof record.name === 'string' ? record.name : name;
|
||||
const returnedBranch = typeof record.branch === 'string' ? record.branch : (returnedName ? `opencode/${returnedName}` : '');
|
||||
const returnedDirectory = typeof record.directory === 'string' ? record.directory : '';
|
||||
|
||||
if (!returnedName || !returnedDirectory) {
|
||||
throw new Error('Worktree create missing name/directory');
|
||||
if (!returnedName || !returnedPath) {
|
||||
throw new Error('Worktree create missing name/path');
|
||||
}
|
||||
|
||||
const metadata: WorktreeMetadata = {
|
||||
source: 'sdk',
|
||||
name: returnedName,
|
||||
path: normalizePath(returnedDirectory),
|
||||
path: normalizePath(returnedPath),
|
||||
projectDirectory,
|
||||
branch: returnedBranch,
|
||||
label: returnedName,
|
||||
label: returnedBranch || returnedName,
|
||||
};
|
||||
|
||||
await waitForSdkWorktreeReady(metadata.path);
|
||||
|
||||
return metadata;
|
||||
}
|
||||
|
||||
export async function validateWorktreeCreate(project: ProjectRef, args: CreateWorktreeArgs): Promise<GitWorktreeValidationResult> {
|
||||
const projectDirectory = project.path;
|
||||
const payload = toCreatePayload(args, projectDirectory);
|
||||
return git.worktree.validate(projectDirectory, payload);
|
||||
}
|
||||
|
||||
export async function removeProjectWorktree(project: ProjectRef, worktree: WorktreeMetadata, options?: {
|
||||
deleteRemoteBranch?: boolean;
|
||||
deleteLocalBranch?: boolean;
|
||||
remoteName?: string;
|
||||
force?: boolean;
|
||||
}): Promise<void> {
|
||||
const projectDirectory = project.path;
|
||||
|
||||
const deleteRemote = Boolean(options?.deleteRemoteBranch);
|
||||
const deleteLocalBranch = options?.deleteLocalBranch === true;
|
||||
const remoteName = options?.remoteName;
|
||||
const scoped = opencodeClient.getScopedApiClient(projectDirectory);
|
||||
const raw = await scoped.worktree.remove({ worktreeRemoveInput: { directory: worktree.path } });
|
||||
const ok = unwrapSdkData(raw);
|
||||
if (ok !== true) {
|
||||
const raw = await git.worktree.remove(projectDirectory, {
|
||||
directory: worktree.path,
|
||||
deleteLocalBranch,
|
||||
});
|
||||
if (!raw?.success) {
|
||||
throw new Error('Worktree removal failed');
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user