fix(worktree): gate sessions on bootstrap readiness (#1762)
Co-authored-by: Leonid Skorobogatyy <bash@opencode.itc.local> Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
This commit is contained in:
committed by
GitHub
co-authored by
Leonid Skorobogatyy
Bohdan Triapitsyn
parent
03e6f789a4
commit
8c1a24089d
@@ -1,5 +1,6 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||||
import { Icon } from "@/components/icon/Icon";
|
import { Icon } from "@/components/icon/Icon";
|
||||||
@@ -9,7 +10,12 @@ import { useSessions } from '@/sync/sync-context';
|
|||||||
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
||||||
import { useDeviceInfo } from '@/lib/device';
|
import { useDeviceInfo } from '@/lib/device';
|
||||||
import { checkIsGitRepository } from '@/lib/gitApi';
|
import { checkIsGitRepository } from '@/lib/gitApi';
|
||||||
import { getWorktreeSetupCommands, saveWorktreeSetupCommands } from '@/lib/openchamberConfig';
|
import {
|
||||||
|
getWorktreeSetupCommands,
|
||||||
|
getWorktreeSetupWaitEnabled,
|
||||||
|
saveWorktreeSetupCommands,
|
||||||
|
saveWorktreeSetupWaitEnabled,
|
||||||
|
} from '@/lib/openchamberConfig';
|
||||||
import { listProjectWorktrees } from '@/lib/worktrees/worktreeManager';
|
import { listProjectWorktrees } from '@/lib/worktrees/worktreeManager';
|
||||||
import { sessionEvents } from '@/lib/sessionEvents';
|
import { sessionEvents } from '@/lib/sessionEvents';
|
||||||
import type { WorktreeMetadata } from '@/types/worktree';
|
import type { WorktreeMetadata } from '@/types/worktree';
|
||||||
@@ -33,6 +39,7 @@ export const WorktreeSectionContent: React.FC<WorktreeSectionContentProps> = ({
|
|||||||
const homeDirectory = useDirectoryStore((state) => state.homeDirectory);
|
const homeDirectory = useDirectoryStore((state) => state.homeDirectory);
|
||||||
|
|
||||||
const [setupCommands, setSetupCommands] = React.useState<string[]>([]);
|
const [setupCommands, setSetupCommands] = React.useState<string[]>([]);
|
||||||
|
const [waitForSetupCommands, setWaitForSetupCommands] = React.useState(false);
|
||||||
const [isLoadingCommands, setIsLoadingCommands] = React.useState(false);
|
const [isLoadingCommands, setIsLoadingCommands] = React.useState(false);
|
||||||
const [isGitRepoLocal, setIsGitRepoLocal] = React.useState<boolean | null>(null);
|
const [isGitRepoLocal, setIsGitRepoLocal] = React.useState<boolean | null>(null);
|
||||||
const [availableWorktrees, setAvailableWorktrees] = React.useState<WorktreeMetadata[]>([]);
|
const [availableWorktrees, setAvailableWorktrees] = React.useState<WorktreeMetadata[]>([]);
|
||||||
@@ -127,13 +134,18 @@ export const WorktreeSectionContent: React.FC<WorktreeSectionContentProps> = ({
|
|||||||
|
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
const commands = await getWorktreeSetupCommands(projectRef);
|
const [commands, waitForSetup] = await Promise.all([
|
||||||
|
getWorktreeSetupCommands(projectRef),
|
||||||
|
getWorktreeSetupWaitEnabled(projectRef),
|
||||||
|
]);
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
setSetupCommands(commands.length > 0 ? commands : ['']);
|
setSetupCommands(commands.length > 0 ? commands : ['']);
|
||||||
|
setWaitForSetupCommands(waitForSetup);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
setSetupCommands(['']);
|
setSetupCommands(['']);
|
||||||
|
setWaitForSetupCommands(false);
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
@@ -179,6 +191,13 @@ export const WorktreeSectionContent: React.FC<WorktreeSectionContentProps> = ({
|
|||||||
void persistSetupCommands(setupCommands);
|
void persistSetupCommands(setupCommands);
|
||||||
}, [persistSetupCommands, setupCommands]);
|
}, [persistSetupCommands, setupCommands]);
|
||||||
|
|
||||||
|
const handleWaitForSetupCommandsChange = React.useCallback((enabled: boolean) => {
|
||||||
|
setWaitForSetupCommands(enabled);
|
||||||
|
if (projectRef) {
|
||||||
|
void saveWorktreeSetupWaitEnabled(projectRef, enabled);
|
||||||
|
}
|
||||||
|
}, [projectRef]);
|
||||||
|
|
||||||
// Delete worktree handler
|
// Delete worktree handler
|
||||||
const handleDeleteWorktree = React.useCallback((worktree: WorktreeMetadata) => {
|
const handleDeleteWorktree = React.useCallback((worktree: WorktreeMetadata) => {
|
||||||
const normalize = (value: string): string => value.replace(/\\/g, '/').replace(/\/+$/, '');
|
const normalize = (value: string): string => value.replace(/\\/g, '/').replace(/\/+$/, '');
|
||||||
@@ -322,6 +341,22 @@ export const WorktreeSectionContent: React.FC<WorktreeSectionContentProps> = ({
|
|||||||
<Icon name="add" className="h-3.5 w-3.5" />
|
<Icon name="add" className="h-3.5 w-3.5" />
|
||||||
{t('settings.openchamber.worktrees.setup.addCommand')}
|
{t('settings.openchamber.worktrees.setup.addCommand')}
|
||||||
</Button>
|
</Button>
|
||||||
|
<label
|
||||||
|
data-settings-item="projects.worktree.setup.wait"
|
||||||
|
className="flex cursor-pointer items-center gap-2 py-1.5"
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
checked={waitForSetupCommands}
|
||||||
|
onChange={handleWaitForSetupCommandsChange}
|
||||||
|
ariaLabel={t('settings.openchamber.worktrees.setup.waitForCommandsAria')}
|
||||||
|
/>
|
||||||
|
<span className={cn(
|
||||||
|
'typography-ui-label font-normal',
|
||||||
|
waitForSetupCommands ? 'text-foreground' : 'text-foreground/60'
|
||||||
|
)}>
|
||||||
|
{t('settings.openchamber.worktrees.setup.waitForCommands')}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -33,7 +33,8 @@ import * as sessionActions from '@/sync/session-actions';
|
|||||||
import { useConfigStore } from '@/stores/useConfigStore';
|
import { useConfigStore } from '@/stores/useConfigStore';
|
||||||
import { validateWorktreeCreate, createWorktree } from '@/lib/worktrees/worktreeManager';
|
import { validateWorktreeCreate, createWorktree } from '@/lib/worktrees/worktreeManager';
|
||||||
import { withWorktreeUpstreamDefaults } from '@/lib/worktrees/worktreeCreate';
|
import { withWorktreeUpstreamDefaults } from '@/lib/worktrees/worktreeCreate';
|
||||||
import { getWorktreeSetupCommands } from '@/lib/openchamberConfig';
|
import { waitForWorktreeBootstrap } from '@/lib/worktrees/worktreeBootstrap';
|
||||||
|
import { getWorktreeSetupCommands, getWorktreeSetupWaitEnabled } from '@/lib/openchamberConfig';
|
||||||
import { getRootBranch } from '@/lib/worktrees/worktreeStatus';
|
import { getRootBranch } from '@/lib/worktrees/worktreeStatus';
|
||||||
import { generateBranchSlug } from '@/lib/git/branchNameGenerator';
|
import { generateBranchSlug } from '@/lib/git/branchNameGenerator';
|
||||||
import { renderMagicPrompt } from '@/lib/magicPrompts';
|
import { renderMagicPrompt } from '@/lib/magicPrompts';
|
||||||
@@ -856,6 +857,10 @@ export function NewWorktreeDialog({
|
|||||||
let createdSessionId: string | null = null;
|
let createdSessionId: string | null = null;
|
||||||
|
|
||||||
if (shouldCreateSession) {
|
if (shouldCreateSession) {
|
||||||
|
if (await getWorktreeSetupWaitEnabled(projectRef)) {
|
||||||
|
await waitForWorktreeBootstrap(metadata.path);
|
||||||
|
}
|
||||||
|
|
||||||
const sessionTitle = linkedIssue
|
const sessionTitle = linkedIssue
|
||||||
? `#${linkedIssue.number} ${linkedIssue.title}`.trim()
|
? `#${linkedIssue.number} ${linkedIssue.title}`.trim()
|
||||||
: linkedPrState
|
: linkedPrState
|
||||||
|
|||||||
@@ -1124,6 +1124,8 @@ export const settingsDict = {
|
|||||||
'settings.openchamber.worktrees.setup.commandPlaceholder': 'e.g., bun install',
|
'settings.openchamber.worktrees.setup.commandPlaceholder': 'e.g., bun install',
|
||||||
'settings.openchamber.worktrees.setup.removeCommandAria': 'Remove command',
|
'settings.openchamber.worktrees.setup.removeCommandAria': 'Remove command',
|
||||||
'settings.openchamber.worktrees.setup.addCommand': 'Add command',
|
'settings.openchamber.worktrees.setup.addCommand': 'Add command',
|
||||||
|
'settings.openchamber.worktrees.setup.waitForCommands': 'Wait for setup commands before creating or sending a session',
|
||||||
|
'settings.openchamber.worktrees.setup.waitForCommandsAria': 'Wait for Worktree setup commands before creating or sending a session',
|
||||||
'settings.openchamber.worktrees.list.title': 'Existing worktrees',
|
'settings.openchamber.worktrees.list.title': 'Existing worktrees',
|
||||||
'settings.openchamber.worktrees.list.tooltip': 'Worktrees live outside the repo (OpenCode-managed). Deleting a worktree also removes linked sessions.',
|
'settings.openchamber.worktrees.list.tooltip': 'Worktrees live outside the repo (OpenCode-managed). Deleting a worktree also removes linked sessions.',
|
||||||
'settings.openchamber.worktrees.list.loading': 'Loading worktrees...',
|
'settings.openchamber.worktrees.list.loading': 'Loading worktrees...',
|
||||||
|
|||||||
@@ -1091,6 +1091,8 @@ export const settingsDict = {
|
|||||||
"settings.openchamber.worktrees.setup.commandPlaceholder": "ej.: bun install",
|
"settings.openchamber.worktrees.setup.commandPlaceholder": "ej.: bun install",
|
||||||
"settings.openchamber.worktrees.setup.removeCommandAria": "Eliminar comando",
|
"settings.openchamber.worktrees.setup.removeCommandAria": "Eliminar comando",
|
||||||
"settings.openchamber.worktrees.setup.addCommand": "Añadir comando",
|
"settings.openchamber.worktrees.setup.addCommand": "Añadir comando",
|
||||||
|
"settings.openchamber.worktrees.setup.waitForCommands": "Esperar a que terminen los comandos de configuración antes de crear o enviar una sesión",
|
||||||
|
"settings.openchamber.worktrees.setup.waitForCommandsAria": "Esperar los comandos de configuración de Worktree antes de crear o enviar una sesión",
|
||||||
"settings.openchamber.worktrees.list.title": "Worktrees existentes",
|
"settings.openchamber.worktrees.list.title": "Worktrees existentes",
|
||||||
"settings.openchamber.worktrees.list.tooltip": "Los worktrees existen fuera del repositorio (gestionados por OpenCode). Eliminar un worktree también elimina las sesiones vinculadas.",
|
"settings.openchamber.worktrees.list.tooltip": "Los worktrees existen fuera del repositorio (gestionados por OpenCode). Eliminar un worktree también elimina las sesiones vinculadas.",
|
||||||
"settings.openchamber.worktrees.list.loading": "Cargando worktrees...",
|
"settings.openchamber.worktrees.list.loading": "Cargando worktrees...",
|
||||||
|
|||||||
@@ -1080,6 +1080,8 @@ export const settingsDict = {
|
|||||||
'settings.openchamber.worktrees.setup.commandPlaceholder': 'p. ex. bun install',
|
'settings.openchamber.worktrees.setup.commandPlaceholder': 'p. ex. bun install',
|
||||||
'settings.openchamber.worktrees.setup.removeCommandAria': 'Supprimer la commande',
|
'settings.openchamber.worktrees.setup.removeCommandAria': 'Supprimer la commande',
|
||||||
'settings.openchamber.worktrees.setup.addCommand': 'Ajouter une commande',
|
'settings.openchamber.worktrees.setup.addCommand': 'Ajouter une commande',
|
||||||
|
'settings.openchamber.worktrees.setup.waitForCommands': 'Attendre la fin des commandes de configuration avant de créer ou d\'envoyer une session',
|
||||||
|
'settings.openchamber.worktrees.setup.waitForCommandsAria': 'Attendre les commandes de configuration Worktree avant de créer ou d\'envoyer une session',
|
||||||
'settings.openchamber.worktrees.list.title': 'Worktrees existants',
|
'settings.openchamber.worktrees.list.title': 'Worktrees existants',
|
||||||
'settings.openchamber.worktrees.list.tooltip': 'Les worktrees vivent en dehors du dépôt (gérés par OpenCode). La suppression d\'un worktree supprime également les sessions liées.',
|
'settings.openchamber.worktrees.list.tooltip': 'Les worktrees vivent en dehors du dépôt (gérés par OpenCode). La suppression d\'un worktree supprime également les sessions liées.',
|
||||||
'settings.openchamber.worktrees.list.loading': 'Chargement des worktrees...',
|
'settings.openchamber.worktrees.list.loading': 'Chargement des worktrees...',
|
||||||
|
|||||||
@@ -1091,6 +1091,8 @@ export const settingsDict = {
|
|||||||
'settings.openchamber.worktrees.setup.commandPlaceholder': '예: bun install',
|
'settings.openchamber.worktrees.setup.commandPlaceholder': '예: bun install',
|
||||||
'settings.openchamber.worktrees.setup.removeCommandAria': '명령어 제거',
|
'settings.openchamber.worktrees.setup.removeCommandAria': '명령어 제거',
|
||||||
'settings.openchamber.worktrees.setup.addCommand': '명령어 추가',
|
'settings.openchamber.worktrees.setup.addCommand': '명령어 추가',
|
||||||
|
'settings.openchamber.worktrees.setup.waitForCommands': '세션을 만들거나 보내기 전에 설정 명령이 끝날 때까지 기다리기',
|
||||||
|
'settings.openchamber.worktrees.setup.waitForCommandsAria': '세션을 만들거나 보내기 전에 Worktree 설정 명령이 끝날 때까지 기다리기',
|
||||||
'settings.openchamber.worktrees.list.title': '기존 worktree',
|
'settings.openchamber.worktrees.list.title': '기존 worktree',
|
||||||
'settings.openchamber.worktrees.list.tooltip': 'Worktree는 repo 외부에 있습니다(OpenCode 관리). worktree를 삭제하면 연결된 세션도 제거됩니다.',
|
'settings.openchamber.worktrees.list.tooltip': 'Worktree는 repo 외부에 있습니다(OpenCode 관리). worktree를 삭제하면 연결된 세션도 제거됩니다.',
|
||||||
'settings.openchamber.worktrees.list.loading': 'worktree 로딩 중...',
|
'settings.openchamber.worktrees.list.loading': 'worktree 로딩 중...',
|
||||||
|
|||||||
@@ -1070,6 +1070,8 @@ export const settingsDict = {
|
|||||||
'settings.openchamber.worktrees.setup.loading': 'Ładowanie...',
|
'settings.openchamber.worktrees.setup.loading': 'Ładowanie...',
|
||||||
'settings.openchamber.worktrees.setup.removeCommandAria': 'Usuń polecenie',
|
'settings.openchamber.worktrees.setup.removeCommandAria': 'Usuń polecenie',
|
||||||
'settings.openchamber.worktrees.setup.title': 'Polecenia konfiguracji',
|
'settings.openchamber.worktrees.setup.title': 'Polecenia konfiguracji',
|
||||||
|
'settings.openchamber.worktrees.setup.waitForCommands': 'Czekaj na zakończenie poleceń konfiguracji przed utworzeniem lub wysłaniem sesji',
|
||||||
|
'settings.openchamber.worktrees.setup.waitForCommandsAria': 'Czekaj na polecenia konfiguracji Worktree przed utworzeniem lub wysłaniem sesji',
|
||||||
'settings.openchamber.worktrees.setup.tooltipPrefix': 'Uruchamiaj automatycznie w nowym katalogu worktree po jego utworzeniu. Użyj',
|
'settings.openchamber.worktrees.setup.tooltipPrefix': 'Uruchamiaj automatycznie w nowym katalogu worktree po jego utworzeniu. Użyj',
|
||||||
'settings.openchamber.worktrees.setup.tooltipSuffix': 'dla katalogu głównego projektu.',
|
'settings.openchamber.worktrees.setup.tooltipSuffix': 'dla katalogu głównego projektu.',
|
||||||
'settings.openchamber.worktrees.state.gitOnly': 'Ustawienia worktree są dostępne tylko dla repozytoriów Git.',
|
'settings.openchamber.worktrees.state.gitOnly': 'Ustawienia worktree są dostępne tylko dla repozytoriów Git.',
|
||||||
|
|||||||
@@ -1091,6 +1091,8 @@ export const settingsDict = {
|
|||||||
"settings.openchamber.worktrees.setup.commandPlaceholder": "ej.: bun install",
|
"settings.openchamber.worktrees.setup.commandPlaceholder": "ej.: bun install",
|
||||||
"settings.openchamber.worktrees.setup.removeCommandAria": "Excluir comando",
|
"settings.openchamber.worktrees.setup.removeCommandAria": "Excluir comando",
|
||||||
"settings.openchamber.worktrees.setup.addCommand": "Adicionar comando",
|
"settings.openchamber.worktrees.setup.addCommand": "Adicionar comando",
|
||||||
|
"settings.openchamber.worktrees.setup.waitForCommands": "Aguardar os comandos de configuração antes de criar ou enviar uma sessão",
|
||||||
|
"settings.openchamber.worktrees.setup.waitForCommandsAria": "Aguardar os comandos de configuração do Worktree antes de criar ou enviar uma sessão",
|
||||||
"settings.openchamber.worktrees.list.title": "Worktrees existentes",
|
"settings.openchamber.worktrees.list.title": "Worktrees existentes",
|
||||||
"settings.openchamber.worktrees.list.tooltip": "Os worktrees existem fuera do repositório (gerenciados por OpenCode). Excluir um worktree também remove sessões vinculadas.",
|
"settings.openchamber.worktrees.list.tooltip": "Os worktrees existem fuera do repositório (gerenciados por OpenCode). Excluir um worktree também remove sessões vinculadas.",
|
||||||
"settings.openchamber.worktrees.list.loading": "Carregando worktrees...",
|
"settings.openchamber.worktrees.list.loading": "Carregando worktrees...",
|
||||||
|
|||||||
@@ -1091,6 +1091,8 @@ export const settingsDict = {
|
|||||||
"settings.openchamber.worktrees.setup.commandPlaceholder": "наприклад, bun install",
|
"settings.openchamber.worktrees.setup.commandPlaceholder": "наприклад, bun install",
|
||||||
"settings.openchamber.worktrees.setup.removeCommandAria": "Видалити команду",
|
"settings.openchamber.worktrees.setup.removeCommandAria": "Видалити команду",
|
||||||
"settings.openchamber.worktrees.setup.addCommand": "Додати команду",
|
"settings.openchamber.worktrees.setup.addCommand": "Додати команду",
|
||||||
|
"settings.openchamber.worktrees.setup.waitForCommands": "Чекати завершення команд налаштування перед створенням або надсиланням сесії",
|
||||||
|
"settings.openchamber.worktrees.setup.waitForCommandsAria": "Чекати завершення команд налаштування Worktree перед створенням або надсиланням сесії",
|
||||||
"settings.openchamber.worktrees.list.title": "Наявні worktree",
|
"settings.openchamber.worktrees.list.title": "Наявні worktree",
|
||||||
"settings.openchamber.worktrees.list.tooltip": "Worktree розташовані поза репозиторієм і керуються OpenCode. Видалення worktree також видаляє пов’язані сесії.",
|
"settings.openchamber.worktrees.list.tooltip": "Worktree розташовані поза репозиторієм і керуються OpenCode. Видалення worktree також видаляє пов’язані сесії.",
|
||||||
"settings.openchamber.worktrees.list.loading": "Завантаження worktree...",
|
"settings.openchamber.worktrees.list.loading": "Завантаження worktree...",
|
||||||
|
|||||||
@@ -1091,6 +1091,8 @@ export const settingsDict = {
|
|||||||
'settings.openchamber.worktrees.setup.commandPlaceholder': '例如:bun install',
|
'settings.openchamber.worktrees.setup.commandPlaceholder': '例如:bun install',
|
||||||
'settings.openchamber.worktrees.setup.removeCommandAria': '移除命令',
|
'settings.openchamber.worktrees.setup.removeCommandAria': '移除命令',
|
||||||
'settings.openchamber.worktrees.setup.addCommand': '添加命令',
|
'settings.openchamber.worktrees.setup.addCommand': '添加命令',
|
||||||
|
'settings.openchamber.worktrees.setup.waitForCommands': '创建或发送会话前等待初始化命令完成',
|
||||||
|
'settings.openchamber.worktrees.setup.waitForCommandsAria': '创建或发送会话前等待 Worktree 初始化命令完成',
|
||||||
'settings.openchamber.worktrees.list.title': '现有工作树',
|
'settings.openchamber.worktrees.list.title': '现有工作树',
|
||||||
'settings.openchamber.worktrees.list.tooltip': '工作树位于仓库外部(由 OpenCode 管理)。删除工作树也会删除关联会话。',
|
'settings.openchamber.worktrees.list.tooltip': '工作树位于仓库外部(由 OpenCode 管理)。删除工作树也会删除关联会话。',
|
||||||
'settings.openchamber.worktrees.list.loading': '正在加载工作树...',
|
'settings.openchamber.worktrees.list.loading': '正在加载工作树...',
|
||||||
|
|||||||
@@ -1012,6 +1012,8 @@
|
|||||||
'settings.openchamber.worktrees.setup.commandPlaceholder': '例如:bun install',
|
'settings.openchamber.worktrees.setup.commandPlaceholder': '例如:bun install',
|
||||||
'settings.openchamber.worktrees.setup.removeCommandAria': '移除命令',
|
'settings.openchamber.worktrees.setup.removeCommandAria': '移除命令',
|
||||||
'settings.openchamber.worktrees.setup.addCommand': '新增命令',
|
'settings.openchamber.worktrees.setup.addCommand': '新增命令',
|
||||||
|
'settings.openchamber.worktrees.setup.waitForCommands': '建立或傳送工作階段前等待設定命令完成',
|
||||||
|
'settings.openchamber.worktrees.setup.waitForCommandsAria': '建立或傳送工作階段前等待 Worktree 設定命令完成',
|
||||||
'settings.openchamber.worktrees.list.title': '現有 worktree',
|
'settings.openchamber.worktrees.list.title': '現有 worktree',
|
||||||
'settings.openchamber.worktrees.list.tooltip': 'Worktree 位於儲存庫外部(由 OpenCode 管理)。刪除 worktree 也會刪除關聯工作階段。',
|
'settings.openchamber.worktrees.list.tooltip': 'Worktree 位於儲存庫外部(由 OpenCode 管理)。刪除 worktree 也會刪除關聯工作階段。',
|
||||||
'settings.openchamber.worktrees.list.loading': '正在載入 worktree...',
|
'settings.openchamber.worktrees.list.loading': '正在載入 worktree...',
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ function getRuntimeFilesAPI(): FilesAPI | null {
|
|||||||
export interface OpenChamberConfig {
|
export interface OpenChamberConfig {
|
||||||
projectPath?: string;
|
projectPath?: string;
|
||||||
'setup-worktree'?: string[];
|
'setup-worktree'?: string[];
|
||||||
|
'setup-worktree-wait'?: boolean;
|
||||||
projectNotes?: string;
|
projectNotes?: string;
|
||||||
projectTodos?: OpenChamberProjectTodoItem[];
|
projectTodos?: OpenChamberProjectTodoItem[];
|
||||||
projectPlanFiles?: OpenChamberProjectPlanFileLink[];
|
projectPlanFiles?: OpenChamberProjectPlanFileLink[];
|
||||||
@@ -699,6 +700,15 @@ export async function saveWorktreeSetupCommands(project: ProjectRef, commands: s
|
|||||||
return updateOpenChamberConfig(project, { 'setup-worktree': filtered });
|
return updateOpenChamberConfig(project, { 'setup-worktree': filtered });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getWorktreeSetupWaitEnabled(project: ProjectRef): Promise<boolean> {
|
||||||
|
const config = await readOpenChamberConfig(project);
|
||||||
|
return config?.['setup-worktree-wait'] === true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveWorktreeSetupWaitEnabled(project: ProjectRef, enabled: boolean): Promise<boolean> {
|
||||||
|
return updateOpenChamberConfig(project, { 'setup-worktree-wait': enabled });
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get this project's pinned draft welcome starters.
|
* Get this project's pinned draft welcome starters.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -373,6 +373,12 @@ export const SETTINGS_SEARCH_ITEMS: readonly SettingsSearchItem[] = [
|
|||||||
titleKey: 'settings.projects.page.section.worktree',
|
titleKey: 'settings.projects.page.section.worktree',
|
||||||
keywords: ['worktree', 'branch', 'repository'],
|
keywords: ['worktree', 'branch', 'repository'],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'projects.worktree.setup.wait',
|
||||||
|
page: 'projects',
|
||||||
|
titleKey: 'settings.openchamber.worktrees.setup.waitForCommands',
|
||||||
|
keywords: ['worktree', 'setup commands', 'bootstrap', 'wait'],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'remote-instances.client-auth',
|
id: 'remote-instances.client-auth',
|
||||||
page: 'remote-instances',
|
page: 'remote-instances',
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import { checkIsGitRepository, previewGitWorktree } from '@/lib/gitApi';
|
|||||||
import { generateBranchName } from '@/lib/git/branchNameGenerator';
|
import { generateBranchName } from '@/lib/git/branchNameGenerator';
|
||||||
import { parseModelIdentifier } from '@/lib/modelIdentifier';
|
import { parseModelIdentifier } from '@/lib/modelIdentifier';
|
||||||
import { getRootBranch } from '@/lib/worktrees/worktreeStatus';
|
import { getRootBranch } from '@/lib/worktrees/worktreeStatus';
|
||||||
import { getWorktreeSetupCommands } from '@/lib/openchamberConfig';
|
import { getWorktreeSetupCommands, getWorktreeSetupWaitEnabled } from '@/lib/openchamberConfig';
|
||||||
import {
|
import {
|
||||||
removeProjectWorktree,
|
removeProjectWorktree,
|
||||||
type ProjectRef,
|
type ProjectRef,
|
||||||
@@ -25,9 +25,16 @@ import {
|
|||||||
rejectPendingDraftWorktreeRequest,
|
rejectPendingDraftWorktreeRequest,
|
||||||
resolvePendingDraftWorktreeRequest,
|
resolvePendingDraftWorktreeRequest,
|
||||||
} from '@/lib/worktrees/pendingDraftWorktree';
|
} from '@/lib/worktrees/pendingDraftWorktree';
|
||||||
|
import { waitForWorktreeBootstrap } from '@/lib/worktrees/worktreeBootstrap';
|
||||||
|
|
||||||
const normalizePath = (value: string): string => value.replace(/\\/g, '/').replace(/\/+$/, '') || value;
|
const normalizePath = (value: string): string => value.replace(/\\/g, '/').replace(/\/+$/, '') || value;
|
||||||
|
|
||||||
|
const waitForWorktreeBootstrapIfEnabled = async (project: ProjectRef, directory: string): Promise<void> => {
|
||||||
|
if (await getWorktreeSetupWaitEnabled(project)) {
|
||||||
|
await waitForWorktreeBootstrap(directory);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const resolveProjectRef = (directory: string): ProjectRef | null => {
|
const resolveProjectRef = (directory: string): ProjectRef | null => {
|
||||||
const normalized = normalizePath(directory);
|
const normalized = normalizePath(directory);
|
||||||
const projects = useProjectsStore.getState().projects;
|
const projects = useProjectsStore.getState().projects;
|
||||||
@@ -417,6 +424,8 @@ export async function createWorktreeSessionForBranch(
|
|||||||
kind,
|
kind,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
await waitForWorktreeBootstrapIfEnabled(projectRef, metadata.path);
|
||||||
|
|
||||||
// Create the session
|
// Create the session
|
||||||
const sessionStore = useSessionUIStore.getState();
|
const sessionStore = useSessionUIStore.getState();
|
||||||
const session = await sessionStore.createSession(undefined, metadata.path);
|
const session = await sessionStore.createSession(undefined, metadata.path);
|
||||||
@@ -520,6 +529,8 @@ export async function createWorktreeSessionForNewBranch(
|
|||||||
kind,
|
kind,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
await waitForWorktreeBootstrapIfEnabled(projectRef, metadata.path);
|
||||||
|
|
||||||
const sessionStore = useSessionUIStore.getState();
|
const sessionStore = useSessionUIStore.getState();
|
||||||
const session = await sessionStore.createSession(undefined, metadata.path);
|
const session = await sessionStore.createSession(undefined, metadata.path);
|
||||||
if (!session) {
|
if (!session) {
|
||||||
|
|||||||
@@ -6,7 +6,10 @@ const registeredDirectories: Array<{ sessionID: string; directory: string }> = [
|
|||||||
const ensureChildCalls: Array<{ directory: string; bootstrap?: boolean }> = [];
|
const ensureChildCalls: Array<{ directory: string; bootstrap?: boolean }> = [];
|
||||||
const worktreeMetadataCalls: Array<{ sessionId: string; path: string }> = [];
|
const worktreeMetadataCalls: Array<{ sessionId: string; path: string }> = [];
|
||||||
const worktreeCreateCalls: Array<{ project: { id?: string; path: string }; args: Record<string, unknown>; options: unknown }> = [];
|
const worktreeCreateCalls: Array<{ project: { id?: string; path: string }; args: Record<string, unknown>; options: unknown }> = [];
|
||||||
|
const worktreeBootstrapWaitCalls: string[] = [];
|
||||||
|
const operationOrder: string[] = [];
|
||||||
let isGitRepository = false;
|
let isGitRepository = false;
|
||||||
|
let waitForWorktreeSetup = false;
|
||||||
const createWorktreeWithDefaultsMock = mock((project: { id?: string; path: string }, args: Record<string, unknown>, options: unknown) => {
|
const createWorktreeWithDefaultsMock = mock((project: { id?: string; path: string }, args: Record<string, unknown>, options: unknown) => {
|
||||||
worktreeCreateCalls.push({ project, args, options });
|
worktreeCreateCalls.push({ project, args, options });
|
||||||
return Promise.resolve({
|
return Promise.resolve({
|
||||||
@@ -52,12 +55,15 @@ mock.module('@/lib/opencode/client', () => ({
|
|||||||
currentDirectory = previous;
|
currentDirectory = previous;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
createSession: async (params?: { title?: string }): Promise<Session> => ({
|
createSession: async (params?: { title?: string }): Promise<Session> => {
|
||||||
id: 'ses_multirun',
|
operationOrder.push(`createSession:${currentDirectory}`);
|
||||||
title: params?.title ?? '',
|
return {
|
||||||
directory: currentDirectory,
|
id: 'ses_multirun',
|
||||||
time: { created: 1, updated: 1 },
|
title: params?.title ?? '',
|
||||||
} as Session),
|
directory: currentDirectory,
|
||||||
|
time: { created: 1, updated: 1 },
|
||||||
|
} as Session;
|
||||||
|
},
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -70,11 +76,20 @@ mock.module('@/lib/worktrees/worktreeCreate', () => ({
|
|||||||
resolveRootTrackingRemote: mock(() => Promise.resolve(null)),
|
resolveRootTrackingRemote: mock(() => Promise.resolve(null)),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
mock.module('@/lib/worktrees/worktreeBootstrap', () => ({
|
||||||
|
waitForWorktreeBootstrap: (directory: string) => {
|
||||||
|
worktreeBootstrapWaitCalls.push(directory);
|
||||||
|
operationOrder.push(`wait:${directory}`);
|
||||||
|
return Promise.resolve();
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
mock.module('@/lib/worktrees/worktreeStatus', () => ({
|
mock.module('@/lib/worktrees/worktreeStatus', () => ({
|
||||||
getRootBranch: mock(() => Promise.resolve('main')),
|
getRootBranch: mock(() => Promise.resolve('main')),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
mock.module('@/lib/openchamberConfig', () => ({
|
mock.module('@/lib/openchamberConfig', () => ({
|
||||||
|
getWorktreeSetupWaitEnabled: mock(() => Promise.resolve(waitForWorktreeSetup)),
|
||||||
saveWorktreeSetupCommands: mock(() => Promise.resolve()),
|
saveWorktreeSetupCommands: mock(() => Promise.resolve()),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -139,7 +154,10 @@ describe('useMultiRunStore', () => {
|
|||||||
ensureChildCalls.length = 0;
|
ensureChildCalls.length = 0;
|
||||||
worktreeMetadataCalls.length = 0;
|
worktreeMetadataCalls.length = 0;
|
||||||
worktreeCreateCalls.length = 0;
|
worktreeCreateCalls.length = 0;
|
||||||
|
worktreeBootstrapWaitCalls.length = 0;
|
||||||
|
operationOrder.length = 0;
|
||||||
isGitRepository = false;
|
isGitRepository = false;
|
||||||
|
waitForWorktreeSetup = false;
|
||||||
childState.session = [];
|
childState.session = [];
|
||||||
childState.sessionTotal = 0;
|
childState.sessionTotal = 0;
|
||||||
childState.limit = 5;
|
childState.limit = 5;
|
||||||
@@ -181,7 +199,30 @@ describe('useMultiRunStore', () => {
|
|||||||
expect(worktreeCreateCalls[0]?.project).toEqual({ id: 'project-1', path: '/repo' });
|
expect(worktreeCreateCalls[0]?.project).toEqual({ id: 'project-1', path: '/repo' });
|
||||||
expect(worktreeCreateCalls[0]?.args.returnAfterDirectoryCreated).toBe(true);
|
expect(worktreeCreateCalls[0]?.args.returnAfterDirectoryCreated).toBe(true);
|
||||||
expect(worktreeCreateCalls[0]?.options).toEqual({ resolvedRootTrackingRemote: null });
|
expect(worktreeCreateCalls[0]?.options).toEqual({ resolvedRootTrackingRemote: null });
|
||||||
|
expect(worktreeBootstrapWaitCalls).toEqual([]);
|
||||||
|
expect(operationOrder).toEqual(['createSession:/repo-worktrees/fix-thing']);
|
||||||
expect(registeredDirectories).toEqual([{ sessionID: 'ses_multirun', directory: '/repo-worktrees/fix-thing' }]);
|
expect(registeredDirectories).toEqual([{ sessionID: 'ses_multirun', directory: '/repo-worktrees/fix-thing' }]);
|
||||||
expect(worktreeMetadataCalls).toEqual([{ sessionId: 'ses_multirun', path: '/repo-worktrees/fix-thing' }]);
|
expect(worktreeMetadataCalls).toEqual([{ sessionId: 'ses_multirun', path: '/repo-worktrees/fix-thing' }]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('waits for isolated worktree bootstrap when setup wait is enabled', async () => {
|
||||||
|
isGitRepository = true;
|
||||||
|
waitForWorktreeSetup = true;
|
||||||
|
|
||||||
|
const result = await useMultiRunStore.getState().createMultiRun({
|
||||||
|
name: 'Fix thing',
|
||||||
|
isolateRuns: true,
|
||||||
|
groups: [{
|
||||||
|
prompt: 'Fix it',
|
||||||
|
models: [{ providerID: 'anthropic', modelID: 'claude-sonnet-4-5' }],
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result?.sessionIds).toEqual(['ses_multirun']);
|
||||||
|
expect(worktreeBootstrapWaitCalls).toEqual(['/repo-worktrees/fix-thing']);
|
||||||
|
expect(operationOrder).toEqual([
|
||||||
|
'wait:/repo-worktrees/fix-thing',
|
||||||
|
'createSession:/repo-worktrees/fix-thing',
|
||||||
|
]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,9 +4,10 @@ import { routeMessage, useSessionUIStore } from '@/sync/session-ui-store';
|
|||||||
import { devtools } from 'zustand/middleware';
|
import { devtools } from 'zustand/middleware';
|
||||||
import type { CreateMultiRunParams, CreateMultiRunResult } from '@/types/multirun';
|
import type { CreateMultiRunParams, CreateMultiRunResult } from '@/types/multirun';
|
||||||
import { opencodeClient } from '@/lib/opencode/client';
|
import { opencodeClient } from '@/lib/opencode/client';
|
||||||
import { saveWorktreeSetupCommands } from '@/lib/openchamberConfig';
|
import { getWorktreeSetupWaitEnabled, saveWorktreeSetupCommands } from '@/lib/openchamberConfig';
|
||||||
import type { ProjectRef } from '@/lib/worktrees/worktreeManager';
|
import type { ProjectRef } from '@/lib/worktrees/worktreeManager';
|
||||||
import { createWorktreeWithDefaults, resolveRootTrackingRemote } from '@/lib/worktrees/worktreeCreate';
|
import { createWorktreeWithDefaults, resolveRootTrackingRemote } from '@/lib/worktrees/worktreeCreate';
|
||||||
|
import { waitForWorktreeBootstrap } from '@/lib/worktrees/worktreeBootstrap';
|
||||||
import { getRootBranch } from '@/lib/worktrees/worktreeStatus';
|
import { getRootBranch } from '@/lib/worktrees/worktreeStatus';
|
||||||
import { checkIsGitRepository } from '@/lib/gitApi';
|
import { checkIsGitRepository } from '@/lib/gitApi';
|
||||||
import { useDirectoryStore } from './useDirectoryStore';
|
import { useDirectoryStore } from './useDirectoryStore';
|
||||||
@@ -244,6 +245,10 @@ export const useMultiRunStore = create<MultiRunStore>()(
|
|||||||
kind: 'standard' as const,
|
kind: 'standard' as const,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (await getWorktreeSetupWaitEnabled(project)) {
|
||||||
|
await waitForWorktreeBootstrap(worktreeMetadata.path);
|
||||||
|
}
|
||||||
|
|
||||||
const session = await opencodeClient.withDirectory(
|
const session = await opencodeClient.withDirectory(
|
||||||
worktreeMetadata.path,
|
worktreeMetadata.path,
|
||||||
() => opencodeClient.createSession({ title: sessionTitle }),
|
() => opencodeClient.createSession({ title: sessionTitle }),
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import { flattenAssistantTextParts } from "@/lib/messages/messageText"
|
|||||||
import { composeForkSessionMessage } from "@/lib/messages/executionMeta"
|
import { composeForkSessionMessage } from "@/lib/messages/executionMeta"
|
||||||
import { waitForPendingDraftWorktreeRequest } from "@/lib/worktrees/pendingDraftWorktree"
|
import { waitForPendingDraftWorktreeRequest } from "@/lib/worktrees/pendingDraftWorktree"
|
||||||
import { waitForWorktreeBootstrap } from "@/lib/worktrees/worktreeBootstrap"
|
import { waitForWorktreeBootstrap } from "@/lib/worktrees/worktreeBootstrap"
|
||||||
|
import { getWorktreeSetupWaitEnabled } from "@/lib/openchamberConfig"
|
||||||
import { resolveProjectForSessionDirectory } from "@/lib/projectResolution"
|
import { resolveProjectForSessionDirectory } from "@/lib/projectResolution"
|
||||||
import {
|
import {
|
||||||
getSyncSessions,
|
getSyncSessions,
|
||||||
@@ -416,6 +417,24 @@ type MaterializedDraftSession = {
|
|||||||
syntheticParts?: SyntheticContextPart[]
|
syntheticParts?: SyntheticContextPart[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const resolveProjectRefForWorktreeDirectory = (directory: string | null, projectId?: string | null): { id: string; path: string } | null => {
|
||||||
|
const projectsState = useProjectsStore.getState()
|
||||||
|
if (projectId) {
|
||||||
|
const project = projectsState.projects.find((entry) => entry.id === projectId)
|
||||||
|
if (project?.path) return { id: project.id, path: project.path }
|
||||||
|
}
|
||||||
|
const resolved = resolveProjectForSessionDirectory(projectsState.projects, useSessionUIStore.getState().availableWorktreesByProject, directory)
|
||||||
|
return resolved?.path ? { id: resolved.id, path: resolved.path } : null
|
||||||
|
}
|
||||||
|
|
||||||
|
const waitForWorktreeBootstrapIfConfigured = async (directory: string | null, projectId?: string | null): Promise<void> => {
|
||||||
|
if (!directory) return
|
||||||
|
const project = resolveProjectRefForWorktreeDirectory(directory, projectId)
|
||||||
|
if (project && await getWorktreeSetupWaitEnabled(project)) {
|
||||||
|
await waitForWorktreeBootstrap(directory)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function materializeOpenDraftSession(selection: {
|
export async function materializeOpenDraftSession(selection: {
|
||||||
providerID: string
|
providerID: string
|
||||||
modelID: string
|
modelID: string
|
||||||
@@ -437,9 +456,7 @@ export async function materializeOpenDraftSession(selection: {
|
|||||||
store.resolvePendingDraftWorktreeTarget(draft.pendingWorktreeRequestId, draftDirectoryOverride)
|
store.resolvePendingDraftWorktreeTarget(draft.pendingWorktreeRequestId, draftDirectoryOverride)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (draftDirectoryOverride) {
|
await waitForWorktreeBootstrapIfConfigured(draftDirectoryOverride, draftProjectId)
|
||||||
await waitForWorktreeBootstrap(draftDirectoryOverride)
|
|
||||||
}
|
|
||||||
|
|
||||||
const created = await store.createSession(draft.title, draftDirectoryOverride, draft.parentID ?? null)
|
const created = await store.createSession(draft.title, draftDirectoryOverride, draft.parentID ?? null)
|
||||||
if (!created?.id) throw new Error("Failed to create session")
|
if (!created?.id) throw new Error("Failed to create session")
|
||||||
@@ -1371,6 +1388,12 @@ export const useSessionUIStore = create<SessionUIState>()((set, get) => ({
|
|||||||
returnAfterDirectoryCreated: true,
|
returnAfterDirectoryCreated: true,
|
||||||
})
|
})
|
||||||
sessionDirectory = normalizePath(createdWorktree.path)
|
sessionDirectory = normalizePath(createdWorktree.path)
|
||||||
|
if (!sessionDirectory) {
|
||||||
|
throw new Error("Worktree create missing name/path")
|
||||||
|
}
|
||||||
|
if (await configModule.getWorktreeSetupWaitEnabled(createdWorktreeProject)) {
|
||||||
|
await waitForWorktreeBootstrap(sessionDirectory)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const session = await get().createSession(undefined, sessionDirectory || null, null)
|
const session = await get().createSession(undefined, sessionDirectory || null, null)
|
||||||
|
|||||||
@@ -1754,6 +1754,19 @@ export async function validateWorktreeCreate(directory: string, input: CreateGit
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const assertWorktreeCreatePreflight = async (directory: string, input: CreateGitWorktreePayload = {}): Promise<void> => {
|
||||||
|
const validation = await validateWorktreeCreate(directory, input);
|
||||||
|
if (validation?.ok) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const message = validation?.errors
|
||||||
|
?.map((error) => error?.message)
|
||||||
|
.filter(Boolean)
|
||||||
|
.join('\n') || 'Failed to validate worktree creation';
|
||||||
|
throw new Error(message);
|
||||||
|
};
|
||||||
|
|
||||||
export async function previewWorktreeCreate(directory: string, input: CreateGitWorktreePayload = {}): Promise<GitWorktreeInfo> {
|
export async function previewWorktreeCreate(directory: string, input: CreateGitWorktreePayload = {}): Promise<GitWorktreeInfo> {
|
||||||
const mode = input?.mode === 'existing' ? 'existing' : 'new';
|
const mode = input?.mode === 'existing' ? 'existing' : 'new';
|
||||||
const context = await resolveWorktreeProjectContext(directory);
|
const context = await resolveWorktreeProjectContext(directory);
|
||||||
@@ -1907,6 +1920,11 @@ async function attachGitWorktreeToCandidate(
|
|||||||
export async function createWorktree(directory: string, input: CreateGitWorktreePayload = {}): Promise<GitWorktreeInfo> {
|
export async function createWorktree(directory: string, input: CreateGitWorktreePayload = {}): Promise<GitWorktreeInfo> {
|
||||||
const mode = input?.mode === 'existing' ? 'existing' : 'new';
|
const mode = input?.mode === 'existing' ? 'existing' : 'new';
|
||||||
const context = await resolveWorktreeProjectContext(directory);
|
const context = await resolveWorktreeProjectContext(directory);
|
||||||
|
|
||||||
|
if (input?.returnAfterDirectoryCreated === true) {
|
||||||
|
await assertWorktreeCreatePreflight(directory, input);
|
||||||
|
}
|
||||||
|
|
||||||
await fs.promises.mkdir(context.worktreeRoot, { recursive: true });
|
await fs.promises.mkdir(context.worktreeRoot, { recursive: true });
|
||||||
|
|
||||||
const preferredName = String(input?.worktreeName || input?.name || '').trim();
|
const preferredName = String(input?.worktreeName || input?.name || '').trim();
|
||||||
|
|||||||
@@ -3550,6 +3550,19 @@ export async function validateWorktreeCreate(directory, input = {}) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const assertWorktreeCreatePreflight = async (directory, input = {}) => {
|
||||||
|
const validation = await validateWorktreeCreate(directory, input);
|
||||||
|
if (validation?.ok) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const message = validation?.errors
|
||||||
|
?.map((error) => error?.message)
|
||||||
|
.filter(Boolean)
|
||||||
|
.join('\n') || 'Failed to validate worktree creation';
|
||||||
|
throw new Error(message);
|
||||||
|
};
|
||||||
|
|
||||||
export async function previewWorktreeCreate(directory, input = {}) {
|
export async function previewWorktreeCreate(directory, input = {}) {
|
||||||
const mode = input?.mode === 'existing' ? 'existing' : 'new';
|
const mode = input?.mode === 'existing' ? 'existing' : 'new';
|
||||||
const context = await resolveWorktreeProjectContext(directory);
|
const context = await resolveWorktreeProjectContext(directory);
|
||||||
@@ -3698,6 +3711,11 @@ async function attachGitWorktreeToCandidate(context, candidate, input = {}) {
|
|||||||
export async function createWorktree(directory, input = {}) {
|
export async function createWorktree(directory, input = {}) {
|
||||||
const mode = input?.mode === 'existing' ? 'existing' : 'new';
|
const mode = input?.mode === 'existing' ? 'existing' : 'new';
|
||||||
const context = await resolveWorktreeProjectContext(directory);
|
const context = await resolveWorktreeProjectContext(directory);
|
||||||
|
|
||||||
|
if (input?.returnAfterDirectoryCreated === true) {
|
||||||
|
await assertWorktreeCreatePreflight(directory, input);
|
||||||
|
}
|
||||||
|
|
||||||
await fsp.mkdir(context.worktreeRoot, { recursive: true });
|
await fsp.mkdir(context.worktreeRoot, { recursive: true });
|
||||||
|
|
||||||
const preferredName = String(input?.worktreeName || input?.name || '').trim();
|
const preferredName = String(input?.worktreeName || input?.name || '').trim();
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import simpleGit from 'simple-git';
|
|||||||
import {
|
import {
|
||||||
checkoutCommit,
|
checkoutCommit,
|
||||||
cherryPick,
|
cherryPick,
|
||||||
|
createWorktree,
|
||||||
getStatus,
|
getStatus,
|
||||||
removeWorktree,
|
removeWorktree,
|
||||||
resolvePrimaryWorktreeRoot,
|
resolvePrimaryWorktreeRoot,
|
||||||
@@ -315,6 +316,53 @@ describe('worktree root resolution', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// createWorktree
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('createWorktree', () => {
|
||||||
|
it('preflights fast create branch-in-use failures before creating the candidate directory', async () => {
|
||||||
|
if (!canRunGit()) return;
|
||||||
|
|
||||||
|
const previousXdgDataHome = process.env.XDG_DATA_HOME;
|
||||||
|
const dataHome = createTempDir();
|
||||||
|
process.env.XDG_DATA_HOME = dataHome;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const repo = createTempDir();
|
||||||
|
const worktree = createTempDir();
|
||||||
|
runGit(repo, ['init', '-b', 'main']);
|
||||||
|
runGit(repo, ['config', 'user.email', 'test@example.com']);
|
||||||
|
runGit(repo, ['config', 'user.name', 'Test User']);
|
||||||
|
fs.writeFileSync(path.join(repo, 'README.md'), '# Test\n');
|
||||||
|
runGit(repo, ['add', 'README.md']);
|
||||||
|
runGit(repo, ['commit', '-m', 'Initial commit']);
|
||||||
|
const projectID = runGit(repo, ['rev-list', '--max-parents=0', '--all']).trim();
|
||||||
|
|
||||||
|
fs.rmSync(worktree, { recursive: true, force: true });
|
||||||
|
runGit(repo, ['worktree', 'add', '-b', 'feature/in-use', worktree, 'HEAD']);
|
||||||
|
const canonicalWorktree = fs.realpathSync(worktree);
|
||||||
|
|
||||||
|
await expect(createWorktree(repo, {
|
||||||
|
mode: 'existing',
|
||||||
|
existingBranch: 'feature/in-use',
|
||||||
|
branchName: 'feature/in-use',
|
||||||
|
worktreeName: 'feature-in-use',
|
||||||
|
returnAfterDirectoryCreated: true,
|
||||||
|
})).rejects.toThrow(`Branch is already checked out in ${canonicalWorktree}`);
|
||||||
|
|
||||||
|
const candidateDirectory = path.join(dataHome, 'opencode', 'worktree', projectID, 'feature-in-use');
|
||||||
|
expect(fs.existsSync(candidateDirectory)).toBe(false);
|
||||||
|
} finally {
|
||||||
|
if (previousXdgDataHome === undefined) {
|
||||||
|
delete process.env.XDG_DATA_HOME;
|
||||||
|
} else {
|
||||||
|
process.env.XDG_DATA_HOME = previousXdgDataHome;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// removeWorktree
|
// removeWorktree
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
Reference in New Issue
Block a user