diff --git a/.gitignore b/.gitignore index 03d5f68f..f98f6cf0 100644 --- a/.gitignore +++ b/.gitignore @@ -69,6 +69,9 @@ workspaces/ *.pid .worktrees/ +# OpenChamber app runtime state (sessions db, screenshots) +.openchamber/ + # Marks a disposable clone dedicated to unattended maintenance tasks. .maintenance-clone test-results/ diff --git a/packages/ui/src/components/sections/behavior/BehaviorPage.tsx b/packages/ui/src/components/sections/behavior/BehaviorPage.tsx index 758c954c..b5d388f7 100644 --- a/packages/ui/src/components/sections/behavior/BehaviorPage.tsx +++ b/packages/ui/src/components/sections/behavior/BehaviorPage.tsx @@ -20,6 +20,7 @@ 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 { @@ -46,6 +47,7 @@ type ResponseStyleValue = ResponseStylePreset | 'custom'; type BehaviorSettingsState = { prompt: string; optimizeSystemPrompt: boolean; + worktreeFetchSource: boolean; responseStyleEnabled: boolean; responseStylePreset: ResponseStyleValue; responseStyleCustomInstructions: string; @@ -54,6 +56,7 @@ type BehaviorSettingsState = { const DEFAULT_BEHAVIOR_SETTINGS: BehaviorSettingsState = { prompt: '', optimizeSystemPrompt: false, + worktreeFetchSource: true, responseStyleEnabled: false, responseStylePreset: 'concise', responseStyleCustomInstructions: '', @@ -103,8 +106,10 @@ 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); @@ -143,12 +148,16 @@ 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 }; } @@ -164,6 +173,7 @@ 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); @@ -184,7 +194,7 @@ export const BehaviorPage: React.FC = () => { void load(); return () => abort.abort(); - }, []); + }, [setSettingsWorktreeFetchSource]); React.useEffect(() => { if (isLoading) return; @@ -265,6 +275,21 @@ 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 { @@ -318,6 +343,21 @@ export const BehaviorPage: React.FC = () => { )} + + + + = { "session.newWorktree.toast.sessionFromPr": "Sesión creada desde PR", "session.newWorktree.toast.worktreeCreated": "Worktree creado", "session.newWorktree.toast.worktreeCreatedDescription": "{target} - configurando en segundo plano", + "session.newWorktree.toast.fetchSourceFailed": "No se pudo obtener del remoto; se creó a partir del estado local", "session.githubIntegration.title": "Seleccionar desde GitHub", "session.githubIntegration.tabs.issues": "Issues", "session.githubIntegration.tabs.pullRequests": "PR", diff --git a/packages/ui/src/lib/i18n/messages/fr.settings.ts b/packages/ui/src/lib/i18n/messages/fr.settings.ts index 0fe60bea..8b0acc56 100644 --- a/packages/ui/src/lib/i18n/messages/fr.settings.ts +++ b/packages/ui/src/lib/i18n/messages/fr.settings.ts @@ -493,6 +493,10 @@ 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 cb58b62d..70eab598 100644 --- a/packages/ui/src/lib/i18n/messages/fr.ts +++ b/packages/ui/src/lib/i18n/messages/fr.ts @@ -1812,6 +1812,7 @@ export const dict = { 'session.newWorktree.toast.sessionFromPr': 'Session créée à partir de PR', 'session.newWorktree.toast.worktreeCreated': 'Worktree créé', 'session.newWorktree.toast.worktreeCreatedDescription': '{target} - démarrage en arrière-plan', + 'session.newWorktree.toast.fetchSourceFailed': 'Impossible de récupérer depuis le dépôt distant — création à partir de l\'état local', 'session.githubIntegration.title': 'Sélectionnez parmi GitHub', 'session.githubIntegration.tabs.issues': 'Problèmes', 'session.githubIntegration.tabs.pullRequests': 'PR', diff --git a/packages/ui/src/lib/i18n/messages/ja.settings.ts b/packages/ui/src/lib/i18n/messages/ja.settings.ts index 729551a6..25468113 100644 --- a/packages/ui/src/lib/i18n/messages/ja.settings.ts +++ b/packages/ui/src/lib/i18n/messages/ja.settings.ts @@ -608,6 +608,10 @@ 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 56b6b27b..8b6d7bc8 100644 --- a/packages/ui/src/lib/i18n/messages/ja.ts +++ b/packages/ui/src/lib/i18n/messages/ja.ts @@ -2051,6 +2051,7 @@ export const dict: Record = { 'session.newWorktree.toast.sessionFromPr': 'PRからセッションを作成しました', 'session.newWorktree.toast.worktreeCreated': 'ワークツリーを作成しました', 'session.newWorktree.toast.worktreeCreatedDescription': '{target} - バックグラウンドでブートストラップ中', + 'session.newWorktree.toast.fetchSourceFailed': 'リモートから取得できませんでした — ローカルの状態で作成しました', 'session.githubIntegration.title': 'GitHubから選択', 'session.githubIntegration.tabs.issues': 'Issue', 'session.githubIntegration.tabs.pullRequests': 'プルリクエスト', diff --git a/packages/ui/src/lib/i18n/messages/ko.settings.ts b/packages/ui/src/lib/i18n/messages/ko.settings.ts index 48d6aa8b..27de0706 100644 --- a/packages/ui/src/lib/i18n/messages/ko.settings.ts +++ b/packages/ui/src/lib/i18n/messages/ko.settings.ts @@ -575,6 +575,10 @@ 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 96781aac..c4b55cad 100644 --- a/packages/ui/src/lib/i18n/messages/ko.ts +++ b/packages/ui/src/lib/i18n/messages/ko.ts @@ -2057,6 +2057,7 @@ export const dict: Record = { 'session.newWorktree.toast.sessionFromPr': 'PR에서 세션을 생성했습니다', 'session.newWorktree.toast.worktreeCreated': '워크트리를 생성했습니다', 'session.newWorktree.toast.worktreeCreatedDescription': '{target} - 백그라운드에서 초기 설정 중', + 'session.newWorktree.toast.fetchSourceFailed': '원격에서 가져오지 못했습니다. 로컬 상태로 생성했습니다', 'session.githubIntegration.title': 'GitHub에서 선택', 'session.githubIntegration.tabs.issues': '이슈', 'session.githubIntegration.tabs.pullRequests': 'PR', diff --git a/packages/ui/src/lib/i18n/messages/pl.settings.ts b/packages/ui/src/lib/i18n/messages/pl.settings.ts index 9d89d843..dc20fd6a 100644 --- a/packages/ui/src/lib/i18n/messages/pl.settings.ts +++ b/packages/ui/src/lib/i18n/messages/pl.settings.ts @@ -160,6 +160,10 @@ 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 77b3864c..e48c6616 100644 --- a/packages/ui/src/lib/i18n/messages/pl.ts +++ b/packages/ui/src/lib/i18n/messages/pl.ts @@ -2916,6 +2916,7 @@ export const dict: Record = { 'session.newWorktree.toast.sessionFromPr': 'Sesja utworzona z PR', 'session.newWorktree.toast.worktreeCreated': 'Drzewo pracy utworzone', 'session.newWorktree.toast.worktreeCreatedDescription': '{target} — inicjalizacja w tle', + 'session.newWorktree.toast.fetchSourceFailed': 'Nie udało się pobrać ze zdalnego — utworzono na podstawie stanu lokalnego', 'session.newWorktree.usingPrBranch': 'Używana gałąź PR: {branch}', 'session.newWorktree.worktreeDirectory': 'Katalog drzewa pracy', 'session.newWorktree.worktreeDirectoryPlaceholder': 'my-worktree-directory', 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 9261f8ca..9d878cce 100644 --- a/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts +++ b/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts @@ -575,6 +575,10 @@ 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 18d7bd12..3c34a646 100644 --- a/packages/ui/src/lib/i18n/messages/pt-BR.ts +++ b/packages/ui/src/lib/i18n/messages/pt-BR.ts @@ -2033,6 +2033,7 @@ export const dict: Record = { "session.newWorktree.toast.sessionFromPr": "Sessão criada a partir da PR", "session.newWorktree.toast.worktreeCreated": "Worktree criado", "session.newWorktree.toast.worktreeCreatedDescription": "{target} - configurando em segundo plano", + "session.newWorktree.toast.fetchSourceFailed": "Não foi possível buscar do remoto — criado a partir do estado local", "session.githubIntegration.title": "Selecionar de GitHub", "session.githubIntegration.tabs.issues": "Issues", "session.githubIntegration.tabs.pullRequests": "PR", diff --git a/packages/ui/src/lib/i18n/messages/tr.settings.ts b/packages/ui/src/lib/i18n/messages/tr.settings.ts index 02651073..02026c04 100644 --- a/packages/ui/src/lib/i18n/messages/tr.settings.ts +++ b/packages/ui/src/lib/i18n/messages/tr.settings.ts @@ -603,6 +603,10 @@ 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 12448fe6..ee421fe1 100644 --- a/packages/ui/src/lib/i18n/messages/tr.ts +++ b/packages/ui/src/lib/i18n/messages/tr.ts @@ -2015,6 +2015,7 @@ export const dict = { 'session.newWorktree.toast.sessionFromPr': 'PR\'dan session oluşturuldu', 'session.newWorktree.toast.worktreeCreated': 'Worktree oluşturuldu', 'session.newWorktree.toast.worktreeCreatedDescription': '{target} - arka planda hazırlanıyor', + 'session.newWorktree.toast.fetchSourceFailed': 'Uzak depodan fetch edilemedi — yerel durumdan oluşturuldu', 'session.githubIntegration.title': 'GitHub\'dan seç', 'session.githubIntegration.tabs.issues': 'Issue\'lar', 'session.githubIntegration.tabs.pullRequests': 'Pull Request\'ler', diff --git a/packages/ui/src/lib/i18n/messages/uk.settings.ts b/packages/ui/src/lib/i18n/messages/uk.settings.ts index 4116ef42..6acf1ade 100644 --- a/packages/ui/src/lib/i18n/messages/uk.settings.ts +++ b/packages/ui/src/lib/i18n/messages/uk.settings.ts @@ -575,6 +575,10 @@ 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 ce612215..3434a635 100644 --- a/packages/ui/src/lib/i18n/messages/uk.ts +++ b/packages/ui/src/lib/i18n/messages/uk.ts @@ -2033,6 +2033,7 @@ export const dict: Record = { "session.newWorktree.toast.sessionFromPr": "Сесію створено з PR", "session.newWorktree.toast.worktreeCreated": "Створено worktree", "session.newWorktree.toast.worktreeCreatedDescription": "{target} - завантаження у фоні", + "session.newWorktree.toast.fetchSourceFailed": "Не вдалося отримати з віддаленого — створено з локального стану", "session.githubIntegration.title": "Виберіть із GitHub", "session.githubIntegration.tabs.issues": "Issue", "session.githubIntegration.tabs.pullRequests": "PR", 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 01024ad1..257f57c8 100644 --- a/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts +++ b/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts @@ -575,6 +575,10 @@ 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 ecb8f316..5de1f7fa 100644 --- a/packages/ui/src/lib/i18n/messages/zh-CN.ts +++ b/packages/ui/src/lib/i18n/messages/zh-CN.ts @@ -2021,6 +2021,7 @@ export const dict: Record = { 'session.newWorktree.toast.sessionFromPr': '已从 PR 创建会话', 'session.newWorktree.toast.worktreeCreated': '工作树已创建', 'session.newWorktree.toast.worktreeCreatedDescription': '{target} - 正在后台初始化', + 'session.newWorktree.toast.fetchSourceFailed': '无法从远程获取 — 已基于本地状态创建', 'session.githubIntegration.title': '从 GitHub 选择', 'session.githubIntegration.tabs.issues': 'Issues', 'session.githubIntegration.tabs.pullRequests': 'Pull Requests', 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 7e5d3f25..341e50f0 100644 --- a/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts +++ b/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts @@ -572,6 +572,10 @@ 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 1dc94f20..221fc24f 100644 --- a/packages/ui/src/lib/i18n/messages/zh-TW.ts +++ b/packages/ui/src/lib/i18n/messages/zh-TW.ts @@ -2025,6 +2025,7 @@ export const dict: Record = { 'session.newWorktree.toast.sessionFromPr': '已從 PR 建立會話', 'session.newWorktree.toast.worktreeCreated': 'Worktree 已建立', 'session.newWorktree.toast.worktreeCreatedDescription': '{target} - 正在背景初始化', + 'session.newWorktree.toast.fetchSourceFailed': '無法從遠端擷取 — 已基於本機狀態建立', 'session.githubIntegration.title': '從 GitHub 選擇', 'session.githubIntegration.tabs.issues': 'Issues', 'session.githubIntegration.tabs.pullRequests': 'Pull Requests', diff --git a/packages/ui/src/lib/persistence.ts b/packages/ui/src/lib/persistence.ts index c5750464..97c37ab6 100644 --- a/packages/ui/src/lib/persistence.ts +++ b/packages/ui/src/lib/persistence.ts @@ -1278,6 +1278,9 @@ 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 14d0caf2..43127f72 100644 --- a/packages/ui/src/lib/settings/search.ts +++ b/packages/ui/src/lib/settings/search.ts @@ -607,6 +607,12 @@ 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 new file mode 100644 index 00000000..20e0a51b --- /dev/null +++ b/packages/ui/src/lib/worktrees/worktreeCreate.test.ts @@ -0,0 +1,305 @@ +import { beforeEach, describe, expect, mock, test } from 'bun:test'; +import type { CreateWorktreeArgs, ProjectRef } from '@/lib/worktrees/worktreeManager'; + +interface MockBranchTracking { + main?: string; +} + +const project: ProjectRef = { id: 'project-1', path: '/repo' }; + +let fetchSourceEnabled = true; +let projectRoot = '/repo'; +let gitStatus: { + current: string; + tracking: string | null; + ahead: number; + 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'))), + getGitBranches: () => Promise.resolve({ + all: [], + current: '', + branches: branchTracking.main + ? { 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', () => ({ + resolveProjectRoot: (directory: string) => Promise.resolve(projectRoot || directory), + invalidateResolvedProjectRootCache: mock(), + getRootBranch: () => Promise.resolve(gitStatus?.current || 'HEAD'), +})); + +mock.module('@/lib/worktrees/worktreeManager', () => ({ + createWorktree: (_project: ProjectRef, args: CreateWorktreeArgs) => { + createdPayloads.push(args); + return Promise.resolve({ + source: 'sdk', + name: 'wt', + path: '/repo/.oc/worktrees/wt', + projectDirectory: '/repo', + branch: 'wt', + label: 'wt', + worktreeRoot: '/repo/.oc/worktrees/wt', + worktreeStatus: 'ready', + headState: 'branch', + worktreeSource: 'created-for-session', + }); + }, +})); + +const { createWorktreeWithDefaults, withWorktreeFetchedStartRef } = await import('./worktreeCreate'); + +const baseArgs = (overrides: CreateWorktreeArgs = {}): CreateWorktreeArgs => ({ + preferredName: 'openchamber/feature', + mode: 'new', + branchName: 'openchamber/feature', + worktreeName: 'openchamber/feature', + ...overrides, +}); + +describe('withWorktreeFetchedStartRef', () => { + 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 () => { + gitStatus = { current: 'main', tracking: 'origin/main', ahead: 0, behind: 15 }; + + const args = await withWorktreeFetchedStartRef(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 () => { + gitStatus = { current: 'main', tracking: 'origin/main', ahead: 0, behind: 3 }; + + const args = await withWorktreeFetchedStartRef(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 () => { + gitStatus = { current: 'main', tracking: 'origin/main', ahead: 2, behind: 15 }; + + const args = baseArgs(); + const resolved = await withWorktreeFetchedStartRef(project, args); + + expect(resolved).toBe(args); + expect(gitFetchCalls).toHaveLength(0); + }); + + test('does not fetch without upstream tracking', async () => { + gitStatus = { current: 'main', tracking: null, ahead: 0, behind: 0 }; + + const args = baseArgs(); + const resolved = await withWorktreeFetchedStartRef(project, args); + + expect(resolved).toBe(args); + expect(gitFetchCalls).toHaveLength(0); + }); + + test('does not fetch when git status is unavailable', async () => { + gitStatus = null; + + const args = baseArgs(); + const resolved = await withWorktreeFetchedStartRef(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); + + 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); + + 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); + + expect(resolved).toBe(args); + expect(gitFetchCalls).toHaveLength(0); + }); + + test('does not fetch in existing mode', async () => { + const args = baseArgs({ mode: 'existing', existingBranch: 'origin/feature' }); + const resolved = await withWorktreeFetchedStartRef(project, args); + + expect(resolved).toBe(args); + expect(gitFetchCalls).toHaveLength(0); + }); + + test('does not fetch on a detached root checkout', async () => { + gitStatus = { current: '', tracking: null, ahead: 0, behind: 0 }; + + const args = baseArgs(); + const resolved = await withWorktreeFetchedStartRef(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()); + + expect(args.startRef).toBe('remotes/origin/main'); + expect(gitFetchCalls).toEqual([{ directory: '/repo', remote: 'origin', branch: 'main' }]); + }); +}); + +describe('createWorktreeWithDefaults fetch 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 () => { + gitStatus = { current: 'main', tracking: 'origin/main', ahead: 0, behind: 15 }; + + await createWorktreeWithDefaults(project, baseArgs()); + + expect(createdPayloads).toHaveLength(1); + expect(createdPayloads[0].startRef).toBe('remotes/origin/main'); + expect(createdPayloads[0].setUpstream).toBe(true); + expect(createdPayloads[0].upstreamRemote).toBe('origin'); + expect(createdPayloads[0].upstreamBranch).toBe('openchamber/feature'); + }); + + test('keeps upstream defaults when the start ref is untouched', async () => { + gitStatus = { current: 'main', tracking: 'origin/main', ahead: 2, behind: 0 }; + + 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('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' })); + + expect(createdPayloads).toHaveLength(1); + expect(createdPayloads[0].startRef).toBe('remotes/origin/main'); + 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 895cb1db..a3a6dcd2 100644 --- a/packages/ui/src/lib/worktrees/worktreeCreate.ts +++ b/packages/ui/src/lib/worktrees/worktreeCreate.ts @@ -1,7 +1,10 @@ -import { getGitBranches, getGitStatus } from '@/lib/gitApi'; +import { toast } from '@/components/ui'; +import { getGitBranches, getGitStatus, gitFetch } from '@/lib/gitApi'; +import { formatMessage, useI18nStore } from '@/lib/i18n'; import type { CreateWorktreeArgs, ProjectRef } from '@/lib/worktrees/worktreeManager'; import { createWorktree } from '@/lib/worktrees/worktreeManager'; -import { getRootBranch } from '@/lib/worktrees/worktreeStatus'; +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\//, ''); @@ -82,7 +85,7 @@ const resolveWorktreeUpstreamDefaults = async ( }; }; -export const withWorktreeUpstreamDefaults = async ( +const withWorktreeUpstreamDefaults = async ( projectDirectory: string, args: CreateWorktreeArgs, options?: { resolvedRootTrackingRemote?: string | null } @@ -110,11 +113,79 @@ export const withWorktreeUpstreamDefaults = async ( }; }; +const REMOTE_START_REF_PATTERN = /^(remotes\/|refs\/remotes\/)/; +const COMMIT_SHA_PATTERN = /^[0-9a-f]{7,40}$/i; + +const normalizeLocalBranchName = (value: string): string => { + return String(value || '') + .trim() + .replace(/^refs\/heads\//, ''); +}; + +export const withWorktreeFetchedStartRef = 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)) { + return args; + } + } + + const projectDirectory = project.path; + const rootDirectory = await resolveProjectRoot(projectDirectory).catch(() => projectDirectory); + const status = await getGitStatus(rootDirectory).catch(() => null); + if (!status) { + return args; + } + + const currentBranch = String(status.current || '').trim(); + const tracking = parseTrackingRef(status.tracking); + if (!currentBranch || !tracking) { + return args; + } + + const baseBranch = rawStartRef && rawStartRef !== 'HEAD' ? normalizeLocalBranchName(rawStartRef) : currentBranch; + if (baseBranch !== currentBranch) { + return args; + } + if (status.ahead > 0) { + 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}` }; +}; + export const createWorktreeWithDefaults = async ( project: ProjectRef, args: CreateWorktreeArgs, options?: { resolvedRootTrackingRemote?: string | null } ) => { - const resolvedArgs = await withWorktreeUpstreamDefaults(project.path, args, options); + const fetchedArgs = await withWorktreeFetchedStartRef(project, args); + const resolvedArgs = await withWorktreeUpstreamDefaults(project.path, fetchedArgs, options); return createWorktree(project, resolvedArgs); }; diff --git a/packages/ui/src/stores/useConfigStore.ts b/packages/ui/src/stores/useConfigStore.ts index 66f1487e..e061a64c 100644 --- a/packages/ui/src/stores/useConfigStore.ts +++ b/packages/ui/src/stores/useConfigStore.ts @@ -56,6 +56,7 @@ interface OpenChamberDefaults { defaultVariant?: string; defaultAgent?: string; autoCreateWorktree?: boolean; + worktreeFetchSource?: boolean; gitmojiEnabled?: boolean; defaultFileViewerPreview?: boolean; zenModel?: string; @@ -128,6 +129,7 @@ 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, @@ -174,6 +176,7 @@ 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, @@ -1053,6 +1056,7 @@ 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; @@ -1140,6 +1144,7 @@ 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; @@ -1221,6 +1226,7 @@ export const useConfigStore = create()( opencodeDefaultAgent: undefined, opencodeDefaultModel: undefined, settingsAutoCreateWorktree: false, + settingsWorktreeFetchSource: true, settingsGitmojiEnabled: false, settingsDefaultFileViewerPreview: false, settingsZenModel: undefined, @@ -2165,6 +2171,7 @@ 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, @@ -2914,6 +2921,10 @@ export const useConfigStore = create()( set({ settingsAutoCreateWorktree: enabled }); }, + setSettingsWorktreeFetchSource: (enabled: boolean) => { + set({ settingsWorktreeFetchSource: enabled }); + }, + setSettingsGitmojiEnabled: (enabled: boolean) => { set({ settingsGitmojiEnabled: enabled }); }, @@ -3456,6 +3467,7 @@ 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/gitService.ts b/packages/vscode/src/gitService.ts index c4141bcc..b1fa8db6 100644 --- a/packages/vscode/src/gitService.ts +++ b/packages/vscode/src/gitService.ts @@ -2001,7 +2001,18 @@ async function attachGitWorktreeToCandidate( if (mode === 'new') { const parsedRemoteStartRef = await resolveRemoteBranchRef(context.primaryWorktree, startRef); if (parsedRemoteStartRef) { - await fetchRemoteBranchRef(context.primaryWorktree, parsedRemoteStartRef.remote, parsedRemoteStartRef.branch); + 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`); + } } } diff --git a/packages/vscode/src/gitService.worktree-fetch-fallback.test.js b/packages/vscode/src/gitService.worktree-fetch-fallback.test.js new file mode 100644 index 00000000..a801de9b --- /dev/null +++ b/packages/vscode/src/gitService.worktree-fetch-fallback.test.js @@ -0,0 +1,115 @@ +import { afterEach, describe, expect, it, mock } from 'bun:test'; +import { execFileSync } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +mock.module('vscode', () => ({ + extensions: { getExtension: () => undefined }, + Uri: { file: (fsPath) => ({ fsPath }) }, +})); + +const { createWorktree } = await import('./gitService.ts?worktree-fetch-fallback-test'); + +const tempDirs = []; + +const createTempDir = () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'openchamber-vscode-git-')); + tempDirs.push(dir); + return dir; +}; + +const runGit = (cwd, args) => + execFileSync('git', args, { + cwd, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + }); + +const createRepositoryWithRemote = () => { + const remote = createTempDir(); + const repository = createTempDir(); + runGit(remote, ['init', '--bare', '--initial-branch=main']); + runGit(repository, ['init', '-b', 'next']); + runGit(repository, ['config', 'user.email', 'test@example.com']); + runGit(repository, ['config', 'user.name', 'Test']); + fs.writeFileSync(path.join(repository, 'README.md'), '# Test\n'); + runGit(repository, ['add', 'README.md']); + runGit(repository, ['commit', '-m', 'init']); + runGit(repository, ['remote', 'add', 'origin', remote]); + runGit(repository, ['push', 'origin', 'HEAD:main']); + runGit(repository, ['fetch', 'origin']); + return { repository }; +}; + +const canRunGit = () => { + try { + execFileSync('git', ['--version'], { stdio: 'ignore' }); + return true; + } catch { + return false; + } +}; + +afterEach(() => { + for (const dir of tempDirs.splice(0)) { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +describe('VS Code worktree create from a remote start ref', () => { + it('creates from the existing remote-tracking ref when the refresh fetch fails', async () => { + if (!canRunGit()) return; + + const previousXdgDataHome = process.env.XDG_DATA_HOME; + process.env.XDG_DATA_HOME = createTempDir(); + + try { + const { repository } = createRepositoryWithRemote(); + runGit(repository, ['remote', 'set-url', 'origin', '/nonexistent/openchamber-unreachable.git']); + + const created = await createWorktree(repository, { + mode: 'new', + branchName: 'openchamber/stale-ref-wt', + worktreeName: 'stale-ref-wt', + startRef: 'remotes/origin/main', + }); + + expect(created.branch).toBe('openchamber/stale-ref-wt'); + const expectedHead = runGit(repository, ['rev-parse', 'refs/remotes/origin/main']).trim(); + expect(runGit(created.path, ['rev-parse', 'HEAD']).trim()).toBe(expectedHead); + } finally { + if (previousXdgDataHome === undefined) { + delete process.env.XDG_DATA_HOME; + } else { + process.env.XDG_DATA_HOME = previousXdgDataHome; + } + } + }, 30_000); + + it('rejects creation when the remote start ref was never fetched and cannot be fetched', async () => { + if (!canRunGit()) return; + + const previousXdgDataHome = process.env.XDG_DATA_HOME; + process.env.XDG_DATA_HOME = createTempDir(); + + try { + const { repository } = createRepositoryWithRemote(); + runGit(repository, ['update-ref', '-d', 'refs/remotes/origin/main']); + runGit(repository, ['remote', 'set-url', 'origin', '/nonexistent/openchamber-unreachable.git']); + + await expect(createWorktree(repository, { + mode: 'new', + branchName: 'openchamber/never-fetched-wt', + worktreeName: 'never-fetched-wt', + startRef: 'remotes/origin/main', + })).rejects.toThrow(/does not appear to be a git repository|Could not read from remote repository/i); + } finally { + if (previousXdgDataHome === undefined) { + delete process.env.XDG_DATA_HOME; + } else { + process.env.XDG_DATA_HOME = previousXdgDataHome; + } + } + }, 30_000); +}); diff --git a/packages/web/server/lib/git/service.js b/packages/web/server/lib/git/service.js index 76daf5af..8abfe29a 100644 --- a/packages/web/server/lib/git/service.js +++ b/packages/web/server/lib/git/service.js @@ -4295,6 +4295,7 @@ async function attachGitWorktreeToCandidate(context, candidate, input = {}) { const parsedRemoteStartRef = await resolveRemoteBranchRef(context.primaryWorktree, startRef); if (parsedRemoteStartRef) { + worktreeAddArgs.splice(2, 0, '--no-track'); inferredUpstream = { remote: parsedRemoteStartRef.remote, branch: parsedRemoteStartRef.branch, @@ -4309,17 +4310,28 @@ async function attachGitWorktreeToCandidate(context, candidate, input = {}) { if (mode === 'new') { const parsedRemoteStartRef = await resolveRemoteBranchRef(context.primaryWorktree, startRef); if (parsedRemoteStartRef) { - await fetchRemoteBranchRef(context.primaryWorktree, parsedRemoteStartRef.remote, parsedRemoteStartRef.branch); + 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 - ? String(inferredUpstream?.remote || input?.upstreamRemote || '').trim() + ? String(input?.upstreamRemote || inferredUpstream?.remote || '').trim() : ''; const upstreamBranch = shouldSetUpstream - ? String(inferredUpstream?.branch || input?.upstreamBranch || '').trim() + ? String(input?.upstreamBranch || inferredUpstream?.branch || '').trim() : ''; const bootstrapStatus = setWorktreeBootstrapState( diff --git a/packages/web/server/lib/git/service.test.js b/packages/web/server/lib/git/service.test.js index 61112684..00356448 100644 --- a/packages/web/server/lib/git/service.test.js +++ b/packages/web/server/lib/git/service.test.js @@ -55,6 +55,14 @@ const runGit = (cwd, args) => stdio: ['ignore', 'pipe', 'pipe'], }); +const readBranchConfig = (cwd, branch, key) => { + try { + return runGit(cwd, ['config', '--get', `branch.${branch}.${key}`]).trim(); + } catch { + return ''; + } +}; + /** * A repository on `next` whose only remote publishes `defaultBranch` and has it * recorded as that remote's HEAD — the shape of every repository whose default @@ -855,6 +863,133 @@ describe('createWorktree', () => { } } }); + + it('does not auto-track the remote start ref when creating a new branch from it', async () => { + if (!canRunGit()) return; + + const previousXdgDataHome = process.env.XDG_DATA_HOME; + const dataHome = createTempDir(); + process.env.XDG_DATA_HOME = dataHome; + + try { + const { repository } = createRepositoryWithRemote({ defaultBranch: 'main' }); + + const created = await createWorktree(repository, { + mode: 'new', + branchName: 'openchamber/feature', + worktreeName: 'feature-wt', + startRef: 'remotes/origin/main', + setUpstream: true, + upstreamRemote: 'origin', + upstreamBranch: 'openchamber/feature', + }); + + expect(created.branch).toBe('openchamber/feature'); + + await expect.poll( + () => getWorktreeBootstrapStatus(created.path).then((status) => status.status === 'ready' || status.status === 'failed'), + { timeout: 5_000 } + ).toBe(true); + + expect(readBranchConfig(created.path, 'openchamber/feature', 'remote')).toBe(''); + expect(readBranchConfig(created.path, 'openchamber/feature', 'merge')).toBe(''); + } finally { + if (previousXdgDataHome === undefined) { + delete process.env.XDG_DATA_HOME; + } else { + process.env.XDG_DATA_HOME = previousXdgDataHome; + } + } + }, 30_000); + + it('falls back to the remote start ref for upstream tracking when no explicit keys are given', async () => { + if (!canRunGit()) return; + + const previousXdgDataHome = process.env.XDG_DATA_HOME; + const dataHome = createTempDir(); + process.env.XDG_DATA_HOME = dataHome; + + try { + const { repository } = createRepositoryWithRemote({ defaultBranch: 'main' }); + + const created = await createWorktree(repository, { + mode: 'new', + branchName: 'openchamber/fallback-wt', + worktreeName: 'fallback-wt', + startRef: 'remotes/origin/main', + setUpstream: true, + }); + + await expect.poll( + () => readBranchConfig(created.path, 'openchamber/fallback-wt', 'merge'), + { timeout: 5_000 } + ).toBe('refs/heads/main'); + expect(readBranchConfig(created.path, 'openchamber/fallback-wt', 'remote')).toBe('origin'); + } finally { + if (previousXdgDataHome === undefined) { + delete process.env.XDG_DATA_HOME; + } else { + process.env.XDG_DATA_HOME = previousXdgDataHome; + } + } + }, 30_000); + + it('creates from a remote start ref when the refresh fetch fails but the ref exists locally', async () => { + if (!canRunGit()) return; + + const previousXdgDataHome = process.env.XDG_DATA_HOME; + const dataHome = createTempDir(); + process.env.XDG_DATA_HOME = dataHome; + + try { + const { repository } = createRepositoryWithRemote({ defaultBranch: 'main' }); + runGit(repository, ['remote', 'set-url', 'origin', '/nonexistent/openchamber-unreachable.git']); + + const created = await createWorktree(repository, { + mode: 'new', + branchName: 'openchamber/stale-ref-wt', + worktreeName: 'stale-ref-wt', + startRef: 'remotes/origin/main', + }); + + expect(created.branch).toBe('openchamber/stale-ref-wt'); + const expectedHead = runGit(repository, ['rev-parse', 'refs/remotes/origin/main']).trim(); + expect(runGit(created.path, ['rev-parse', 'HEAD']).trim()).toBe(expectedHead); + } finally { + if (previousXdgDataHome === undefined) { + delete process.env.XDG_DATA_HOME; + } else { + process.env.XDG_DATA_HOME = previousXdgDataHome; + } + } + }, 30_000); + + it('rejects creation from a remote start ref that was never fetched and cannot be fetched', async () => { + if (!canRunGit()) return; + + const previousXdgDataHome = process.env.XDG_DATA_HOME; + const dataHome = createTempDir(); + process.env.XDG_DATA_HOME = dataHome; + + try { + const { repository } = createRepositoryWithRemote({ defaultBranch: 'main' }); + runGit(repository, ['update-ref', '-d', 'refs/remotes/origin/main']); + runGit(repository, ['remote', 'set-url', 'origin', '/nonexistent/openchamber-unreachable.git']); + + await expect(createWorktree(repository, { + mode: 'new', + branchName: 'openchamber/never-fetched-wt', + worktreeName: 'never-fetched-wt', + startRef: 'remotes/origin/main', + })).rejects.toThrow(/does not appear to be a git repository|Could not read from remote repository/i); + } finally { + if (previousXdgDataHome === undefined) { + delete process.env.XDG_DATA_HOME; + } else { + process.env.XDG_DATA_HOME = previousXdgDataHome; + } + } + }, 30_000); }); // --------------------------------------------------------------------------- diff --git a/packages/web/server/lib/opencode/settings-helpers.js b/packages/web/server/lib/opencode/settings-helpers.js index 68b7aa85..5b135dff 100644 --- a/packages/web/server/lib/opencode/settings-helpers.js +++ b/packages/web/server/lib/opencode/settings-helpers.js @@ -480,6 +480,9 @@ 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; }