From 698677182421910596ccdfee3b02b4df366b662d Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 2 Aug 2025 02:09:42 +0200 Subject: [PATCH] Clean up lots of follow mode features --- .../client/components/session-create-form.ts | 23 +-- .../session-create-form/git-utils.ts | 4 +- .../quick-start-section.ts | 2 +- .../worktree-creation.test.ts | 1 - web/src/client/components/session-list.ts | 114 +++++++------- .../session-list/repository-header.ts | 6 +- web/src/client/components/worktree-manager.ts | 24 ++- web/src/client/services/git-service.test.ts | 37 ----- web/src/client/services/git-service.ts | 57 +------ web/src/server/api-socket-server.ts | 26 +--- web/src/server/routes/git.ts | 77 ---------- web/src/server/routes/repositories.ts | 69 --------- web/src/server/routes/worktrees.test.ts | 6 +- web/src/server/routes/worktrees.ts | 139 ++++++++++-------- web/src/server/utils/git-utils.ts | 18 --- web/src/test/e2e/follow-mode.test.ts | 10 +- .../integration/worktree-workflows.test.ts | 70 ++++----- 17 files changed, 194 insertions(+), 489 deletions(-) diff --git a/web/src/client/components/session-create-form.ts b/web/src/client/components/session-create-form.ts index 70f641e8..649ecb79 100644 --- a/web/src/client/components/session-create-form.ts +++ b/web/src/client/components/session-create-form.ts @@ -484,24 +484,13 @@ export class SessionCreateForm extends LitElement { // Not using worktree but selected a different branch - attempt to switch logger.log(`Attempting to switch from ${this.currentBranch} to ${this.selectedBaseBranch}`); - try { - if (this.gitService && this.gitRepoInfo.repoPath) { - await this.gitService.switchBranch(this.gitRepoInfo.repoPath, this.selectedBaseBranch); - effectiveBranch = this.selectedBaseBranch; - logger.log(`Successfully switched to branch: ${this.selectedBaseBranch}`); - } - } catch (error) { - // Branch switch failed - show warning but continue with current branch - logger.warn(`Failed to switch branch: ${error}`); - effectiveBranch = this.currentBranch; + // Direct branch switching without worktrees is no longer supported + logger.log( + `Selected branch ${this.selectedBaseBranch} differs from current branch ${this.currentBranch}, but direct branch switching is not supported. Using current branch.` + ); + effectiveBranch = this.currentBranch; - const errorMessage = error instanceof Error ? error.message : 'Unknown error'; - const isUncommittedChanges = errorMessage.toLowerCase().includes('uncommitted changes'); - - this.branchSwitchWarning = isUncommittedChanges - ? `Cannot switch to ${this.selectedBaseBranch} due to uncommitted changes. Creating session on ${this.currentBranch}.` - : `Failed to switch to ${this.selectedBaseBranch}: ${errorMessage}. Creating session on ${this.currentBranch}.`; - } + this.branchSwitchWarning = `Cannot switch to ${this.selectedBaseBranch} without a worktree. Create a worktree or use the current branch ${this.currentBranch}.`; } else { // Using current branch effectiveBranch = this.selectedBaseBranch || this.currentBranch; diff --git a/web/src/client/components/session-create-form/git-utils.ts b/web/src/client/components/session-create-form/git-utils.ts index 09d146ff..5b7270cb 100644 --- a/web/src/client/components/session-create-form/git-utils.ts +++ b/web/src/client/components/session-create-form/git-utils.ts @@ -46,14 +46,14 @@ export async function checkFollowMode( authClient: AuthClient ): Promise<{ followMode: boolean; followBranch: string | null }> { try { - const response = await fetch(`/api/git/follow?${new URLSearchParams({ path: repoPath })}`, { + const response = await fetch(`/api/worktrees?${new URLSearchParams({ repoPath })}`, { headers: authClient.getAuthHeader(), }); if (response.ok) { const data = await response.json(); return { - followMode: data.followMode || false, + followMode: !!data.followBranch, followBranch: data.followBranch || null, }; } else { diff --git a/web/src/client/components/session-create-form/quick-start-section.ts b/web/src/client/components/session-create-form/quick-start-section.ts index b0bec884..34e8b21a 100644 --- a/web/src/client/components/session-create-form/quick-start-section.ts +++ b/web/src/client/components/session-create-form/quick-start-section.ts @@ -54,7 +54,7 @@ export class QuickStartSection extends LitElement { render() { return html` -
+
${ this.editMode ? html` diff --git a/web/src/client/components/session-create-form/worktree-creation.test.ts b/web/src/client/components/session-create-form/worktree-creation.test.ts index 9b5e4be1..2e0b6102 100644 --- a/web/src/client/components/session-create-form/worktree-creation.test.ts +++ b/web/src/client/components/session-create-form/worktree-creation.test.ts @@ -16,7 +16,6 @@ describe('Worktree Creation UI', () => { listWorktrees: vi.fn(), createWorktree: vi.fn(), deleteWorktree: vi.fn(), - switchBranch: vi.fn(), setFollowMode: vi.fn(), } as unknown as GitService; diff --git a/web/src/client/components/session-list.ts b/web/src/client/components/session-list.ts index 2ff0d71c..f7081137 100644 --- a/web/src/client/components/session-list.ts +++ b/web/src/client/components/session-list.ts @@ -22,6 +22,7 @@ import { repeat } from 'lit/directives/repeat.js'; import type { Session } from '../../shared/types.js'; import { HttpMethod } from '../../shared/types.js'; import type { AuthClient } from '../services/auth-client.js'; +import type { Worktree } from '../services/git-service.js'; import './session-card.js'; import './inline-edit.js'; import './session-list/compact-session-card.js'; @@ -29,6 +30,7 @@ import './session-list/repository-header.js'; import { getBaseRepoName } from '../../shared/utils/git.js'; import { Z_INDEX } from '../utils/constants.js'; import { createLogger } from '../utils/logger.js'; +import { formatPathForDisplay } from '../utils/path-utils.js'; const logger = createLogger('session-list'); @@ -50,10 +52,7 @@ export class SessionList extends LitElement { @state() private repoFollowMode = new Map(); @state() private loadingFollowMode = new Set(); @state() private showFollowDropdown = new Map(); - @state() private repoWorktrees = new Map< - string, - Array<{ path: string; branch: string; HEAD: string; detached: boolean }> - >(); + @state() private repoWorktrees = new Map(); @state() private loadingWorktrees = new Set(); @state() private showWorktreeDropdown = new Map(); @@ -79,8 +78,8 @@ export class SessionList extends LitElement { private async loadFollowModeForAllRepos() { const repoGroups = this.groupSessionsByRepo(this.sessions); for (const [repoPath] of repoGroups) { - if (repoPath && !this.repoFollowMode.has(repoPath)) { - this.loadFollowModeForRepo(repoPath); + if (repoPath && !this.repoWorktrees.has(repoPath)) { + // loadWorktreesForRepo now also loads follow mode this.loadWorktreesForRepo(repoPath); } } @@ -99,6 +98,8 @@ export class SessionList extends LitElement { const isInsideSelector = target.closest('[id^="branch-selector-"]') || target.closest('.branch-dropdown') || + target.closest('[id^="follow-selector-"]') || + target.closest('.follow-dropdown') || target.closest('[id^="worktree-selector-"]') || target.closest('.worktree-dropdown'); @@ -389,36 +390,6 @@ export class SessionList extends LitElement { return getBaseRepoName(repoPath); } - private async loadFollowModeForRepo(repoPath: string) { - if (this.loadingFollowMode.has(repoPath)) { - return; - } - - this.loadingFollowMode.add(repoPath); - this.requestUpdate(); - - try { - const response = await fetch( - `/api/repositories/follow-mode?${new URLSearchParams({ path: repoPath })}`, - { - headers: this.authClient.getAuthHeader(), - } - ); - - if (response.ok) { - const { followBranch } = await response.json(); - this.repoFollowMode.set(repoPath, followBranch); - } else { - logger.error(`Failed to load follow mode for ${repoPath}`); - } - } catch (error) { - logger.error('Error loading follow mode:', error); - } finally { - this.loadingFollowMode.delete(repoPath); - this.requestUpdate(); - } - } - private async handleFollowModeChange(repoPath: string, followBranch: string | undefined) { this.repoFollowMode.set(repoPath, followBranch); // Close all dropdowns for this repo (they might have different section keys) @@ -432,13 +403,17 @@ export class SessionList extends LitElement { this.requestUpdate(); try { - const response = await fetch('/api/repositories/follow-mode', { + const response = await fetch('/api/worktrees/follow', { method: HttpMethod.POST, headers: { 'Content-Type': 'application/json', ...this.authClient.getAuthHeader(), }, - body: JSON.stringify({ repoPath, followBranch }), + body: JSON.stringify({ + repoPath, + branch: followBranch, + enable: !!followBranch, + }), }); if (!response.ok) { @@ -448,7 +423,7 @@ export class SessionList extends LitElement { const event = new CustomEvent('show-toast', { detail: { message: followBranch - ? `Following worktree branch: ${followBranch}` + ? `Following worktree branch: ${followBranch.replace(/^refs\/heads\//, '')}` : 'Follow mode disabled', type: 'success', }, @@ -470,19 +445,27 @@ export class SessionList extends LitElement { private toggleFollowDropdown(dropdownKey: string) { const isOpen = this.showFollowDropdown.get(dropdownKey) || false; - // Create new maps to avoid intermediate states during update - const newFollowDropdown = new Map(); - const newWorktreeDropdown = new Map(); + // Create new maps preserving existing state + const newFollowDropdown = new Map(this.showFollowDropdown); + const newWorktreeDropdown = new Map(this.showWorktreeDropdown); - // Only set the clicked dropdown if it wasn't already open - if (!isOpen) { + if (isOpen) { + // Close this dropdown + newFollowDropdown.delete(dropdownKey); + } else { + // Close all other dropdowns and open this one + newFollowDropdown.clear(); newFollowDropdown.set(dropdownKey, true); + // Extract repo path from dropdown key for loading const repoPath = dropdownKey.split(':')[0]; - // Load follow mode if not already loaded - this.loadFollowModeForRepo(repoPath); + // Load worktrees and follow mode if not already loaded + this.loadWorktreesForRepo(repoPath); } + // Close all worktree dropdowns to avoid conflicts + newWorktreeDropdown.clear(); + // Update state atomically this.showFollowDropdown = newFollowDropdown; this.showWorktreeDropdown = newWorktreeDropdown; @@ -497,12 +480,35 @@ export class SessionList extends LitElement { const dropdownKey = `${repoPath}:${sectionType}`; const isDropdownOpen = this.showFollowDropdown.get(dropdownKey) || false; - // Only show if there are worktrees - if (worktrees.length === 0) { + // Get sessions in this repo group to determine current context + const repoSessions = this.sessions.filter( + (session) => (session.gitMainRepoPath || session.gitRepoPath) === repoPath + ); + + // The main repository is the one whose path matches the repoPath + // All other worktrees are linked worktrees in separate directories + const actualWorktrees = worktrees.filter((wt) => { + // Normalize paths for comparison (handle macOS /private symlinks) + const normalizedWorktreePath = wt.path.replace(/^\/private/, ''); + const normalizedRepoPath = repoPath.replace(/^\/private/, ''); + return normalizedWorktreePath !== normalizedRepoPath; + }); + + // Determine if any session in this group is in a worktree (not the main repo) + const isInWorktree = repoSessions.some((session) => { + if (!session.workingDir) return false; + // Check if session is in any actual worktree path + return actualWorktrees.some((wt) => session.workingDir?.startsWith(wt.path)); + }); + + // Show follow mode dropdown if: + // 1. We're currently in a worktree (affects main repository), OR + // 2. We're in main repo AND there are actual worktrees to follow + if (!isInWorktree && actualWorktrees.length === 0) { return html``; } - const displayText = followMode ? followMode : 'Standalone'; + const displayText = followMode ? followMode.replace(/^refs\/heads\//, '') : 'Standalone'; return html`
@@ -541,17 +547,17 @@ export class SessionList extends LitElement { ${!followMode ? html`` : ''} - ${worktrees.map( + ${actualWorktrees.map( (worktree) => html` @@ -582,6 +588,8 @@ export class SessionList extends LitElement { if (response.ok) { const data = await response.json(); this.repoWorktrees.set(repoPath, data.worktrees || []); + // Also set follow mode from the worktrees API response + this.repoFollowMode.set(repoPath, data.followBranch); } else { logger.error(`Failed to load worktrees for ${repoPath}`); } diff --git a/web/src/client/components/session-list/repository-header.ts b/web/src/client/components/session-list/repository-header.ts index 7a650ef3..6f604711 100644 --- a/web/src/client/components/session-list/repository-header.ts +++ b/web/src/client/components/session-list/repository-header.ts @@ -30,14 +30,16 @@ export class RepositoryHeader extends LitElement { private renderFollowModeIndicator() { if (!this.followMode) return ''; + const cleanBranchName = this.followMode.replace(/^refs\/heads\//, ''); + return html` + title="Following worktree: ${cleanBranchName}"> - ${this.followMode} + ${cleanBranchName} `; } diff --git a/web/src/client/components/worktree-manager.ts b/web/src/client/components/worktree-manager.ts index fbc51f1c..6b8c0cd0 100644 --- a/web/src/client/components/worktree-manager.ts +++ b/web/src/client/components/worktree-manager.ts @@ -81,19 +81,17 @@ export class WorktreeManager extends LitElement { return; } - try { - await this.gitService.switchBranch(this.repoPath, branch); - this.dispatchEvent(new CustomEvent('back')); - } catch (err) { - logger.error('Failed to switch branch:', err); - this.dispatchEvent( - new CustomEvent('error', { - detail: { - message: `Failed to switch to ${branch}: ${err instanceof Error ? err.message : 'Unknown error'}`, - }, - }) - ); - } + // Direct branch switching without worktrees is no longer supported + logger.log( + `Branch switching to ${branch} requested, but direct branch switching is not supported. Use worktrees instead.` + ); + this.dispatchEvent( + new CustomEvent('error', { + detail: { + message: `Direct branch switching is no longer supported. Create a worktree for branch '${branch}' instead.`, + }, + }) + ); } private async handleDeleteWorktree(branch: string, hasChanges: boolean) { diff --git a/web/src/client/services/git-service.test.ts b/web/src/client/services/git-service.test.ts index f21934f7..5c153a16 100644 --- a/web/src/client/services/git-service.test.ts +++ b/web/src/client/services/git-service.test.ts @@ -327,42 +327,6 @@ describe('GitService', () => { }); }); - describe('switchBranch', () => { - it('should switch to a branch', async () => { - const mockFetch = vi.fn(async () => ({ - ok: true, - status: 200, - json: async () => ({}), - })); - global.fetch = mockFetch as typeof global.fetch; - - await gitService.switchBranch('/home/user/project', 'develop'); - - const call = mockFetch.mock.calls[0]; - expect(call[0]).toBe('/api/worktrees/switch'); - expect(call[1]?.method).toBe('POST'); - - const requestBody = JSON.parse(call[1]?.body as string); - expect(requestBody).toEqual({ - repoPath: '/home/user/project', - branch: 'develop', - }); - }); - - it('should handle switch errors', async () => { - const mockFetch = vi.fn(async () => ({ - ok: false, - status: 404, - json: async () => ({ error: 'Branch not found' }), - })); - global.fetch = mockFetch as typeof global.fetch; - - await expect(gitService.switchBranch('/home/user/project', 'nonexistent')).rejects.toThrow( - 'Branch not found' - ); - }); - }); - describe('setFollowMode', () => { it('should enable follow mode', async () => { const mockFetch = vi.fn(async () => ({ @@ -451,7 +415,6 @@ describe('GitService', () => { () => gitService.createWorktree('/repo', 'branch', '/path'), () => gitService.deleteWorktree('/repo', 'branch'), () => gitService.pruneWorktrees('/repo'), - () => gitService.switchBranch('/repo', 'branch'), () => gitService.setFollowMode('/repo', 'branch', true), ]; diff --git a/web/src/client/services/git-service.ts b/web/src/client/services/git-service.ts index e2b7fc5a..2b35d7e9 100644 --- a/web/src/client/services/git-service.ts +++ b/web/src/client/services/git-service.ts @@ -344,61 +344,6 @@ export class GitService { } } - /** - * Switch to a branch and enable follow mode - * - * Performs a Git checkout to switch the main repository to a different branch - * and enables follow mode for that branch. This operation affects the main - * repository, not worktrees. - * - * **What this does:** - * 1. Attempts to checkout the specified branch in the main repository - * 2. If successful, enables follow mode for that branch - * 3. If checkout fails (e.g., uncommitted changes), the operation is aborted - * - * **Follow mode behavior:** - * - Once enabled, the main repository will automatically follow any checkout - * operations performed in worktrees of the followed branch - * - Follow mode state is stored in Git config as `vibetunnel.followBranch` - * - * @param repoPath - Absolute path to the repository root - * @param branch - Branch name to switch to (must exist in the repository) - * - * @example - * ```typescript - * // Switch main repository to feature branch and enable follow mode - * await gitService.switchBranch('/path/to/repo', 'feature/new-ui'); - * - * // Now the main repository is on 'feature/new-ui' branch - * // and will follow any checkout operations in its worktrees - * ``` - * - * @throws Error if: - * - The branch doesn't exist - * - There are uncommitted changes preventing the switch - * - The repository path is invalid - * - The API request fails - */ - async switchBranch(repoPath: string, branch: string): Promise { - try { - const response = await fetch('/api/worktrees/switch', { - method: HttpMethod.POST, - headers: { - 'Content-Type': 'application/json', - ...this.authClient.getAuthHeader(), - }, - body: JSON.stringify({ repoPath, branch }), - }); - if (!response.ok) { - const error = await response.json().catch(() => ({ error: 'Unknown error' })); - throw new Error(error.error || `Failed to switch branch: ${response.statusText}`); - } - } catch (error) { - logger.error('Failed to switch branch:', error); - throw error; - } - } - /** * Enable or disable follow mode * @@ -407,7 +352,7 @@ export class GitService { * checkout that branch whenever any of its worktrees perform a checkout operation. * * This feature uses Git hooks (post-checkout, post-commit) and stores state in - * the Git config as `vibetunnel.followBranch`. + * the Git config as `vibetunnel.followWorktree`. * * **Important behaviors:** * - Only one branch can have follow mode enabled at a time diff --git a/web/src/server/api-socket-server.ts b/web/src/server/api-socket-server.ts index 33c7fe6a..9509afec 100644 --- a/web/src/server/api-socket-server.ts +++ b/web/src/server/api-socket-server.ts @@ -231,22 +231,7 @@ export class ApiSocketServer { }; } } catch (_e) { - // Check for legacy follow mode - try { - const { stdout } = await execGit(['config', 'vibetunnel.followBranch'], { - cwd: mainRepoPath, - }); - const followBranch = stdout.trim(); - if (followBranch) { - followMode = { - enabled: true, - branch: followBranch, - repoPath: prettifyPath(mainRepoPath), - }; - } - } catch (_e2) { - // No follow mode configured - } + // No follow mode configured } } catch (_error) { // Not in a git repo @@ -430,15 +415,6 @@ export class ApiSocketServer { cwd: absoluteMainRepo, }); - // Also try to unset the old config for backward compatibility - try { - await execGit(['config', '--local', '--unset', 'vibetunnel.followBranch'], { - cwd: absoluteMainRepo, - }); - } catch { - // Ignore if it doesn't exist - } - // Get the worktree path that was being followed let followedWorktree: string | undefined; try { diff --git a/web/src/server/routes/git.ts b/web/src/server/routes/git.ts index 9fd37cca..e5f2a383 100644 --- a/web/src/server/routes/git.ts +++ b/web/src/server/routes/git.ts @@ -528,83 +528,6 @@ export function createGitRoutes(): Router { } }); - /** - * GET /api/git/follow - * Check follow mode status for a repository - */ - router.get('/git/follow', async (req, res) => { - try { - const { path: queryPath } = req.query; - - if (!queryPath || typeof queryPath !== 'string') { - return res.status(400).json({ - error: 'Missing or invalid path parameter', - }); - } - - // Resolve the path to absolute - const absolutePath = resolveAbsolutePath(queryPath); - logger.debug(`Checking follow mode for path: ${absolutePath}`); - - // First check if it's a git repository - let repoPath: string; - try { - const { stdout } = await execGit(['rev-parse', '--show-toplevel'], { - cwd: absolutePath, - }); - repoPath = stdout.trim(); - } catch (error) { - if (isNotGitRepositoryError(error)) { - return res.json({ - isGitRepo: false, - followMode: false, - }); - } - throw error; - } - - // Get follow mode configuration - let followBranch: string | undefined; - let followMode = false; - - try { - const { stdout } = await execGit(['config', 'vibetunnel.followBranch'], { - cwd: repoPath, - }); - followBranch = stdout.trim(); - followMode = !!followBranch; - } catch (_error) { - // Config not set - follow mode is disabled - logger.debug('Follow branch not configured'); - } - - // Get current branch - let currentBranch: string | undefined; - try { - const { stdout } = await execGit(['branch', '--show-current'], { - cwd: repoPath, - }); - currentBranch = stdout.trim(); - } catch (_error) { - logger.debug('Could not get current branch'); - } - - return res.json({ - isGitRepo: true, - repoPath, - followMode, - followBranch: followBranch || null, - currentBranch: currentBranch || null, - }); - } catch (error) { - logger.error('Error checking follow mode:', error); - return res.status(500).json({ - error: 'Failed to check follow mode', - message: error instanceof Error ? error.message : String(error), - }); - } - }); - /** * GET /api/git/notifications * Get pending notifications for the web UI diff --git a/web/src/server/routes/repositories.ts b/web/src/server/routes/repositories.ts index ef279b0d..89abaca0 100644 --- a/web/src/server/routes/repositories.ts +++ b/web/src/server/routes/repositories.ts @@ -94,75 +94,6 @@ export function createRepositoryRoutes(): Router { } }); - // Get follow mode status for a repository - router.get('/repositories/follow-mode', async (req, res) => { - try { - const repoPath = req.query.path as string; - - if (!repoPath || typeof repoPath !== 'string') { - return res.status(400).json({ - error: 'Missing or invalid path parameter', - }); - } - - const expandedPath = resolveAbsolutePath(repoPath); - logger.debug(`[GET /repositories/follow-mode] Getting follow mode for: ${expandedPath}`); - - try { - const { stdout } = await execAsync('git config vibetunnel.followBranch', { - cwd: expandedPath, - }); - const followBranch = stdout.trim(); - res.json({ followBranch: followBranch || undefined }); - } catch { - // Config not set - follow mode is disabled - res.json({ followBranch: undefined }); - } - } catch (error) { - logger.error('[GET /repositories/follow-mode] Error getting follow mode:', error); - res.status(500).json({ error: 'Failed to get follow mode' }); - } - }); - - // Set follow mode for a repository - router.post('/repositories/follow-mode', async (req, res) => { - try { - const { repoPath, followBranch } = req.body; - - if (!repoPath || typeof repoPath !== 'string') { - return res.status(400).json({ - error: 'Missing or invalid repoPath parameter', - }); - } - - const expandedPath = resolveAbsolutePath(repoPath); - logger.debug( - `[POST /repositories/follow-mode] Setting follow mode for ${expandedPath} to: ${followBranch}` - ); - - if (followBranch) { - // Set follow mode - await execAsync(`git config vibetunnel.followBranch "${followBranch}"`, { - cwd: expandedPath, - }); - } else { - // Clear follow mode - try { - await execAsync('git config --unset vibetunnel.followBranch', { - cwd: expandedPath, - }); - } catch { - // Config might not exist, that's okay - } - } - - res.json({ success: true }); - } catch (error) { - logger.error('[POST /repositories/follow-mode] Error setting follow mode:', error); - res.status(500).json({ error: 'Failed to set follow mode' }); - } - }); - return router; } diff --git a/web/src/server/routes/worktrees.test.ts b/web/src/server/routes/worktrees.test.ts index d26173ba..97036e04 100644 --- a/web/src/server/routes/worktrees.test.ts +++ b/web/src/server/routes/worktrees.test.ts @@ -320,7 +320,7 @@ branch refs/heads/feature ); expect(mockExecFile).toHaveBeenCalledWith( 'git', - ['config', '--local', 'vibetunnel.followBranch', 'develop'], + ['config', '--local', 'vibetunnel.followWorktree', '/path/to/worktree'], expect.objectContaining({ cwd: '/home/user/project' }) ); }); @@ -369,12 +369,12 @@ branch refs/heads/feature }); it('should handle config unset when already disabled', async () => { - const error = new Error('error: key "vibetunnel.followBranch" not found') as Error & { + const error = new Error('error: key "vibetunnel.followWorktree" not found') as Error & { exitCode: number; stderr: string; }; error.exitCode = 5; - error.stderr = 'error: key "vibetunnel.followBranch" not found'; + error.stderr = 'error: key "vibetunnel.followWorktree" not found'; mockExecFile.mockRejectedValueOnce(error); const response = await request(app).post('/api/worktrees/follow').send({ diff --git a/web/src/server/routes/worktrees.ts b/web/src/server/routes/worktrees.ts index a5c52310..dfc2acfc 100644 --- a/web/src/server/routes/worktrees.ts +++ b/web/src/server/routes/worktrees.ts @@ -267,15 +267,31 @@ export function createWorktreeRoutes(): Router { const baseBranch = await detectDefaultBranch(absoluteRepoPath); logger.debug(`Using base branch: ${baseBranch}`); - // Get follow branch if configured + // Get follow worktree if configured let followBranch: string | undefined; try { - const { stdout } = await execGit(['config', 'vibetunnel.followBranch'], { + const { stdout } = await execGit(['config', 'vibetunnel.followWorktree'], { cwd: absoluteRepoPath, }); - followBranch = stdout.trim() || undefined; + const followWorktreePath = stdout.trim(); + + if (followWorktreePath) { + // Find the branch for this worktree path - we need to parse worktrees first + // This is a bit of a circular dependency, so let's get minimal worktree info + const { stdout: worktreeListOutput } = await execGit( + ['worktree', 'list', '--porcelain'], + { + cwd: absoluteRepoPath, + } + ); + const allWorktrees = parseWorktreePorcelain(worktreeListOutput); + const followWorktree = allWorktrees.find((w: Worktree) => w.path === followWorktreePath); + if (followWorktree) { + followBranch = followWorktree.branch.replace(/^refs\/heads\//, ''); + } + } } catch { - // No follow branch configured + // No follow worktree configured } // Get worktree list @@ -444,63 +460,6 @@ export function createWorktreeRoutes(): Router { } }); - /** - * POST /api/worktrees/switch - * Switch main repository to a branch and enable follow mode - */ - router.post('/worktrees/switch', async (req, res) => { - try { - const { repoPath, branch } = req.body; - - if (!repoPath || typeof repoPath !== 'string') { - return res.status(400).json({ - error: 'Missing or invalid repoPath in request body', - }); - } - - if (!branch || typeof branch !== 'string') { - return res.status(400).json({ - error: 'Missing or invalid branch in request body', - }); - } - - const absoluteRepoPath = path.resolve(repoPath); - logger.debug(`Switching to branch: ${branch} in repo: ${absoluteRepoPath}`); - - // Check for uncommitted changes before switching - const hasChanges = await hasUncommittedChanges(absoluteRepoPath); - if (hasChanges) { - return res.status(400).json({ - error: 'Cannot switch branches with uncommitted changes', - details: 'Please commit or stash your changes before switching branches', - }); - } - - // Switch to the branch - await execGit(['checkout', branch], { cwd: absoluteRepoPath }); - - // Enable follow mode for the switched branch - await execGit(['config', '--local', 'vibetunnel.followBranch', branch], { - cwd: absoluteRepoPath, - }); - - logger.info(`Successfully switched to branch: ${branch} with follow mode enabled`); - return res.json({ - success: true, - message: 'Switched to branch and enabled follow mode', - branch, - currentBranch: branch, - }); - } catch (error) { - logger.error('Error switching branch:', error); - const gitError = error as GitError; - return res.status(500).json({ - error: 'Failed to switch branch', - details: gitError.stderr || gitError.message, - }); - } - }); - /** * POST /api/worktrees * Create a new worktree @@ -616,13 +575,63 @@ export function createWorktreeRoutes(): Router { logger.info('Git hooks installed successfully'); } - // Set the follow mode config to the branch name - await execGit(['config', '--local', 'vibetunnel.followBranch', branch], { + // Get worktree information to find the path for this branch + const { stdout: worktreeListOutput } = await execGit(['worktree', 'list', '--porcelain'], { + cwd: absoluteRepoPath, + }); + const allWorktrees = parseWorktreePorcelain(worktreeListOutput); + const worktree = allWorktrees.find( + (w) => + w.branch === branch || + w.branch === `refs/heads/${branch}` || + w.branch.replace(/^refs\/heads\//, '') === branch + ); + + if (!worktree) { + return res.status(400).json({ + error: `No worktree found for branch: ${branch}`, + }); + } + + // Set the follow worktree path (not branch name) + await execGit(['config', '--local', 'vibetunnel.followWorktree', worktree.path], { cwd: absoluteRepoPath, }); logger.info(`Follow mode enabled for branch: ${branch}`); + // Immediately sync main repository to the followed branch + try { + // Strip refs/heads/ prefix if present + const cleanBranch = branch.replace(/^refs\/heads\//, ''); + + // Check if the branch exists locally + const { stdout: branchList } = await execGit(['branch', '--list', cleanBranch], { + cwd: absoluteRepoPath, + }); + + if (branchList.trim()) { + // Branch exists locally, switch to it + await execGit(['checkout', cleanBranch], { cwd: absoluteRepoPath }); + logger.info(`Main repository switched to branch: ${cleanBranch}`); + } else { + // Branch doesn't exist locally, try to fetch and create it + try { + await execGit(['fetch', 'origin', `${cleanBranch}:${cleanBranch}`], { + cwd: absoluteRepoPath, + }); + await execGit(['checkout', cleanBranch], { cwd: absoluteRepoPath }); + logger.info(`Fetched and switched to branch: ${cleanBranch}`); + } catch (error) { + logger.warn(`Could not fetch/switch to branch ${cleanBranch}:`, error); + // Don't fail follow mode enable if branch switch fails + } + } + } catch (error) { + logger.warn(`Could not immediately switch to branch ${branch}:`, error); + // Don't fail follow mode enable if branch switch fails + } + // Send notification to Mac app if (controlUnixHandler.isMacAppConnected()) { const notification = createControlEvent('system', 'notification', { @@ -642,8 +651,8 @@ export function createWorktreeRoutes(): Router { hooksInstallResult: hooksInstallResult, }); } else { - // Unset the follow branch config - await execGit(['config', '--local', '--unset', 'vibetunnel.followBranch'], { + // Unset the follow worktree config + await execGit(['config', '--local', '--unset', 'vibetunnel.followWorktree'], { cwd: absoluteRepoPath, }); diff --git a/web/src/server/utils/git-utils.ts b/web/src/server/utils/git-utils.ts index c5435d36..21cfbb4f 100644 --- a/web/src/server/utils/git-utils.ts +++ b/web/src/server/utils/git-utils.ts @@ -86,21 +86,3 @@ export async function isWorktree(gitPath: string): Promise { return false; } } - -/** - * Get follow mode status for a repository - * @param repoPath Repository path - * @returns Current follow branch or undefined - */ -export async function getFollowBranch(repoPath: string): Promise { - try { - const { stdout } = await execFile('git', ['config', 'vibetunnel.followBranch'], { - cwd: repoPath, - }); - const followBranch = stdout.trim(); - return followBranch || undefined; - } catch { - // Config not set - follow mode is disabled - return undefined; - } -} diff --git a/web/src/test/e2e/follow-mode.test.ts b/web/src/test/e2e/follow-mode.test.ts index 491f57e9..518f23d3 100644 --- a/web/src/test/e2e/follow-mode.test.ts +++ b/web/src/test/e2e/follow-mode.test.ts @@ -194,9 +194,9 @@ describe.skip('Follow Mode End-to-End Tests', () => { expect(response.body.enabled).toBe(true); expect(response.body.branch).toBe('develop'); - // Verify git config was set - const { stdout: configOutput } = await gitExec(['config', 'vibetunnel.followBranch']); - expect(configOutput).toBe('develop'); + // Verify git config was set (should contain worktree path, not branch name) + const { stdout: configOutput } = await gitExec(['config', 'vibetunnel.followWorktree']); + expect(configOutput).toBe(worktreePath); // Should be the worktree path, not branch name // Verify hooks were installed const postCommitExists = await fs @@ -271,7 +271,7 @@ describe.skip('Follow Mode End-to-End Tests', () => { // Check that follow mode was disabled (config should not exist) try { - await gitExec(['config', 'vibetunnel.followBranch']); + await gitExec(['config', 'vibetunnel.followWorktree']); // If we get here, the config still exists expect(true).toBe(false); // Fail the test } catch (error) { @@ -299,7 +299,7 @@ describe.skip('Follow Mode End-to-End Tests', () => { // Verify git config was removed try { - await gitExec(['config', 'vibetunnel.followBranch']); + await gitExec(['config', 'vibetunnel.followWorktree']); // If we get here, the config still exists expect(true).toBe(false); // Fail the test } catch (error) { diff --git a/web/src/test/integration/worktree-workflows.test.ts b/web/src/test/integration/worktree-workflows.test.ts index 0e23d9dc..024f8497 100644 --- a/web/src/test/integration/worktree-workflows.test.ts +++ b/web/src/test/integration/worktree-workflows.test.ts @@ -93,42 +93,6 @@ describe('Worktree Workflows Integration Tests', () => { expect(featureWorktree.path).toContain('worktree-feature-test-feature'); }); - it('should switch branches in main worktree', async () => { - // Switch to bugfix branch (not used by any worktree) - const switchResponse = await request(testServer.app).post('/api/worktrees/switch').send({ - repoPath: gitRepo.repoPath, - branch: 'bugfix/critical-fix', - }); - - expect(switchResponse.status).toBe(200); - expect(switchResponse.body.success).toBe(true); - expect(switchResponse.body.currentBranch).toBe('bugfix/critical-fix'); - - // Verify the branch was actually switched - const { stdout } = await gitRepo.gitExec(['branch', '--show-current']); - expect(stdout).toBe('bugfix/critical-fix'); - - // Switch back to main - await gitRepo.gitExec(['checkout', 'main']); - }); - - it('should handle uncommitted changes when switching branches', async () => { - // Create uncommitted changes - await fs.writeFile(path.join(gitRepo.repoPath, 'uncommitted.txt'), 'test content'); - - // Try to switch branch (should fail) - const switchResponse = await request(testServer.app).post('/api/worktrees/switch').send({ - repoPath: gitRepo.repoPath, - branch: 'develop', - }); - - expect(switchResponse.status).toBe(400); - expect(switchResponse.body.error).toContain('uncommitted changes'); - - // Clean up - await fs.unlink(path.join(gitRepo.repoPath, 'uncommitted.txt')); - }); - it('should delete worktree', async () => { // Create a temporary worktree to delete const tempBranch = 'temp/delete-test'; @@ -202,31 +166,47 @@ describe('Worktree Workflows Integration Tests', () => { }); describe('Follow Mode', () => { - it('should enable follow mode', async () => { + it('should enable follow mode for existing worktree', async () => { + // Use the existing worktree for feature/test-feature const response = await request(testServer.app).post('/api/worktrees/follow').send({ repoPath: gitRepo.repoPath, - branch: 'develop', + branch: 'feature/test-feature', enable: true, }); expect(response.status).toBe(200); expect(response.body.success).toBe(true); expect(response.body.enabled).toBe(true); - expect(response.body.branch).toBe('develop'); + expect(response.body.branch).toBe('feature/test-feature'); - // Verify git config was set - const { stdout } = await gitRepo.gitExec(['config', 'vibetunnel.followBranch']); - expect(stdout).toBe('develop'); + // Verify git config was set (should contain worktree path, not branch name) + const { stdout } = await gitRepo.gitExec(['config', 'vibetunnel.followWorktree']); + // The worktree path should end with the branch slug + expect(stdout).toContain('worktree-feature-test-feature'); }); it('should disable follow mode', async () => { + // First, get the list of worktrees to find the correct path + const worktreesResponse = await request(testServer.app) + .get('/api/worktrees') + .query({ repoPath: gitRepo.repoPath }); + + const featureWorktree = worktreesResponse.body.worktrees.find((w: { branch: string }) => + w.branch.includes('feature/test-feature') + ); + // First enable follow mode - await gitRepo.gitExec(['config', '--local', 'vibetunnel.followBranch', 'develop']); + await gitRepo.gitExec([ + 'config', + '--local', + 'vibetunnel.followWorktree', + featureWorktree.path, + ]); // Disable it const response = await request(testServer.app).post('/api/worktrees/follow').send({ repoPath: gitRepo.repoPath, - branch: 'develop', + branch: 'feature/test-feature', enable: false, }); @@ -236,7 +216,7 @@ describe('Worktree Workflows Integration Tests', () => { // Verify git config was removed try { - await gitRepo.gitExec(['config', 'vibetunnel.followBranch']); + await gitRepo.gitExec(['config', 'vibetunnel.followWorktree']); expect(true).toBe(false); // Should not reach here } catch (error) { // Expected - config should not exist