From 0d8709a72e106483277fbefc50736d5f27e59593 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9D=96=8E=F0=9D=96=9A=F0=9D=96=91=F0=9D=96=8E?= =?UTF-8?q?=F0=9D=96=8E=F0=9D=96=86?= Date: Thu, 3 Sep 2026 15:17:28 +0300 Subject: [PATCH] fix(worktrees): remove worktrees in the background (#3319) * refactor(worktrees): fetch source once during creation * fix(worktrees): remove worktrees in background * fix(worktrees): show background removal progress * fix(worktrees): name the worktree in removal toasts --- .../src/apps/MobileDeleteWorktreeDialog.tsx | 64 +++----- .../sections/behavior/BehaviorPage.tsx | 42 +----- .../src/components/session/SessionDialogs.tsx | 122 +++++++-------- packages/ui/src/lib/api/types.ts | 1 + packages/ui/src/lib/desktop.ts | 1 - .../ui/src/lib/i18n/messages/de.settings.ts | 4 - packages/ui/src/lib/i18n/messages/de.ts | 5 +- .../ui/src/lib/i18n/messages/en.settings.ts | 4 - packages/ui/src/lib/i18n/messages/en.ts | 5 +- .../ui/src/lib/i18n/messages/es.settings.ts | 4 - packages/ui/src/lib/i18n/messages/es.ts | 5 +- .../ui/src/lib/i18n/messages/fr.settings.ts | 4 - packages/ui/src/lib/i18n/messages/fr.ts | 5 +- .../ui/src/lib/i18n/messages/ja.settings.ts | 4 - packages/ui/src/lib/i18n/messages/ja.ts | 5 +- .../ui/src/lib/i18n/messages/ko.settings.ts | 4 - packages/ui/src/lib/i18n/messages/ko.ts | 5 +- .../ui/src/lib/i18n/messages/pl.settings.ts | 4 - packages/ui/src/lib/i18n/messages/pl.ts | 5 +- .../src/lib/i18n/messages/pt-BR.settings.ts | 4 - packages/ui/src/lib/i18n/messages/pt-BR.ts | 5 +- .../ui/src/lib/i18n/messages/tr.settings.ts | 4 - packages/ui/src/lib/i18n/messages/tr.ts | 5 +- .../ui/src/lib/i18n/messages/uk.settings.ts | 4 - packages/ui/src/lib/i18n/messages/uk.ts | 5 +- .../src/lib/i18n/messages/zh-CN.settings.ts | 4 - packages/ui/src/lib/i18n/messages/zh-CN.ts | 5 +- .../src/lib/i18n/messages/zh-TW.settings.ts | 4 - packages/ui/src/lib/i18n/messages/zh-TW.ts | 5 +- packages/ui/src/lib/persistence.ts | 3 - packages/ui/src/lib/settings/search.ts | 6 - .../src/lib/worktrees/worktreeCreate.test.ts | 142 +++--------------- .../ui/src/lib/worktrees/worktreeCreate.ts | 31 +--- .../src/lib/worktrees/worktreeManager.test.ts | 24 +++ .../ui/src/lib/worktrees/worktreeManager.ts | 11 ++ packages/ui/src/stores/useConfigStore.ts | 12 -- packages/vscode/src/DOCUMENTATION.md | 1 + packages/vscode/src/gitService.ts | 96 ++++++++---- ...gitService.worktree-fetch-fallback.test.js | 6 +- packages/web/server/lib/git/DOCUMENTATION.md | 3 +- packages/web/server/lib/git/service.js | 91 +++++++---- packages/web/server/lib/git/service.test.js | 6 +- .../server/lib/opencode/settings-helpers.js | 3 - 43 files changed, 326 insertions(+), 447 deletions(-) diff --git a/packages/ui/src/apps/MobileDeleteWorktreeDialog.tsx b/packages/ui/src/apps/MobileDeleteWorktreeDialog.tsx index a39ccca5..8a0c454d 100644 --- a/packages/ui/src/apps/MobileDeleteWorktreeDialog.tsx +++ b/packages/ui/src/apps/MobileDeleteWorktreeDialog.tsx @@ -7,7 +7,7 @@ import { toast } from '@/components/ui'; import { useI18n } from '@/lib/i18n'; import { cn } from '@/lib/utils'; import { getWorktreeStatus } from '@/lib/worktrees/worktreeStatus'; -import { removeProjectWorktree, type ProjectRef } from '@/lib/worktrees/worktreeManager'; +import { getWorktreeDisplayName, removeProjectWorktree, type ProjectRef } from '@/lib/worktrees/worktreeManager'; import { useDirectoryStore } from '@/stores/useDirectoryStore'; import { useGlobalSessionsStore } from '@/stores/useGlobalSessionsStore'; import { useSessionUIStore } from '@/sync/session-ui-store'; @@ -91,9 +91,24 @@ export const MobileDeleteWorktreeDialog: React.FC { + const removeWorktreeInBackground = React.useCallback((target: WorktreeMetadata, sessionIds: string[]) => { + const name = getWorktreeDisplayName(target); + const toastId = toast.loading(t('sessions.sidebar.sessionDialogs.worktree.removingTitle', { name })); void (async () => { try { + if (sessionIds.length > 0) { + const { failedIds } = await archiveSessions(sessionIds); + if (failedIds.length > 0) { + toast.error( + failedIds.length === 1 + ? t('sessions.sidebar.bulkActions.failedArchiveSingle', { count: failedIds.length }) + : t('sessions.sidebar.bulkActions.failedArchivePlural', { count: failedIds.length }), + { id: toastId, description: t('sessions.sidebar.dialogs.deleteResult.tryAgain') }, + ); + return; + } + } + await removeProjectWorktree(project, target, { deleteRemoteBranch: hasBranch && deleteRemoteBranch, deleteLocalBranch: hasBranch && deleteLocalBranch, @@ -104,7 +119,8 @@ export const MobileDeleteWorktreeDialog: React.FC { + const handleConfirm = () => { if (!worktree || isProcessing) return; setIsProcessing(true); - try { - if (linkedSessions.length > 0) { - const { archivedIds, failedIds } = await archiveSessions(linkedSessions.map((session) => session.id)); - if (failedIds.length > 0) { - if (archivedIds.length > 0) { - toast.success( - archivedIds.length === 1 - ? t('sessions.sidebar.bulkActions.archivedSingle', { count: archivedIds.length }) - : t('sessions.sidebar.bulkActions.archivedPlural', { count: archivedIds.length }), - ); - } - toast.error( - failedIds.length === 1 - ? t('sessions.sidebar.bulkActions.failedArchiveSingle', { count: failedIds.length }) - : t('sessions.sidebar.bulkActions.failedArchivePlural', { count: failedIds.length }), - { description: t('sessions.sidebar.dialogs.deleteResult.tryAgain') }, - ); - setIsProcessing(false); - return; - } - } - removeWorktreeInBackground(worktree); - onClose(); - } catch (error) { - toast.error(t('sessions.sidebar.sessionDialogs.worktree.errorRemoveTitle'), { - description: error instanceof Error ? error.message : t('sessions.sidebar.dialogs.deleteResult.tryAgain'), - }); - setIsProcessing(false); - } finally { - if (!open) { - setIsProcessing(false); - } - } + removeWorktreeInBackground(worktree, linkedSessions.map((session) => session.id)); + onClose(); }; if (!worktree) return null; diff --git a/packages/ui/src/components/sections/behavior/BehaviorPage.tsx b/packages/ui/src/components/sections/behavior/BehaviorPage.tsx index b5d388f7..758c954c 100644 --- a/packages/ui/src/components/sections/behavior/BehaviorPage.tsx +++ b/packages/ui/src/components/sections/behavior/BehaviorPage.tsx @@ -20,7 +20,6 @@ import { } from '@/lib/responseStyle'; import type { DesktopSettings } from '@/lib/desktop'; import { runtimeFetch } from '@/lib/runtime-fetch'; -import { useConfigStore } from '@/stores/useConfigStore'; import { noteDeferredRestartFromPayload, recordDeferredOpenCodeRestart } from '@/lib/opencode/deferredRestart'; import { SettingsPageLayout } from '@/components/sections/shared/SettingsPageLayout'; import { @@ -47,7 +46,6 @@ type ResponseStyleValue = ResponseStylePreset | 'custom'; type BehaviorSettingsState = { prompt: string; optimizeSystemPrompt: boolean; - worktreeFetchSource: boolean; responseStyleEnabled: boolean; responseStylePreset: ResponseStyleValue; responseStyleCustomInstructions: string; @@ -56,7 +54,6 @@ type BehaviorSettingsState = { const DEFAULT_BEHAVIOR_SETTINGS: BehaviorSettingsState = { prompt: '', optimizeSystemPrompt: false, - worktreeFetchSource: true, responseStyleEnabled: false, responseStylePreset: 'concise', responseStyleCustomInstructions: '', @@ -106,10 +103,8 @@ const saveBehaviorSetting = async (settings: Partial, fallbackE export const BehaviorPage: React.FC = () => { const { t } = useI18n(); const isVSCode = useIsVSCodeRuntime(); - const setSettingsWorktreeFetchSource = useConfigStore((state) => state.setSettingsWorktreeFetchSource); const [prompt, setPrompt] = React.useState(''); const [optimizeSystemPrompt, setOptimizeSystemPrompt] = React.useState(false); - const [worktreeFetchSource, setWorktreeFetchSource] = React.useState(DEFAULT_BEHAVIOR_SETTINGS.worktreeFetchSource); const [responseStyleEnabled, setResponseStyleEnabled] = React.useState(DEFAULT_BEHAVIOR_SETTINGS.responseStyleEnabled); const [responseStylePreset, setResponseStylePreset] = React.useState(DEFAULT_BEHAVIOR_SETTINGS.responseStylePreset); const [responseStyleCustomInstructions, setResponseStyleCustomInstructions] = React.useState(DEFAULT_BEHAVIOR_SETTINGS.responseStyleCustomInstructions); @@ -148,16 +143,12 @@ export const BehaviorPage: React.FC = () => { nextSettings = { ...nextSettings, optimizeSystemPrompt: data.optimizeSystemPrompt === true, - worktreeFetchSource: data.worktreeFetchSource !== false, responseStyleEnabled: data.responseStyleEnabled === true, responseStylePreset: sanitizeResponseStylePreset(data.responseStylePreset), responseStyleCustomInstructions: typeof data.responseStyleCustomInstructions === 'string' ? data.responseStyleCustomInstructions : '', }; - if (typeof data.worktreeFetchSource === 'boolean') { - setSettingsWorktreeFetchSource(data.worktreeFetchSource); - } if (typeof data.globalBehaviorPrompt === 'string') { nextSettings = { ...nextSettings, prompt: data.globalBehaviorPrompt }; } @@ -173,7 +164,6 @@ export const BehaviorPage: React.FC = () => { setPrompt(nextSettings.prompt); setOptimizeSystemPrompt(nextSettings.optimizeSystemPrompt); setInitialOptimizeSystemPrompt(nextSettings.optimizeSystemPrompt); - setWorktreeFetchSource(nextSettings.worktreeFetchSource); setResponseStyleEnabled(nextSettings.responseStyleEnabled); setResponseStylePreset(nextSettings.responseStylePreset); setResponseStyleCustomInstructions(nextSettings.responseStyleCustomInstructions); @@ -194,7 +184,7 @@ export const BehaviorPage: React.FC = () => { void load(); return () => abort.abort(); - }, [setSettingsWorktreeFetchSource]); + }, []); React.useEffect(() => { if (isLoading) return; @@ -275,21 +265,6 @@ export const BehaviorPage: React.FC = () => { } }; - const handleWorktreeFetchSourceChange = (enabled: boolean) => { - const previous = worktreeFetchSource; - setWorktreeFetchSource(enabled); - setSettingsWorktreeFetchSource(enabled); - void saveBehaviorSetting( - { worktreeFetchSource: enabled }, - t('settings.behavior.page.toast.saveFailed'), - ).catch((error) => { - setWorktreeFetchSource(previous); - setSettingsWorktreeFetchSource(previous); - const message = error instanceof Error ? error.message : t('settings.behavior.page.toast.saveFailed'); - toast.error(message); - }); - }; - const handleSavePromptOptimization = async () => { setIsApplyingPromptOptimization(true); try { @@ -343,21 +318,6 @@ export const BehaviorPage: React.FC = () => { )} - - - - { const setNewSessionDraftTarget = useSessionUIStore((s) => s.setNewSessionDraftTarget); const setDraftBootstrapPendingDirectory = useSessionUIStore((s) => s.setDraftBootstrapPendingDirectory); const deleteSession = sessionActions.deleteSession; - const archiveSession = sessionActions.archiveSession; const deleteSessions = useSessionUIStore((s) => s.deleteSessions); const archiveSessions = useSessionUIStore((s) => s.archiveSessions); const showDeletionDialog = useUIStore((state) => state.showDeletionDialog); @@ -333,7 +332,8 @@ export const SessionDialogs: React.FC = () => { const removeSelectedWorktree = React.useCallback(async ( worktree: WorktreeMetadata, - deleteLocalBranch: boolean + deleteLocalBranch: boolean, + toastId?: string | number, ): Promise => { const shouldRemoveRemote = deleteDialogShouldRemoveRemote && canRemoveRemoteBranches; const projectRef = getProjectRefForWorktree(worktree); @@ -366,7 +366,8 @@ export const SessionDialogs: React.FC = () => { return true; } catch (error) { - toast.error(t('sessions.sidebar.sessionDialogs.worktree.errorRemoveTitle'), { + toast.error(t('sessions.sidebar.sessionDialogs.worktree.errorRemoveTitle', { name: getWorktreeDisplayName(worktree) }), { + id: toastId, description: renderToastDescription(error instanceof Error ? error.message : t('sessions.sidebar.dialogs.deleteResult.tryAgain')), }); return false; @@ -375,22 +376,45 @@ export const SessionDialogs: React.FC = () => { const removeSelectedWorktreeInBackground = React.useCallback(( worktree: WorktreeMetadata, + sessionIds: string[], deleteLocalBranch: boolean ): void => { const shouldRemoveRemote = deleteDialogShouldRemoveRemote && canRemoveRemoteBranches; + const toastId = toast.loading(t('sessions.sidebar.sessionDialogs.worktree.removingTitle', { name: getWorktreeDisplayName(worktree) })); void (async () => { - const removed = await removeSelectedWorktree(worktree, deleteLocalBranch); - if (!removed) { - return; + try { + if (sessionIds.length > 0) { + const { failedIds } = await archiveSessions(sessionIds); + if (failedIds.length > 0) { + toast.error(failedIds.length === 1 + ? t('sessions.sidebar.bulkActions.failedArchiveSingle', { count: failedIds.length }) + : t('sessions.sidebar.bulkActions.failedArchivePlural', { count: failedIds.length }), { + id: toastId, + description: renderToastDescription(t('sessions.sidebar.dialogs.deleteResult.tryAgain')), + }); + return; + } + } + + const removed = await removeSelectedWorktree(worktree, deleteLocalBranch, toastId); + if (!removed) { + return; + } + const archiveNote = shouldRemoveRemote + ? t('sessions.sidebar.sessionDialogs.worktree.removedWithRemote') + : t('sessions.sidebar.sessionDialogs.worktree.removed'); + toast.success(t('sessions.sidebar.sessionDialogs.worktree.removedTitle', { name: getWorktreeDisplayName(worktree) }), { + id: toastId, + description: renderToastDescription(archiveNote), + }); + } catch (error) { + toast.error(t('sessions.sidebar.sessionDialogs.worktree.errorRemoveTitle'), { + id: toastId, + description: renderToastDescription(error instanceof Error ? error.message : t('sessions.sidebar.dialogs.deleteResult.tryAgain')), + }); } - const archiveNote = shouldRemoveRemote - ? t('sessions.sidebar.sessionDialogs.worktree.removedWithRemote') - : t('sessions.sidebar.sessionDialogs.worktree.removed'); - toast.success(t('sessions.sidebar.sessionDialogs.worktree.removedTitle'), { - description: renderToastDescription(archiveNote), - }); })(); - }, [canRemoveRemoteBranches, deleteDialogShouldRemoveRemote, removeSelectedWorktree, t]); + }, [archiveSessions, canRemoveRemoteBranches, deleteDialogShouldRemoveRemote, removeSelectedWorktree, t]); const handleConfirmDelete = React.useCallback(async () => { if (!deleteDialog) { @@ -403,21 +427,21 @@ export const SessionDialogs: React.FC = () => { const removeRemoteBranch = shouldArchive && deleteDialogShouldRemoveRemote; const deleteLocalBranch = shouldArchive && deleteDialogShouldDeleteLocalBranch; - if (deleteDialog.sessions.length === 0 && isWorktreeDelete && deleteDialog.worktree) { - removeSelectedWorktreeInBackground(deleteDialog.worktree, deleteLocalBranch); + if (isWorktreeDelete && deleteDialog.worktree) { + removeSelectedWorktreeInBackground( + deleteDialog.worktree, + deleteDialog.sessions.map((session) => session.id), + deleteLocalBranch, + ); closeDeleteDialog(); return; } if (deleteDialog.sessions.length === 1) { const target = deleteDialog.sessions[0]; - const success = isWorktreeDelete - ? await archiveSession(target.id) - : await deleteSession(target.id); + const success = await deleteSession(target.id); if (!success) { - toast.error(isWorktreeDelete - ? t('sessions.sidebar.session.archive.error') - : t('sessions.sidebar.session.delete.error')); + toast.error(t('sessions.sidebar.session.delete.error')); setIsProcessingDelete(false); return; } @@ -426,9 +450,7 @@ export const SessionDialogs: React.FC = () => { ? t('sessions.sidebar.sessionDialogs.worktree.removedWithRemote') : t('sessions.sidebar.sessionDialogs.worktree.attachedArchived') : undefined; - toast.success(isWorktreeDelete - ? t('sessions.sidebar.session.archive.success') - : t('sessions.sidebar.session.delete.success'), { + toast.success(t('sessions.sidebar.session.delete.success'), { description: renderToastDescription(archiveNote), action: { label: t('sessions.sidebar.sessionDialogs.ok'), @@ -439,22 +461,9 @@ export const SessionDialogs: React.FC = () => { const ids = deleteDialog.sessions.map((session) => session.id); let deletedIds: string[] = []; let failedIds: string[] = []; - if (isWorktreeDelete) { - const result = await archiveSessions(ids); - deletedIds = result.archivedIds; - failedIds = result.failedIds; - } else { - const result = await deleteSessions(ids); - deletedIds = result.deletedIds; - failedIds = result.failedIds; - } - - if (isWorktreeDelete && deleteDialog.worktree && failedIds.length === 0) { - // Remove selected worktree even if per-session metadata is missing. - // Use same projectRef logic as the no-sessions path. - removeSelectedWorktreeInBackground(deleteDialog.worktree, deleteLocalBranch); - // sync handles session refresh automatically - } + const result = await deleteSessions(ids); + deletedIds = result.deletedIds; + failedIds = result.failedIds; if (deletedIds.length > 0) { const archiveNote = !isWorktreeDelete && shouldArchive @@ -464,24 +473,16 @@ export const SessionDialogs: React.FC = () => { : undefined; const successDescription = failedIds.length > 0 - ? (isWorktreeDelete - ? (failedIds.length === 1 - ? t('sessions.sidebar.dialogs.deleteResult.singleFailedToArchive', { count: failedIds.length }) - : t('sessions.sidebar.dialogs.deleteResult.manyFailedToArchive', { count: failedIds.length })) - : (failedIds.length === 1 + ? (failedIds.length === 1 ? t('sessions.sidebar.dialogs.deleteResult.singleFailedToDelete', { count: failedIds.length }) - : t('sessions.sidebar.dialogs.deleteResult.manyFailedToDelete', { count: failedIds.length }))) + : t('sessions.sidebar.dialogs.deleteResult.manyFailedToDelete', { count: failedIds.length })) : deleteDialog.dateLabel ? t('sessions.sidebar.dialogs.deleteResult.removedFromDate', { dateLabel: deleteDialog.dateLabel }) : undefined; const combinedDescription = [successDescription, archiveNote].filter(Boolean).join(' '); - toast.success(isWorktreeDelete - ? (deletedIds.length === 1 - ? t('sessions.sidebar.bulkActions.archivedSingle', { count: deletedIds.length }) - : t('sessions.sidebar.bulkActions.archivedPlural', { count: deletedIds.length })) - : (deletedIds.length === 1 + toast.success(deletedIds.length === 1 ? t('sessions.sidebar.bulkActions.deletedSingle', { count: deletedIds.length }) - : t('sessions.sidebar.bulkActions.deletedPlural', { count: deletedIds.length })), { + : t('sessions.sidebar.bulkActions.deletedPlural', { count: deletedIds.length }), { description: renderToastDescription(combinedDescription || undefined), action: { label: t('sessions.sidebar.sessionDialogs.ok'), @@ -491,13 +492,9 @@ export const SessionDialogs: React.FC = () => { } if (failedIds.length > 0) { - toast.error(isWorktreeDelete - ? (failedIds.length === 1 - ? t('sessions.sidebar.bulkActions.failedArchiveSingle', { count: failedIds.length }) - : t('sessions.sidebar.bulkActions.failedArchivePlural', { count: failedIds.length })) - : (failedIds.length === 1 + toast.error(failedIds.length === 1 ? t('sessions.sidebar.bulkActions.failedDeleteSingle', { count: failedIds.length }) - : t('sessions.sidebar.bulkActions.failedDeletePlural', { count: failedIds.length })), { + : t('sessions.sidebar.bulkActions.failedDeletePlural', { count: failedIds.length }), { description: renderToastDescription(t('sessions.sidebar.dialogs.deleteResult.tryAgain')), }); if (deletedIds.length === 0) { @@ -507,11 +504,6 @@ export const SessionDialogs: React.FC = () => { } } - if (isWorktreeDelete && deleteDialog.sessions.length === 1 && deleteDialog.worktree) { - removeSelectedWorktreeInBackground(deleteDialog.worktree, deleteLocalBranch); - // sync bootstrap refreshes sessions automatically - } - closeDeleteDialog(); } finally { setIsProcessingDelete(false); @@ -522,8 +514,6 @@ export const SessionDialogs: React.FC = () => { deleteDialogShouldDeleteLocalBranch, deleteSession, deleteSessions, - archiveSession, - archiveSessions, closeDeleteDialog, shouldArchiveWorktree, isWorktreeDelete, diff --git a/packages/ui/src/lib/api/types.ts b/packages/ui/src/lib/api/types.ts index 910896cb..4cceb29f 100644 --- a/packages/ui/src/lib/api/types.ts +++ b/packages/ui/src/lib/api/types.ts @@ -438,6 +438,7 @@ export interface GitWorktreeCreateResult { path: string; directoryCreated?: true; bootstrapStatus?: GitWorktreeBootstrapStatus; + sourceFetchFailed?: true; } export interface RemoveGitWorktreePayload { diff --git a/packages/ui/src/lib/desktop.ts b/packages/ui/src/lib/desktop.ts index 573c0bf5..3da4af7f 100644 --- a/packages/ui/src/lib/desktop.ts +++ b/packages/ui/src/lib/desktop.ts @@ -146,7 +146,6 @@ export type DesktopSettings = { defaultGitIdentityId?: string; // ''/undefined = unset, 'global' or profile id openInAppId?: string; autoCreateWorktree?: boolean; - worktreeFetchSource?: boolean; followUpBehavior?: 'steer' | 'queue'; queueModeEnabled?: boolean; gitmojiEnabled?: boolean; diff --git a/packages/ui/src/lib/i18n/messages/de.settings.ts b/packages/ui/src/lib/i18n/messages/de.settings.ts index 29d5db88..8e7ad3c5 100644 --- a/packages/ui/src/lib/i18n/messages/de.settings.ts +++ b/packages/ui/src/lib/i18n/messages/de.settings.ts @@ -2188,10 +2188,6 @@ export const settingsDict = { 'settings.behavior.page.systemPromptOptimization.enable': 'Größe des System-Prompts optimieren', 'settings.behavior.page.systemPromptOptimization.enableAria': 'Größe des OpenCode-System-Prompts optimieren', 'settings.behavior.page.systemPromptOptimization.info': 'Reduziert den System-Prompt für die Build- und Plan-Agenten schätzungsweise um 40 %. Andere Agenten bleiben unverändert. Dadurch können benutzerdefinierte Definitionen entfernt werden, die Build oder Plan überschreiben, daher nicht für Workflows aktivieren, die diese Agenten anpassen. Ein Neustart von OpenCode wendet die Änderung an.', - 'settings.behavior.page.section.worktrees': 'Worktrees', - 'settings.behavior.page.worktreeFetchSource.enable': 'Remote vor dem Erstellen von Worktrees abrufen', - 'settings.behavior.page.worktreeFetchSource.enableAria': 'Änderungen vom Remote abrufen, bevor Worktrees erstellt werden', - 'settings.behavior.page.worktreeFetchSource.info': 'Neue Worktrees verzweigen vom abgerufenen Remote-Stand des aktuellen Branches statt vom lokalen Checkout und sind dadurch nicht veraltet. Beim Erstellen wird der lokale Stand verwendet, wenn das Remote nicht erreichbar ist.', 'settings.behavior.page.systemPromptOptimization.restarting': 'OpenCode wird neu gestartet, um die System-Prompt-Optimierung anzuwenden…', 'settings.openchamber.desktopNetwork.field.windowControls': 'Fenstersteuerungen', 'settings.openchamber.desktopNetwork.field.windowControlsStyle': 'Stil', diff --git a/packages/ui/src/lib/i18n/messages/de.ts b/packages/ui/src/lib/i18n/messages/de.ts index dfd97107..3036942f 100644 --- a/packages/ui/src/lib/i18n/messages/de.ts +++ b/packages/ui/src/lib/i18n/messages/de.ts @@ -600,8 +600,9 @@ export const dict = { 'sessions.sidebar.sessionDialogs.directory.errorOpenDescription': 'Desktop konnte keinen Dateizugriff gewähren.', 'sessions.sidebar.sessionDialogs.directory.errorAddProjectTitle': 'Fehler beim Hinzufügen des Projekts', 'sessions.sidebar.sessionDialogs.directory.errorAddProjectDescription': 'Bitte wählen Sie einen gültigen Verzeichnispfad aus.', - 'sessions.sidebar.sessionDialogs.worktree.errorRemoveTitle': 'Fehler beim Entfernen des Worktrees', - 'sessions.sidebar.sessionDialogs.worktree.removedTitle': 'Worktree entfernt', + 'sessions.sidebar.sessionDialogs.worktree.errorRemoveTitle': 'Fehler beim Entfernen des Worktrees „{name}“', + 'sessions.sidebar.sessionDialogs.worktree.removingTitle': 'Worktree „{name}“ wird entfernt…', + 'sessions.sidebar.sessionDialogs.worktree.removedTitle': 'Worktree „{name}“ entfernt', 'sessions.sidebar.sessionDialogs.worktree.removed': 'Worktree entfernt.', 'sessions.sidebar.sessionDialogs.worktree.removedWithRemote': 'Worktree und Remote-Branch entfernt.', 'sessions.sidebar.sessionDialogs.worktree.attachedArchived': 'Angefügter Worktree archiviert.', diff --git a/packages/ui/src/lib/i18n/messages/en.settings.ts b/packages/ui/src/lib/i18n/messages/en.settings.ts index 34a44530..4b44ad31 100644 --- a/packages/ui/src/lib/i18n/messages/en.settings.ts +++ b/packages/ui/src/lib/i18n/messages/en.settings.ts @@ -607,10 +607,6 @@ export const settingsDict = { 'settings.behavior.page.systemPromptOptimization.enable': 'Optimize system prompt size', 'settings.behavior.page.systemPromptOptimization.enableAria': 'Optimize OpenCode system prompt size', 'settings.behavior.page.systemPromptOptimization.info': 'Reduces the system prompt by an estimated 40% for the build and plan agents. Other agents are not changed. This can remove custom definitions that override build or plan, so do not enable it for workflows that customize those agents. Restarting OpenCode applies the change.', - 'settings.behavior.page.section.worktrees': 'Worktrees', - 'settings.behavior.page.worktreeFetchSource.enable': 'Fetch remote before creating worktrees', - 'settings.behavior.page.worktreeFetchSource.enableAria': 'Fetch changes from remote before creating worktrees', - 'settings.behavior.page.worktreeFetchSource.info': 'New worktrees branch from the fetched remote state of the current branch instead of the local checkout, so they are not stale. Creation falls back to the local state when the remote is unreachable.', 'settings.behavior.page.systemPromptOptimization.restarting': 'Restarting OpenCode to apply system prompt optimization…', 'settings.behavior.page.field.systemPromptPlaceholder': 'You are a helpful AI assistant...\n\nUse this space to define absolute rules for how the AI should behave across all sessions and providers.', 'settings.behavior.page.section.responseStyle': 'Response style', diff --git a/packages/ui/src/lib/i18n/messages/en.ts b/packages/ui/src/lib/i18n/messages/en.ts index e9c8d045..af1d6167 100644 --- a/packages/ui/src/lib/i18n/messages/en.ts +++ b/packages/ui/src/lib/i18n/messages/en.ts @@ -696,8 +696,9 @@ export const dict = { 'sessions.sidebar.sessionDialogs.directory.errorOpenDescription': 'Desktop could not grant file access.', 'sessions.sidebar.sessionDialogs.directory.errorAddProjectTitle': 'Failed to add project', 'sessions.sidebar.sessionDialogs.directory.errorAddProjectDescription': 'Please select a valid directory path.', - 'sessions.sidebar.sessionDialogs.worktree.errorRemoveTitle': 'Failed to remove worktree', - 'sessions.sidebar.sessionDialogs.worktree.removedTitle': 'Worktree removed', + 'sessions.sidebar.sessionDialogs.worktree.errorRemoveTitle': 'Failed to remove worktree “{name}”', + 'sessions.sidebar.sessionDialogs.worktree.removingTitle': 'Removing worktree “{name}”…', + 'sessions.sidebar.sessionDialogs.worktree.removedTitle': 'Worktree “{name}” removed', 'sessions.sidebar.sessionDialogs.worktree.removed': 'Worktree removed.', 'sessions.sidebar.sessionDialogs.worktree.removedWithRemote': 'Worktree and remote branch removed.', 'sessions.sidebar.sessionDialogs.worktree.attachedArchived': 'Attached worktree archived.', diff --git a/packages/ui/src/lib/i18n/messages/es.settings.ts b/packages/ui/src/lib/i18n/messages/es.settings.ts index 54011980..2b656990 100644 --- a/packages/ui/src/lib/i18n/messages/es.settings.ts +++ b/packages/ui/src/lib/i18n/messages/es.settings.ts @@ -575,10 +575,6 @@ export const settingsDict = { "settings.behavior.page.systemPromptOptimization.enable": "Optimizar el tamaño del prompt del sistema", "settings.behavior.page.systemPromptOptimization.enableAria": "Optimizar el tamaño del prompt del sistema de OpenCode", "settings.behavior.page.systemPromptOptimization.info": "Reduce el prompt del sistema aproximadamente un 40 % para los agentes build y plan. Los demás agentes no cambian. Puede eliminar definiciones personalizadas que sobrescriban build o plan, así que no lo actives en esos flujos de trabajo. El cambio se aplica al reiniciar OpenCode.", - "settings.behavior.page.section.worktrees": "Worktrees", - "settings.behavior.page.worktreeFetchSource.enable": "Obtener cambios del remoto antes de crear worktrees", - "settings.behavior.page.worktreeFetchSource.enableAria": "Obtener cambios del repositorio remoto antes de crear worktrees", - "settings.behavior.page.worktreeFetchSource.info": "Los nuevos worktrees se crean a partir del estado remoto obtenido de la rama actual en lugar del checkout local, por lo que no quedan obsoletos. La creación usa el estado local si el remoto no está accesible.", "settings.behavior.page.systemPromptOptimization.restarting": "Reiniciando OpenCode para aplicar la optimización del prompt del sistema…", "settings.behavior.page.field.systemPromptPlaceholder": "Eres un asistente de IA útil...\n\nUsa este espacio para definir reglas absolutas sobre cómo la IA debe comportarse en todas las sesiones y proveedores.", "settings.behavior.page.section.responseStyle": "Estilo de respuesta", diff --git a/packages/ui/src/lib/i18n/messages/es.ts b/packages/ui/src/lib/i18n/messages/es.ts index 672ad084..c1ca296f 100644 --- a/packages/ui/src/lib/i18n/messages/es.ts +++ b/packages/ui/src/lib/i18n/messages/es.ts @@ -697,8 +697,9 @@ export const dict: Record = { "sessions.sidebar.sessionDialogs.directory.errorOpenDescription": "Desktop no pudo otorgar acceso a archivos.", "sessions.sidebar.sessionDialogs.directory.errorAddProjectTitle": "No se pudo añadir proyecto", "sessions.sidebar.sessionDialogs.directory.errorAddProjectDescription": "Selecciona una ruta de directorio válida.", - "sessions.sidebar.sessionDialogs.worktree.errorRemoveTitle": "No se pudo eliminar worktree", - "sessions.sidebar.sessionDialogs.worktree.removedTitle": "Worktree eliminado", + "sessions.sidebar.sessionDialogs.worktree.errorRemoveTitle": "No se pudo eliminar el worktree «{name}»", + "sessions.sidebar.sessionDialogs.worktree.removingTitle": "Eliminando el worktree «{name}»…", + "sessions.sidebar.sessionDialogs.worktree.removedTitle": "Worktree «{name}» eliminado", "sessions.sidebar.sessionDialogs.worktree.removed": "Worktree eliminado.", "sessions.sidebar.sessionDialogs.worktree.removedWithRemote": "Worktree y rama remota eliminados.", "sessions.sidebar.sessionDialogs.worktree.attachedArchived": "Worktree adjunto archivado.", diff --git a/packages/ui/src/lib/i18n/messages/fr.settings.ts b/packages/ui/src/lib/i18n/messages/fr.settings.ts index 8b0acc56..0fe60bea 100644 --- a/packages/ui/src/lib/i18n/messages/fr.settings.ts +++ b/packages/ui/src/lib/i18n/messages/fr.settings.ts @@ -493,10 +493,6 @@ export const settingsDict = { 'settings.behavior.page.systemPromptOptimization.enable': 'Optimiser la taille du prompt système', 'settings.behavior.page.systemPromptOptimization.enableAria': 'Optimiser la taille du prompt système d’OpenCode', 'settings.behavior.page.systemPromptOptimization.info': 'Réduit le prompt système d’environ 40 % pour les agents build et plan. Les autres agents ne sont pas modifiés. Cela peut supprimer les définitions personnalisées qui remplacent build ou plan ; ne l’activez donc pas pour ces workflows. Le redémarrage d’OpenCode applique la modification.', - 'settings.behavior.page.section.worktrees': 'Worktrees', - 'settings.behavior.page.worktreeFetchSource.enable': 'Récupérer le dépôt distant avant de créer des worktrees', - 'settings.behavior.page.worktreeFetchSource.enableAria': 'Récupérer les changements du dépôt distant avant de créer des worktrees', - 'settings.behavior.page.worktreeFetchSource.info': 'Les nouveaux worktrees sont créés depuis l’état distant récupéré de la branche courante plutôt que depuis le checkout local, afin d’éviter qu’ils ne soient obsolètes. La création utilise l’état local si le dépôt distant est inaccessible.', 'settings.behavior.page.systemPromptOptimization.restarting': 'Redémarrage d’OpenCode pour appliquer l’optimisation du prompt système…', 'settings.behavior.page.field.systemPromptPlaceholder': 'Vous êtes un assistant IA utile...\n\nUtilisez cet espace pour définir des règles absolues sur la façon dont l\'IA doit se comporter dans toutes les sessions et tous les fournisseurs.', 'settings.behavior.page.section.responseStyle': 'Style de réponse', diff --git a/packages/ui/src/lib/i18n/messages/fr.ts b/packages/ui/src/lib/i18n/messages/fr.ts index 70eab598..2804fdeb 100644 --- a/packages/ui/src/lib/i18n/messages/fr.ts +++ b/packages/ui/src/lib/i18n/messages/fr.ts @@ -525,8 +525,9 @@ export const dict = { 'sessions.sidebar.sessionDialogs.directory.errorOpenDescription': 'Desktop n\'a pas pu accorder l\'accès aux fichiers.', 'sessions.sidebar.sessionDialogs.directory.errorAddProjectTitle': 'Échec de l\'ajout du projet', 'sessions.sidebar.sessionDialogs.directory.errorAddProjectDescription': 'Veuillez sélectionner un chemin de répertoire valide.', - 'sessions.sidebar.sessionDialogs.worktree.errorRemoveTitle': 'Échec de la suppression du worktree', - 'sessions.sidebar.sessionDialogs.worktree.removedTitle': 'Worktree supprimé', + 'sessions.sidebar.sessionDialogs.worktree.errorRemoveTitle': 'Échec de la suppression du worktree « {name} »', + 'sessions.sidebar.sessionDialogs.worktree.removingTitle': 'Suppression du worktree « {name} »…', + 'sessions.sidebar.sessionDialogs.worktree.removedTitle': 'Worktree « {name} » supprimé', 'sessions.sidebar.sessionDialogs.worktree.removed': 'Worktree supprimé.', 'sessions.sidebar.sessionDialogs.worktree.removedWithRemote': 'Worktree et branche du dépôt distant supprimés.', 'sessions.sidebar.sessionDialogs.worktree.attachedArchived': 'Worktree ci-joint archivé.', diff --git a/packages/ui/src/lib/i18n/messages/ja.settings.ts b/packages/ui/src/lib/i18n/messages/ja.settings.ts index 25468113..729551a6 100644 --- a/packages/ui/src/lib/i18n/messages/ja.settings.ts +++ b/packages/ui/src/lib/i18n/messages/ja.settings.ts @@ -608,10 +608,6 @@ export const settingsDict = { 'settings.behavior.page.systemPromptOptimization.enable': 'システムプロンプトのサイズを最適化', 'settings.behavior.page.systemPromptOptimization.enableAria': 'OpenCode のシステムプロンプトサイズを最適化', 'settings.behavior.page.systemPromptOptimization.info': 'build と plan エージェントのシステムプロンプトを約 40% 削減します。他のエージェントは変更されません。build または plan を上書きするカスタム定義が削除される可能性があるため、そのようなワークフローでは有効にしないでください。OpenCode の再起動後に変更が適用されます。', - 'settings.behavior.page.section.worktrees': 'ワークツリー', - 'settings.behavior.page.worktreeFetchSource.enable': 'ワークツリー作成前にリモートから取得', - 'settings.behavior.page.worktreeFetchSource.enableAria': 'ワークツリー作成前にリモートから変更を取得する', - 'settings.behavior.page.worktreeFetchSource.info': '新しいワークツリーは、ローカルのチェックアウトではなく現在のブランチのリモート状態を取得して作成されるため、最新の状態になります。リモートにアクセスできない場合はローカルの状態で作成されます。', 'settings.behavior.page.systemPromptOptimization.restarting': 'システムプロンプトの最適化を適用するため OpenCode を再起動しています…', 'settings.behavior.page.field.systemPromptPlaceholder': 'あなたは役立つ AI アシスタントです...\n\nこのスペースを使用して、すべての Session と Provider にわたって AI の動作方法に関する絶対的なルールを定義してください。', 'settings.behavior.page.section.responseStyle': '応答スタイル', diff --git a/packages/ui/src/lib/i18n/messages/ja.ts b/packages/ui/src/lib/i18n/messages/ja.ts index 8b6d7bc8..bbf1c0a9 100644 --- a/packages/ui/src/lib/i18n/messages/ja.ts +++ b/packages/ui/src/lib/i18n/messages/ja.ts @@ -697,8 +697,9 @@ export const dict: Record = { 'sessions.sidebar.sessionDialogs.directory.errorOpenDescription': 'デスクトップがファイルアクセスを許可しませんでした。', 'sessions.sidebar.sessionDialogs.directory.errorAddProjectTitle': 'プロジェクトの追加に失敗しました', 'sessions.sidebar.sessionDialogs.directory.errorAddProjectDescription': '有効なディレクトリパスを選択してください。', - 'sessions.sidebar.sessionDialogs.worktree.errorRemoveTitle': 'ワークツリーの削除に失敗しました', - 'sessions.sidebar.sessionDialogs.worktree.removedTitle': 'ワークツリーを削除しました', + 'sessions.sidebar.sessionDialogs.worktree.errorRemoveTitle': 'ワークツリー「{name}」の削除に失敗しました', + 'sessions.sidebar.sessionDialogs.worktree.removingTitle': 'ワークツリー「{name}」を削除しています…', + 'sessions.sidebar.sessionDialogs.worktree.removedTitle': 'ワークツリー「{name}」を削除しました', 'sessions.sidebar.sessionDialogs.worktree.removed': 'ワークツリーを削除しました。', 'sessions.sidebar.sessionDialogs.worktree.removedWithRemote': 'ワークツリーとリモートブランチを削除しました。', 'sessions.sidebar.sessionDialogs.worktree.attachedArchived': '添付のワークツリーをアーカイブしました。', diff --git a/packages/ui/src/lib/i18n/messages/ko.settings.ts b/packages/ui/src/lib/i18n/messages/ko.settings.ts index 27de0706..48d6aa8b 100644 --- a/packages/ui/src/lib/i18n/messages/ko.settings.ts +++ b/packages/ui/src/lib/i18n/messages/ko.settings.ts @@ -575,10 +575,6 @@ export const settingsDict = { 'settings.behavior.page.systemPromptOptimization.enable': '시스템 프롬프트 크기 최적화', 'settings.behavior.page.systemPromptOptimization.enableAria': 'OpenCode 시스템 프롬프트 크기 최적화', 'settings.behavior.page.systemPromptOptimization.info': 'build 및 plan 에이전트의 시스템 프롬프트를 약 40% 줄입니다. 다른 에이전트는 변경되지 않습니다. build 또는 plan을 재정의하는 사용자 지정 정의가 제거될 수 있으므로 이러한 워크플로에서는 활성화하지 마세요. OpenCode를 다시 시작하면 변경 사항이 적용됩니다.', - 'settings.behavior.page.section.worktrees': '워크트리', - 'settings.behavior.page.worktreeFetchSource.enable': '워크트리를 만들기 전에 원격에서 가져오기', - 'settings.behavior.page.worktreeFetchSource.enableAria': '워크트리를 만들기 전에 원격에서 변경 사항 가져오기', - 'settings.behavior.page.worktreeFetchSource.info': '새 워크트리는 로컬 체크아웃 대신 현재 브랜치의 원격 상태를 가져와서 만들어지므로 최신 상태를 유지합니다. 원격에 접근할 수 없으면 로컬 상태로 만듭니다.', 'settings.behavior.page.systemPromptOptimization.restarting': '시스템 프롬프트 최적화를 적용하기 위해 OpenCode를 다시 시작하는 중…', 'settings.behavior.page.field.systemPromptPlaceholder': '당신은 유용한 AI 어시스턴트입니다...\n\n모든 세션과 공급자에서 AI가 어떻게 행동해야 하는지에 대한 절대적인 규칙을 정의할 수 있습니다.', 'settings.behavior.page.section.responseStyle': '응답 스타일', diff --git a/packages/ui/src/lib/i18n/messages/ko.ts b/packages/ui/src/lib/i18n/messages/ko.ts index c4b55cad..64d3ddad 100644 --- a/packages/ui/src/lib/i18n/messages/ko.ts +++ b/packages/ui/src/lib/i18n/messages/ko.ts @@ -697,8 +697,9 @@ export const dict: Record = { 'sessions.sidebar.sessionDialogs.directory.errorOpenDescription': '데스크톱에서 파일 접근 권한을 부여하지 못했습니다.', 'sessions.sidebar.sessionDialogs.directory.errorAddProjectTitle': '프로젝트 추가 실패', 'sessions.sidebar.sessionDialogs.directory.errorAddProjectDescription': '유효한 디렉터리 경로를 선택하세요.', - 'sessions.sidebar.sessionDialogs.worktree.errorRemoveTitle': '워크트리 제거 실패', - 'sessions.sidebar.sessionDialogs.worktree.removedTitle': '워크트리 제거됨', + 'sessions.sidebar.sessionDialogs.worktree.errorRemoveTitle': '워크트리 “{name}” 제거 실패', + 'sessions.sidebar.sessionDialogs.worktree.removingTitle': '워크트리 “{name}” 제거 중…', + 'sessions.sidebar.sessionDialogs.worktree.removedTitle': '워크트리 “{name}” 제거됨', 'sessions.sidebar.sessionDialogs.worktree.removed': '워크트리가 제거되었습니다.', 'sessions.sidebar.sessionDialogs.worktree.removedWithRemote': '워크트리와 리모트 브랜치가 제거되었습니다.', 'sessions.sidebar.sessionDialogs.worktree.attachedArchived': '첨부됨 워크트리 보관됨.', diff --git a/packages/ui/src/lib/i18n/messages/pl.settings.ts b/packages/ui/src/lib/i18n/messages/pl.settings.ts index dc20fd6a..9d89d843 100644 --- a/packages/ui/src/lib/i18n/messages/pl.settings.ts +++ b/packages/ui/src/lib/i18n/messages/pl.settings.ts @@ -160,10 +160,6 @@ export const settingsDict = { 'settings.behavior.page.systemPromptOptimization.enable': 'Optymalizuj rozmiar promptu systemowego', 'settings.behavior.page.systemPromptOptimization.enableAria': 'Optymalizuj rozmiar promptu systemowego OpenCode', 'settings.behavior.page.systemPromptOptimization.info': 'Zmniejsza prompt systemowy o około 40% dla agentów build i plan. Inni agenci pozostają bez zmian. Może to usunąć niestandardowe definicje zastępujące build lub plan, dlatego nie włączaj tej opcji w takich przepływach pracy. Zmiana zostanie zastosowana po ponownym uruchomieniu OpenCode.', - 'settings.behavior.page.section.worktrees': 'Drzewa pracy', - 'settings.behavior.page.worktreeFetchSource.enable': 'Pobierz zmiany ze zdalnego przed utworzeniem drzew pracy', - 'settings.behavior.page.worktreeFetchSource.enableAria': 'Pobierz zmiany ze zdalnego repozytorium przed utworzeniem drzew pracy', - 'settings.behavior.page.worktreeFetchSource.info': 'Nowe drzewa pracy powstają z pobranego zdalnego stanu bieżącej gałęzi, a nie z lokalnej kopii roboczej, dzięki czemu nie są nieaktualne. Jeśli zdalne repozytorium jest niedostępne, używany jest stan lokalny.', 'settings.behavior.page.systemPromptOptimization.restarting': 'Ponowne uruchamianie OpenCode w celu zastosowania optymalizacji promptu systemowego…', 'settings.behavior.page.title': 'Zachowanie', 'settings.behavior.page.toast.saveFailed': 'Nie udało się zapisać zachowania', diff --git a/packages/ui/src/lib/i18n/messages/pl.ts b/packages/ui/src/lib/i18n/messages/pl.ts index e48c6616..2b030572 100644 --- a/packages/ui/src/lib/i18n/messages/pl.ts +++ b/packages/ui/src/lib/i18n/messages/pl.ts @@ -697,8 +697,9 @@ export const dict: Record = { 'sessions.sidebar.sessionDialogs.directory.errorOpenDescription': 'Aplikacja desktopowa nie mogła nadać dostępu do plików.', 'sessions.sidebar.sessionDialogs.directory.errorAddProjectTitle': 'Nie udało się dodać projektu', 'sessions.sidebar.sessionDialogs.directory.errorAddProjectDescription': 'Wybierz prawidłową ścieżkę katalogu.', - 'sessions.sidebar.sessionDialogs.worktree.errorRemoveTitle': 'Nie udało się usunąć drzewa pracy', - 'sessions.sidebar.sessionDialogs.worktree.removedTitle': 'Drzewo pracy usunięte', + 'sessions.sidebar.sessionDialogs.worktree.errorRemoveTitle': 'Nie udało się usunąć drzewa pracy „{name}”', + 'sessions.sidebar.sessionDialogs.worktree.removingTitle': 'Usuwanie drzewa pracy „{name}”…', + 'sessions.sidebar.sessionDialogs.worktree.removedTitle': 'Drzewo pracy „{name}” usunięte', 'sessions.sidebar.sessionDialogs.worktree.removed': 'Drzewo pracy usunięte.', 'sessions.sidebar.sessionDialogs.worktree.removedWithRemote': 'Drzewo pracy i zdalna gałąź usunięte.', 'sessions.sidebar.sessionDialogs.worktree.attachedArchived': 'Dołączone drzewo pracy zarchiwizowane.', diff --git a/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts b/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts index 9d878cce..9261f8ca 100644 --- a/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts +++ b/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts @@ -575,10 +575,6 @@ export const settingsDict = { "settings.behavior.page.systemPromptOptimization.enable": "Otimizar o tamanho do prompt do sistema", "settings.behavior.page.systemPromptOptimization.enableAria": "Otimizar o tamanho do prompt do sistema do OpenCode", "settings.behavior.page.systemPromptOptimization.info": "Reduz o prompt do sistema em cerca de 40% para os agentes build e plan. Os outros agentes não são alterados. Isso pode remover definições personalizadas que substituem build ou plan, portanto não ative nesses fluxos de trabalho. A alteração é aplicada após reiniciar o OpenCode.", - "settings.behavior.page.section.worktrees": "Worktrees", - "settings.behavior.page.worktreeFetchSource.enable": "Buscar do remoto antes de criar worktrees", - "settings.behavior.page.worktreeFetchSource.enableAria": "Buscar alterações do remoto antes de criar worktrees", - "settings.behavior.page.worktreeFetchSource.info": "Novos worktrees são criados a partir do estado remoto buscado do branch atual, e não do checkout local, para que não fiquem desatualizados. A criação usa o estado local quando o remoto está inacessível.", "settings.behavior.page.systemPromptOptimization.restarting": "Reiniciando o OpenCode para aplicar a otimização do prompt do sistema…", "settings.behavior.page.field.systemPromptPlaceholder": "Você é um assistente de IA útil...\n\nUse este espaço para definir regras absolutas sobre como a IA deve se comportar em todas as sessões e provedores.", "settings.behavior.page.section.responseStyle": "Estilo de resposta", diff --git a/packages/ui/src/lib/i18n/messages/pt-BR.ts b/packages/ui/src/lib/i18n/messages/pt-BR.ts index 3c34a646..0636e87e 100644 --- a/packages/ui/src/lib/i18n/messages/pt-BR.ts +++ b/packages/ui/src/lib/i18n/messages/pt-BR.ts @@ -697,8 +697,9 @@ export const dict: Record = { "sessions.sidebar.sessionDialogs.directory.errorOpenDescription": "O desktop não pôde conceder acesso aos arquivos.", "sessions.sidebar.sessionDialogs.directory.errorAddProjectTitle": "Não foi possível adicionar projeto", "sessions.sidebar.sessionDialogs.directory.errorAddProjectDescription": "Selecione uma caminho de diretório válida.", - "sessions.sidebar.sessionDialogs.worktree.errorRemoveTitle": "Não foi possível excluir worktree", - "sessions.sidebar.sessionDialogs.worktree.removedTitle": "Worktree excluído", + "sessions.sidebar.sessionDialogs.worktree.errorRemoveTitle": "Não foi possível excluir o worktree “{name}”", + "sessions.sidebar.sessionDialogs.worktree.removingTitle": "Excluindo o worktree “{name}”…", + "sessions.sidebar.sessionDialogs.worktree.removedTitle": "Worktree “{name}” excluído", "sessions.sidebar.sessionDialogs.worktree.removed": "Worktree excluído.", "sessions.sidebar.sessionDialogs.worktree.removedWithRemote": "Worktree e branch remota excluídos.", "sessions.sidebar.sessionDialogs.worktree.attachedArchived": "Worktree adjunto archivado.", diff --git a/packages/ui/src/lib/i18n/messages/tr.settings.ts b/packages/ui/src/lib/i18n/messages/tr.settings.ts index 02026c04..02651073 100644 --- a/packages/ui/src/lib/i18n/messages/tr.settings.ts +++ b/packages/ui/src/lib/i18n/messages/tr.settings.ts @@ -603,10 +603,6 @@ export const settingsDict = { 'settings.behavior.page.systemPromptOptimization.enable': 'System prompt boyutunu optimize et', 'settings.behavior.page.systemPromptOptimization.enableAria': 'OpenCode system prompt boyutunu optimize et', 'settings.behavior.page.systemPromptOptimization.info': 'System prompt\'u, build ve plan agent\'ları için tahmini %40 küçültür. Diğer agent\'lar değişmez. Bu, build veya plan\'ı geçersiz kılan özel tanımları kaldırabileceği için bu agent\'ları özelleştiren iş akışlarında etkinleştirmeyin. Değişiklik, OpenCode yeniden başlatıldığında uygulanır.', - 'settings.behavior.page.section.worktrees': 'Worktree\'ler', - 'settings.behavior.page.worktreeFetchSource.enable': 'Worktree oluşturmadan önce uzak depodan fetch et', - 'settings.behavior.page.worktreeFetchSource.enableAria': 'Worktree oluşturmadan önce uzak depodan değişiklikleri fetch et', - 'settings.behavior.page.worktreeFetchSource.info': 'Yeni worktree\'ler yerel checkout yerine geçerli dalın uzak depodan fetch edilen durumuna dayanır, böylece güncel kalır. Uzak depoya erişilemezse yerel durum kullanılır.', 'settings.behavior.page.systemPromptOptimization.restarting': 'System prompt optimizasyonunu uygulamak için OpenCode yeniden başlatılıyor…', 'settings.behavior.page.field.systemPromptPlaceholder': 'Sen yardımcı bir AI asistanısın...\\n\\nBu alanı, AI\'ın tüm session\'lar ve provider\'lar genelinde nasıl davranacağına dair mutlak kuralları tanımlamak için kullanın.', 'settings.behavior.page.section.responseStyle': 'Yanıt stili', diff --git a/packages/ui/src/lib/i18n/messages/tr.ts b/packages/ui/src/lib/i18n/messages/tr.ts index ee421fe1..775fe6b6 100644 --- a/packages/ui/src/lib/i18n/messages/tr.ts +++ b/packages/ui/src/lib/i18n/messages/tr.ts @@ -678,8 +678,9 @@ export const dict = { 'sessions.sidebar.sessionDialogs.directory.errorOpenDescription': 'Masaüstü uygulaması dosya erişimi izni veremedi.', 'sessions.sidebar.sessionDialogs.directory.errorAddProjectTitle': 'Proje eklenemedi', 'sessions.sidebar.sessionDialogs.directory.errorAddProjectDescription': 'Geçerli bir dizin yolu seçin.', - 'sessions.sidebar.sessionDialogs.worktree.errorRemoveTitle': 'Worktree kaldırılamadı', - 'sessions.sidebar.sessionDialogs.worktree.removedTitle': 'Worktree kaldırıldı', + 'sessions.sidebar.sessionDialogs.worktree.errorRemoveTitle': '“{name}” worktree kaldırılamadı', + 'sessions.sidebar.sessionDialogs.worktree.removingTitle': '“{name}” worktree kaldırılıyor…', + 'sessions.sidebar.sessionDialogs.worktree.removedTitle': '“{name}” worktree kaldırıldı', 'sessions.sidebar.sessionDialogs.worktree.removed': 'Worktree kaldırıldı.', 'sessions.sidebar.sessionDialogs.worktree.removedWithRemote': 'Worktree ve uzak branch kaldırıldı.', 'sessions.sidebar.sessionDialogs.worktree.attachedArchived': 'Bağlı worktree arşivlendi.', diff --git a/packages/ui/src/lib/i18n/messages/uk.settings.ts b/packages/ui/src/lib/i18n/messages/uk.settings.ts index 6acf1ade..4116ef42 100644 --- a/packages/ui/src/lib/i18n/messages/uk.settings.ts +++ b/packages/ui/src/lib/i18n/messages/uk.settings.ts @@ -575,10 +575,6 @@ export const settingsDict = { "settings.behavior.page.systemPromptOptimization.enable": "Оптимізувати розмір системного промпту", "settings.behavior.page.systemPromptOptimization.enableAria": "Оптимізувати розмір системного промпту OpenCode", "settings.behavior.page.systemPromptOptimization.info": "Зменшує системний промпт орієнтовно на 40% для агентів build і plan. Інші агенти не змінюються. Це може видалити власні визначення, які перевизначають build або plan, тому не вмикайте цю функцію для таких робочих процесів. Зміна застосовується після перезапуску OpenCode.", - "settings.behavior.page.section.worktrees": "Worktree", - "settings.behavior.page.worktreeFetchSource.enable": "Отримувати зміни з віддаленого перед створенням worktree", - "settings.behavior.page.worktreeFetchSource.enableAria": "Отримати зміни з віддаленого репозиторію перед створенням worktree", - "settings.behavior.page.worktreeFetchSource.info": "Нові worktree створюються з отриманого віддаленого стану поточної гілки, а не з локального checkout, тому не є застарілими. Якщо віддалений репозиторій недоступний, використовується локальний стан.", "settings.behavior.page.systemPromptOptimization.restarting": "Перезапуск OpenCode для застосування оптимізації системного промпту…", "settings.behavior.page.field.systemPromptPlaceholder": "Ви — корисний AI-асистент...\n\nВикористовуйте цей простір для визначення абсолютних правил поведінки AI для всіх сесій та провайдерів.", "settings.behavior.page.section.responseStyle": "Стиль відповіді", diff --git a/packages/ui/src/lib/i18n/messages/uk.ts b/packages/ui/src/lib/i18n/messages/uk.ts index 3434a635..276dc0c2 100644 --- a/packages/ui/src/lib/i18n/messages/uk.ts +++ b/packages/ui/src/lib/i18n/messages/uk.ts @@ -697,8 +697,9 @@ export const dict: Record = { "sessions.sidebar.sessionDialogs.directory.errorOpenDescription": "Десктопний застосунок не зміг надати доступ до файлу.", "sessions.sidebar.sessionDialogs.directory.errorAddProjectTitle": "Не вдалося додати проєкт", "sessions.sidebar.sessionDialogs.directory.errorAddProjectDescription": "Виберіть правильний шлях до каталогу.", - "sessions.sidebar.sessionDialogs.worktree.errorRemoveTitle": "Не вдалося видалити worktree", - "sessions.sidebar.sessionDialogs.worktree.removedTitle": "Worktree видалено", + "sessions.sidebar.sessionDialogs.worktree.errorRemoveTitle": "Не вдалося видалити worktree «{name}»", + "sessions.sidebar.sessionDialogs.worktree.removingTitle": "Видаляємо worktree «{name}»…", + "sessions.sidebar.sessionDialogs.worktree.removedTitle": "Worktree «{name}» видалено", "sessions.sidebar.sessionDialogs.worktree.removed": "Worktree видалено.", "sessions.sidebar.sessionDialogs.worktree.removedWithRemote": "Worktree та віддалену гілку видалено.", "sessions.sidebar.sessionDialogs.worktree.attachedArchived": "Прикріплене worktree заархівовано.", diff --git a/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts b/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts index 257f57c8..01024ad1 100644 --- a/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts +++ b/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts @@ -575,10 +575,6 @@ export const settingsDict = { 'settings.behavior.page.systemPromptOptimization.enable': '优化系统提示词大小', 'settings.behavior.page.systemPromptOptimization.enableAria': '优化 OpenCode 系统提示词大小', 'settings.behavior.page.systemPromptOptimization.info': '预计可将 build 和 plan 代理的系统提示词缩减约 40%,其他代理不会改变。这可能会移除覆盖 build 或 plan 的自定义定义,因此此类工作流请勿启用。重启 OpenCode 后应用更改。', - 'settings.behavior.page.section.worktrees': '工作树', - 'settings.behavior.page.worktreeFetchSource.enable': '创建工作树前先从远程获取', - 'settings.behavior.page.worktreeFetchSource.enableAria': '创建工作树前先从远程仓库获取变更', - 'settings.behavior.page.worktreeFetchSource.info': '新工作树基于当前分支从远程获取的状态创建,而不是本地检出,因此不会滞后。远程不可用时将回退为本地状态。', 'settings.behavior.page.systemPromptOptimization.restarting': '正在重启 OpenCode 以应用系统提示词优化…', 'settings.behavior.page.field.systemPromptPlaceholder': '你是一个有帮助的 AI 助手...\n\n使用此空间定义 AI 在所有会话和提供商中的绝对行为规则。', 'settings.behavior.page.section.responseStyle': '回复风格', diff --git a/packages/ui/src/lib/i18n/messages/zh-CN.ts b/packages/ui/src/lib/i18n/messages/zh-CN.ts index 5de1f7fa..3fcb1c2a 100644 --- a/packages/ui/src/lib/i18n/messages/zh-CN.ts +++ b/packages/ui/src/lib/i18n/messages/zh-CN.ts @@ -697,8 +697,9 @@ export const dict: Record = { 'sessions.sidebar.sessionDialogs.directory.errorOpenDescription': '桌面端无法授予文件访问权限。', 'sessions.sidebar.sessionDialogs.directory.errorAddProjectTitle': '添加项目失败', 'sessions.sidebar.sessionDialogs.directory.errorAddProjectDescription': '请选择有效的目录路径。', - 'sessions.sidebar.sessionDialogs.worktree.errorRemoveTitle': '移除工作树失败', - 'sessions.sidebar.sessionDialogs.worktree.removedTitle': '工作树已移除', + 'sessions.sidebar.sessionDialogs.worktree.errorRemoveTitle': '移除工作树“{name}”失败', + 'sessions.sidebar.sessionDialogs.worktree.removingTitle': '正在移除工作树“{name}”…', + 'sessions.sidebar.sessionDialogs.worktree.removedTitle': '工作树“{name}”已移除', 'sessions.sidebar.sessionDialogs.worktree.removed': '工作树已移除。', 'sessions.sidebar.sessionDialogs.worktree.removedWithRemote': '工作树和远程分支已移除。', 'sessions.sidebar.sessionDialogs.worktree.attachedArchived': '关联工作树已归档。', diff --git a/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts b/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts index 341e50f0..7e5d3f25 100644 --- a/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts +++ b/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts @@ -572,10 +572,6 @@ export const settingsDict = { 'settings.behavior.page.systemPromptOptimization.enable': '最佳化系統提示詞大小', 'settings.behavior.page.systemPromptOptimization.enableAria': '最佳化 OpenCode 系統提示詞大小', 'settings.behavior.page.systemPromptOptimization.info': '預計可將 build 和 plan 代理程式的系統提示詞縮減約 40%,其他代理程式不會變更。這可能會移除覆寫 build 或 plan 的自訂定義,因此此類工作流程請勿啟用。重新啟動 OpenCode 後套用變更。', - 'settings.behavior.page.section.worktrees': '工作樹', - 'settings.behavior.page.worktreeFetchSource.enable': '建立工作樹前先從遠端擷取', - 'settings.behavior.page.worktreeFetchSource.enableAria': '建立工作樹前先從遠端儲存庫擷取變更', - 'settings.behavior.page.worktreeFetchSource.info': '新工作樹基於目前分支從遠端擷取的狀態建立,而非本機檢出,因此不會落後。遠端無法使用時將改用本機狀態。', 'settings.behavior.page.systemPromptOptimization.restarting': '正在重新啟動 OpenCode 以套用系統提示詞最佳化…', 'settings.behavior.page.field.systemPromptPlaceholder': '你是一個有幫助的 AI 助助理...\n\n在此設定 AI 在所有工作階段與服務提供者中都必須遵守的絕對行為規則。', 'settings.behavior.page.section.responseStyle': '回覆風格', diff --git a/packages/ui/src/lib/i18n/messages/zh-TW.ts b/packages/ui/src/lib/i18n/messages/zh-TW.ts index 221fc24f..5f4263b7 100644 --- a/packages/ui/src/lib/i18n/messages/zh-TW.ts +++ b/packages/ui/src/lib/i18n/messages/zh-TW.ts @@ -710,8 +710,9 @@ export const dict: Record = { 'sessions.sidebar.sessionDialogs.directory.errorOpenDescription': '桌面端無法授予檔案存取權限。', 'sessions.sidebar.sessionDialogs.directory.errorAddProjectTitle': '新增專案失敗', 'sessions.sidebar.sessionDialogs.directory.errorAddProjectDescription': '請選擇有效的目錄路徑。', - 'sessions.sidebar.sessionDialogs.worktree.errorRemoveTitle': '移除 worktree 失敗', - 'sessions.sidebar.sessionDialogs.worktree.removedTitle': 'worktree 已移除', + 'sessions.sidebar.sessionDialogs.worktree.errorRemoveTitle': '移除 worktree「{name}」失敗', + 'sessions.sidebar.sessionDialogs.worktree.removingTitle': '正在移除 worktree「{name}」…', + 'sessions.sidebar.sessionDialogs.worktree.removedTitle': 'worktree「{name}」已移除', 'sessions.sidebar.sessionDialogs.worktree.removed': 'worktree 已移除。', 'sessions.sidebar.sessionDialogs.worktree.removedWithRemote': 'worktree 和遠端分支已移除。', 'sessions.sidebar.sessionDialogs.worktree.attachedArchived': '關聯 worktree 已封存。', diff --git a/packages/ui/src/lib/persistence.ts b/packages/ui/src/lib/persistence.ts index 97c37ab6..c5750464 100644 --- a/packages/ui/src/lib/persistence.ts +++ b/packages/ui/src/lib/persistence.ts @@ -1278,9 +1278,6 @@ const sanitizeWebSettings = (payload: unknown): DesktopSettings | null => { if (typeof candidate.autoCreateWorktree === 'boolean') { result.autoCreateWorktree = candidate.autoCreateWorktree; } - if (typeof candidate.worktreeFetchSource === 'boolean') { - result.worktreeFetchSource = candidate.worktreeFetchSource; - } if (typeof candidate.gitmojiEnabled === 'boolean') { result.gitmojiEnabled = candidate.gitmojiEnabled; } diff --git a/packages/ui/src/lib/settings/search.ts b/packages/ui/src/lib/settings/search.ts index 43127f72..14d0caf2 100644 --- a/packages/ui/src/lib/settings/search.ts +++ b/packages/ui/src/lib/settings/search.ts @@ -607,12 +607,6 @@ const SETTINGS_SEARCH_ITEMS: readonly SettingsSearchItem[] = [ titleKey: 'settings.openchamber.worktrees.setup.waitForCommands', keywords: ['worktree', 'setup commands', 'bootstrap', 'wait'], }, - { - id: 'behavior.worktree-fetch-source', - page: 'behavior', - titleKey: 'settings.behavior.page.section.worktrees', - keywords: ['worktree', 'fetch', 'remote', 'source branch', 'stale'], - }, { id: 'remote-instances.client-auth', page: 'remote-instances', diff --git a/packages/ui/src/lib/worktrees/worktreeCreate.test.ts b/packages/ui/src/lib/worktrees/worktreeCreate.test.ts index 20e0a51b..642e10db 100644 --- a/packages/ui/src/lib/worktrees/worktreeCreate.test.ts +++ b/packages/ui/src/lib/worktrees/worktreeCreate.test.ts @@ -7,7 +7,6 @@ interface MockBranchTracking { const project: ProjectRef = { id: 'project-1', path: '/repo' }; -let fetchSourceEnabled = true; let projectRoot = '/repo'; let gitStatus: { current: string; @@ -16,25 +15,7 @@ let gitStatus: { behind: number; } | null = null; let branchTracking: MockBranchTracking = {}; -let gitFetchError: Error | null = null; -let gitFetchSuccess = true; -const gitFetchCalls: Array<{ directory: string; remote?: string; branch?: string }> = []; const createdPayloads: CreateWorktreeArgs[] = []; -const toastWarnings: string[] = []; - -mock.module('@/components/ui', () => ({ - toast: { - warning: (message: string) => { - toastWarnings.push(message); - }, - }, -})); - -mock.module('@/stores/useConfigStore', () => ({ - useConfigStore: { - getState: () => ({ settingsWorktreeFetchSource: fetchSourceEnabled }), - }, -})); mock.module('@/lib/gitApi', () => ({ getGitStatus: () => (gitStatus ? Promise.resolve(gitStatus) : Promise.reject(new Error('no status'))), @@ -45,13 +26,6 @@ mock.module('@/lib/gitApi', () => ({ ? { main: { current: false, name: 'main', commit: 'abc1234', label: '', tracking: branchTracking.main } } : {}, }), - gitFetch: (directory: string, options: { remote?: string; branch?: string } = {}) => { - gitFetchCalls.push({ directory, ...options }); - if (gitFetchError) { - return Promise.reject(gitFetchError); - } - return Promise.resolve({ success: gitFetchSuccess }); - }, })); mock.module('@/lib/worktrees/worktreeStatus', () => ({ @@ -78,7 +52,7 @@ mock.module('@/lib/worktrees/worktreeManager', () => ({ }, })); -const { createWorktreeWithDefaults, withWorktreeFetchedStartRef } = await import('./worktreeCreate'); +const { createWorktreeWithDefaults, withWorktreeRemoteStartRef } = await import('./worktreeCreate'); const baseArgs = (overrides: CreateWorktreeArgs = {}): CreateWorktreeArgs => ({ preferredName: 'openchamber/feature', @@ -88,174 +62,116 @@ const baseArgs = (overrides: CreateWorktreeArgs = {}): CreateWorktreeArgs => ({ ...overrides, }); -describe('withWorktreeFetchedStartRef', () => { +describe('withWorktreeRemoteStartRef', () => { beforeEach(() => { - fetchSourceEnabled = true; projectRoot = '/repo'; gitStatus = { current: 'main', tracking: 'origin/main', ahead: 0, behind: 0 }; branchTracking = {}; - gitFetchError = null; - gitFetchSuccess = true; - gitFetchCalls.length = 0; createdPayloads.length = 0; - toastWarnings.length = 0; }); - test('bases the new worktree on the fetched remote-tracking ref', async () => { + test('selects the tracked remote ref for the runtime to fetch', async () => { gitStatus = { current: 'main', tracking: 'origin/main', ahead: 0, behind: 15 }; - const args = await withWorktreeFetchedStartRef(project, baseArgs()); + const args = await withWorktreeRemoteStartRef(project, baseArgs()); expect(args.startRef).toBe('remotes/origin/main'); - expect(gitFetchCalls).toEqual([{ directory: '/repo', remote: 'origin', branch: 'main' }]); }); - test('fetches the branch the explicit start ref names when it is the current branch', async () => { + test('selects the tracked remote ref when the explicit start ref names the current branch', async () => { gitStatus = { current: 'main', tracking: 'origin/main', ahead: 0, behind: 3 }; - const args = await withWorktreeFetchedStartRef(project, baseArgs({ startRef: 'main' })); + const args = await withWorktreeRemoteStartRef(project, baseArgs({ startRef: 'main' })); expect(args.startRef).toBe('remotes/origin/main'); - expect(gitFetchCalls).toHaveLength(1); }); - test('falls back to the original args when the fetch fails', async () => { - gitStatus = { current: 'main', tracking: 'origin/main', ahead: 0, behind: 15 }; - gitFetchError = new Error('network down'); - - const args = baseArgs(); - const resolved = await withWorktreeFetchedStartRef(project, args); - - expect(resolved).toBe(args); - expect(resolved.startRef).toBe(undefined); - expect(gitFetchCalls).toHaveLength(1); - expect(toastWarnings).toHaveLength(1); - }); - - test('falls back to the original args when the fetch resolves unsuccessful', async () => { - gitStatus = { current: 'main', tracking: 'origin/main', ahead: 0, behind: 15 }; - gitFetchSuccess = false; - - const args = baseArgs(); - const resolved = await withWorktreeFetchedStartRef(project, args); - - expect(resolved).toBe(args); - expect(resolved.startRef).toBe(undefined); - expect(gitFetchCalls).toHaveLength(1); - expect(toastWarnings).toHaveLength(1); - }); - - test('does not fetch when disabled in config', async () => { - fetchSourceEnabled = false; - - const args = baseArgs(); - const resolved = await withWorktreeFetchedStartRef(project, args); - - expect(resolved).toBe(args); - expect(gitFetchCalls).toHaveLength(0); - expect(toastWarnings).toHaveLength(0); - }); - - test('does not fetch when the base branch has local-only commits', async () => { + test('keeps the local base when it has unpublished commits', async () => { gitStatus = { current: 'main', tracking: 'origin/main', ahead: 2, behind: 15 }; const args = baseArgs(); - const resolved = await withWorktreeFetchedStartRef(project, args); + const resolved = await withWorktreeRemoteStartRef(project, args); expect(resolved).toBe(args); - expect(gitFetchCalls).toHaveLength(0); }); - test('does not fetch without upstream tracking', async () => { + test('keeps the local base without upstream tracking', async () => { gitStatus = { current: 'main', tracking: null, ahead: 0, behind: 0 }; const args = baseArgs(); - const resolved = await withWorktreeFetchedStartRef(project, args); + const resolved = await withWorktreeRemoteStartRef(project, args); expect(resolved).toBe(args); - expect(gitFetchCalls).toHaveLength(0); }); - test('does not fetch when git status is unavailable', async () => { + test('keeps the requested base when git status is unavailable', async () => { gitStatus = null; const args = baseArgs(); - const resolved = await withWorktreeFetchedStartRef(project, args); + const resolved = await withWorktreeRemoteStartRef(project, args); expect(resolved).toBe(args); - expect(gitFetchCalls).toHaveLength(0); }); test('keeps an explicit non-root local start ref untouched', async () => { gitStatus = { current: 'main', tracking: 'origin/main', ahead: 0, behind: 15 }; const args = baseArgs({ startRef: 'release/1.2' }); - const resolved = await withWorktreeFetchedStartRef(project, args); + const resolved = await withWorktreeRemoteStartRef(project, args); expect(resolved).toBe(args); - expect(gitFetchCalls).toHaveLength(0); }); test('keeps an explicit remote start ref untouched', async () => { const args = baseArgs({ startRef: 'remotes/origin/main' }); - const resolved = await withWorktreeFetchedStartRef(project, args); + const resolved = await withWorktreeRemoteStartRef(project, args); expect(resolved).toBe(args); - expect(gitFetchCalls).toHaveLength(0); }); test('keeps a commit SHA start ref untouched', async () => { const sha = 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0'; const args = baseArgs({ startRef: sha }); - const resolved = await withWorktreeFetchedStartRef(project, args); + const resolved = await withWorktreeRemoteStartRef(project, args); expect(resolved).toBe(args); - expect(gitFetchCalls).toHaveLength(0); }); - test('does not fetch in existing mode', async () => { + test('keeps the requested base in existing mode', async () => { const args = baseArgs({ mode: 'existing', existingBranch: 'origin/feature' }); - const resolved = await withWorktreeFetchedStartRef(project, args); + const resolved = await withWorktreeRemoteStartRef(project, args); expect(resolved).toBe(args); - expect(gitFetchCalls).toHaveLength(0); }); - test('does not fetch on a detached root checkout', async () => { + test('keeps the requested base on a detached root checkout', async () => { gitStatus = { current: '', tracking: null, ahead: 0, behind: 0 }; const args = baseArgs(); - const resolved = await withWorktreeFetchedStartRef(project, args); + const resolved = await withWorktreeRemoteStartRef(project, args); expect(resolved).toBe(args); - expect(gitFetchCalls).toHaveLength(0); }); test('reads the root checkout state, not the project directory state', async () => { projectRoot = '/primary'; gitStatus = { current: 'main', tracking: 'origin/main', ahead: 0, behind: 15 }; - const args = await withWorktreeFetchedStartRef(project, baseArgs()); + const args = await withWorktreeRemoteStartRef(project, baseArgs()); expect(args.startRef).toBe('remotes/origin/main'); - expect(gitFetchCalls).toEqual([{ directory: '/repo', remote: 'origin', branch: 'main' }]); }); }); -describe('createWorktreeWithDefaults fetch integration', () => { +describe('createWorktreeWithDefaults remote source integration', () => { beforeEach(() => { - fetchSourceEnabled = true; projectRoot = '/repo'; gitStatus = { current: 'main', tracking: 'origin/main', ahead: 0, behind: 0 }; branchTracking = { main: 'origin/main' }; - gitFetchError = null; - gitFetchSuccess = true; - gitFetchCalls.length = 0; createdPayloads.length = 0; - toastWarnings.length = 0; }); - test('sets the new branch\'s own upstream when refreshed from remote', async () => { + test('sets the new branch\'s own upstream when using the tracked remote source', async () => { gitStatus = { current: 'main', tracking: 'origin/main', ahead: 0, behind: 15 }; await createWorktreeWithDefaults(project, baseArgs()); @@ -279,19 +195,6 @@ describe('createWorktreeWithDefaults fetch integration', () => { expect(createdPayloads[0].upstreamBranch).toBe('openchamber/feature'); }); - test('keeps upstream defaults when the fetch falls back', async () => { - gitStatus = { current: 'main', tracking: 'origin/main', ahead: 0, behind: 15 }; - gitFetchError = new Error('network down'); - - await createWorktreeWithDefaults(project, baseArgs()); - - expect(createdPayloads).toHaveLength(1); - expect(createdPayloads[0].startRef).toBe(undefined); - expect(createdPayloads[0].setUpstream).toBe(true); - expect(createdPayloads[0].upstreamRemote).toBe('origin'); - expect(createdPayloads[0].upstreamBranch).toBe('openchamber/feature'); - }); - test('passes an explicit remote start ref through with upstream defaults as before', async () => { await createWorktreeWithDefaults(project, baseArgs({ startRef: 'remotes/origin/main' })); @@ -300,6 +203,5 @@ describe('createWorktreeWithDefaults fetch integration', () => { expect(createdPayloads[0].setUpstream).toBe(true); expect(createdPayloads[0].upstreamRemote).toBe('origin'); expect(createdPayloads[0].upstreamBranch).toBe('openchamber/feature'); - expect(gitFetchCalls).toHaveLength(0); }); }); diff --git a/packages/ui/src/lib/worktrees/worktreeCreate.ts b/packages/ui/src/lib/worktrees/worktreeCreate.ts index a3a6dcd2..52890f8f 100644 --- a/packages/ui/src/lib/worktrees/worktreeCreate.ts +++ b/packages/ui/src/lib/worktrees/worktreeCreate.ts @@ -1,10 +1,7 @@ -import { toast } from '@/components/ui'; -import { getGitBranches, getGitStatus, gitFetch } from '@/lib/gitApi'; -import { formatMessage, useI18nStore } from '@/lib/i18n'; +import { getGitBranches, getGitStatus } from '@/lib/gitApi'; import type { CreateWorktreeArgs, ProjectRef } from '@/lib/worktrees/worktreeManager'; import { createWorktree } from '@/lib/worktrees/worktreeManager'; import { getRootBranch, resolveProjectRoot } from '@/lib/worktrees/worktreeStatus'; -import { useConfigStore } from '@/stores/useConfigStore'; const parseTrackingRef = (tracking: string | null | undefined): { remote: string; branch: string } | null => { const value = String(tracking || '').trim().replace(/^remotes\//, ''); @@ -122,17 +119,13 @@ const normalizeLocalBranchName = (value: string): string => { .replace(/^refs\/heads\//, ''); }; -export const withWorktreeFetchedStartRef = async ( +export const withWorktreeRemoteStartRef = async ( project: ProjectRef, args: CreateWorktreeArgs ): Promise => { if (args.mode === 'existing') { return args; } - if (useConfigStore.getState().settingsWorktreeFetchSource === false) { - return args; - } - const rawStartRef = String(args.startRef || '').trim(); if (rawStartRef && rawStartRef !== 'HEAD') { if (REMOTE_START_REF_PATTERN.test(rawStartRef) || COMMIT_SHA_PATTERN.test(rawStartRef)) { @@ -161,22 +154,6 @@ export const withWorktreeFetchedStartRef = async ( return args; } - const fallbackToLocalSource = () => { - toast.warning( - formatMessage(useI18nStore.getState().dictionary, 'session.newWorktree.toast.fetchSourceFailed'), - ); - return args; - }; - - try { - const fetchResult = await gitFetch(projectDirectory, { remote: tracking.remote, branch: tracking.branch }); - if (fetchResult.success !== true) { - return fallbackToLocalSource(); - } - } catch { - return fallbackToLocalSource(); - } - return { ...args, startRef: `remotes/${tracking.remote}/${tracking.branch}` }; }; @@ -185,7 +162,7 @@ export const createWorktreeWithDefaults = async ( args: CreateWorktreeArgs, options?: { resolvedRootTrackingRemote?: string | null } ) => { - const fetchedArgs = await withWorktreeFetchedStartRef(project, args); - const resolvedArgs = await withWorktreeUpstreamDefaults(project.path, fetchedArgs, options); + const remoteArgs = await withWorktreeRemoteStartRef(project, args); + const resolvedArgs = await withWorktreeUpstreamDefaults(project.path, remoteArgs, options); return createWorktree(project, resolvedArgs); }; diff --git a/packages/ui/src/lib/worktrees/worktreeManager.test.ts b/packages/ui/src/lib/worktrees/worktreeManager.test.ts index 6a8698cc..109fac8b 100644 --- a/packages/ui/src/lib/worktrees/worktreeManager.test.ts +++ b/packages/ui/src/lib/worktrees/worktreeManager.test.ts @@ -26,6 +26,7 @@ const createdWorktree = { let createdWorktreeResult: GitWorktreeCreateResult = createdWorktree; const bootstrapWatcherCalls: string[] = []; const bootstrapWatcherOptions: Array<{ onReady?: () => void }> = []; +const warningToasts: string[] = []; const sessionState = { availableWorktreesByProject: new Map(), @@ -40,6 +41,17 @@ mock.module('@/lib/openchamberConfig', () => ({ substituteCommandVariables: (command: string) => command, })); +mock.module('@/components/ui', () => ({ + toast: { + warning: (message: string) => warningToasts.push(message), + }, +})); + +mock.module('@/lib/i18n', () => ({ + formatMessage: () => 'session.newWorktree.toast.fetchSourceFailed', + useI18nStore: { getState: () => ({ dictionary: {} }) }, +})); + mock.module('@/lib/worktrees/worktreeBootstrap', () => ({ clearWorktreeBootstrapState: mock(), markWorktreeBootstrapPending: mock(), @@ -130,6 +142,7 @@ describe('worktreeManager list invalidation', () => { validatePayloads.length = 0; bootstrapWatcherCalls.length = 0; bootstrapWatcherOptions.length = 0; + warningToasts.length = 0; createdWorktreeResult = createdWorktree; sessionState.availableWorktreesByProject = new Map(); sessionState.availableWorktrees = []; @@ -137,6 +150,17 @@ describe('worktreeManager list invalidation', () => { attachmentState.attachments = new Map(); }); + test('warns when worktree creation falls back after a source fetch failure', async () => { + createdWorktreeResult = { ...createdWorktree, sourceFetchFailed: true }; + + await createWorktree({ id: 'project-fetch-fallback', path: '/repo' }, { + branchName: 'feature', + worktreeName: 'feature', + }); + + expect(warningToasts).toEqual(['session.newWorktree.toast.fetchSourceFailed']); + }); + test('retries an in-flight list when a worktree is created before it resolves', async () => { const project = { id: 'project-1', path: '/repo' }; const listing = listProjectWorktrees(project); diff --git a/packages/ui/src/lib/worktrees/worktreeManager.ts b/packages/ui/src/lib/worktrees/worktreeManager.ts index 67cf5235..b14de347 100644 --- a/packages/ui/src/lib/worktrees/worktreeManager.ts +++ b/packages/ui/src/lib/worktrees/worktreeManager.ts @@ -1,4 +1,6 @@ import { substituteCommandVariables } from '@/lib/openchamberConfig'; +import { toast } from '@/components/ui'; +import { formatMessage, useI18nStore } from '@/lib/i18n'; import type { WorktreeMetadata } from '@/types/worktree'; import { deleteRemoteBranch, @@ -58,6 +60,10 @@ const normalizePath = (value: string): string => { return replaced.length > 1 ? replaced.replace(/\/+$/, '') : replaced; }; +/** The name the sidebar shows for a worktree, used in worktree-scoped toasts. */ +export const getWorktreeDisplayName = (worktree: WorktreeMetadata): string => + worktree.branch || worktree.label || worktree.path; + export const getLatestWorktreeMetadata = (metadata: WorktreeMetadata): WorktreeMetadata => { const target = normalizePath(metadata.path); const state = useSessionUIStore.getState(); @@ -508,6 +514,11 @@ export async function createWorktree(project: ProjectRef, args: CreateWorktreeAr const payload = toCreatePayload(args, projectDirectory); const created = await git.worktree.create(projectDirectory, payload); + if (created?.sourceFetchFailed) { + toast.warning( + formatMessage(useI18nStore.getState().dictionary, 'session.newWorktree.toast.fetchSourceFailed'), + ); + } const returnedName = typeof created?.name === 'string' ? created.name : ''; const returnedBranch = typeof created?.branch === 'string' ? created.branch : ''; const returnedPath = typeof created?.path === 'string' ? created.path : ''; diff --git a/packages/ui/src/stores/useConfigStore.ts b/packages/ui/src/stores/useConfigStore.ts index e061a64c..66f1487e 100644 --- a/packages/ui/src/stores/useConfigStore.ts +++ b/packages/ui/src/stores/useConfigStore.ts @@ -56,7 +56,6 @@ interface OpenChamberDefaults { defaultVariant?: string; defaultAgent?: string; autoCreateWorktree?: boolean; - worktreeFetchSource?: boolean; gitmojiEnabled?: boolean; defaultFileViewerPreview?: boolean; zenModel?: string; @@ -129,7 +128,6 @@ const requestOpenChamberDefaults = async (): Promise => { defaultVariant: defaultVariant.length > 0 ? defaultVariant : undefined, defaultAgent: defaultAgent.length > 0 ? defaultAgent : undefined, autoCreateWorktree: typeof data?.autoCreateWorktree === 'boolean' ? data.autoCreateWorktree : undefined, - worktreeFetchSource: typeof data?.worktreeFetchSource === 'boolean' ? data.worktreeFetchSource : undefined, gitmojiEnabled, defaultFileViewerPreview, zenModel: zenModel.length > 0 ? zenModel : undefined, @@ -176,7 +174,6 @@ const requestOpenChamberDefaults = async (): Promise => { defaultVariant: defaultVariant.length > 0 ? defaultVariant : undefined, defaultAgent: defaultAgent.length > 0 ? defaultAgent : undefined, autoCreateWorktree: typeof data?.autoCreateWorktree === 'boolean' ? data.autoCreateWorktree : undefined, - worktreeFetchSource: typeof data?.worktreeFetchSource === 'boolean' ? data.worktreeFetchSource : undefined, gitmojiEnabled, defaultFileViewerPreview, zenModel: zenModel.length > 0 ? zenModel : undefined, @@ -1056,7 +1053,6 @@ interface ConfigStore { // when neither our settingsDefaultModel nor the resolved agent pins a model. opencodeDefaultModel: string | undefined; settingsAutoCreateWorktree: boolean; - settingsWorktreeFetchSource: boolean; settingsGitmojiEnabled: boolean; settingsDefaultFileViewerPreview: boolean; settingsZenModel: string | undefined; @@ -1144,7 +1140,6 @@ interface ConfigStore { setSettingsDefaultVariant: (variant: string | undefined) => void; setSettingsDefaultAgent: (agent: string | undefined) => void; setSettingsAutoCreateWorktree: (enabled: boolean) => void; - setSettingsWorktreeFetchSource: (enabled: boolean) => void; setSettingsGitmojiEnabled: (enabled: boolean) => void; setSettingsDefaultFileViewerPreview: (enabled: boolean) => void; setSettingsZenModel: (model: string | undefined) => void; @@ -1226,7 +1221,6 @@ export const useConfigStore = create()( opencodeDefaultAgent: undefined, opencodeDefaultModel: undefined, settingsAutoCreateWorktree: false, - settingsWorktreeFetchSource: true, settingsGitmojiEnabled: false, settingsDefaultFileViewerPreview: false, settingsZenModel: undefined, @@ -2171,7 +2165,6 @@ export const useConfigStore = create()( settingsDefaultVariant: openChamberDefaults.defaultVariant, settingsDefaultAgent: openChamberDefaults.defaultAgent, settingsAutoCreateWorktree: openChamberDefaults.autoCreateWorktree ?? false, - settingsWorktreeFetchSource: openChamberDefaults.worktreeFetchSource ?? true, settingsGitmojiEnabled: openChamberDefaults.gitmojiEnabled ?? false, settingsDefaultFileViewerPreview: openChamberDefaults.defaultFileViewerPreview ?? false, settingsZenModel: resolvedZenModel, @@ -2921,10 +2914,6 @@ export const useConfigStore = create()( set({ settingsAutoCreateWorktree: enabled }); }, - setSettingsWorktreeFetchSource: (enabled: boolean) => { - set({ settingsWorktreeFetchSource: enabled }); - }, - setSettingsGitmojiEnabled: (enabled: boolean) => { set({ settingsGitmojiEnabled: enabled }); }, @@ -3467,7 +3456,6 @@ export const useConfigStore = create()( settingsDefaultVariant: state.settingsDefaultVariant, settingsDefaultAgent: state.settingsDefaultAgent, settingsAutoCreateWorktree: state.settingsAutoCreateWorktree, - settingsWorktreeFetchSource: state.settingsWorktreeFetchSource, settingsGitmojiEnabled: state.settingsGitmojiEnabled, settingsDefaultFileViewerPreview: state.settingsDefaultFileViewerPreview, settingsZenModel: state.settingsZenModel, diff --git a/packages/vscode/src/DOCUMENTATION.md b/packages/vscode/src/DOCUMENTATION.md index 35029076..2db47dea 100644 --- a/packages/vscode/src/DOCUMENTATION.md +++ b/packages/vscode/src/DOCUMENTATION.md @@ -23,6 +23,7 @@ Keep `bridge.ts` as a thin orchestration layer that delegates message handling t - `gitService.ts` - Owns VS Code Git and worktree operations. + - Fetches the current tracked source branch once before worktree creation. Fetch failure falls back to the local branch and reports it to the shared UI. - Fast worktree creation reports bootstrap phases explicitly: `directory-created`, then `git-ready` after Git population/upstream work, and `setup-ready` after setup commands. Existing worktrees without tracked bootstrap state fall back to `ready`/`setup-ready`; shared webview consumers also accept legacy responses without `phase`. - Worktree removal waits for an active create/bootstrap task for the same directory so background Git and setup work cannot race deletion or restore stale bootstrap state. - Worktree population enables Git `core.longpaths` (local repo config plus `-c core.longpaths=true` on `git reset --hard`) so deeply nested checkouts under the managed data-dir worktree root do not fail on Windows MAX_PATH with "Filename too long". diff --git a/packages/vscode/src/gitService.ts b/packages/vscode/src/gitService.ts index b1fa8db6..ce9bde2c 100644 --- a/packages/vscode/src/gitService.ts +++ b/packages/vscode/src/gitService.ts @@ -860,6 +860,7 @@ export interface GitWorktreeInfo { path: string; directoryCreated?: true; bootstrapStatus?: WorktreeBootstrapStatus; + sourceFetchFailed?: true; } type WorktreeListEntry = { @@ -1987,6 +1988,7 @@ async function attachGitWorktreeToCandidate( const parsedRemoteStartRef = await resolveRemoteBranchRef(context.primaryWorktree, startRef); if (parsedRemoteStartRef) { + worktreeAddArgs.splice(2, 0, '--no-track'); inferredUpstream = { remote: parsedRemoteStartRef.remote, branch: parsedRemoteStartRef.branch, @@ -1994,28 +1996,10 @@ async function attachGitWorktreeToCandidate( } } - if (ensureRemoteName && ensureRemoteUrl) { + if (mode === 'existing' && ensureRemoteName && ensureRemoteUrl) { await ensureRemoteWithUrl(context.primaryWorktree, ensureRemoteName, ensureRemoteUrl); } - if (mode === 'new') { - const parsedRemoteStartRef = await resolveRemoteBranchRef(context.primaryWorktree, startRef); - if (parsedRemoteStartRef) { - try { - await fetchRemoteBranchRef(context.primaryWorktree, parsedRemoteStartRef.remote, parsedRemoteStartRef.branch); - } catch (error) { - const refExists = await runGitCommand( - context.primaryWorktree, - ['show-ref', '--verify', '--quiet', parsedRemoteStartRef.fullRef], - ); - if (!refExists.success) { - throw error; - } - console.warn(`[GitService] failed to refresh ${parsedRemoteStartRef.remote}/${parsedRemoteStartRef.branch}, proceeding with the existing remote-tracking ref`); - } - } - } - await runGitCommandOrThrow(context.primaryWorktree, worktreeAddArgs, 'Failed to create git worktree'); const shouldSetUpstream = Boolean(input?.setUpstream); @@ -2059,6 +2043,59 @@ async function attachGitWorktreeToCandidate( }; } +const prepareWorktreeCreateSource = async ( + context: Awaited>, + input: CreateGitWorktreePayload, +): Promise<{ input: CreateGitWorktreePayload; sourceFetchFailed: boolean }> => { + if (input.mode === 'existing') { + return { input, sourceFetchFailed: false }; + } + + const ensureRemoteName = String(input.ensureRemoteName || '').trim(); + const ensureRemoteUrl = String(input.ensureRemoteUrl || '').trim(); + if (ensureRemoteName && ensureRemoteUrl) { + await ensureRemoteWithUrl(context.primaryWorktree, ensureRemoteName, ensureRemoteUrl); + } + + const startRef = normalizeStartRef(input.startRef); + const remoteStartRef = await resolveRemoteBranchRef(context.primaryWorktree, startRef); + if (!remoteStartRef) { + return { input, sourceFetchFailed: false }; + } + + const status = await getGitStatus(context.primaryWorktree, { mode: 'light' }).catch(() => null); + const trackingRef = status?.tracking + ? await resolveRemoteBranchRef(context.primaryWorktree, status.tracking) + : null; + const canFallbackToLocal = Boolean( + status?.current + && status.ahead === 0 + && trackingRef?.fullRef === remoteStartRef.fullRef + ); + + try { + await fetchRemoteBranchRef(context.primaryWorktree, remoteStartRef.remote, remoteStartRef.branch); + return { input, sourceFetchFailed: false }; + } catch (error) { + if (canFallbackToLocal && status?.current) { + return { + input: { ...input, startRef: status.current }, + sourceFetchFailed: true, + }; + } + + const refExists = await runGitCommand( + context.primaryWorktree, + ['show-ref', '--verify', '--quiet', remoteStartRef.fullRef], + ); + if (!refExists.success) { + throw error; + } + console.warn(`[GitService] failed to refresh ${remoteStartRef.remote}/${remoteStartRef.branch}, proceeding with the existing remote-tracking ref`); + return { input, sourceFetchFailed: false }; + } +}; + export async function createWorktree(directory: string, input: CreateGitWorktreePayload = {}): Promise { const mode = input?.mode === 'existing' ? 'existing' : 'new'; const context = await resolveWorktreeProjectContext(directory); @@ -2066,11 +2103,13 @@ export async function createWorktree(directory: string, input: CreateGitWorktree if (input?.returnAfterDirectoryCreated === true) { await assertWorktreeCreatePreflight(directory, input); } + const prepared = await prepareWorktreeCreateSource(context, input); + const preparedInput = prepared.input; await fs.promises.mkdir(context.worktreeRoot, { recursive: true }); - const preferredName = String(input?.worktreeName || input?.name || '').trim(); - const preferredBranchName = cleanBranchName(String(input?.branchName || '').trim()); + const preferredName = String(preparedInput.worktreeName || preparedInput.name || '').trim(); + const preferredBranchName = cleanBranchName(String(preparedInput.branchName || '').trim()); const candidate = await resolveCandidateDirectory( context.worktreeRoot, @@ -2079,7 +2118,7 @@ export async function createWorktree(directory: string, input: CreateGitWorktree context.primaryWorktree ); - if (input?.returnAfterDirectoryCreated === true) { + if (preparedInput.returnAfterDirectoryCreated === true) { await fs.promises.mkdir(candidate.directory, { recursive: false }); const bootstrapStatus = setWorktreeBootstrapState( @@ -2093,17 +2132,17 @@ export async function createWorktree(directory: string, input: CreateGitWorktree updatedAt: Date.now(), }; const localBranch = mode === 'existing' - ? cleanBranchName(String(input?.branchName || input?.existingBranch || candidate.branch || '').trim()) + ? cleanBranchName(String(preparedInput.branchName || preparedInput.existingBranch || candidate.branch || '').trim()) : candidate.branch; - const task = attachGitWorktreeToCandidate(context, candidate, input).catch(async (error) => { + const task = attachGitWorktreeToCandidate(context, candidate, preparedInput).catch(async (error) => { setWorktreeBootstrapFailure(candidate.directory, error); await cleanupFailedFastWorktreeCreate(context, candidate); console.warn('[GitService] Background worktree creation failed:', error instanceof Error ? error.message : String(error)); }); trackWorktreeBootstrapTask(candidate.directory, task); - return { + const result: GitWorktreeInfo = { head: '', name: candidate.name, branch: localBranch, @@ -2111,9 +2150,14 @@ export async function createWorktree(directory: string, input: CreateGitWorktree directoryCreated: true, bootstrapStatus, }; + if (prepared.sourceFetchFailed) { + result.sourceFetchFailed = true; + } + return result; } - return attachGitWorktreeToCandidate(context, candidate, input); + const result = await attachGitWorktreeToCandidate(context, candidate, preparedInput); + return prepared.sourceFetchFailed ? { ...result, sourceFetchFailed: true } : result; } export async function getWorktreeBootstrapStatus(directory: string): Promise { diff --git a/packages/vscode/src/gitService.worktree-fetch-fallback.test.js b/packages/vscode/src/gitService.worktree-fetch-fallback.test.js index a801de9b..d6fff585 100644 --- a/packages/vscode/src/gitService.worktree-fetch-fallback.test.js +++ b/packages/vscode/src/gitService.worktree-fetch-fallback.test.js @@ -58,7 +58,7 @@ afterEach(() => { }); describe('VS Code worktree create from a remote start ref', () => { - it('creates from the existing remote-tracking ref when the refresh fetch fails', async () => { + it('falls back to the tracked local branch when the source fetch fails', async () => { if (!canRunGit()) return; const previousXdgDataHome = process.env.XDG_DATA_HOME; @@ -66,6 +66,7 @@ describe('VS Code worktree create from a remote start ref', () => { try { const { repository } = createRepositoryWithRemote(); + runGit(repository, ['branch', '--set-upstream-to=origin/main', 'next']); runGit(repository, ['remote', 'set-url', 'origin', '/nonexistent/openchamber-unreachable.git']); const created = await createWorktree(repository, { @@ -76,7 +77,8 @@ describe('VS Code worktree create from a remote start ref', () => { }); expect(created.branch).toBe('openchamber/stale-ref-wt'); - const expectedHead = runGit(repository, ['rev-parse', 'refs/remotes/origin/main']).trim(); + expect(created.sourceFetchFailed).toBe(true); + const expectedHead = runGit(repository, ['rev-parse', 'next']).trim(); expect(runGit(created.path, ['rev-parse', 'HEAD']).trim()).toBe(expectedHead); } finally { if (previousXdgDataHome === undefined) { diff --git a/packages/web/server/lib/git/DOCUMENTATION.md b/packages/web/server/lib/git/DOCUMENTATION.md index f328dbb3..8403627f 100644 --- a/packages/web/server/lib/git/DOCUMENTATION.md +++ b/packages/web/server/lib/git/DOCUMENTATION.md @@ -49,7 +49,7 @@ The following functions are exported and used by the web server: ### Worktree Operations - `getWorktrees(directory)`: List all git worktrees for a repository. - `validateWorktreeCreate(directory, input)`: Validate worktree creation parameters (mode, branchName, startRef, upstream config). -- `createWorktree(directory, input)`: Create a new worktree (supports 'new' and 'existing' modes, upstream setup). After populating the worktree, the repository's `post-checkout` hook runs once with git's standard arguments (null ref as previous HEAD, the checked-out HEAD, and flag `1`) from the worktree directory, mirroring `git worktree add` without `--no-checkout`; a missing or non-executable hook is skipped and a failing hook is logged as a warning, never failing worktree creation or the session bootstrap. +- `createWorktree(directory, input)`: Create a new worktree (supports 'new' and 'existing' modes, upstream setup). When the current tracked branch has no unpublished commits, the UI supplies its remote-tracking ref and this operation fetches that branch once before creating the worktree. A failed fetch falls back to the local branch and reports `sourceFetchFailed`; other remote start refs still require an existing local ref when their fetch fails. After populating the worktree, the repository's `post-checkout` hook runs once with git's standard arguments (null ref as previous HEAD, the checked-out HEAD, and flag `1`) from the worktree directory, mirroring `git worktree add` without `--no-checkout`; a missing or non-executable hook is skipped and a failing hook is logged as a warning, never failing worktree creation or the session bootstrap. - `removeWorktree(directory, input)`: Remove a worktree (optionally delete local branch). - `isLinkedWorktree(directory)`: Check if directory is a linked worktree (not primary). @@ -143,6 +143,7 @@ The following functions are internal helpers used by exported functions: - `path`: Absolute path to worktree directory. - `directoryCreated`: Present when create returned after the target directory exists while background Git/bootstrap work continues. - `bootstrapStatus`: Background setup state. The legacy `status` remains `pending`, `ready`, or `failed`, while `phase` reports `directory-created`, `git-ready`, or `setup-ready`. Fast create starts at `pending`/`directory-created`; population and upstream Git completion advances to `pending`/`git-ready` before setup/start scripts; completed setup is `ready`/`setup-ready`. A missing in-memory state falls back to `ready`/`setup-ready`; clients continue to accept legacy status responses that omit `phase`. +- `sourceFetchFailed`: Present when the automatic source-branch fetch failed and creation fell back to the tracked local branch. - Fast-create background failures remove OpenCode sandbox metadata for directories that never became Git worktrees, and remove the pre-created directory only if it is still empty. User-created files are never recursively deleted by this cleanup. - Worktree removal waits for any active create/bootstrap task for that directory before deleting it, preventing a background Git or setup task from restoring removed state or racing filesystem cleanup. - Worktree bootstrap retries transient `index.lock` conflicts. If the lock remains byte-for-byte and metadata-identical across the retry window, it is treated as stale, removed, and population continues automatically; changing locks are left untouched and reported as failures. diff --git a/packages/web/server/lib/git/service.js b/packages/web/server/lib/git/service.js index 8abfe29a..df0607ae 100644 --- a/packages/web/server/lib/git/service.js +++ b/packages/web/server/lib/git/service.js @@ -4303,28 +4303,10 @@ async function attachGitWorktreeToCandidate(context, candidate, input = {}) { } } - if (ensureRemoteName && ensureRemoteUrl) { + if (mode === 'existing' && ensureRemoteName && ensureRemoteUrl) { await ensureRemoteWithUrl(context.primaryWorktree, ensureRemoteName, ensureRemoteUrl); } - if (mode === 'new') { - const parsedRemoteStartRef = await resolveRemoteBranchRef(context.primaryWorktree, startRef); - if (parsedRemoteStartRef) { - try { - await fetchRemoteBranchRef(context.primaryWorktree, parsedRemoteStartRef.remote, parsedRemoteStartRef.branch); - } catch (error) { - const refExists = await runGitCommand( - context.primaryWorktree, - ['show-ref', '--verify', '--quiet', parsedRemoteStartRef.fullRef] - ); - if (!refExists.success) { - throw error; - } - console.warn(`Worktree create: failed to refresh ${parsedRemoteStartRef.remote}/${parsedRemoteStartRef.branch}, proceeding with the existing remote-tracking ref`); - } - } - } - await runGitCommandOrThrow(context.primaryWorktree, worktreeAddArgs, 'Failed to create git worktree'); const upstreamRemote = shouldSetUpstream @@ -4366,6 +4348,50 @@ async function attachGitWorktreeToCandidate(context, candidate, input = {}) { }; } +const prepareWorktreeCreateSource = async (context, input = {}) => { + if (input?.mode === 'existing') { + return { input, sourceFetchFailed: false }; + } + + const startRef = normalizeStartRef(input?.startRef); + const remoteStartRef = await resolveRemoteBranchRef(context.primaryWorktree, startRef); + if (!remoteStartRef) { + return { input, sourceFetchFailed: false }; + } + + const status = await getStatus(context.primaryWorktree, { mode: 'light' }).catch(() => null); + const trackingRef = status?.tracking + ? await resolveRemoteBranchRef(context.primaryWorktree, status.tracking) + : null; + const canFallbackToLocal = Boolean( + status?.current + && status.ahead === 0 + && trackingRef?.fullRef === remoteStartRef.fullRef + ); + + try { + await fetchRemoteBranchRef(context.primaryWorktree, remoteStartRef.remote, remoteStartRef.branch); + return { input, sourceFetchFailed: false }; + } catch (error) { + if (canFallbackToLocal) { + return { + input: { ...input, startRef: status.current }, + sourceFetchFailed: true, + }; + } + + const refExists = await runGitCommand( + context.primaryWorktree, + ['show-ref', '--verify', '--quiet', remoteStartRef.fullRef] + ); + if (!refExists.success) { + throw error; + } + console.warn(`Worktree create: failed to refresh ${remoteStartRef.remote}/${remoteStartRef.branch}, proceeding with the existing remote-tracking ref`); + return { input, sourceFetchFailed: false }; + } +}; + export async function createWorktree(directory, input = {}) { const mode = input?.mode === 'existing' ? 'existing' : 'new'; const context = await resolveWorktreeProjectContext(directory); @@ -4374,10 +4400,18 @@ export async function createWorktree(directory, input = {}) { await assertWorktreeCreatePreflight(directory, input); } + const ensureRemoteName = String(input?.ensureRemoteName || '').trim(); + const ensureRemoteUrl = String(input?.ensureRemoteUrl || '').trim(); + if (ensureRemoteName && ensureRemoteUrl) { + await ensureRemoteWithUrl(context.primaryWorktree, ensureRemoteName, ensureRemoteUrl); + } + const prepared = await prepareWorktreeCreateSource(context, input); + const preparedInput = prepared.input; + await fsp.mkdir(context.worktreeRoot, { recursive: true }); - const preferredName = String(input?.worktreeName || input?.name || '').trim(); - const preferredBranchName = cleanBranchName(String(input?.branchName || '').trim()); + const preferredName = String(preparedInput?.worktreeName || preparedInput?.name || '').trim(); + const preferredBranchName = cleanBranchName(String(preparedInput?.branchName || '').trim()); const candidate = await resolveCandidateDirectory( context.worktreeRoot, @@ -4386,7 +4420,7 @@ export async function createWorktree(directory, input = {}) { context.primaryWorktree ); - if (input?.returnAfterDirectoryCreated === true) { + if (preparedInput?.returnAfterDirectoryCreated === true) { await fsp.mkdir(candidate.directory, { recursive: false }); const bootstrapStatus = setWorktreeBootstrapState( @@ -4395,10 +4429,10 @@ export async function createWorktree(directory, input = {}) { WORKTREE_BOOTSTRAP_PHASE_DIRECTORY_CREATED ); const localBranch = mode === 'existing' - ? cleanBranchName(String(input?.branchName || input?.existingBranch || candidate.branch || '').trim()) + ? cleanBranchName(String(preparedInput?.branchName || preparedInput?.existingBranch || candidate.branch || '').trim()) : candidate.branch; - const task = attachGitWorktreeToCandidate(context, candidate, input).catch(async (error) => { + const task = attachGitWorktreeToCandidate(context, candidate, preparedInput).catch(async (error) => { setWorktreeBootstrapState( candidate.directory, WORKTREE_BOOTSTRAP_FAILED, @@ -4410,7 +4444,7 @@ export async function createWorktree(directory, input = {}) { }); trackWorktreeBootstrapTask(candidate.directory, task); - return { + const result = { head: '', name: candidate.name, branch: localBranch, @@ -4418,9 +4452,14 @@ export async function createWorktree(directory, input = {}) { directoryCreated: true, bootstrapStatus, }; + if (prepared.sourceFetchFailed) { + result.sourceFetchFailed = true; + } + return result; } - return attachGitWorktreeToCandidate(context, candidate, input); + const result = await attachGitWorktreeToCandidate(context, candidate, preparedInput); + return prepared.sourceFetchFailed ? { ...result, sourceFetchFailed: true } : result; } export async function getWorktreeBootstrapStatus(directory) { diff --git a/packages/web/server/lib/git/service.test.js b/packages/web/server/lib/git/service.test.js index 00356448..b5921050 100644 --- a/packages/web/server/lib/git/service.test.js +++ b/packages/web/server/lib/git/service.test.js @@ -934,7 +934,7 @@ describe('createWorktree', () => { } }, 30_000); - it('creates from a remote start ref when the refresh fetch fails but the ref exists locally', async () => { + it('falls back to the tracked local branch when the source fetch fails', async () => { if (!canRunGit()) return; const previousXdgDataHome = process.env.XDG_DATA_HOME; @@ -943,6 +943,7 @@ describe('createWorktree', () => { try { const { repository } = createRepositoryWithRemote({ defaultBranch: 'main' }); + runGit(repository, ['branch', '--set-upstream-to=origin/main', 'next']); runGit(repository, ['remote', 'set-url', 'origin', '/nonexistent/openchamber-unreachable.git']); const created = await createWorktree(repository, { @@ -953,7 +954,8 @@ describe('createWorktree', () => { }); expect(created.branch).toBe('openchamber/stale-ref-wt'); - const expectedHead = runGit(repository, ['rev-parse', 'refs/remotes/origin/main']).trim(); + expect(created.sourceFetchFailed).toBe(true); + const expectedHead = runGit(repository, ['rev-parse', 'next']).trim(); expect(runGit(created.path, ['rev-parse', 'HEAD']).trim()).toBe(expectedHead); } finally { if (previousXdgDataHome === undefined) { diff --git a/packages/web/server/lib/opencode/settings-helpers.js b/packages/web/server/lib/opencode/settings-helpers.js index 5b135dff..68b7aa85 100644 --- a/packages/web/server/lib/opencode/settings-helpers.js +++ b/packages/web/server/lib/opencode/settings-helpers.js @@ -480,9 +480,6 @@ export const createSettingsHelpers = (dependencies) => { if (typeof candidate.autoCreateWorktree === 'boolean') { result.autoCreateWorktree = candidate.autoCreateWorktree; } - if (typeof candidate.worktreeFetchSource === 'boolean') { - result.worktreeFetchSource = candidate.worktreeFetchSource; - } if (typeof candidate.gitmojiEnabled === 'boolean') { result.gitmojiEnabled = candidate.gitmojiEnabled; }