Clean up lots of follow mode features

This commit is contained in:
Peter Steinberger 2025-08-02 02:09:42 +02:00
parent 4c4af17640
commit 6986771824
17 changed files with 194 additions and 489 deletions

View file

@ -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;

View file

@ -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 {

View file

@ -54,7 +54,7 @@ export class QuickStartSection extends LitElement {
render() {
return html`
<div class="${this.editMode ? '' : 'mb-3 sm:mb-4'}">
<div class="${this.editMode ? 'mt-3 sm:mt-4 mb-3 sm:mb-4' : 'mb-3 sm:mb-4'}">
${
this.editMode
? html`

View file

@ -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;

View file

@ -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<string, string | undefined>();
@state() private loadingFollowMode = new Set<string>();
@state() private showFollowDropdown = new Map<string, boolean>();
@state() private repoWorktrees = new Map<
string,
Array<{ path: string; branch: string; HEAD: string; detached: boolean }>
>();
@state() private repoWorktrees = new Map<string, Worktree[]>();
@state() private loadingWorktrees = new Set<string>();
@state() private showWorktreeDropdown = new Map<string, boolean>();
@ -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<string, boolean>();
const newWorktreeDropdown = new Map<string, boolean>();
// 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`
<div class="relative">
@ -541,17 +547,17 @@ export class SessionList extends LitElement {
${!followMode ? html`<span class="text-accent-primary">✓</span>` : ''}
</button>
${worktrees.map(
${actualWorktrees.map(
(worktree) => html`
<button
class="w-full text-left px-3 py-2 text-xs hover:bg-bg-elevated transition-colors flex items-center justify-between"
@click=${() => this.handleFollowModeChange(repoPath, worktree.branch)}
>
<div class="flex items-center gap-2">
<div class="flex flex-col gap-1">
<span class="font-mono ${followMode === worktree.branch ? 'text-accent-primary font-semibold' : ''}">
Follow: ${worktree.branch}
Follow: ${worktree.branch.replace(/^refs\/heads\//, '')}
</span>
<span class="text-[10px] text-text-muted">${worktree.path}</span>
<span class="text-[10px] text-text-muted">${formatPathForDisplay(worktree.path)}</span>
</div>
${followMode === worktree.branch ? html`<span class="text-accent-primary">✓</span>` : ''}
</button>
@ -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}`);
}

View file

@ -30,14 +30,16 @@ export class RepositoryHeader extends LitElement {
private renderFollowModeIndicator() {
if (!this.followMode) return '';
const cleanBranchName = this.followMode.replace(/^refs\/heads\//, '');
return html`
<span class="text-[10px] px-1.5 py-0.5 bg-purple-500/20 text-purple-400 rounded flex items-center gap-1"
title="Following worktree: ${this.followMode}">
title="Following worktree: ${cleanBranchName}">
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4" />
</svg>
${this.followMode}
${cleanBranchName}
</span>
`;
}

View file

@ -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) {

View file

@ -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),
];

View file

@ -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<void> {
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

View file

@ -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 {

View file

@ -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

View file

@ -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;
}

View file

@ -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({

View file

@ -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,
});

View file

@ -86,21 +86,3 @@ export async function isWorktree(gitPath: string): Promise<boolean> {
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<string | undefined> {
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;
}
}

View file

@ -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) {

View file

@ -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