mirror of
https://github.com/samsonjs/vibetunnel.git
synced 2026-03-25 09:25:50 +00:00
Clean up lots of follow mode features
This commit is contained in:
parent
4c4af17640
commit
6986771824
17 changed files with 194 additions and 489 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue