vibetunnel/web/src/client/services/git-service.ts
2025-08-02 02:09:42 +02:00

402 lines
14 KiB
TypeScript

/**
* Git Service
*
* Handles Git-related API calls including repository info, worktrees, and follow mode.
* This service provides a client-side interface to interact with Git repositories
* through the VibeTunnel server API.
*
* ## Main Features
* - Repository detection and status checking
* - Git worktree management (list, create, delete, prune)
* - Branch switching with follow mode support
* - Repository change detection
*
* ## Usage Example
* ```typescript
* const gitService = new GitService(authClient);
*
* // Check if current path is a git repository
* const repoInfo = await gitService.checkGitRepo('/path/to/project');
* if (repoInfo.isGitRepo) {
* // List all worktrees
* const { worktrees } = await gitService.listWorktrees(repoInfo.repoPath);
*
* // Create a new worktree
* await gitService.createWorktree(
* repoInfo.repoPath,
* 'feature/new-branch',
* '/path/to/worktree'
* );
* }
* ```
*
* @see web/src/server/controllers/git-controller.ts for server-side implementation
* @see web/src/server/controllers/worktree-controller.ts for worktree endpoints
*/
import { HttpMethod } from '../../shared/types.js';
import { createLogger } from '../utils/logger.js';
import type { AuthClient } from './auth-client.js';
const logger = createLogger('git-service');
/**
* Git repository information
*
* @property isGitRepo - Whether the path is within a Git repository
* @property repoPath - Absolute path to the repository root (if isGitRepo is true)
* @property hasChanges - Whether the repository has uncommitted changes
* @property isWorktree - Whether the current path is a Git worktree (not the main repository)
*/
export interface GitRepoInfo {
isGitRepo: boolean;
repoPath?: string;
hasChanges?: boolean;
isWorktree?: boolean;
}
/**
* Git worktree information
*
* A worktree allows you to have multiple working directories attached to the same repository.
* Each worktree has its own working directory and can check out a different branch.
*
* @property path - Absolute path to the worktree directory
* @property branch - Branch name checked out in this worktree
* @property HEAD - Current commit SHA
* @property detached - Whether HEAD is detached (not on a branch)
* @property prunable - Whether this worktree can be pruned (directory missing)
* @property locked - Whether this worktree is locked (prevents deletion)
* @property lockedReason - Reason why the worktree is locked
*
* Extended statistics (populated by the server):
* @property commitsAhead - Number of commits ahead of the base branch
* @property filesChanged - Number of files with changes
* @property insertions - Number of lines added
* @property deletions - Number of lines removed
* @property hasUncommittedChanges - Whether there are uncommitted changes
*
* UI helper properties:
* @property isMainWorktree - Whether this is the main worktree (not a linked worktree)
* @property isCurrentWorktree - Whether this worktree matches the current session path
*/
export interface Worktree {
path: string;
branch: string;
HEAD: string;
detached: boolean;
prunable?: boolean;
locked?: boolean;
lockedReason?: string;
// Extended stats
commitsAhead?: number;
filesChanged?: number;
insertions?: number;
deletions?: number;
hasUncommittedChanges?: boolean;
// UI helpers
isMainWorktree?: boolean;
isCurrentWorktree?: boolean;
}
/**
* Response from listing worktrees
*
* @property worktrees - Array of all worktrees for the repository
* @property baseBranch - The default/main branch of the repository (e.g., 'main' or 'master')
* @property followBranch - Currently active branch for follow mode (if any)
*/
export interface WorktreeListResponse {
worktrees: Worktree[];
baseBranch: string;
followBranch?: string;
}
/**
* GitService provides client-side methods for interacting with Git repositories
* through the VibeTunnel API. All methods require authentication via AuthClient.
*
* The service handles:
* - Error logging and propagation
* - Authentication headers
* - Request/response serialization
* - URL encoding for path parameters
*/
export class GitService {
constructor(private authClient: AuthClient) {}
/**
* Check if a path is within a Git repository
*
* This method determines if the given path is part of a Git repository and
* provides additional information about the repository state.
*
* @param path - Absolute path to check (e.g., '/Users/alice/projects/myapp')
* @returns Promise resolving to repository information
*
* @example
* ```typescript
* const info = await gitService.checkGitRepo('/Users/alice/projects/myapp');
* if (info.isGitRepo) {
* console.log(`Repository at: ${info.repoPath}`);
* console.log(`Has changes: ${info.hasChanges}`);
* }
* ```
*
* @throws Error if the API request fails
*/
async checkGitRepo(path: string): Promise<GitRepoInfo> {
try {
const response = await fetch(`/api/git/repo-info?path=${encodeURIComponent(path)}`, {
headers: this.authClient.getAuthHeader(),
});
if (!response.ok) {
throw new Error(`Failed to check git repo: ${response.statusText}`);
}
return await response.json();
} catch (error) {
logger.error('Failed to check git repo:', error);
throw error;
}
}
/**
* List all worktrees for a repository
*
* Retrieves information about all worktrees associated with the repository,
* including their branches, paths, and change statistics.
*
* @param repoPath - Absolute path to the repository root
* @returns Promise resolving to worktree list with base branch and follow mode info
*
* @example
* ```typescript
* const { worktrees, baseBranch } = await gitService.listWorktrees('/path/to/repo');
*
* // Find worktrees with uncommitted changes
* const dirtyWorktrees = worktrees.filter(wt => wt.hasUncommittedChanges);
*
* // Check if a specific branch has a worktree
* const hasBranch = worktrees.some(wt => wt.branch === 'feature/new-ui');
* ```
*
* @throws Error if the API request fails or repository is invalid
*/
async listWorktrees(repoPath: string): Promise<WorktreeListResponse> {
try {
const response = await fetch(`/api/worktrees?repoPath=${encodeURIComponent(repoPath)}`, {
headers: this.authClient.getAuthHeader(),
});
if (!response.ok) {
throw new Error(`Failed to list worktrees: ${response.statusText}`);
}
return await response.json();
} catch (error) {
logger.error('Failed to list worktrees:', error);
throw error;
}
}
/**
* Create a new worktree
*
* Creates a new Git worktree linked to the repository. This allows you to
* work on multiple branches simultaneously in different directories.
*
* @param repoPath - Absolute path to the repository root
* @param branch - Branch name for the new worktree (will be created if doesn't exist)
* @param path - Absolute path where the worktree should be created
* @param baseBranch - Optional base branch to create the new branch from (defaults to repository's default branch)
*
* @example
* ```typescript
* // Create a worktree for a new feature branch
* await gitService.createWorktree(
* '/Users/alice/myproject',
* 'feature/dark-mode',
* '/Users/alice/myproject-dark-mode'
* );
*
* // Create a worktree based on a specific branch
* await gitService.createWorktree(
* '/Users/alice/myproject',
* 'hotfix/security-patch',
* '/Users/alice/myproject-hotfix',
* 'release/v2.0'
* );
* ```
*
* @throws Error if:
* - The branch already has a worktree
* - The target path already exists
* - The repository path is invalid
* - Git operation fails
*/
async createWorktree(
repoPath: string,
branch: string,
path: string,
baseBranch?: string
): Promise<void> {
try {
const response = await fetch('/api/worktrees', {
method: HttpMethod.POST,
headers: {
'Content-Type': 'application/json',
...this.authClient.getAuthHeader(),
},
body: JSON.stringify({ repoPath, branch, path, baseBranch }),
});
if (!response.ok) {
const error = await response.json().catch(() => ({ error: 'Unknown error' }));
throw new Error(error.error || `Failed to create worktree: ${response.statusText}`);
}
} catch (error) {
logger.error('Failed to create worktree:', error);
throw error;
}
}
/**
* Delete a worktree
*
* Removes a worktree from the repository. The worktree directory will be
* deleted and the branch association will be removed.
*
* @param repoPath - Absolute path to the repository root
* @param branch - Branch name of the worktree to delete
* @param force - Force deletion even if there are uncommitted changes (default: false)
*
* @example
* ```typescript
* // Safe delete (will fail if there are uncommitted changes)
* await gitService.deleteWorktree('/path/to/repo', 'feature/old-feature');
*
* // Force delete (discards uncommitted changes)
* await gitService.deleteWorktree('/path/to/repo', 'feature/old-feature', true);
* ```
*
* @throws Error if:
* - The worktree doesn't exist
* - The worktree has uncommitted changes (unless force=true)
* - The worktree is locked
* - Attempting to delete the main worktree
*/
async deleteWorktree(repoPath: string, branch: string, force = false): Promise<void> {
try {
const params = new URLSearchParams({ repoPath });
if (force) params.append('force', 'true');
const response = await fetch(`/api/worktrees/${encodeURIComponent(branch)}?${params}`, {
method: HttpMethod.DELETE,
headers: this.authClient.getAuthHeader(),
});
if (!response.ok) {
const error = await response.json().catch(() => ({ error: 'Unknown error' }));
throw new Error(error.error || `Failed to delete worktree: ${response.statusText}`);
}
} catch (error) {
logger.error('Failed to delete worktree:', error);
throw error;
}
}
/**
* Prune worktree information
*
* Cleans up worktree administrative data for worktrees whose directories
* have been manually deleted. This is equivalent to `git worktree prune`.
*
* @param repoPath - Absolute path to the repository root
*
* @example
* ```typescript
* // Clean up after manually deleting worktree directories
* await gitService.pruneWorktrees('/path/to/repo');
*
* // Typical workflow after manual cleanup
* const { worktrees } = await gitService.listWorktrees('/path/to/repo');
* const prunableCount = worktrees.filter(wt => wt.prunable).length;
* if (prunableCount > 0) {
* await gitService.pruneWorktrees('/path/to/repo');
* console.log(`Pruned ${prunableCount} worktrees`);
* }
* ```
*
* @throws Error if the API request fails or repository is invalid
*/
async pruneWorktrees(repoPath: string): Promise<void> {
try {
const response = await fetch('/api/worktrees/prune', {
method: HttpMethod.POST,
headers: {
'Content-Type': 'application/json',
...this.authClient.getAuthHeader(),
},
body: JSON.stringify({ repoPath }),
});
if (!response.ok) {
throw new Error(`Failed to prune worktrees: ${response.statusText}`);
}
} catch (error) {
logger.error('Failed to prune worktrees:', error);
throw error;
}
}
/**
* Enable or disable follow mode
*
* Controls automatic synchronization between the main repository and worktrees.
* When follow mode is enabled for a branch, the main repository will automatically
* 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.followWorktree`.
*
* **Important behaviors:**
* - Only one branch can have follow mode enabled at a time
* - Follow mode is automatically disabled if uncommitted changes prevent switching
* - Git hooks are installed automatically when accessing a repository
* - The `vt git event` command handles the synchronization
*
* @param repoPath - Absolute path to the repository root
* @param branch - Branch name to set follow mode for
* @param enable - True to enable follow mode, false to disable
*
* @example
* ```typescript
* // Enable follow mode for main branch
* await gitService.setFollowMode('/path/to/repo', 'main', true);
* // Now when you checkout in any worktree, main repo follows to 'main'
*
* // Disable follow mode
* await gitService.setFollowMode('/path/to/repo', 'main', false);
*
* // Switch follow mode to a different branch
* await gitService.setFollowMode('/path/to/repo', 'main', false);
* await gitService.setFollowMode('/path/to/repo', 'feature/ui', true);
* ```
*
* @throws Error if the API request fails or parameters are invalid
*/
async setFollowMode(repoPath: string, branch: string, enable: boolean): Promise<void> {
try {
const response = await fetch('/api/worktrees/follow', {
method: HttpMethod.POST,
headers: {
'Content-Type': 'application/json',
...this.authClient.getAuthHeader(),
},
body: JSON.stringify({ repoPath, branch, enable }),
});
if (!response.ok) {
const error = await response.json().catch(() => ({ error: 'Unknown error' }));
throw new Error(error.error || `Failed to set follow mode: ${response.statusText}`);
}
} catch (error) {
logger.error('Failed to set follow mode:', error);
throw error;
}
}
}