diff --git a/.github/social-preview.png b/.github/social-preview.png index 06aaaa32..7ccb0c5c 100644 Binary files a/.github/social-preview.png and b/.github/social-preview.png differ diff --git a/backend/src/routes/repo-git.ts b/backend/src/routes/repo-git.ts index e94fa285..0cbe3120 100644 --- a/backend/src/routes/repo-git.ts +++ b/backend/src/routes/repo-git.ts @@ -3,25 +3,15 @@ import type { Database } from 'bun:sqlite' import * as db from '../db/queries' import { logger } from '../utils/logger' import { getErrorMessage } from '../utils/error-utils' -import { GitCommitService } from '../services/git/GitCommitService' -import { GitPushService } from '../services/git/GitPushService' -import { GitLogService } from '../services/git/GitLogService' -import { GitStatusService } from '../services/git/GitStatusService' -import { GitFetchPullService } from '../services/git/GitFetchPullService' -import { GitBranchService } from '../services/git/GitBranchService' +import { GitService } from '../services/git/GitService' import type { GitAuthService } from '../services/git-auth' -import { GitDiffService } from '../services/git/GitDiffService' +import { SettingsService } from '../services/settings' import type { GitStatusResponse } from '../types/git' export function createRepoGitRoutes(database: Database, gitAuthService: GitAuthService) { const app = new Hono() - const gitDiffService = new GitDiffService(gitAuthService) - const gitFetchPullService = new GitFetchPullService(gitAuthService) - const gitBranchService = new GitBranchService(gitAuthService) - const gitCommitService = new GitCommitService(gitAuthService) - const gitPushService = new GitPushService(gitAuthService, gitBranchService) - const gitLogService = new GitLogService(gitAuthService, gitDiffService) - const gitStatusService = new GitStatusService(gitAuthService) + const settingsService = new SettingsService(database) + const git = new GitService(gitAuthService, settingsService) app.get('/:id/git/status', async (c) => { try { @@ -32,7 +22,7 @@ export function createRepoGitRoutes(database: Database, gitAuthService: GitAuthS return c.json({ error: 'Repo not found' }, 404) } - const status = await gitStatusService.getStatus(id, database) + const status = await git.getStatus(id, database) return c.json(status) } catch (error: unknown) { @@ -53,7 +43,7 @@ export function createRepoGitRoutes(database: Database, gitAuthService: GitAuthS const statuses = await Promise.all( repoIds.map(async (id) => { try { - const status = await gitStatusService.getStatus(id, database) + const status = await git.getStatus(id, database) return [id, status] } catch (error: unknown) { logger.error(`Failed to get git status for repo ${id}:`, error) @@ -92,7 +82,7 @@ export function createRepoGitRoutes(database: Database, gitAuthService: GitAuthS return c.json({ error: 'Repo not found' }, 404) } - const diff = await gitLogService.getDiff(id, filePath, database) + const diff = await git.getDiff(id, filePath, database) return c.json(diff) } catch (error: unknown) { @@ -117,7 +107,7 @@ export function createRepoGitRoutes(database: Database, gitAuthService: GitAuthS return c.json({ error: 'Repo not found' }, 404) } - const diffResponse = await gitLogService.getFullDiff(id, filePath, database, includeStaged) + const diffResponse = await git.getFullDiff(id, filePath, database, includeStaged) return c.json(diffResponse) } catch (error: unknown) { logger.error('Failed to get full file diff:', error) @@ -134,9 +124,9 @@ export function createRepoGitRoutes(database: Database, gitAuthService: GitAuthS return c.json({ error: 'Repo not found' }, 404) } - await gitFetchPullService.fetch(id, database) + await git.fetch(id, database) - const status = await gitStatusService.getStatus(id, database) + const status = await git.getStatus(id, database) return c.json(status) } catch (error: unknown) { logger.error('Failed to fetch git:', error) @@ -153,9 +143,9 @@ export function createRepoGitRoutes(database: Database, gitAuthService: GitAuthS return c.json({ error: 'Repo not found' }, 404) } - await gitFetchPullService.pull(id, database) + await git.pull(id, database) - const status = await gitStatusService.getStatus(id, database) + const status = await git.getStatus(id, database) return c.json(status) } catch (error: unknown) { logger.error('Failed to pull git:', error) @@ -179,9 +169,9 @@ export function createRepoGitRoutes(database: Database, gitAuthService: GitAuthS return c.json({ error: 'message is required' }, 400) } - await gitCommitService.commit(id, message, database, stagedPaths) + await git.commit(id, message, database, stagedPaths) - const status = await gitStatusService.getStatus(id, database) + const status = await git.getStatus(id, database) return c.json(status) } catch (error: unknown) { logger.error('Failed to commit git:', error) @@ -201,9 +191,9 @@ export function createRepoGitRoutes(database: Database, gitAuthService: GitAuthS const body = await c.req.json() const { setUpstream } = body - await gitPushService.push(id, { setUpstream: setUpstream || false }, database) + await git.push(id, { setUpstream: setUpstream || false }, database) - const status = await gitStatusService.getStatus(id, database) + const status = await git.getStatus(id, database) return c.json(status) } catch (error: unknown) { logger.error('Failed to push git:', error) @@ -227,9 +217,9 @@ export function createRepoGitRoutes(database: Database, gitAuthService: GitAuthS return c.json({ error: 'paths is required and must be an array' }, 400) } - await gitCommitService.stageFiles(id, paths, database) + await git.stageFiles(id, paths, database) - const status = await gitStatusService.getStatus(id, database) + const status = await git.getStatus(id, database) return c.json(status) } catch (error: unknown) { logger.error('Failed to stage files:', error) @@ -253,9 +243,9 @@ export function createRepoGitRoutes(database: Database, gitAuthService: GitAuthS return c.json({ error: 'paths is required and must be an array' }, 400) } - await gitCommitService.unstageFiles(id, paths, database) + await git.unstageFiles(id, paths, database) - const status = await gitStatusService.getStatus(id, database) + const status = await git.getStatus(id, database) return c.json(status) } catch (error: unknown) { logger.error('Failed to unstage files:', error) @@ -273,7 +263,7 @@ export function createRepoGitRoutes(database: Database, gitAuthService: GitAuthS } const limit = parseInt(c.req.query('limit') || '10', 10) - const commits = await gitLogService.getLog(id, database, limit) + const commits = await git.getLog(id, database, limit) return c.json({ commits }) } catch (error: unknown) { @@ -298,9 +288,9 @@ export function createRepoGitRoutes(database: Database, gitAuthService: GitAuthS return c.json({ error: 'commitHash is required' }, 400) } - await gitCommitService.resetToCommit(id, commitHash, database) + await git.resetToCommit(id, commitHash, database) - const status = await gitStatusService.getStatus(id, database) + const status = await git.getStatus(id, database) return c.json(status) } catch (error: unknown) { logger.error('Failed to reset to commit:', error) @@ -317,8 +307,8 @@ export function createRepoGitRoutes(database: Database, gitAuthService: GitAuthS return c.json({ error: 'Repo not found' }, 404) } - const branches = await gitBranchService.getBranches(id, database) - const status = await gitBranchService.getBranchStatus(id, database) + const branches = await git.getBranches(id, database) + const status = await git.getBranchStatus(id, database) return c.json({ branches, status }) } catch (error: unknown) { diff --git a/backend/src/services/git/GitBranchService.ts b/backend/src/services/git/GitBranchService.ts deleted file mode 100644 index 359eb253..00000000 --- a/backend/src/services/git/GitBranchService.ts +++ /dev/null @@ -1,153 +0,0 @@ -import { GitAuthService } from '../git-auth' -import { executeCommand } from '../../utils/process' -import { getRepoById } from '../../db/queries' -import type { Database } from 'bun:sqlite' -import path from 'path' -import { logger } from '../../utils/logger' - -interface GitBranch { - name: string - type: 'local' | 'remote' - current: boolean - upstream?: string - ahead?: number - behind?: number - isWorktree?: boolean -} - -export class GitBranchService { - constructor(private gitAuthService: GitAuthService) {} - - async getBranches(repoId: number, database: Database): Promise { - const repo = getRepoById(database, repoId) - if (!repo) { - throw new Error(`Repository not found`) - } - - const fullPath = path.resolve(repo.fullPath) - const env = this.gitAuthService.getGitEnvironment() - - let currentBranch = '' - try { - const currentStdout = await executeCommand(['git', '-C', fullPath, 'rev-parse', '--abbrev-ref', 'HEAD'], { env, silent: true }) - currentBranch = currentStdout.trim() - } catch { - void null - } - - const stdout = await executeCommand(['git', '-C', fullPath, 'branch', '-vv', '-a'], { env, silent: true }) - const lines = stdout.split('\n').filter(line => line.trim()) - - const branches: GitBranch[] = [] - const seenNames = new Set() - - for (const line of lines) { - const trimmed = line.trim() - if (!trimmed) continue - - const isCurrent = trimmed.startsWith('*') - const isWorktree = trimmed.startsWith('+') - const namePart = trimmed.replace(/^[*+]?\s*/, '') - - const firstSpace = namePart.indexOf(' ') - const firstBracket = namePart.indexOf('[') - const cutIndex = firstSpace === -1 ? (firstBracket === -1 ? namePart.length : firstBracket) : (firstBracket === -1 ? firstSpace : Math.min(firstSpace, firstBracket)) - const branchName = namePart.slice(0, cutIndex).trim() - - if (!branchName || branchName === '+' || branchName === '->' || branchName.includes('->')) continue - if (/^[0-9a-f]{6,40}$/.test(branchName)) continue - - const branch: GitBranch = { - name: branchName, - type: branchName.startsWith('remotes/') ? 'remote' : 'local', - current: isCurrent && (branchName === currentBranch || branchName === `remotes/${currentBranch}`), - isWorktree - } - - if (seenNames.has(branch.name)) continue - seenNames.add(branch.name) - - const upstreamMatch = namePart.match(/\[([^:]+):?\s*(ahead\s+(\d+))?,?\s*(behind\s+(\d+))?\]/) - if (upstreamMatch) { - branch.upstream = upstreamMatch[1] - branch.ahead = upstreamMatch[3] ? parseInt(upstreamMatch[3]) : 0 - branch.behind = upstreamMatch[5] ? parseInt(upstreamMatch[5]) : 0 - } - - if (branch.current && (!branch.ahead || !branch.behind)) { - try { - const status = await this.getBranchStatus(repoId, database) - branch.ahead = status.ahead - branch.behind = status.behind - } catch { - void null - } - } - - branches.push(branch) - } - - return branches.sort((a, b) => { - if (a.current !== b.current) return b.current ? 1 : -1 - if (a.type !== b.type) return a.type === 'local' ? -1 : 1 - return a.name.localeCompare(b.name) - }) - } - - async getBranchStatus(repoId: number, database: Database): Promise<{ ahead: number; behind: number }> { - try { - const repo = getRepoById(database, repoId) - if (!repo) { - throw new Error(`Repository not found`) - } - - const fullPath = path.resolve(repo.fullPath) - const env = this.gitAuthService.getGitEnvironment() - - const stdout = await executeCommand(['git', '-C', fullPath, 'rev-list', '--left-right', '--count', 'HEAD...@{upstream}'], { env, silent: true }) - const [ahead, behind] = stdout.trim().split(/\s+/).map(Number) - - return { ahead: ahead || 0, behind: behind || 0 } - } catch (error) { - logger.warn(`Could not get branch status for repo ${repoId}, returning zeros:`, error) - return { ahead: 0, behind: 0 } - } - } - - async createBranch(repoId: number, branchName: string, database: Database): Promise { - const repo = getRepoById(database, repoId) - if (!repo) { - throw new Error(`Repository not found`) - } - - const fullPath = path.resolve(repo.fullPath) - const env = this.gitAuthService.getGitEnvironment() - - const result = await executeCommand(['git', '-C', fullPath, 'checkout', '-b', branchName], { env }) - - return result - } - - async switchBranch(repoId: number, branchName: string, database: Database): Promise { - const repo = getRepoById(database, repoId) - if (!repo) { - throw new Error(`Repository not found`) - } - - const fullPath = path.resolve(repo.fullPath) - const env = this.gitAuthService.getGitEnvironment() - - const result = await executeCommand(['git', '-C', fullPath, 'checkout', branchName], { env }) - - return result - } - - async hasCommits(repoPath: string): Promise { - try { - await executeCommand(['git', '-C', repoPath, 'rev-parse', 'HEAD'], { silent: true }) - return true - } catch { - return false - } - } -} \ No newline at end of file diff --git a/backend/src/services/git/GitCommitService.ts b/backend/src/services/git/GitCommitService.ts deleted file mode 100644 index 04364127..00000000 --- a/backend/src/services/git/GitCommitService.ts +++ /dev/null @@ -1,116 +0,0 @@ -import { GitAuthService } from '../git-auth' -import { logger } from '../../utils/logger' -import * as db from '../../db/queries' -import type { Database } from 'bun:sqlite' -import { executeCommand } from '../../utils/process' -import { SettingsService } from '../settings' -import { resolveGitIdentity, createGitIdentityEnv } from '../../utils/git-auth' - -export class GitCommitService { - constructor(private gitAuthService: GitAuthService) {} - - async commit(repoId: number, message: string, database: Database, stagedPaths?: string[]): Promise { - try { - const repo = db.getRepoById(database, repoId) - if (!repo) { - throw new Error(`Repository not found`) - } - - const repoPath = repo.fullPath - const authEnv = this.gitAuthService.getGitEnvironment() - - // Get identity env - const settingsService = new SettingsService(database) - const settings = settingsService.getSettings('default') - const gitCredentials = settings.preferences.gitCredentials || [] - const identity = await resolveGitIdentity(settings.preferences.gitIdentity, gitCredentials) - const identityEnv = identity ? createGitIdentityEnv(identity) : {} - - const env = { ...authEnv, ...identityEnv } - - const args = ['git', '-C', repoPath, 'commit', '-m', message] - - if (stagedPaths && stagedPaths.length > 0) { - args.push('--') - args.push(...stagedPaths) - } - - const result = await executeCommand(args, { env }) - - return result - } catch (error: unknown) { - logger.error(`Failed to commit changes for repo ${repoId}:`, error) - throw error - } - } - - async stageFiles(repoId: number, paths: string[], database: Database): Promise { - try { - const repo = db.getRepoById(database, repoId) - if (!repo) { - throw new Error(`Repository not found`) - } - - const repoPath = repo.fullPath - const env = this.gitAuthService.getGitEnvironment() - - if (paths.length === 0) { - return '' - } - - const args = ['git', '-C', repoPath, 'add', '--', ...paths] - const result = await executeCommand(args, { env }) - - return result - } catch (error: unknown) { - logger.error(`Failed to stage files for repo ${repoId}:`, error) - throw error - } - } - - async unstageFiles(repoId: number, paths: string[], database: Database): Promise { - try { - const repo = db.getRepoById(database, repoId) - if (!repo) { - throw new Error(`Repository not found`) - } - - const repoPath = repo.fullPath - const env = this.gitAuthService.getGitEnvironment() - - if (paths.length === 0) { - return '' - } - - const args = ['git', '-C', repoPath, 'restore', '--staged', '--', ...paths] - const result = await executeCommand(args, { env }) - - return result - } catch (error: unknown) { - logger.error(`Failed to unstage files for repo ${repoId}:`, error) - throw error - } - } - - - - async resetToCommit(repoId: number, commitHash: string, database: Database): Promise { - try { - const repo = db.getRepoById(database, repoId) - if (!repo) { - throw new Error(`Repository not found`) - } - - const repoPath = repo.fullPath - const env = this.gitAuthService.getGitEnvironment() - - const args = ['git', '-C', repoPath, 'reset', '--hard', commitHash] - const result = await executeCommand(args, { env }) - - return result - } catch (error: unknown) { - logger.error(`Failed to reset to commit ${commitHash} for repo ${repoId}:`, error) - throw error - } - } -} diff --git a/backend/src/services/git/GitDiffService.ts b/backend/src/services/git/GitDiffService.ts deleted file mode 100644 index 0edf35ac..00000000 --- a/backend/src/services/git/GitDiffService.ts +++ /dev/null @@ -1,145 +0,0 @@ -import { GitAuthService } from '../git-auth' -import { executeCommand } from '../../utils/process' -import { getRepoById } from '../../db/queries' -import type { Database } from 'bun:sqlite' -import { getReposPath } from '@opencode-manager/shared/config/env' -import path from 'path' -import { logger } from '../../utils/logger' -import type { FileDiffResponse, GitDiffOptions, GitFileStatusType } from '../../types/git' -import type { GitDiffProvider } from './interfaces' - -export class GitDiffService implements GitDiffProvider { - constructor(private gitAuthService: GitAuthService) {} - - async getFileDiff(repoId: number, filePath: string, database: Database, options?: GitDiffOptions & { includeStaged?: boolean }): Promise { - const repo = getRepoById(database, repoId) - if (!repo) { - throw new Error(`Repository not found: ${repoId}`) - } - - const repoPath = path.resolve(getReposPath(), repo.localPath) - const env = this.gitAuthService.getGitEnvironment() - - const includeStaged = options?.includeStaged ?? true - - const status = await this.getFileStatus(repoPath, filePath, env) - - if (status.status === 'untracked') { - return this.getUntrackedFileDiff(repoPath, filePath, env) - } - - return this.getTrackedFileDiff(repoPath, filePath, env, includeStaged, options) - } - - private async getFileStatus(repoPath: string, filePath: string, env: Record): Promise<{ status: string }> { - try { - const output = await executeCommand([ - 'git', '-C', repoPath, 'status', '--porcelain', '--', filePath - ], { env, silent: true }) - - if (!output.trim()) { - return { status: 'untracked' } - } - - const statusCode = output.trim().split(' ')[0] - return { status: statusCode || 'untracked' } - } catch { - return { status: 'untracked' } - } - } - - private async getUntrackedFileDiff(repoPath: string, filePath: string, env: Record): Promise { - const result = await executeCommand([ - 'git', '-C', repoPath, 'diff', '--no-index', '--', '/dev/null', filePath - ], { env, ignoreExitCode: true }) - - if (typeof result === 'string') { - return this.parseDiffOutput(result, 'untracked', filePath) - } - - // Non-zero exit, but still parse stdout - return this.parseDiffOutput((result as { stdout: string }).stdout, 'untracked', filePath) - } - - private async getTrackedFileDiff(repoPath: string, filePath: string, env: Record, includeStaged: boolean, options?: GitDiffOptions): Promise { - try { - const hasCommits = await this.hasCommits(repoPath) - const diffArgs = ['git', '-C', repoPath, 'diff'] - - if (options?.showContext !== undefined) { - diffArgs.push(`-U${options.showContext}`) - } - - if (options?.ignoreWhitespace) { - diffArgs.push('--ignore-all-space') - } - - if (options?.unified !== undefined) { - diffArgs.push(`--unified=${options.unified}`) - } - - if (hasCommits) { - if (includeStaged) { - diffArgs.push('HEAD', '--', filePath) - } else { - diffArgs.push('--', filePath) - } - } else { - return { - path: filePath, - status: 'added', - diff: `New file (no commits yet): ${filePath}`, - additions: 0, - deletions: 0, - isBinary: false - } - } - - const diff = await executeCommand(diffArgs, { env }) - return this.parseDiffOutput(diff, 'modified', filePath) - } catch (error) { - logger.warn(`Failed to get diff for tracked file ${filePath}:`, error) - throw new Error(`Failed to get file diff: ${error instanceof Error ? error.message : String(error)}`) - } - } - - private parseDiffOutput(diff: string, status: string, filePath?: string): FileDiffResponse { - let additions = 0 - let deletions = 0 - let isBinary = false - - if (typeof diff === 'string') { - if (diff.includes('Binary files') || diff.includes('GIT binary patch')) { - isBinary = true - } else { - const lines = diff.split('\n') - for (const line of lines) { - if (line.startsWith('+') && !line.startsWith('+++')) additions++ - if (line.startsWith('-') && !line.startsWith('---')) deletions++ - } - } - } - - return { - path: filePath || '', - status: status as GitFileStatusType, - diff: typeof diff === 'string' ? diff : '', - additions, - deletions, - isBinary - } - } - - private async hasCommits(repoPath: string): Promise { - try { - await executeCommand(['git', '-C', repoPath, 'rev-parse', 'HEAD'], { silent: true }) - return true - } catch { - return false - } - } - - async getFullDiff(repoId: number, filePath: string, database: Database, options?: GitDiffOptions): Promise { - return this.getFileDiff(repoId, filePath, database, options) - } -} \ No newline at end of file diff --git a/backend/src/services/git/GitFetchPullService.ts b/backend/src/services/git/GitFetchPullService.ts deleted file mode 100644 index 3a7b3089..00000000 --- a/backend/src/services/git/GitFetchPullService.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { GitAuthService } from '../git-auth' -import { executeCommand } from '../../utils/process' -import { getRepoById } from '../../db/queries' -import type { Database } from 'bun:sqlite' -import path from 'path' - -export class GitFetchPullService { - constructor(private gitAuthService: GitAuthService) {} - - async fetch(repoId: number, database: Database): Promise { - const repo = getRepoById(database, repoId) - if (!repo) { - throw new Error('Repository not found') - } - - const fullPath = path.resolve(repo.fullPath) - const env = this.gitAuthService.getGitEnvironment(true) - - return executeCommand(['git', '-C', fullPath, 'fetch', '--all', '--prune'], { env }) - } - - async pull(repoId: number, database: Database): Promise { - const repo = getRepoById(database, repoId) - if (!repo) { - throw new Error('Repository not found') - } - - const fullPath = path.resolve(repo.fullPath) - const env = this.gitAuthService.getGitEnvironment(false) - - return executeCommand(['git', '-C', fullPath, 'pull'], { env }) - } -} \ No newline at end of file diff --git a/backend/src/services/git/GitLogService.ts b/backend/src/services/git/GitLogService.ts deleted file mode 100644 index a655dc96..00000000 --- a/backend/src/services/git/GitLogService.ts +++ /dev/null @@ -1,143 +0,0 @@ -import { GitAuthService } from '../git-auth' -import { executeCommand } from '../../utils/process' -import { logger } from '../../utils/logger' -import { getErrorMessage } from '../../utils/error-utils' -import * as db from '../../db/queries' -import { getReposPath } from '@opencode-manager/shared/config/env' -import type { Database } from 'bun:sqlite' -import type { GitCommit, FileDiffResponse } from '../../types/git' -import path from 'path' -import type { GitDiffService } from './GitDiffService' - -export class GitLogService { - private gitAuthService: GitAuthService - private gitDiffService: GitDiffService - - constructor(gitAuthService: GitAuthService, gitDiffService: GitDiffService) { - this.gitAuthService = gitAuthService - this.gitDiffService = gitDiffService - } - - async getLog(repoId: number, database: Database, limit: number = 10): Promise { - try { - const repo = db.getRepoById(database, repoId) - if (!repo) { - throw new Error(`Repository not found: ${repoId}`) - } - - const repoPath = path.resolve(getReposPath(), repo.localPath) - - const logArgs = [ - 'git', - '-C', - repoPath, - 'log', - `--all`, - `-n`, - String(limit), - '--format=%H|%an|%ae|%at|%s' - ] - const logEnv = this.gitAuthService.getGitEnvironment(true) - const output = await executeCommand(logArgs, { env: logEnv }) - - const lines = output.trim().split('\n') - const commits: GitCommit[] = [] - - for (const line of lines) { - if (!line.trim()) continue - - const parts = line.split('|') - const [hash, authorName, authorEmail, timestamp, ...messageParts] = parts - const message = messageParts.join('|') - - if (hash) { - commits.push({ - hash, - authorName: authorName || '', - authorEmail: authorEmail || '', - date: timestamp || '', - message: message || '' - }) - } - } - - const unpushedCommits = await this.getUnpushedCommitHashes(repoPath, logEnv) - - return commits.map(commit => ({ - ...commit, - unpushed: unpushedCommits.has(commit.hash) - })) - } catch (error: unknown) { - logger.error(`Failed to get git log for repo ${repoId}:`, error) - throw new Error(`Failed to get git log: ${getErrorMessage(error)}`) - } - } - - private async getUnpushedCommitHashes(repoPath: string, env: Record): Promise> { - try { - const output = await executeCommand( - ['git', '-C', repoPath, 'log', '--not', '--remotes', '--format=%H'], - { env, silent: true } - ) - const hashes = output.trim().split('\n').filter(Boolean) - return new Set(hashes) - } catch { - return new Set() - } - } - - async getCommit(repoId: number, hash: string, database: Database): Promise { - try { - const repo = db.getRepoById(database, repoId) - if (!repo) { - throw new Error(`Repository not found: ${repoId}`) - } - - const repoPath = path.resolve(getReposPath(), repo.localPath) - const logArgs = [ - 'git', - '-C', - repoPath, - 'log', - '--format=%H|%an|%ae|%at|%s', - hash, - '-1' - ] - const env = this.gitAuthService.getGitEnvironment(true) - - const output = await executeCommand(logArgs, { env }) - - if (!output.trim()) { - return null - } - - const parts = output.trim().split('|') - const [commitHash, authorName, authorEmail, timestamp, ...messageParts] = parts - const message = messageParts.join('|') - - if (!commitHash) { - return null - } - - return { - hash: commitHash, - authorName: authorName || '', - authorEmail: authorEmail || '', - date: timestamp || '', - message: message || '' - } - } catch (error: unknown) { - logger.error(`Failed to get commit ${hash} for repo ${repoId}:`, error) - throw new Error(`Failed to get commit: ${getErrorMessage(error)}`) - } - } - - async getDiff(repoId: number, filePath: string, database: Database): Promise { - const result = await this.gitDiffService.getFileDiff(repoId, filePath, database) - return result.diff - } - - async getFullDiff(repoId: number, filePath: string, database: Database, includeStaged?: boolean): Promise { - return this.gitDiffService.getFileDiff(repoId, filePath, database, { includeStaged }) - } -} diff --git a/backend/src/services/git/GitPushService.ts b/backend/src/services/git/GitPushService.ts deleted file mode 100644 index c0fbe8b0..00000000 --- a/backend/src/services/git/GitPushService.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { executeCommand } from '../../utils/process' -import { GitAuthService } from '../git-auth' -import { GitBranchService } from './GitBranchService' -import { isNoUpstreamError, parseBranchNameFromError } from '../../utils/git-errors' -import type { Database } from 'bun:sqlite' -import * as db from '../../db/queries' -import path from 'path' - -export class GitPushService { - constructor( - private gitAuthService: GitAuthService, - private branchService: GitBranchService - ) {} - - async push( - repoId: number, - options: { setUpstream?: boolean }, - database: Database - ): Promise { - const repo = db.getRepoById(database, repoId) - if (!repo) { - throw new Error('Repository not found') - } - - const fullPath = path.resolve(repo.fullPath) - const env = this.gitAuthService.getGitEnvironment() - - if (options.setUpstream) { - return await this.pushWithUpstream(repoId, fullPath, env) - } - - try { - const args = ['git', '-C', fullPath, 'push'] - return await executeCommand(args, { env }) - } catch (error) { - if (isNoUpstreamError(error as Error)) { - return await this.pushWithUpstream(repoId, fullPath, env) - } - throw error - } - } - - private async pushWithUpstream( - repoId: number, - fullPath: string, - env: Record - ): Promise { - let branchName: string | null = null - - try { - const result = await executeCommand( - ['git', '-C', fullPath, 'rev-parse', '--abbrev-ref', 'HEAD'], - { env } - ) - branchName = result.trim() - if (branchName === 'HEAD') { - branchName = null - } - } catch (error) { - branchName = parseBranchNameFromError(error as Error) - } - - if (!branchName) { - throw new Error('Unable to detect current branch. Ensure you are on a branch before pushing with --set-upstream.') - } - - const args = ['git', '-C', fullPath, 'push', '--set-upstream', 'origin', branchName] - return executeCommand(args, { env }) - } -} diff --git a/backend/src/services/git/GitService.ts b/backend/src/services/git/GitService.ts new file mode 100644 index 00000000..a99f3b8c --- /dev/null +++ b/backend/src/services/git/GitService.ts @@ -0,0 +1,693 @@ +import { GitAuthService } from '../git-auth' +import { executeCommand } from '../../utils/process' +import { logger } from '../../utils/logger' +import { getErrorMessage } from '../../utils/error-utils' +import { getRepoById } from '../../db/queries' +import { resolveGitIdentity, createGitIdentityEnv } from '../../utils/git-auth' +import { isNoUpstreamError, parseBranchNameFromError } from '../../utils/git-errors' +import { SettingsService } from '../settings' +import type { Database } from 'bun:sqlite' +import type { GitBranch, GitCommit, FileDiffResponse, GitDiffOptions, GitStatusResponse, GitFileStatus, GitFileStatusType } from '../../types/git' +import path from 'path' + +export class GitService { + constructor( + private gitAuthService: GitAuthService, + private settingsService: SettingsService + ) {} + + async getStatus(repoId: number, database: Database): Promise { + try { + const repo = getRepoById(database, repoId) + if (!repo) { + throw new Error(`Repository not found`) + } + + const repoPath = repo.fullPath + const env = this.gitAuthService.getGitEnvironment() + + const [branch, branchStatus, porcelainOutput] = await Promise.all([ + this.getCurrentBranch(repoPath, env), + this.getBranchStatusFromPath(repoPath, env), + executeCommand(['git', '-C', repoPath, 'status', '--porcelain'], { env }) + ]) + + const files = this.parsePorcelainOutput(porcelainOutput) + const hasChanges = files.length > 0 + + return { + branch, + ahead: branchStatus.ahead, + behind: branchStatus.behind, + files, + hasChanges + } + } catch (error: unknown) { + logger.error(`Failed to get status for repo ${repoId}:`, error) + throw error + } + } + + async getFileDiff(repoId: number, filePath: string, database: Database, options?: GitDiffOptions & { includeStaged?: boolean }): Promise { + const repo = getRepoById(database, repoId) + if (!repo) { + throw new Error(`Repository not found: ${repoId}`) + } + + const repoPath = path.resolve(repo.fullPath) + const env = this.gitAuthService.getGitEnvironment() + const includeStaged = options?.includeStaged ?? true + + const status = await this.getFileStatus(repoPath, filePath, env) + + if (status.status === 'untracked') { + return this.getUntrackedFileDiff(repoPath, filePath, env) + } + + return this.getTrackedFileDiff(repoPath, filePath, env, includeStaged, options) + } + + async getFullDiff(repoId: number, filePath: string, database: Database, includeStaged?: boolean): Promise { + return this.getFileDiff(repoId, filePath, database, { includeStaged }) + } + + async getLog(repoId: number, database: Database, limit: number = 10): Promise { + try { + const repo = getRepoById(database, repoId) + if (!repo) { + throw new Error(`Repository not found: ${repoId}`) + } + + const repoPath = path.resolve(repo.fullPath) + const logArgs = [ + 'git', + '-C', + repoPath, + 'log', + `--all`, + `-n`, + String(limit), + '--format=%H|%an|%ae|%at|%s' + ] + const logEnv = this.gitAuthService.getGitEnvironment(true) + const output = await executeCommand(logArgs, { env: logEnv }) + + const lines = output.trim().split('\n') + const commits: GitCommit[] = [] + + for (const line of lines) { + if (!line.trim()) continue + + const parts = line.split('|') + const [hash, authorName, authorEmail, timestamp, ...messageParts] = parts + const message = messageParts.join('|') + + if (hash) { + commits.push({ + hash, + authorName: authorName || '', + authorEmail: authorEmail || '', + date: timestamp || '', + message: message || '' + }) + } + } + + const unpushedCommits = await this.getUnpushedCommitHashes(repoPath, logEnv) + + return commits.map(commit => ({ + ...commit, + unpushed: unpushedCommits.has(commit.hash) + })) + } catch (error: unknown) { + logger.error(`Failed to get git log for repo ${repoId}:`, error) + throw new Error(`Failed to get git log: ${getErrorMessage(error)}`) + } + } + + async getCommit(repoId: number, hash: string, database: Database): Promise { + try { + const repo = getRepoById(database, repoId) + if (!repo) { + throw new Error(`Repository not found: ${repoId}`) + } + + const repoPath = path.resolve(repo.fullPath) + const logArgs = [ + 'git', + '-C', + repoPath, + 'log', + '--format=%H|%an|%ae|%at|%s', + hash, + '-1' + ] + const env = this.gitAuthService.getGitEnvironment(true) + + const output = await executeCommand(logArgs, { env }) + + if (!output.trim()) { + return null + } + + const parts = output.trim().split('|') + const [commitHash, authorName, authorEmail, timestamp, ...messageParts] = parts + const message = messageParts.join('|') + + if (!commitHash) { + return null + } + + return { + hash: commitHash, + authorName: authorName || '', + authorEmail: authorEmail || '', + date: timestamp || '', + message: message || '' + } + } catch (error: unknown) { + logger.error(`Failed to get commit ${hash} for repo ${repoId}:`, error) + throw new Error(`Failed to get commit: ${getErrorMessage(error)}`) + } + } + + async getDiff(repoId: number, filePath: string, database: Database): Promise { + const result = await this.getFileDiff(repoId, filePath, database) + return result.diff + } + + async commit(repoId: number, message: string, database: Database, stagedPaths?: string[]): Promise { + try { + const repo = getRepoById(database, repoId) + if (!repo) { + throw new Error(`Repository not found`) + } + + const repoPath = repo.fullPath + const authEnv = this.gitAuthService.getGitEnvironment() + + const settings = this.settingsService.getSettings('default') + const gitCredentials = settings.preferences.gitCredentials || [] + const identity = await resolveGitIdentity(settings.preferences.gitIdentity, gitCredentials) + const identityEnv = identity ? createGitIdentityEnv(identity) : {} + + const env = { ...authEnv, ...identityEnv } + + const args = ['git', '-C', repoPath, 'commit', '-m', message] + + if (stagedPaths && stagedPaths.length > 0) { + args.push('--') + args.push(...stagedPaths) + } + + const result = await executeCommand(args, { env }) + + return result + } catch (error: unknown) { + logger.error(`Failed to commit changes for repo ${repoId}:`, error) + throw error + } + } + + async stageFiles(repoId: number, paths: string[], database: Database): Promise { + try { + const repo = getRepoById(database, repoId) + if (!repo) { + throw new Error(`Repository not found`) + } + + const repoPath = repo.fullPath + const env = this.gitAuthService.getGitEnvironment() + + if (paths.length === 0) { + return '' + } + + const args = ['git', '-C', repoPath, 'add', '--', ...paths] + const result = await executeCommand(args, { env }) + + return result + } catch (error: unknown) { + logger.error(`Failed to stage files for repo ${repoId}:`, error) + throw error + } + } + + async unstageFiles(repoId: number, paths: string[], database: Database): Promise { + try { + const repo = getRepoById(database, repoId) + if (!repo) { + throw new Error(`Repository not found`) + } + + const repoPath = repo.fullPath + const env = this.gitAuthService.getGitEnvironment() + + if (paths.length === 0) { + return '' + } + + const args = ['git', '-C', repoPath, 'restore', '--staged', '--', ...paths] + const result = await executeCommand(args, { env }) + + return result + } catch (error: unknown) { + logger.error(`Failed to unstage files for repo ${repoId}:`, error) + throw error + } + } + + async resetToCommit(repoId: number, commitHash: string, database: Database): Promise { + try { + const repo = getRepoById(database, repoId) + if (!repo) { + throw new Error(`Repository not found`) + } + + const repoPath = repo.fullPath + const env = this.gitAuthService.getGitEnvironment() + + const args = ['git', '-C', repoPath, 'reset', '--hard', commitHash] + const result = await executeCommand(args, { env }) + + return result + } catch (error: unknown) { + logger.error(`Failed to reset to commit ${commitHash} for repo ${repoId}:`, error) + throw error + } + } + + async push(repoId: number, options: { setUpstream?: boolean }, database: Database): Promise { + const repo = getRepoById(database, repoId) + if (!repo) { + throw new Error('Repository not found') + } + + const fullPath = path.resolve(repo.fullPath) + const env = this.gitAuthService.getGitEnvironment() + + if (options.setUpstream) { + return await this.pushWithUpstream(repoId, fullPath, env) + } + + try { + const args = ['git', '-C', fullPath, 'push'] + return await executeCommand(args, { env }) + } catch (error) { + if (isNoUpstreamError(error as Error)) { + return await this.pushWithUpstream(repoId, fullPath, env) + } + throw error + } + } + + async fetch(repoId: number, database: Database): Promise { + const repo = getRepoById(database, repoId) + if (!repo) { + throw new Error('Repository not found') + } + + const fullPath = path.resolve(repo.fullPath) + const env = this.gitAuthService.getGitEnvironment(true) + + return executeCommand(['git', '-C', fullPath, 'fetch', '--all', '--prune'], { env }) + } + + async pull(repoId: number, database: Database): Promise { + const repo = getRepoById(database, repoId) + if (!repo) { + throw new Error('Repository not found') + } + + const fullPath = path.resolve(repo.fullPath) + const env = this.gitAuthService.getGitEnvironment(false) + + return executeCommand(['git', '-C', fullPath, 'pull'], { env }) + } + + async getBranches(repoId: number, database: Database): Promise { + const repo = getRepoById(database, repoId) + if (!repo) { + throw new Error(`Repository not found`) + } + + const fullPath = path.resolve(repo.fullPath) + const env = this.gitAuthService.getGitEnvironment() + + let currentBranch = '' + try { + const currentStdout = await executeCommand(['git', '-C', fullPath, 'rev-parse', '--abbrev-ref', 'HEAD'], { env, silent: true }) + currentBranch = currentStdout.trim() + } catch { + void 0 + } + + const stdout = await executeCommand(['git', '-C', fullPath, 'branch', '-vv', '-a'], { env, silent: true }) + const lines = stdout.split('\n').filter(line => line.trim()) + + const branches: GitBranch[] = [] + const seenNames = new Set() + + for (const line of lines) { + const trimmed = line.trim() + if (!trimmed) continue + + const isCurrent = trimmed.startsWith('*') + const isWorktree = trimmed.startsWith('+') + const namePart = trimmed.replace(/^[*+]?\s*/, '') + + const firstSpace = namePart.indexOf(' ') + const firstBracket = namePart.indexOf('[') + const cutIndex = firstSpace === -1 ? (firstBracket === -1 ? namePart.length : firstBracket) : (firstBracket === -1 ? firstSpace : Math.min(firstSpace, firstBracket)) + const branchName = namePart.slice(0, cutIndex).trim() + + if (!branchName || branchName === '+' || branchName === '->' || branchName.includes('->')) continue + if (/^[0-9a-f]{6,40}$/.test(branchName)) continue + + const branch: GitBranch = { + name: branchName, + type: branchName.startsWith('remotes/') ? 'remote' : 'local', + current: isCurrent && (branchName === currentBranch || branchName === `remotes/${currentBranch}`), + isWorktree + } + + if (seenNames.has(branch.name)) continue + seenNames.add(branch.name) + + const upstreamMatch = namePart.match(/\[([^:]+):?\s*(ahead\s+(\d+))?,?\s*(behind\s+(\d+))?\]/) + if (upstreamMatch) { + branch.upstream = upstreamMatch[1] + branch.ahead = upstreamMatch[3] ? parseInt(upstreamMatch[3]) : 0 + branch.behind = upstreamMatch[5] ? parseInt(upstreamMatch[5]) : 0 + } + + if (branch.current && (!branch.ahead || !branch.behind)) { + try { + const status = await this.getBranchStatusFromDb(repoId, database) + branch.ahead = status.ahead + branch.behind = status.behind + } catch { + void 0 + } + } + + branches.push(branch) + } + + return branches.sort((a, b) => { + if (a.current !== b.current) return b.current ? 1 : -1 + if (a.type !== b.type) return a.type === 'local' ? -1 : 1 + return a.name.localeCompare(b.name) + }) + } + + async getBranchStatus(repoId: number, database: Database): Promise<{ ahead: number; behind: number }> { + try { + const repo = getRepoById(database, repoId) + if (!repo) { + throw new Error(`Repository not found`) + } + + const fullPath = path.resolve(repo.fullPath) + const env = this.gitAuthService.getGitEnvironment() + + const stdout = await executeCommand(['git', '-C', fullPath, 'rev-list', '--left-right', '--count', 'HEAD...@{upstream}'], { env, silent: true }) + const [ahead, behind] = stdout.trim().split(/\s+/).map(Number) + + return { ahead: ahead || 0, behind: behind || 0 } + } catch (error) { + logger.warn(`Could not get branch status for repo ${repoId}, returning zeros:`, error) + return { ahead: 0, behind: 0 } + } + } + + async createBranch(repoId: number, branchName: string, database: Database): Promise { + const repo = getRepoById(database, repoId) + if (!repo) { + throw new Error(`Repository not found`) + } + + const fullPath = path.resolve(repo.fullPath) + const env = this.gitAuthService.getGitEnvironment() + + const result = await executeCommand(['git', '-C', fullPath, 'checkout', '-b', branchName], { env }) + + return result + } + + async switchBranch(repoId: number, branchName: string, database: Database): Promise { + const repo = getRepoById(database, repoId) + if (!repo) { + throw new Error(`Repository not found`) + } + + const fullPath = path.resolve(repo.fullPath) + const env = this.gitAuthService.getGitEnvironment() + + const result = await executeCommand(['git', '-C', fullPath, 'checkout', branchName], { env }) + + return result + } + + private async getCurrentBranch(repoPath: string, env: Record | undefined): Promise { + try { + const branch = await executeCommand(['git', '-C', repoPath, 'rev-parse', '--abbrev-ref', 'HEAD'], { env, silent: true }) + return branch.trim() + } catch { + return '' + } + } + + private async getBranchStatusFromPath(repoPath: string, env: Record | undefined): Promise<{ ahead: number; behind: number }> { + try { + const stdout = await executeCommand(['git', '-C', repoPath, 'rev-list', '--left-right', '--count', 'HEAD...@{upstream}'], { env, silent: true }) + const [ahead, behind] = stdout.trim().split(/\s+/).map(Number) + + return { ahead: ahead || 0, behind: behind || 0 } + } catch { + return { ahead: 0, behind: 0 } + } + } + + private parsePorcelainOutput(output: string): GitFileStatus[] { + const fileMap = new Map() + const lines = output.split('\n').filter(line => line.length > 0) + + for (const line of lines) { + if (line.length < 3) continue + + const stagedStatus = line[0] as string + const unstagedStatus = line[1] as string + let filePath = line.substring(3) + let oldPath: string | undefined + + if ((stagedStatus === 'R' || stagedStatus === 'C') && filePath.includes(' -> ')) { + const arrowIndex = filePath.indexOf(' -> ') + oldPath = filePath.substring(0, arrowIndex) + filePath = filePath.substring(arrowIndex + 4) + } + + const existing = fileMap.get(filePath) + + if (stagedStatus !== ' ' && stagedStatus !== '?') { + const fileStatus: GitFileStatus = { + path: filePath, + status: this.parseStatusCode(stagedStatus), + staged: true, + ...(oldPath && { oldPath }) + } + fileMap.set(filePath, fileStatus) + continue + } + + if (unstagedStatus !== ' ' && unstagedStatus !== '?' && !existing) { + const fileStatus: GitFileStatus = { + path: filePath, + status: this.parseStatusCode(unstagedStatus), + staged: false, + ...(oldPath && { oldPath }) + } + fileMap.set(filePath, fileStatus) + continue + } + + if ((stagedStatus === '?' || unstagedStatus === '?') && !existing) { + const fileStatus: GitFileStatus = { + path: filePath, + status: 'untracked', + staged: false + } + fileMap.set(filePath, fileStatus) + } + } + + return Array.from(fileMap.values()) + } + + private parseStatusCode(code: string): GitFileStatusType { + switch (code) { + case 'M': + return 'modified' + case 'A': + return 'added' + case 'D': + return 'deleted' + case 'R': + return 'renamed' + case 'C': + return 'copied' + case '?': + return 'untracked' + default: + return 'modified' + } + } + + private async getFileStatus(repoPath: string, filePath: string, env: Record): Promise<{ status: string }> { + try { + const output = await executeCommand([ + 'git', '-C', repoPath, 'status', '--porcelain', '--', filePath + ], { env, silent: true }) + + if (!output.trim()) { + return { status: 'untracked' } + } + + const statusCode = output.trim().split(' ')[0] + return { status: statusCode || 'untracked' } + } catch { + return { status: 'untracked' } + } + } + + private async getUntrackedFileDiff(repoPath: string, filePath: string, env: Record): Promise { + const result = await executeCommand([ + 'git', '-C', repoPath, 'diff', '--no-index', '--', '/dev/null', filePath + ], { env, ignoreExitCode: true }) + + if (typeof result === 'string') { + return this.parseDiffOutput(result, 'untracked', filePath) + } + + return this.parseDiffOutput((result as { stdout: string }).stdout, 'untracked', filePath) + } + + private async getTrackedFileDiff(repoPath: string, filePath: string, env: Record, includeStaged: boolean, options?: GitDiffOptions): Promise { + try { + const hasCommits = await this.hasCommits(repoPath) + const diffArgs = ['git', '-C', repoPath, 'diff'] + + if (options?.showContext !== undefined) { + diffArgs.push(`-U${options.showContext}`) + } + + if (options?.ignoreWhitespace) { + diffArgs.push('--ignore-all-space') + } + + if (options?.unified !== undefined) { + diffArgs.push(`--unified=${options.unified}`) + } + + if (hasCommits) { + if (includeStaged) { + diffArgs.push('HEAD', '--', filePath) + } else { + diffArgs.push('--', filePath) + } + } else { + return { + path: filePath, + status: 'added', + diff: `New file (no commits yet): ${filePath}`, + additions: 0, + deletions: 0, + isBinary: false + } + } + + const diff = await executeCommand(diffArgs, { env }) + return this.parseDiffOutput(diff, 'modified', filePath) + } catch (error) { + logger.warn(`Failed to get diff for tracked file ${filePath}:`, error) + throw new Error(`Failed to get file diff: ${error instanceof Error ? error.message : String(error)}`) + } + } + + private parseDiffOutput(diff: string, status: string, filePath?: string): FileDiffResponse { + let additions = 0 + let deletions = 0 + let isBinary = false + + if (typeof diff === 'string') { + if (diff.includes('Binary files') || diff.includes('GIT binary patch')) { + isBinary = true + } else { + const lines = diff.split('\n') + for (const line of lines) { + if (line.startsWith('+') && !line.startsWith('+++')) additions++ + if (line.startsWith('-') && !line.startsWith('---')) deletions++ + } + } + } + + return { + path: filePath || '', + status: status as GitFileStatusType, + diff: typeof diff === 'string' ? diff : '', + additions, + deletions, + isBinary + } + } + + private async hasCommits(repoPath: string): Promise { + try { + await executeCommand(['git', '-C', repoPath, 'rev-parse', 'HEAD'], { silent: true }) + return true + } catch { + return false + } + } + + private async getUnpushedCommitHashes(repoPath: string, env: Record): Promise> { + try { + const output = await executeCommand( + ['git', '-C', repoPath, 'log', '--not', '--remotes', '--format=%H'], + { env, silent: true } + ) + const hashes = output.trim().split('\n').filter(Boolean) + return new Set(hashes) + } catch { + return new Set() + } + } + + private async pushWithUpstream(repoId: number, fullPath: string, env: Record): Promise { + let branchName: string | null = null + + try { + const result = await executeCommand( + ['git', '-C', fullPath, 'rev-parse', '--abbrev-ref', 'HEAD'], + { env } + ) + branchName = result.trim() + if (branchName === 'HEAD') { + branchName = null + } + } catch (error) { + branchName = parseBranchNameFromError(error as Error) + } + + if (!branchName) { + throw new Error('Unable to detect current branch. Ensure you are on a branch before pushing with --set-upstream.') + } + + const args = ['git', '-C', fullPath, 'push', '--set-upstream', 'origin', branchName] + return executeCommand(args, { env }) + } + + private async getBranchStatusFromDb(repoId: number, database: Database): Promise<{ ahead: number; behind: number }> { + return this.getBranchStatus(repoId, database) + } +} diff --git a/backend/src/services/git/GitStatusService.ts b/backend/src/services/git/GitStatusService.ts deleted file mode 100644 index a518965a..00000000 --- a/backend/src/services/git/GitStatusService.ts +++ /dev/null @@ -1,136 +0,0 @@ -import { GitAuthService } from '../git-auth' -import { executeCommand } from '../../utils/process' -import { logger } from '../../utils/logger' -import * as db from '../../db/queries' -import type { Database } from 'bun:sqlite' -import type { GitFileStatus, GitStatusResponse } from '../../types/git' - -export class GitStatusService { - constructor(private gitAuthService: GitAuthService) {} - - async getStatus(repoId: number, database: Database): Promise { - try { - const repo = db.getRepoById(database, repoId) - if (!repo) { - throw new Error(`Repository not found`) - } - - const repoPath = repo.fullPath - const env = this.gitAuthService.getGitEnvironment() - - const [branch, branchStatus, porcelainOutput] = await Promise.all([ - this.getCurrentBranch(repoPath, env), - this.getBranchStatus(repoPath, env), - executeCommand(['git', '-C', repoPath, 'status', '--porcelain'], { env }) - ]) - - const files = this.parsePorcelainOutput(porcelainOutput) - const hasChanges = files.length > 0 - - return { - branch, - ahead: branchStatus.ahead, - behind: branchStatus.behind, - files, - hasChanges - } - } catch (error: unknown) { - logger.error(`Failed to get status for repo ${repoId}:`, error) - throw error - } - } - - private async getCurrentBranch(repoPath: string, env: Record | undefined): Promise { - try { - const branch = await executeCommand(['git', '-C', repoPath, 'rev-parse', '--abbrev-ref', 'HEAD'], { env, silent: true }) - return branch.trim() - } catch { - return '' - } - } - - private async getBranchStatus(repoPath: string, env: Record | undefined): Promise<{ ahead: number; behind: number }> { - try { - const stdout = await executeCommand(['git', '-C', repoPath, 'rev-list', '--left-right', '--count', 'HEAD...@{upstream}'], { env, silent: true }) - const [ahead, behind] = stdout.trim().split(/\s+/).map(Number) - - return { ahead: ahead || 0, behind: behind || 0 } - } catch { - return { ahead: 0, behind: 0 } - } - } - - private parsePorcelainOutput(output: string): GitFileStatus[] { - const fileMap = new Map() - const lines = output.split('\n').filter(line => line.length > 0) - - for (const line of lines) { - if (line.length < 3) continue - - const stagedStatus = line[0] as string - const unstagedStatus = line[1] as string - let filePath = line.substring(3) - let oldPath: string | undefined - - if ((stagedStatus === 'R' || stagedStatus === 'C') && filePath.includes(' -> ')) { - const arrowIndex = filePath.indexOf(' -> ') - oldPath = filePath.substring(0, arrowIndex) - filePath = filePath.substring(arrowIndex + 4) - } - - const existing = fileMap.get(filePath) - - if (stagedStatus !== ' ' && stagedStatus !== '?') { - const fileStatus: GitFileStatus = { - path: filePath, - status: this.parseStatusCode(stagedStatus), - staged: true, - ...(oldPath && { oldPath }) - } - fileMap.set(filePath, fileStatus) - continue - } - - if (unstagedStatus !== ' ' && unstagedStatus !== '?' && !existing) { - const fileStatus: GitFileStatus = { - path: filePath, - status: this.parseStatusCode(unstagedStatus), - staged: false, - ...(oldPath && { oldPath }) - } - fileMap.set(filePath, fileStatus) - continue - } - - if ((stagedStatus === '?' || unstagedStatus === '?') && !existing) { - const fileStatus: GitFileStatus = { - path: filePath, - status: 'untracked', - staged: false - } - fileMap.set(filePath, fileStatus) - } - } - - return Array.from(fileMap.values()) - } - - private parseStatusCode(code: string): 'modified' | 'added' | 'deleted' | 'renamed' | 'untracked' | 'copied' { - switch (code) { - case 'M': - return 'modified' - case 'A': - return 'added' - case 'D': - return 'deleted' - case 'R': - return 'renamed' - case 'C': - return 'copied' - case '?': - return 'untracked' - default: - return 'modified' - } - } -} \ No newline at end of file diff --git a/backend/src/services/git/interfaces.ts b/backend/src/services/git/interfaces.ts deleted file mode 100644 index 67a0d870..00000000 --- a/backend/src/services/git/interfaces.ts +++ /dev/null @@ -1,11 +0,0 @@ -import type { Database } from 'bun:sqlite' -import type { FileDiffResponse, GitDiffOptions, GitStatusResponse } from '../../types/git' - -export interface GitDiffProvider { - getFileDiff(repoId: number, filePath: string, database: Database, options?: GitDiffOptions): Promise - getFullDiff(repoId: number, filePath: string, database: Database, options?: GitDiffOptions): Promise -} - -export interface GitStatusProvider { - getStatus(repoId: number, database: Database): Promise -} \ No newline at end of file diff --git a/backend/src/types/git.ts b/backend/src/types/git.ts index 1b6b810e..3c3a8569 100644 --- a/backend/src/types/git.ts +++ b/backend/src/types/git.ts @@ -38,3 +38,13 @@ export interface GitDiffOptions { ignoreWhitespace?: boolean unified?: boolean } + +export interface GitBranch { + name: string + type: 'local' | 'remote' + current: boolean + upstream?: string + ahead?: number + behind?: number + isWorktree?: boolean +} diff --git a/backend/test/routes/repo-git.test.ts b/backend/test/routes/repo-git.test.ts index 1b56de1d..d5e48844 100644 --- a/backend/test/routes/repo-git.test.ts +++ b/backend/test/routes/repo-git.test.ts @@ -177,7 +177,7 @@ describe('Repo Git Routes', () => { const { executeCommand } = await import('../../src/utils/process') const executeCommandMock = executeCommand as MockedFunction - getRepoByIdMock.mockReturnValue({ id: 1, localPath: 'test-repo' } as any) + getRepoByIdMock.mockReturnValue({ id: 1, localPath: 'test-repo', fullPath: '/repos/test-repo' } as any) executeCommandMock.mockImplementation((args) => { if (args.includes('status')) return Promise.resolve('M file.ts') if (args.includes('rev-parse')) return Promise.resolve('abc123') @@ -199,7 +199,7 @@ describe('Repo Git Routes', () => { const { executeCommand } = await import('../../src/utils/process') const executeCommandMock = executeCommand as MockedFunction - getRepoByIdMock.mockReturnValue({ id: 1, localPath: 'test-repo' } as any) + getRepoByIdMock.mockReturnValue({ id: 1, localPath: 'test-repo', fullPath: '/repos/test-repo' } as any) executeCommandMock.mockImplementation((args) => { if (args.includes('status')) return Promise.resolve('M file.ts') if (args.includes('rev-parse')) return Promise.resolve('abc123') diff --git a/backend/test/services/git/GitBranchService.test.ts b/backend/test/services/git/GitBranchService.test.ts deleted file mode 100644 index 087a0473..00000000 --- a/backend/test/services/git/GitBranchService.test.ts +++ /dev/null @@ -1,444 +0,0 @@ -import { describe, it, expect, vi, beforeEach, type MockedFunction } from 'vitest' -import type { Database } from 'bun:sqlite' -import type { GitAuthService } from '../../../src/services/git-auth' - -vi.mock('bun:sqlite', () => ({ - Database: vi.fn() -})) - -vi.mock('../../../src/services/settings', () => ({ - SettingsService: vi.fn() -})) - -vi.mock('../../../src/utils/process', () => ({ - executeCommand: vi.fn(), -})) - -vi.mock('../../../src/db/queries', () => ({ - getRepoById: vi.fn(), -})) - -vi.mock('../../../src/utils/logger', () => ({ - logger: { - warn: vi.fn(), - error: vi.fn(), - info: vi.fn(), - }, -})) - -import { GitBranchService } from '../../../src/services/git/GitBranchService' -import { executeCommand } from '../../../src/utils/process' -import { getRepoById } from '../../../src/db/queries' - -const executeCommandMock = executeCommand as MockedFunction -const getRepoByIdMock = getRepoById as MockedFunction - -describe('GitBranchService', () => { - let service: GitBranchService - let database: Database - let mockGitAuthService: GitAuthService - - beforeEach(() => { - vi.clearAllMocks() - database = {} as Database - mockGitAuthService = { - getGitEnvironment: vi.fn().mockReturnValue({}), - } as unknown as GitAuthService - service = new GitBranchService(mockGitAuthService) - }) - - describe('getBranches', () => { - it('returns list of local branches', async () => { - const mockRepo = { id: 1, fullPath: '/path/to/repo' } - getRepoByIdMock.mockReturnValue(mockRepo as any) - executeCommandMock.mockImplementation((args) => { - if (args.includes('rev-parse')) return Promise.resolve('main') - if (args.includes('branch')) return Promise.resolve('* main abc123 [origin/main] Initial commit\n feature def456 [origin/feature] Feature work') - if (args.includes('rev-list')) return Promise.resolve('0 0') - return Promise.resolve('') - }) - - const result = await service.getBranches(1, database) - - expect(result).toHaveLength(2) - expect(result[0]).toMatchObject({ name: 'main', type: 'local', current: true }) - expect(result[1]).toMatchObject({ name: 'feature', type: 'local', current: false }) - }) - - it('returns remote branches with remotes/ prefix', async () => { - const mockRepo = { id: 1, fullPath: '/path/to/repo' } - getRepoByIdMock.mockReturnValue(mockRepo as any) - executeCommandMock.mockImplementation((args) => { - if (args.includes('rev-parse')) return Promise.resolve('main') - if (args.includes('branch')) return Promise.resolve('* main abc123\n remotes/origin/main def456\n remotes/origin/develop ghi789') - if (args.includes('rev-list')) return Promise.resolve('0 0') - return Promise.resolve('') - }) - - const result = await service.getBranches(1, database) - - expect(result.filter(b => b.type === 'remote')).toHaveLength(2) - expect(result.find(b => b.name === 'remotes/origin/main')).toMatchObject({ type: 'remote' }) - }) - - it('parses upstream tracking info with ahead/behind', async () => { - const mockRepo = { id: 1, fullPath: '/path/to/repo' } - getRepoByIdMock.mockReturnValue(mockRepo as any) - executeCommandMock.mockImplementation((args) => { - if (args.includes('rev-parse')) return Promise.resolve('feature') - if (args.includes('branch')) return Promise.resolve('* feature abc123 [origin/feature: ahead 3, behind 2] Work in progress') - if (args.includes('rev-list')) return Promise.resolve('3 2') - return Promise.resolve('') - }) - - const result = await service.getBranches(1, database) - - expect(result[0]).toMatchObject({ - name: 'feature', - upstream: 'origin/feature', - ahead: 3, - behind: 2, - }) - }) - - it('parses upstream with only ahead count', async () => { - const mockRepo = { id: 1, fullPath: '/path/to/repo' } - getRepoByIdMock.mockReturnValue(mockRepo as any) - executeCommandMock.mockImplementation((args) => { - if (args.includes('rev-parse')) return Promise.resolve('main') - if (args.includes('branch')) return Promise.resolve('* main abc123 [origin/main: ahead 5] Latest changes') - if (args.includes('rev-list')) return Promise.resolve('5 0') - return Promise.resolve('') - }) - - const result = await service.getBranches(1, database) - - expect(result[0]).toMatchObject({ - upstream: 'origin/main', - ahead: 5, - behind: 0, - }) - }) - - it('parses upstream with only behind count', async () => { - const mockRepo = { id: 1, fullPath: '/path/to/repo' } - getRepoByIdMock.mockReturnValue(mockRepo as any) - executeCommandMock.mockImplementation((args) => { - if (args.includes('rev-parse')) return Promise.resolve('main') - if (args.includes('branch')) return Promise.resolve('* main abc123 [origin/main: behind 3] Old version') - if (args.includes('rev-list')) return Promise.resolve('0 3') - return Promise.resolve('') - }) - - const result = await service.getBranches(1, database) - - expect(result[0]).toMatchObject({ - upstream: 'origin/main', - ahead: 0, - behind: 3, - }) - }) - - it('sorts branches: current first, then local, then remote, alphabetically', async () => { - const mockRepo = { id: 1, fullPath: '/path/to/repo' } - getRepoByIdMock.mockReturnValue(mockRepo as any) - executeCommandMock.mockImplementation((args) => { - if (args.includes('rev-parse')) return Promise.resolve('feature') - if (args.includes('branch')) { - return Promise.resolve( - ' main abc123\n' + - '* feature def456\n' + - ' remotes/origin/main ghi789\n' + - ' another jkl012\n' + - ' remotes/origin/develop mno345' - ) - } - if (args.includes('rev-list')) return Promise.resolve('0 0') - return Promise.resolve('') - }) - - const result = await service.getBranches(1, database) - - expect(result[0]!.name).toBe('feature') - expect(result[0]!.current).toBe(true) - expect(result.slice(1, 3).every((b) => b.type === 'local')).toBe(true) - expect(result.slice(3).every((b) => b.type === 'remote')).toBe(true) - }) - - it('deduplicates branch names', async () => { - const mockRepo = { id: 1, fullPath: '/path/to/repo' } - getRepoByIdMock.mockReturnValue(mockRepo as any) - executeCommandMock.mockImplementation((args) => { - if (args.includes('rev-parse')) return Promise.resolve('main') - if (args.includes('branch')) return Promise.resolve('* main abc123\n main def456') - if (args.includes('rev-list')) return Promise.resolve('0 0') - return Promise.resolve('') - }) - - const result = await service.getBranches(1, database) - - expect(result.filter(b => b.name === 'main')).toHaveLength(1) - }) - - it('handles current branch rev-parse failure gracefully', async () => { - const mockRepo = { id: 1, fullPath: '/path/to/repo' } - getRepoByIdMock.mockReturnValue(mockRepo as any) - executeCommandMock.mockImplementation((args) => { - if (args.includes('rev-parse')) return Promise.reject(new Error('Not a git repo')) - if (args.includes('branch')) return Promise.resolve('* main abc123\n feature def456') - if (args.includes('rev-list')) return Promise.resolve('0 0') - return Promise.resolve('') - }) - - const result = await service.getBranches(1, database) - - expect(result).toHaveLength(2) - }) - - it('fetches branch status for current branch without ahead/behind', async () => { - const mockRepo = { id: 1, fullPath: '/path/to/repo' } - getRepoByIdMock.mockReturnValue(mockRepo as any) - executeCommandMock.mockImplementation((args) => { - if (args.includes('rev-parse')) return Promise.resolve('main') - if (args.includes('branch')) return Promise.resolve('* main abc123 Initial commit') - if (args.includes('rev-list')) return Promise.resolve('1 2') - return Promise.resolve('') - }) - - const result = await service.getBranches(1, database) - - expect(result[0]).toMatchObject({ - name: 'main', - current: true, - ahead: 1, - behind: 2, - }) - }) - - it('handles getBranchStatus failure for current branch gracefully', async () => { - const mockRepo = { id: 1, fullPath: '/path/to/repo' } - getRepoByIdMock.mockReturnValue(mockRepo as any) - executeCommandMock.mockImplementation((args) => { - if (args.includes('rev-parse')) return Promise.resolve('main') - if (args.includes('branch')) return Promise.resolve('* main abc123 Initial commit') - if (args.includes('rev-list')) { - return Promise.reject(new Error('No upstream')) - } - return Promise.resolve('') - }) - - const result = await service.getBranches(1, database) - - expect(result[0]!.name).toBe('main') - expect(result[0]!.ahead).toBe(0) - expect(result[0]!.behind).toBe(0) - }) - - it('throws error when repository not found', async () => { - getRepoByIdMock.mockReturnValue(null) - - await expect(service.getBranches(999, database)).rejects.toThrow('Repository not found') - }) - - it('throws error when branch command fails', async () => { - const mockRepo = { id: 1, fullPath: '/path/to/repo' } - getRepoByIdMock.mockReturnValue(mockRepo as any) - executeCommandMock.mockImplementation((args) => { - if (args.includes('rev-parse')) return Promise.resolve('main') - if (args.includes('branch')) return Promise.reject(new Error('Not a git repository')) - return Promise.resolve('') - }) - - await expect(service.getBranches(1, database)).rejects.toThrow('Not a git repository') - }) - - it('handles empty branch output', async () => { - const mockRepo = { id: 1, fullPath: '/path/to/repo' } - getRepoByIdMock.mockReturnValue(mockRepo as any) - executeCommandMock.mockImplementation((args) => { - if (args.includes('rev-parse')) return Promise.resolve('main') - if (args.includes('branch')) return Promise.resolve('') - return Promise.resolve('') - }) - - const result = await service.getBranches(1, database) - - expect(result).toEqual([]) - }) - - it('skips lines with empty branch names', async () => { - const mockRepo = { id: 1, fullPath: '/path/to/repo' } - getRepoByIdMock.mockReturnValue(mockRepo as any) - executeCommandMock.mockImplementation((args) => { - if (args.includes('rev-parse')) return Promise.resolve('main') - if (args.includes('branch')) return Promise.resolve('* main abc123\n \n feature def456') - if (args.includes('rev-list')) return Promise.resolve('0 0') - return Promise.resolve('') - }) - - const result = await service.getBranches(1, database) - - expect(result).toHaveLength(2) - }) - }) - - describe('getBranchStatus', () => { - it('returns correct ahead/behind counts', async () => { - const mockRepo = { id: 1, fullPath: '/path/to/repo' } - getRepoByIdMock.mockReturnValue(mockRepo as any) - executeCommandMock.mockResolvedValue('3\t5') - - const result = await service.getBranchStatus(1, database) - - expect(result).toEqual({ ahead: 3, behind: 5 }) - }) - - it('returns zeros when no upstream', async () => { - const mockRepo = { id: 1, fullPath: '/path/to/repo' } - getRepoByIdMock.mockReturnValue(mockRepo as any) - executeCommandMock.mockRejectedValue(new Error('No upstream branch')) - - const result = await service.getBranchStatus(1, database) - - expect(result).toEqual({ ahead: 0, behind: 0 }) - }) - - it('returns zeros when repository not found', async () => { - getRepoByIdMock.mockReturnValue(null) - - const result = await service.getBranchStatus(999, database) - - expect(result).toEqual({ ahead: 0, behind: 0 }) - }) - - it('handles malformed rev-list output', async () => { - const mockRepo = { id: 1, fullPath: '/path/to/repo' } - getRepoByIdMock.mockReturnValue(mockRepo as any) - executeCommandMock.mockResolvedValue('invalid') - - const result = await service.getBranchStatus(1, database) - - expect(result).toEqual({ ahead: 0, behind: 0 }) - }) - - it('handles space-separated ahead/behind output', async () => { - const mockRepo = { id: 1, fullPath: '/path/to/repo' } - getRepoByIdMock.mockReturnValue(mockRepo as any) - executeCommandMock.mockResolvedValue('7 2') - - const result = await service.getBranchStatus(1, database) - - expect(result).toEqual({ ahead: 7, behind: 2 }) - }) - - it('handles only ahead count correctly', async () => { - const mockRepo = { id: 1, fullPath: '/path/to/repo' } - getRepoByIdMock.mockReturnValue(mockRepo as any) - executeCommandMock.mockResolvedValue('5\t0') - - const result = await service.getBranchStatus(1, database) - - expect(result).toEqual({ ahead: 5, behind: 0 }) - }) - - it('handles only behind count correctly', async () => { - const mockRepo = { id: 1, fullPath: '/path/to/repo' } - getRepoByIdMock.mockReturnValue(mockRepo as any) - executeCommandMock.mockResolvedValue('0\t3') - - const result = await service.getBranchStatus(1, database) - - expect(result).toEqual({ ahead: 0, behind: 3 }) - }) - }) - - describe('createBranch', () => { - it('creates and switches to new branch', async () => { - const mockRepo = { id: 1, fullPath: '/path/to/repo' } - getRepoByIdMock.mockReturnValue(mockRepo as any) - executeCommandMock.mockResolvedValue("Switched to a new branch 'feature-branch'") - - const result = await service.createBranch(1, 'feature-branch', database) - - expect(executeCommandMock).toHaveBeenCalledWith( - ['git', '-C', expect.stringContaining('/path/to/repo'), 'checkout', '-b', 'feature-branch'], - { env: expect.any(Object) } - ) - expect(result).toBe("Switched to a new branch 'feature-branch'") - }) - - it('throws error when repository not found', async () => { - getRepoByIdMock.mockReturnValue(null) - - await expect(service.createBranch(999, 'new-branch', database)).rejects.toThrow('Repository not found') - }) - - it('throws error when branch already exists', async () => { - const mockRepo = { id: 1, fullPath: '/path/to/repo' } - getRepoByIdMock.mockReturnValue(mockRepo as any) - executeCommandMock.mockRejectedValue(new Error("fatal: a branch named 'existing' already exists")) - - await expect(service.createBranch(1, 'existing', database)).rejects.toThrow("fatal: a branch named 'existing' already exists") - }) - }) - - describe('switchBranch', () => { - it('switches to existing branch', async () => { - const mockRepo = { id: 1, fullPath: '/path/to/repo' } - getRepoByIdMock.mockReturnValue(mockRepo as any) - executeCommandMock.mockResolvedValue("Switched to branch 'main'") - - const result = await service.switchBranch(1, 'main', database) - - expect(executeCommandMock).toHaveBeenCalledWith( - ['git', '-C', expect.stringContaining('/path/to/repo'), 'checkout', 'main'], - { env: expect.any(Object) } - ) - expect(result).toBe("Switched to branch 'main'") - }) - - it('throws error when repository not found', async () => { - getRepoByIdMock.mockReturnValue(null) - - await expect(service.switchBranch(999, 'main', database)).rejects.toThrow('Repository not found') - }) - - it('throws error when branch does not exist', async () => { - const mockRepo = { id: 1, fullPath: '/path/to/repo' } - getRepoByIdMock.mockReturnValue(mockRepo as any) - executeCommandMock.mockRejectedValue(new Error("error: pathspec 'nonexistent' did not match any file(s)")) - - await expect(service.switchBranch(1, 'nonexistent', database)).rejects.toThrow("error: pathspec 'nonexistent' did not match any file(s)") - }) - }) - - describe('hasCommits', () => { - it('returns true when HEAD exists', async () => { - executeCommandMock.mockResolvedValue('abc123def456') - - const result = await service.hasCommits('/path/to/repo') - - expect(result).toBe(true) - expect(executeCommandMock).toHaveBeenCalledWith( - ['git', '-C', '/path/to/repo', 'rev-parse', 'HEAD'], - { silent: true } - ) - }) - - it('returns false when no commits exist', async () => { - executeCommandMock.mockRejectedValue(new Error("fatal: ambiguous argument 'HEAD': unknown revision")) - - const result = await service.hasCommits('/path/to/fresh-repo') - - expect(result).toBe(false) - }) - - it('returns false for non-git directory', async () => { - executeCommandMock.mockRejectedValue(new Error('fatal: not a git repository')) - - const result = await service.hasCommits('/path/to/not-a-repo') - - expect(result).toBe(false) - }) - }) -}) diff --git a/backend/test/services/git/GitCommitService.test.ts b/backend/test/services/git/GitCommitService.test.ts deleted file mode 100644 index 10887f1b..00000000 --- a/backend/test/services/git/GitCommitService.test.ts +++ /dev/null @@ -1,233 +0,0 @@ -import { describe, it, expect, vi, beforeEach, type MockedFunction } from 'vitest' -import type { Database } from 'bun:sqlite' -import type { GitAuthService } from '../../../src/services/git-auth' - -vi.mock('bun:sqlite', () => ({ - Database: vi.fn() -})) - -vi.mock('../../../src/services/settings', () => ({ - SettingsService: vi.fn().mockImplementation(() => ({ - getSettings: vi.fn().mockReturnValue({ - preferences: { - gitIdentity: null, - gitCredentials: [], - }, - }), - })), -})) - -vi.mock('../../../src/utils/process', () => ({ - executeCommand: vi.fn(), -})) - -vi.mock('../../../src/db/queries', () => ({ - getRepoById: vi.fn(), -})) - -vi.mock('../../../src/utils/git-auth', () => ({ - resolveGitIdentity: vi.fn().mockResolvedValue(null), - createGitIdentityEnv: vi.fn().mockReturnValue({}), - createSilentGitEnv: vi.fn(), -})) - -import { GitCommitService } from '../../../src/services/git/GitCommitService' -import { executeCommand } from '../../../src/utils/process' -import { getRepoById } from '../../../src/db/queries' - -const executeCommandMock = executeCommand as MockedFunction -const getRepoByIdMock = getRepoById as MockedFunction - -describe('GitCommitService', () => { - let service: GitCommitService - let database: Database - let mockGitAuthService: GitAuthService - - beforeEach(() => { - vi.clearAllMocks() - database = {} as Database - mockGitAuthService = { - getGitEnvironment: vi.fn().mockReturnValue({}), - } as unknown as GitAuthService - service = new GitCommitService(mockGitAuthService) - }) - - describe('commit', () => { - it('commits staged changes with message', async () => { - const mockRepo = { - id: 1, - fullPath: '/path/to/repo', - } - getRepoByIdMock.mockReturnValue(mockRepo as any) - executeCommandMock.mockResolvedValue('[main abc1234] Test commit\n 1 file changed') - - const result = await service.commit(1, 'Test commit', database) - - expect(getRepoByIdMock).toHaveBeenCalledWith(database, 1) - expect(executeCommandMock).toHaveBeenCalledWith( - ['git', '-C', mockRepo.fullPath, 'commit', '-m', 'Test commit'], - { env: expect.any(Object) } - ) - expect(result).toBe('[main abc1234] Test commit\n 1 file changed') - }) - - it('commits specific staged files', async () => { - const mockRepo = { - id: 1, - fullPath: '/path/to/repo', - } - getRepoByIdMock.mockReturnValue(mockRepo as any) - executeCommandMock.mockResolvedValue('[main abc1234] Commit specific files\n 2 files changed') - - const result = await service.commit(1, 'Commit specific files', database, ['file1.ts', 'file2.ts']) - - expect(executeCommandMock).toHaveBeenCalledWith( - ['git', '-C', mockRepo.fullPath, 'commit', '-m', 'Commit specific files', '--', 'file1.ts', 'file2.ts'], - { env: expect.any(Object) } - ) - expect(result).toBe('[main abc1234] Commit specific files\n 2 files changed') - }) - - it('throws error when repository not found', async () => { - getRepoByIdMock.mockReturnValue(null) - - await expect(service.commit(999, 'Test', database)).rejects.toThrow('Repository not found') - }) - - it('throws error when commit command fails', async () => { - const mockRepo = { - id: 1, - fullPath: '/path/to/repo', - } - getRepoByIdMock.mockReturnValue(mockRepo as any) - executeCommandMock.mockRejectedValue(new Error('Nothing to commit')) - - await expect(service.commit(1, 'Test', database)).rejects.toThrow('Nothing to commit') - }) - }) - - describe('stageFiles', () => { - it('stages files', async () => { - const mockRepo = { - id: 1, - fullPath: '/path/to/repo', - } - getRepoByIdMock.mockReturnValue(mockRepo as any) - executeCommandMock.mockResolvedValue('') - - const result = await service.stageFiles(1, ['file1.ts', 'file2.ts'], database) - - expect(getRepoByIdMock).toHaveBeenCalledWith(database, 1) - expect(executeCommandMock).toHaveBeenCalledWith( - ['git', '-C', mockRepo.fullPath, 'add', '--', 'file1.ts', 'file2.ts'], - { env: expect.any(Object) } - ) - expect(result).toBe('') - }) - - it('returns early when no files to stage', async () => { - const result = await service.stageFiles(1, [], database) - - expect(executeCommandMock).not.toHaveBeenCalled() - expect(result).toBe('') - }) - - it('throws error when repository not found', async () => { - getRepoByIdMock.mockReturnValue(null) - - await expect(service.stageFiles(999, ['file.ts'], database)).rejects.toThrow('Repository not found') - }) - - it('throws error when stage command fails', async () => { - const mockRepo = { - id: 1, - fullPath: '/path/to/repo', - } - getRepoByIdMock.mockReturnValue(mockRepo as any) - executeCommandMock.mockRejectedValue(new Error('Pathspec error')) - - await expect(service.stageFiles(1, ['invalid.txt'], database)).rejects.toThrow('Pathspec error') - }) - }) - - describe('unstageFiles', () => { - it('unstages files', async () => { - const mockRepo = { - id: 1, - fullPath: '/path/to/repo', - } - getRepoByIdMock.mockReturnValue(mockRepo as any) - executeCommandMock.mockResolvedValue('') - - const result = await service.unstageFiles(1, ['file1.ts', 'file2.ts'], database) - - expect(getRepoByIdMock).toHaveBeenCalledWith(database, 1) - expect(executeCommandMock).toHaveBeenCalledWith( - ['git', '-C', mockRepo.fullPath, 'restore', '--staged', '--', 'file1.ts', 'file2.ts'], - { env: expect.any(Object) } - ) - expect(result).toBe('') - }) - - it('returns early when no files to unstage', async () => { - const result = await service.unstageFiles(1, [], database) - - expect(executeCommandMock).not.toHaveBeenCalled() - expect(result).toBe('') - }) - - it('throws error when repository not found', async () => { - getRepoByIdMock.mockReturnValue(null) - - await expect(service.unstageFiles(999, ['file.ts'], database)).rejects.toThrow('Repository not found') - }) - - it('throws error when unstage command fails', async () => { - const mockRepo = { - id: 1, - fullPath: '/path/to/repo', - } - getRepoByIdMock.mockReturnValue(mockRepo as any) - executeCommandMock.mockRejectedValue(new Error('Error unstaging')) - - await expect(service.unstageFiles(1, ['file.ts'], database)).rejects.toThrow('Error unstaging') - }) - }) - - describe('resetToCommit', () => { - it('resets to specific commit', async () => { - const mockRepo = { - id: 1, - fullPath: '/path/to/repo', - } - getRepoByIdMock.mockReturnValue(mockRepo as any) - executeCommandMock.mockResolvedValue('HEAD is now at abc123') - - const result = await service.resetToCommit(1, 'abc123', database) - - expect(getRepoByIdMock).toHaveBeenCalledWith(database, 1) - expect(executeCommandMock).toHaveBeenCalledWith( - ['git', '-C', mockRepo.fullPath, 'reset', '--hard', 'abc123'], - { env: expect.any(Object) } - ) - expect(result).toBe('HEAD is now at abc123') - }) - - it('throws error when repository not found', async () => { - getRepoByIdMock.mockReturnValue(null) - - await expect(service.resetToCommit(999, 'abc123', database)).rejects.toThrow('Repository not found') - }) - - it('throws error when reset command fails', async () => { - const mockRepo = { - id: 1, - fullPath: '/path/to/repo', - } - getRepoByIdMock.mockReturnValue(mockRepo as any) - executeCommandMock.mockRejectedValue(new Error('Invalid commit hash')) - - await expect(service.resetToCommit(1, 'invalid', database)).rejects.toThrow('Invalid commit hash') - }) - }) -}) diff --git a/backend/test/services/git/GitDiffService.test.ts b/backend/test/services/git/GitDiffService.test.ts deleted file mode 100644 index 3af39780..00000000 --- a/backend/test/services/git/GitDiffService.test.ts +++ /dev/null @@ -1,440 +0,0 @@ -import { describe, it, expect, vi, beforeEach, type MockedFunction } from 'vitest' -import type { Database } from 'bun:sqlite' -import type { GitAuthService } from '../../../src/services/git-auth' - -vi.mock('bun:sqlite', () => ({ - Database: vi.fn() -})) - -vi.mock('../../../src/services/settings', () => ({ - SettingsService: vi.fn() -})) - -vi.mock('../../../src/utils/process', () => ({ - executeCommand: vi.fn(), -})) - -vi.mock('../../../src/db/queries', () => ({ - getRepoById: vi.fn(), -})) - -vi.mock('@opencode-manager/shared/config/env', () => ({ - getReposPath: vi.fn(() => '/repos'), -})) - -vi.mock('../../../src/utils/logger', () => ({ - logger: { - warn: vi.fn(), - error: vi.fn(), - info: vi.fn(), - }, -})) - -import { GitDiffService } from '../../../src/services/git/GitDiffService' -import { executeCommand } from '../../../src/utils/process' -import { getRepoById } from '../../../src/db/queries' - -const executeCommandMock = executeCommand as MockedFunction -const getRepoByIdMock = getRepoById as MockedFunction - -describe('GitDiffService', () => { - let service: GitDiffService - let database: Database - let mockGitAuthService: GitAuthService - - beforeEach(() => { - vi.clearAllMocks() - database = {} as Database - mockGitAuthService = { - getGitEnvironment: vi.fn().mockReturnValue({}), - } as unknown as GitAuthService - service = new GitDiffService(mockGitAuthService) - }) - - describe('getFileDiff', () => { - it('returns diff for untracked file (empty status output)', async () => { - const mockRepo = { id: 1, localPath: 'test-repo' } - getRepoByIdMock.mockReturnValue(mockRepo as any) - executeCommandMock.mockImplementation((args) => { - if (args.includes('status')) return Promise.resolve('') - if (args.includes('--no-index')) { - return Promise.resolve( - 'diff --git a/dev/null b/newfile.ts\n' + - '--- /dev/null\n' + - '+++ b/newfile.ts\n' + - '+export const hello = "world";\n' + - '+export const foo = "bar";' - ) - } - return Promise.resolve('') - }) - - const result = await service.getFileDiff(1, 'newfile.ts', database) - - expect(result.status).toBe('untracked') - expect(result.additions).toBe(2) - expect(result.deletions).toBe(0) - expect(result.isBinary).toBe(false) - }) - - it('returns diff for modified tracked file with staged changes', async () => { - const mockRepo = { id: 1, localPath: 'test-repo' } - getRepoByIdMock.mockReturnValue(mockRepo as any) - executeCommandMock.mockImplementation((args) => { - if (args.includes('status')) return Promise.resolve('M file.ts') - if (args.includes('rev-parse')) return Promise.resolve('abc123') - if (args.includes('diff') && args.includes('HEAD')) { - return Promise.resolve( - 'diff --git a/file.ts b/file.ts\n' + - '--- a/file.ts\n' + - '+++ b/file.ts\n' + - '-const old = "value";\n' + - '+const new = "value";\n' + - '+const added = true;' - ) - } - return Promise.resolve('') - }) - - const result = await service.getFileDiff(1, 'file.ts', database, { includeStaged: true }) - - expect(result.status).toBe('modified') - expect(result.additions).toBe(2) - expect(result.deletions).toBe(1) - }) - - it('returns diff for unstaged changes only', async () => { - const mockRepo = { id: 1, localPath: 'test-repo' } - getRepoByIdMock.mockReturnValue(mockRepo as any) - executeCommandMock.mockImplementation((args) => { - if (args.includes('status')) return Promise.resolve('MM file.ts') - if (args.includes('rev-parse')) return Promise.resolve('abc123') - if (args.includes('diff') && !args.includes('HEAD')) { - return Promise.resolve( - 'diff --git a/file.ts b/file.ts\n' + - '--- a/file.ts\n' + - '+++ b/file.ts\n' + - '-unstaged change' - ) - } - return Promise.resolve('') - }) - - const result = await service.getFileDiff(1, 'file.ts', database, { includeStaged: false }) - - expect(result.status).toBe('modified') - expect(result.deletions).toBe(1) - }) - - it('handles file with no porcelain output as untracked', async () => { - const mockRepo = { id: 1, localPath: 'test-repo' } - getRepoByIdMock.mockReturnValue(mockRepo as any) - executeCommandMock.mockImplementation((args) => { - if (args.includes('status')) return Promise.resolve('') - if (args.includes('--no-index')) return Promise.resolve('+new content') - return Promise.resolve('') - }) - - const result = await service.getFileDiff(1, 'file.ts', database) - - expect(result.status).toBe('untracked') - }) - - it('handles status command failure as untracked', async () => { - const mockRepo = { id: 1, localPath: 'test-repo' } - getRepoByIdMock.mockReturnValue(mockRepo as any) - executeCommandMock.mockImplementation((args) => { - if (args.includes('status')) return Promise.reject(new Error('Git error')) - if (args.includes('--no-index')) return Promise.resolve('+content') - return Promise.resolve('') - }) - - const result = await service.getFileDiff(1, 'file.ts', database) - - expect(result.status).toBe('untracked') - }) - - it('returns special message for new file with no commits', async () => { - const mockRepo = { id: 1, localPath: 'test-repo' } - getRepoByIdMock.mockReturnValue(mockRepo as any) - executeCommandMock.mockImplementation((args) => { - if (args.includes('status')) return Promise.resolve('A newfile.ts') - if (args.includes('rev-parse')) return Promise.reject(new Error('No commits')) - return Promise.resolve('') - }) - - const result = await service.getFileDiff(1, 'newfile.ts', database) - - expect(result.status).toBe('added') - expect(result.diff).toContain('New file (no commits yet)') - expect(result.additions).toBe(0) - expect(result.deletions).toBe(0) - expect(result.isBinary).toBe(false) - }) - - it('throws error when repository not found', async () => { - getRepoByIdMock.mockReturnValue(null) - - await expect(service.getFileDiff(999, 'file.ts', database)).rejects.toThrow('Repository not found: 999') - }) - - it('throws error when diff command fails', async () => { - const mockRepo = { id: 1, localPath: 'test-repo' } - getRepoByIdMock.mockReturnValue(mockRepo as any) - executeCommandMock.mockImplementation((args) => { - if (args.includes('status')) return Promise.resolve('M file.ts') - if (args.includes('rev-parse')) return Promise.resolve('abc123') - if (args.includes('diff')) return Promise.reject(new Error('Diff failed')) - return Promise.resolve('') - }) - - await expect(service.getFileDiff(1, 'file.ts', database)).rejects.toThrow('Failed to get file diff: Diff failed') - }) - - it('applies showContext option', async () => { - const mockRepo = { id: 1, localPath: 'test-repo' } - getRepoByIdMock.mockReturnValue(mockRepo as any) - executeCommandMock.mockImplementation((args) => { - if (args.includes('status')) return Promise.resolve('M file.ts') - if (args.includes('rev-parse')) return Promise.resolve('abc123') - if (args.includes('diff')) { - expect(args).toContain('-U10') - return Promise.resolve('diff output') - } - return Promise.resolve('') - }) - - await service.getFileDiff(1, 'file.ts', database, { showContext: 10 }) - }) - - it('applies ignoreWhitespace option', async () => { - const mockRepo = { id: 1, localPath: 'test-repo' } - getRepoByIdMock.mockReturnValue(mockRepo as any) - executeCommandMock.mockImplementation((args) => { - if (args.includes('status')) return Promise.resolve('M file.ts') - if (args.includes('rev-parse')) return Promise.resolve('abc123') - if (args.includes('diff')) { - expect(args).toContain('--ignore-all-space') - return Promise.resolve('diff output') - } - return Promise.resolve('') - }) - - await service.getFileDiff(1, 'file.ts', database, { ignoreWhitespace: true }) - }) - - it('applies unified option', async () => { - const mockRepo = { id: 1, localPath: 'test-repo' } - getRepoByIdMock.mockReturnValue(mockRepo as any) - executeCommandMock.mockImplementation((args) => { - if (args.includes('status')) return Promise.resolve('M file.ts') - if (args.includes('rev-parse')) return Promise.resolve('abc123') - if (args.includes('diff')) { - expect(args).toContain('--unified=true') - return Promise.resolve('diff output') - } - return Promise.resolve('') - }) - - await service.getFileDiff(1, 'file.ts', database, { unified: true }) - }) - - it('detects binary files from "Binary files" message', async () => { - const mockRepo = { id: 1, localPath: 'test-repo' } - getRepoByIdMock.mockReturnValue(mockRepo as any) - executeCommandMock.mockImplementation((args) => { - if (args.includes('status')) return Promise.resolve('M image.png') - if (args.includes('rev-parse')) return Promise.resolve('abc123') - if (args.includes('diff')) return Promise.resolve('Binary files a/image.png and b/image.png differ') - return Promise.resolve('') - }) - - const result = await service.getFileDiff(1, 'image.png', database) - - expect(result.isBinary).toBe(true) - expect(result.additions).toBe(0) - expect(result.deletions).toBe(0) - }) - - it('detects binary files from "GIT binary patch" message', async () => { - const mockRepo = { id: 1, localPath: 'test-repo' } - getRepoByIdMock.mockReturnValue(mockRepo as any) - executeCommandMock.mockImplementation((args) => { - if (args.includes('status')) return Promise.resolve('M data.bin') - if (args.includes('rev-parse')) return Promise.resolve('abc123') - if (args.includes('diff')) return Promise.resolve('GIT binary patch\nliteral 1234\n...') - return Promise.resolve('') - }) - - const result = await service.getFileDiff(1, 'data.bin', database) - - expect(result.isBinary).toBe(true) - }) - - it('handles untracked file with object result from executeCommand', async () => { - const mockRepo = { id: 1, localPath: 'test-repo' } - getRepoByIdMock.mockReturnValue(mockRepo as any) - executeCommandMock.mockImplementation((args) => { - if (args.includes('status')) return Promise.resolve('') - if (args.includes('--no-index')) { - return Promise.resolve({ stdout: '+new line\n+another line' } as any) - } - return Promise.resolve('') - }) - - const result = await service.getFileDiff(1, 'newfile.ts', database) - - expect(result.status).toBe('untracked') - expect(result.additions).toBe(2) - }) - - it('defaults includeStaged to true', async () => { - const mockRepo = { id: 1, localPath: 'test-repo' } - getRepoByIdMock.mockReturnValue(mockRepo as any) - let diffArgsUsed: string[] = [] - executeCommandMock.mockImplementation((args) => { - if (args.includes('status')) return Promise.resolve('M file.ts') - if (args.includes('rev-parse')) return Promise.resolve('abc123') - if (args.includes('diff')) { - diffArgsUsed = args as string[] - return Promise.resolve('diff output') - } - return Promise.resolve('') - }) - - await service.getFileDiff(1, 'file.ts', database) - - expect(diffArgsUsed).toContain('HEAD') - }) - }) - - describe('getFullDiff', () => { - it('delegates to getFileDiff', async () => { - const mockRepo = { id: 1, localPath: 'test-repo' } - getRepoByIdMock.mockReturnValue(mockRepo as any) - executeCommandMock.mockImplementation((args) => { - if (args.includes('status')) return Promise.resolve('M file.ts') - if (args.includes('rev-parse')) return Promise.resolve('abc123') - if (args.includes('diff')) return Promise.resolve('+added line') - return Promise.resolve('') - }) - - const result = await service.getFullDiff(1, 'file.ts', database) - - expect(result.status).toBe('modified') - expect(result.additions).toBe(1) - }) - - it('passes options through to getFileDiff', async () => { - const mockRepo = { id: 1, localPath: 'test-repo' } - getRepoByIdMock.mockReturnValue(mockRepo as any) - executeCommandMock.mockImplementation((args) => { - if (args.includes('status')) return Promise.resolve('M file.ts') - if (args.includes('rev-parse')) return Promise.resolve('abc123') - if (args.includes('diff')) { - expect(args).toContain('--ignore-all-space') - return Promise.resolve('diff output') - } - return Promise.resolve('') - }) - - await service.getFullDiff(1, 'file.ts', database, { ignoreWhitespace: true }) - }) - }) - - describe('parseDiffOutput (through public methods)', () => { - it('correctly counts additions and deletions', async () => { - const mockRepo = { id: 1, localPath: 'test-repo' } - getRepoByIdMock.mockReturnValue(mockRepo as any) - executeCommandMock.mockImplementation((args) => { - if (args.includes('status')) return Promise.resolve('M file.ts') - if (args.includes('rev-parse')) return Promise.resolve('abc123') - if (args.includes('diff')) { - return Promise.resolve( - '--- a/file.ts\n' + - '+++ b/file.ts\n' + - '-removed line 1\n' + - '-removed line 2\n' + - '+added line 1\n' + - '+added line 2\n' + - '+added line 3\n' + - ' unchanged line' - ) - } - return Promise.resolve('') - }) - - const result = await service.getFileDiff(1, 'file.ts', database) - - expect(result.additions).toBe(3) - expect(result.deletions).toBe(2) - }) - - it('does not count --- and +++ header lines', async () => { - const mockRepo = { id: 1, localPath: 'test-repo' } - getRepoByIdMock.mockReturnValue(mockRepo as any) - executeCommandMock.mockImplementation((args) => { - if (args.includes('status')) return Promise.resolve('M file.ts') - if (args.includes('rev-parse')) return Promise.resolve('abc123') - if (args.includes('diff')) { - return Promise.resolve( - '--- a/file.ts\n' + - '+++ b/file.ts\n' + - '+only real addition' - ) - } - return Promise.resolve('') - }) - - const result = await service.getFileDiff(1, 'file.ts', database) - - expect(result.additions).toBe(1) - expect(result.deletions).toBe(0) - }) - - it('returns empty path when filePath is not provided', async () => { - const mockRepo = { id: 1, localPath: 'test-repo' } - getRepoByIdMock.mockReturnValue(mockRepo as any) - executeCommandMock.mockImplementation((args) => { - if (args.includes('status')) return Promise.resolve('?? ') - if (args.includes('--no-index')) return Promise.resolve('+content') - return Promise.resolve('') - }) - - const result = await service.getFileDiff(1, '', database) - - expect(result.path).toBe('') - }) - }) - - describe('hasCommits (through getFileDiff)', () => { - it('returns true when HEAD exists', async () => { - const mockRepo = { id: 1, localPath: 'test-repo' } - getRepoByIdMock.mockReturnValue(mockRepo as any) - executeCommandMock.mockImplementation((args) => { - if (args.includes('status')) return Promise.resolve('M file.ts') - if (args.includes('rev-parse')) return Promise.resolve('abc123') - if (args.includes('diff')) return Promise.resolve('diff output') - return Promise.resolve('') - }) - - const result = await service.getFileDiff(1, 'file.ts', database) - - expect(result.diff).toBe('diff output') - }) - - it('returns false when no commits exist', async () => { - const mockRepo = { id: 1, localPath: 'test-repo' } - getRepoByIdMock.mockReturnValue(mockRepo as any) - executeCommandMock.mockImplementation((args) => { - if (args.includes('status')) return Promise.resolve('A file.ts') - if (args.includes('rev-parse')) return Promise.reject(new Error('No commits')) - return Promise.resolve('') - }) - - const result = await service.getFileDiff(1, 'file.ts', database) - - expect(result.diff).toContain('New file (no commits yet)') - }) - }) -}) diff --git a/backend/test/services/git/GitLogService.test.ts b/backend/test/services/git/GitLogService.test.ts deleted file mode 100644 index defc3730..00000000 --- a/backend/test/services/git/GitLogService.test.ts +++ /dev/null @@ -1,379 +0,0 @@ -import { describe, it, expect, vi, beforeEach, type MockedFunction } from 'vitest' - -vi.mock('bun:sqlite', () => ({ - Database: vi.fn() -})) - -vi.mock('../../../src/services/settings', () => ({ - SettingsService: vi.fn() -})) - -import { GitLogService } from '../../../src/services/git/GitLogService' -import type { Database } from 'bun:sqlite' -import { executeCommand } from '../../../src/utils/process' -import { getRepoById } from '../../../src/db/queries' - -const mockGitAuthService = { - getGitEnvironment: vi.fn(), -} - -const mockGitDiffService = { - getFileDiff: vi.fn(), -} - -vi.mock('../../../src/utils/process', () => ({ - executeCommand: vi.fn(), -})) - -vi.mock('../../../src/db/queries', () => ({ - getRepoById: vi.fn(), -})) - -vi.mock('../../../src/services/git-auth', () => ({ - GitAuthService: vi.fn().mockImplementation(() => mockGitAuthService), -})) - -vi.mock('@opencode-manager/shared/config/env', () => ({ - getReposPath: vi.fn(() => '/repos'), -})) - -const executeCommandMock = executeCommand as MockedFunction -const getRepoByIdMock = getRepoById as MockedFunction - -describe('GitLogService', () => { - let service: GitLogService - let database: Database - - beforeEach(() => { - vi.clearAllMocks() - database = {} as Database - service = new GitLogService(mockGitAuthService as any, mockGitDiffService as any) - mockGitAuthService.getGitEnvironment.mockResolvedValue({}) - }) - - describe('getLog', () => { - it('returns list of commits', async () => { - const mockRepo = { - id: 1, - localPath: 'test-repo', - } - getRepoByIdMock.mockReturnValue(mockRepo as any) - executeCommandMock - .mockResolvedValueOnce( - 'abc123|John Doe|john@example.com|1704110400|First commit\n' + - 'def456|Jane Smith|jane@example.com|1704200400|Second commit' - ) - .mockResolvedValueOnce('') - - const result = await service.getLog(1, database, 10) - - expect(getRepoByIdMock).toHaveBeenCalledWith(database, 1) - expect(executeCommandMock).toHaveBeenCalledWith( - ['git', '-C', '/repos/test-repo', 'log', '--all', '-n', '10', '--format=%H|%an|%ae|%at|%s'], - { env: expect.any(Object) } - ) - expect(result).toHaveLength(2) - expect(result[0]).toEqual({ - hash: 'abc123', - authorName: 'John Doe', - authorEmail: 'john@example.com', - date: '1704110400', - message: 'First commit', - unpushed: false, - }) - expect(result[1]).toEqual({ - hash: 'def456', - authorName: 'Jane Smith', - authorEmail: 'jane@example.com', - date: '1704200400', - message: 'Second commit', - unpushed: false, - }) - }) - - it('respects limit parameter', async () => { - const mockRepo = { - id: 1, - localPath: 'test-repo', - } - getRepoByIdMock.mockReturnValue(mockRepo as any) - executeCommandMock - .mockResolvedValueOnce('abc123|John Doe|john@example.com|1704110400|First commit') - .mockResolvedValueOnce('') - - await service.getLog(1, database, 5) - - expect(executeCommandMock).toHaveBeenCalledWith( - ['git', '-C', '/repos/test-repo', 'log', '--all', '-n', '5', '--format=%H|%an|%ae|%at|%s'], - { env: expect.any(Object) } - ) - }) - - it('handles commits with empty messages', async () => { - const mockRepo = { - id: 1, - localPath: 'test-repo', - } - getRepoByIdMock.mockReturnValue(mockRepo as any) - executeCommandMock - .mockResolvedValueOnce('abc123|John Doe|john@example.com|1704110400|') - .mockResolvedValueOnce('') - - const result = await service.getLog(1, database, 10) - - expect(result).toHaveLength(1) - expect(result[0]?.message).toBe('') - }) - - it('handles commits with multi-line messages', async () => { - const mockRepo = { - id: 1, - localPath: 'test-repo', - } - getRepoByIdMock.mockReturnValue(mockRepo as any) - executeCommandMock - .mockResolvedValueOnce('abc123|John Doe|john@example.com|1704110400|Multi|line commit') - .mockResolvedValueOnce('') - - const result = await service.getLog(1, database, 10) - - expect(result).toHaveLength(1) - expect(result[0]?.message).toBe('Multi|line commit') - }) - - it('handles empty log output', async () => { - const mockRepo = { - id: 1, - localPath: 'test-repo', - } - getRepoByIdMock.mockReturnValue(mockRepo as any) - executeCommandMock - .mockResolvedValueOnce('') - .mockResolvedValueOnce('') - - const result = await service.getLog(1, database, 10) - - expect(result).toEqual([]) - }) - - it('handles whitespace lines in output', async () => { - const mockRepo = { - id: 1, - localPath: 'test-repo', - } - getRepoByIdMock.mockReturnValue(mockRepo as any) - executeCommandMock - .mockResolvedValueOnce('\n\nabc123|John Doe|john@example.com|1704110400|Test\n\n') - .mockResolvedValueOnce('') - - const result = await service.getLog(1, database, 10) - - expect(result).toHaveLength(1) - expect(result[0]?.hash).toBe('abc123') - }) - - it('skips lines without hash', async () => { - const mockRepo = { - id: 1, - localPath: 'test-repo', - } - getRepoByIdMock.mockReturnValue(mockRepo as any) - executeCommandMock - .mockResolvedValueOnce('|John Doe|john@example.com|1704110400|No hash') - .mockResolvedValueOnce('') - - const result = await service.getLog(1, database, 10) - - expect(result).toEqual([]) - }) - - it('throws error when repository not found', async () => { - getRepoByIdMock.mockReturnValue(null) - - await expect(service.getLog(999, database, 10)).rejects.toThrow('Repository not found: 999') - }) - - it('throws error when log command fails', async () => { - const mockRepo = { - id: 1, - localPath: 'test-repo', - } - getRepoByIdMock.mockReturnValue(mockRepo as any) - executeCommandMock.mockRejectedValue(new Error('Not a git repository')) - - await expect(service.getLog(1, database, 10)).rejects.toThrow('Failed to get git log') - }) - - it('handles commits with empty timestamps', async () => { - const mockRepo = { - id: 1, - localPath: 'test-repo', - } - getRepoByIdMock.mockReturnValue(mockRepo as any) - executeCommandMock - .mockResolvedValueOnce( - 'abc123|John Doe|john@example.com|1704110400|Valid commit\n' + - 'def456|Jane Smith|jane@example.com||Empty timestamp\n' - ) - .mockResolvedValueOnce('') - - const result = await service.getLog(1, database, 10) - - expect(result).toHaveLength(2) - expect(result[0]).toEqual({ - hash: 'abc123', - authorName: 'John Doe', - authorEmail: 'john@example.com', - date: '1704110400', - message: 'Valid commit', - unpushed: false, - }) - expect(result[1]?.date).toBe('') - }) - - it('handles commits with missing date field', async () => { - const mockRepo = { - id: 1, - localPath: 'test-repo', - } - getRepoByIdMock.mockReturnValue(mockRepo as any) - executeCommandMock - .mockResolvedValueOnce('abc123|John Doe|john@example.com') - .mockResolvedValueOnce('') - - const result = await service.getLog(1, database, 10) - - expect(result).toHaveLength(1) - expect(result[0]).toEqual({ - hash: 'abc123', - authorName: 'John Doe', - authorEmail: 'john@example.com', - date: '', - message: '', - unpushed: false, - }) - }) - }) - - describe('getCommit', () => { - it('returns commit details for valid hash', async () => { - const mockRepo = { - id: 1, - localPath: 'test-repo', - } - getRepoByIdMock.mockReturnValue(mockRepo as any) - executeCommandMock.mockResolvedValue('abc123|John Doe|john@example.com|1704110400|Test commit') - - const result = await service.getCommit(1, 'abc123', database) - - expect(getRepoByIdMock).toHaveBeenCalledWith(database, 1) - expect(executeCommandMock).toHaveBeenCalledWith( - ['git', '-C', '/repos/test-repo', 'log', '--format=%H|%an|%ae|%at|%s', 'abc123', '-1'], - { env: expect.any(Object) } - ) - expect(result).toEqual({ - hash: 'abc123', - authorName: 'John Doe', - authorEmail: 'john@example.com', - date: '1704110400', - message: 'Test commit', - }) - }) - - it('returns null when commit hash not found', async () => { - const mockRepo = { - id: 1, - localPath: 'test-repo', - } - getRepoByIdMock.mockReturnValue(mockRepo as any) - executeCommandMock.mockResolvedValue('') - - const result = await service.getCommit(1, 'nonexistent', database) - - expect(result).toBeNull() - }) - - it('returns null when output is empty', async () => { - const mockRepo = { - id: 1, - localPath: 'test-repo', - } - getRepoByIdMock.mockReturnValue(mockRepo as any) - executeCommandMock.mockResolvedValue(' ') - - const result = await service.getCommit(1, 'abc123', database) - - expect(result).toBeNull() - }) - - it('returns null when hash field is empty', async () => { - const mockRepo = { - id: 1, - localPath: 'test-repo', - } - getRepoByIdMock.mockReturnValue(mockRepo as any) - executeCommandMock.mockResolvedValue('|John Doe|john@example.com|1704110400|Test') - - const result = await service.getCommit(1, 'abc123', database) - - expect(result).toBeNull() - }) - - it('handles commits with empty messages', async () => { - const mockRepo = { - id: 1, - localPath: 'test-repo', - } - getRepoByIdMock.mockReturnValue(mockRepo as any) - executeCommandMock.mockResolvedValue('abc123|John Doe|john@example.com|1704110400|') - - const result = await service.getCommit(1, 'abc123', database) - - expect(result).not.toBeNull() - expect(result?.message).toBe('') - }) - - it('throws error when repository not found', async () => { - getRepoByIdMock.mockReturnValue(null) - - await expect(service.getCommit(999, 'abc123', database)).rejects.toThrow('Repository not found: 999') - }) - - it('throws error when command fails', async () => { - const mockRepo = { - id: 1, - localPath: 'test-repo', - } - getRepoByIdMock.mockReturnValue(mockRepo as any) - executeCommandMock.mockRejectedValue(new Error('Invalid commit hash')) - - await expect(service.getCommit(1, 'invalid', database)).rejects.toThrow('Failed to get commit') - }) - }) - - describe('getDiff', () => { - it('returns diff for file', async () => { - const expectedDiff = 'diff --git a/file.ts b/file.ts\nindex 123..456 100644\n--- a/file.ts\n+++ b/file.ts\n@@ -1,1 +1,1 @@\n-old line\n+new line' - mockGitDiffService.getFileDiff.mockResolvedValue({ diff: expectedDiff }) - - const result = await service.getDiff(1, 'file.ts', database) - - expect(mockGitDiffService.getFileDiff).toHaveBeenCalledWith(1, 'file.ts', database) - expect(result).toBe(expectedDiff) - }) - - it('returns empty diff when file has no changes', async () => { - mockGitDiffService.getFileDiff.mockResolvedValue({ diff: '' }) - - const result = await service.getDiff(1, 'file.ts', database) - - expect(result).toBe('') - }) - - it('throws error when diff command fails', async () => { - mockGitDiffService.getFileDiff.mockRejectedValue(new Error('Repository not found: 999')) - - await expect(service.getDiff(999, 'file.ts', database)).rejects.toThrow('Repository not found: 999') - }) - }) -}) diff --git a/backend/test/services/git/GitPushService.test.ts b/backend/test/services/git/GitPushService.test.ts deleted file mode 100644 index 59a80c6d..00000000 --- a/backend/test/services/git/GitPushService.test.ts +++ /dev/null @@ -1,199 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest' -import type { Database } from 'bun:sqlite' -import type { GitAuthService } from '../../../src/services/git-auth' - -vi.mock('bun:sqlite', () => ({ - Database: vi.fn() -})) - -vi.mock('../../../src/services/settings', () => ({ - SettingsService: vi.fn() -})) - -vi.mock('../../../src/utils/process', () => ({ - executeCommand: vi.fn(), -})) - -vi.mock('../../../src/db/queries', () => ({ - getRepoById: vi.fn(), -})) - -vi.mock('../../../src/services/git/GitBranchService', () => ({ - GitBranchService: vi.fn().mockImplementation(() => ({ - getBranches: vi.fn().mockResolvedValue([ - { - name: 'main', - type: 'local', - current: true, - upstream: 'origin/main' - }, - { - name: 'feature-branch', - type: 'local', - current: false, - upstream: undefined - } - ]) - })) -})) - -describe('GitPushService', () => { - let executeCommand: ReturnType - let getRepoById: ReturnType - let mockGitAuthService: GitAuthService - let mockBranchService: any - - beforeEach(async () => { - vi.clearAllMocks() - const process = await import('../../../src/utils/process') - const queries = await import('../../../src/db/queries') - executeCommand = process.executeCommand as ReturnType - getRepoById = queries.getRepoById as ReturnType - mockGitAuthService = { - getGitEnvironment: vi.fn().mockReturnValue({}), - } as unknown as GitAuthService - - const { GitBranchService } = await import('../../../src/services/git/GitBranchService') - mockBranchService = new GitBranchService(mockGitAuthService) - - // Reset executeCommand to default resolved value - executeCommand.mockResolvedValue('Everything up-to-date') - }) - - describe('push', () => { - it('pushes changes to remote with existing upstream', async () => { - const { GitPushService } = await import('../../../src/services/git/GitPushService') - const service = new GitPushService(mockGitAuthService, mockBranchService) - const database = {} as Database - const mockRepo = { - id: 1, - fullPath: '/path/to/repo', - } - getRepoById.mockReturnValue(mockRepo) - executeCommand.mockResolvedValue('Everything up-to-date') - - const result = await service.push(1, {}, database) - - expect(getRepoById).toHaveBeenCalledWith(database, 1) - expect(executeCommand).toHaveBeenCalledWith( - ['git', '-C', '/path/to/repo', 'push'], - { env: expect.any(Object) } - ) - expect(result).toBe('Everything up-to-date') - }) - - it('auto-creates remote branch when no upstream exists', async () => { - // Test the flow without triggering Bun's git error detection - const { isNoUpstreamError } = await import('../../../src/utils/git-errors') - const { GitPushService } = await import('../../../src/services/git/GitPushService') - const service = new GitPushService(mockGitAuthService, mockBranchService) - - // Verify error detection works - const testError = new Error('The current branch feature-branch has no upstream branch') - expect(isNoUpstreamError(testError)).toBe(true) - - // Test the service flow with a non-triggering error - const mockError = new Error('Push failed - no remote branch tracking configured') - - const database = {} as Database - const mockRepo = { - id: 1, - fullPath: '/path/to/repo', - } - getRepoById.mockReturnValue(mockRepo) - - executeCommand - .mockReset() - .mockRejectedValueOnce(mockError) - .mockResolvedValueOnce('feature-branch\n') - - // This should throw since our mock error doesn't trigger upstream detection - await expect(service.push(1, {}, database)).rejects.toThrow('Push failed - no remote branch tracking configured') - }) - - it('respects setUpstream option', async () => { - const { GitPushService } = await import('../../../src/services/git/GitPushService') - const service = new GitPushService(mockGitAuthService, mockBranchService) - const database = {} as Database - const mockRepo = { - id: 1, - fullPath: '/path/to/repo', - } - getRepoById.mockReturnValue(mockRepo) - executeCommand - .mockReset() - .mockResolvedValueOnce('main\n') - .mockResolvedValueOnce('') - - const result = await service.push(1, { setUpstream: true }, database) - - expect(executeCommand).toHaveBeenNthCalledWith( - 1, - ['git', '-C', '/path/to/repo', 'rev-parse', '--abbrev-ref', 'HEAD'], - expect.any(Object) - ) - expect(executeCommand).toHaveBeenNthCalledWith( - 2, - ['git', '-C', '/path/to/repo', 'push', '--set-upstream', 'origin', 'main'], - expect.any(Object) - ) - expect(result).toBe('') - }) - - it('throws error when repository not found', async () => { - const { GitPushService } = await import('../../../src/services/git/GitPushService') - const service = new GitPushService(mockGitAuthService, mockBranchService) - const database = {} as Database - getRepoById.mockReturnValue(null) - - await expect(service.push(999, {}, database)).rejects.toThrow('Repository not found') - }) - - it('throws error when push command fails with non-upstream error', async () => { - const { GitPushService } = await import('../../../src/services/git/GitPushService') - const service = new GitPushService(mockGitAuthService, mockBranchService) - const database = {} as Database - const mockRepo = { - id: 1, - fullPath: '/path/to/repo', - } - const authError = new Error('Authentication failed') - getRepoById.mockReturnValue(mockRepo) - executeCommand.mockRejectedValue(authError) - - await expect(service.push(1, {}, database)).rejects.toThrow('Authentication failed') - }) - - // Note: Skipping due to Bun test runner issue with git error detection - it.skip('falls back to HEAD when branch detection fails during upstream retry', async () => { - // This test would verify fallback behavior when branch service fails - // but Bun's test runner treats any Error with "upstream" as real git error - }) - }) - - describe('Git Error Utilities', () => { - it('detects no upstream branch error', async () => { - const { isNoUpstreamError } = await import('../../../src/utils/git-errors') - const error1 = new Error('The current branch main has no upstream branch') - const error2 = new Error('fatal: no upstream configured for branch') - const error3 = new Error('Authentication failed') - const error4 = new Error('no upstream branch') - - expect(isNoUpstreamError(error1)).toBe(true) - expect(isNoUpstreamError(error2)).toBe(true) - expect(isNoUpstreamError(error3)).toBe(false) - expect(isNoUpstreamError(error4)).toBe(true) - }) - - it('parses branch name from error message', async () => { - const { parseBranchNameFromError } = await import('../../../src/utils/git-errors') - const error1 = new Error('The current branch feature-branch has no upstream branch') - const error2 = new Error('The current branch main has no upstream branch') - const error3 = new Error('Some other error') - - expect(parseBranchNameFromError(error1)).toBe('feature-branch') - expect(parseBranchNameFromError(error2)).toBe('main') - expect(parseBranchNameFromError(error3)).toBe(null) - }) - }) -}) \ No newline at end of file diff --git a/backend/test/services/git/GitService.test.ts b/backend/test/services/git/GitService.test.ts new file mode 100644 index 00000000..895b79ec --- /dev/null +++ b/backend/test/services/git/GitService.test.ts @@ -0,0 +1,523 @@ +import { describe, it, expect, vi, beforeEach, type MockedFunction } from 'vitest' +import type { Database } from 'bun:sqlite' +import type { GitAuthService } from '../../../src/services/git-auth' + +vi.mock('bun:sqlite', () => ({ + Database: vi.fn() +})) + +vi.mock('../../../src/services/settings', () => ({ + SettingsService: vi.fn().mockImplementation(() => ({ + getSettings: vi.fn().mockReturnValue({ + preferences: { + gitIdentity: null, + gitCredentials: [], + }, + }), + })), +})) + +vi.mock('../../../src/utils/process', () => ({ + executeCommand: vi.fn(), +})) + +vi.mock('../../../src/db/queries', () => ({ + getRepoById: vi.fn(), +})) + +vi.mock('../../../src/utils/git-auth', () => ({ + resolveGitIdentity: vi.fn().mockResolvedValue(null), + createGitIdentityEnv: vi.fn().mockReturnValue({}), + createSilentGitEnv: vi.fn(), +})) + +vi.mock('../../../src/utils/git-errors', () => ({ + isNoUpstreamError: vi.fn().mockReturnValue(false), + parseBranchNameFromError: vi.fn().mockReturnValue(null), +})) + +import { GitService } from '../../../src/services/git/GitService' +import { executeCommand } from '../../../src/utils/process' +import { getRepoById } from '../../../src/db/queries' + +const executeCommandMock = executeCommand as MockedFunction +const getRepoByIdMock = getRepoById as MockedFunction + +describe('GitService', () => { + let service: GitService + let database: Database + let mockGitAuthService: GitAuthService + let mockSettingsService: any + + beforeEach(() => { + vi.clearAllMocks() + database = {} as Database + mockGitAuthService = { + getGitEnvironment: vi.fn().mockReturnValue({}), + } as unknown as GitAuthService + mockSettingsService = { + getSettings: vi.fn().mockReturnValue({ + preferences: { + gitIdentity: null, + gitCredentials: [], + }, + }), + } + service = new GitService(mockGitAuthService, mockSettingsService) + }) + + describe('getStatus', () => { + it('returns empty status for clean repository', async () => { + const mockRepo = { + id: 1, + fullPath: '/path/to/repo', + } + getRepoByIdMock.mockReturnValue(mockRepo as any) + executeCommandMock.mockImplementation((args) => { + if (args.includes('rev-parse')) return Promise.resolve('main') + if (args.includes('rev-list')) return Promise.resolve('0 0') + if (args.includes('status')) return Promise.resolve('') + return Promise.resolve('') + }) + + const result = await service.getStatus(1, database) + + expect(result.branch).toBe('main') + expect(result.ahead).toBe(0) + expect(result.behind).toBe(0) + expect(result.files).toEqual([]) + expect(result.hasChanges).toBe(false) + }) + + it('parses modified files correctly', async () => { + const mockRepo = { + id: 1, + fullPath: '/path/to/repo', + } + getRepoByIdMock.mockReturnValue(mockRepo as any) + executeCommandMock.mockImplementation((args) => { + if (args.includes('rev-parse')) return Promise.resolve('main') + if (args.includes('rev-list')) return Promise.resolve('0 0') + if (args.includes('status')) return Promise.resolve('MM file.ts') + return Promise.resolve('') + }) + + const result = await service.getStatus(1, database) + + expect(result.files).toHaveLength(1) + expect(result.files[0]).toEqual({ path: 'file.ts', status: 'modified', staged: true }) + expect(result.hasChanges).toBe(true) + }) + }) + + describe('getFileDiff', () => { + it('returns diff for untracked file', async () => { + const mockRepo = { id: 1, fullPath: '/path/to/repo' } + getRepoByIdMock.mockReturnValue(mockRepo as any) + executeCommandMock.mockImplementation((args) => { + if (args.includes('status')) return Promise.resolve('') + if (args.includes('--no-index')) { + return Promise.resolve( + 'diff --git a/dev/null b/newfile.ts\n' + + '--- /dev/null\n' + + '+++ b/newfile.ts\n' + + '+export const hello = "world";\n' + + '+export const foo = "bar";' + ) + } + return Promise.resolve('') + }) + + const result = await service.getFileDiff(1, 'newfile.ts', database) + + expect(result.status).toBe('untracked') + expect(result.additions).toBe(2) + expect(result.deletions).toBe(0) + expect(result.isBinary).toBe(false) + }) + + it('returns diff for modified tracked file', async () => { + const mockRepo = { id: 1, fullPath: '/path/to/repo' } + getRepoByIdMock.mockReturnValue(mockRepo as any) + executeCommandMock.mockImplementation((args) => { + if (args.includes('status')) return Promise.resolve('M file.ts') + if (args.includes('rev-parse')) return Promise.resolve('abc123') + if (args.includes('diff')) { + return Promise.resolve( + 'diff --git a/file.ts b/file.ts\n' + + '--- a/file.ts\n' + + '+++ b/file.ts\n' + + '-const old = "value";\n' + + '+const new = "value";\n' + + '+const added = true;' + ) + } + return Promise.resolve('') + }) + + const result = await service.getFileDiff(1, 'file.ts', database, { includeStaged: true }) + + expect(result.status).toBe('modified') + expect(result.additions).toBe(2) + expect(result.deletions).toBe(1) + }) + }) + + describe('getFullDiff', () => { + it('delegates to getFileDiff', async () => { + const mockRepo = { id: 1, fullPath: '/path/to/repo' } + getRepoByIdMock.mockReturnValue(mockRepo as any) + executeCommandMock.mockImplementation((args) => { + if (args.includes('status')) return Promise.resolve('M file.ts') + if (args.includes('rev-parse')) return Promise.resolve('abc123') + if (args.includes('diff')) return Promise.resolve('+added line') + return Promise.resolve('') + }) + + const result = await service.getFullDiff(1, 'file.ts', database, true) + + expect(result.status).toBe('modified') + expect(result.additions).toBe(1) + }) + }) + + describe('getLog', () => { + it('returns list of commits', async () => { + const mockRepo = { + id: 1, + fullPath: '/path/to/repo', + } + getRepoByIdMock.mockReturnValue(mockRepo as any) + executeCommandMock + .mockResolvedValueOnce( + 'abc123|John Doe|john@example.com|1704110400|First commit\n' + + 'def456|Jane Smith|jane@example.com|1704200400|Second commit' + ) + .mockResolvedValueOnce('') + + const result = await service.getLog(1, database, 10) + + expect(getRepoByIdMock).toHaveBeenCalledWith(database, 1) + expect(result).toHaveLength(2) + expect(result[0]).toEqual({ + hash: 'abc123', + authorName: 'John Doe', + authorEmail: 'john@example.com', + date: '1704110400', + message: 'First commit', + unpushed: false, + }) + expect(result[1]).toEqual({ + hash: 'def456', + authorName: 'Jane Smith', + authorEmail: 'jane@example.com', + date: '1704200400', + message: 'Second commit', + unpushed: false, + }) + }) + + it('handles empty log output', async () => { + const mockRepo = { + id: 1, + fullPath: '/path/to/repo', + } + getRepoByIdMock.mockReturnValue(mockRepo as any) + executeCommandMock.mockResolvedValueOnce('').mockResolvedValueOnce('') + + const result = await service.getLog(1, database, 10) + + expect(result).toEqual([]) + }) + }) + + describe('getCommit', () => { + it('returns commit details for valid hash', async () => { + const mockRepo = { + id: 1, + fullPath: '/path/to/repo', + } + getRepoByIdMock.mockReturnValue(mockRepo as any) + executeCommandMock.mockResolvedValue('abc123|John Doe|john@example.com|1704110400|Test commit') + + const result = await service.getCommit(1, 'abc123', database) + + expect(result).toEqual({ + hash: 'abc123', + authorName: 'John Doe', + authorEmail: 'john@example.com', + date: '1704110400', + message: 'Test commit', + }) + }) + + it('returns null when commit hash not found', async () => { + const mockRepo = { + id: 1, + fullPath: '/path/to/repo', + } + getRepoByIdMock.mockReturnValue(mockRepo as any) + executeCommandMock.mockResolvedValue('') + + const result = await service.getCommit(1, 'nonexistent', database) + + expect(result).toBeNull() + }) + }) + + describe('getDiff', () => { + it('returns diff for file', async () => { + const expectedDiff = 'diff --git a/file.ts b/file.ts\nindex 123..456 100644\n--- a/file.ts\n+++ b/file.ts\n@@ -1,1 +1,1 @@\n-old line\n+new line' + const mockRepo = { id: 1, fullPath: '/path/to/repo' } + getRepoByIdMock.mockReturnValue(mockRepo as any) + executeCommandMock.mockImplementation((args) => { + if (args.includes('status')) return Promise.resolve('M file.ts') + if (args.includes('rev-parse')) return Promise.resolve('abc123') + if (args.includes('diff')) return Promise.resolve(expectedDiff) + return Promise.resolve('') + }) + + const result = await service.getDiff(1, 'file.ts', database) + + expect(result).toBe(expectedDiff) + }) + }) + + describe('commit', () => { + it('commits staged changes with message', async () => { + const mockRepo = { + id: 1, + fullPath: '/path/to/repo', + } + getRepoByIdMock.mockReturnValue(mockRepo as any) + executeCommandMock.mockResolvedValue('[main abc1234] Test commit\n 1 file changed') + + const result = await service.commit(1, 'Test commit', database) + + expect(getRepoByIdMock).toHaveBeenCalledWith(database, 1) + expect(executeCommandMock).toHaveBeenCalledWith( + ['git', '-C', mockRepo.fullPath, 'commit', '-m', 'Test commit'], + { env: expect.any(Object) } + ) + expect(result).toBe('[main abc1234] Test commit\n 1 file changed') + }) + + it('commits specific staged files', async () => { + const mockRepo = { + id: 1, + fullPath: '/path/to/repo', + } + getRepoByIdMock.mockReturnValue(mockRepo as any) + executeCommandMock.mockResolvedValue('[main abc1234] Commit specific files\n 2 files changed') + + const result = await service.commit(1, 'Commit specific files', database, ['file1.ts', 'file2.ts']) + + expect(executeCommandMock).toHaveBeenCalledWith( + ['git', '-C', mockRepo.fullPath, 'commit', '-m', 'Commit specific files', '--', 'file1.ts', 'file2.ts'], + { env: expect.any(Object) } + ) + expect(result).toBe('[main abc1234] Commit specific files\n 2 files changed') + }) + }) + + describe('stageFiles', () => { + it('stages files', async () => { + const mockRepo = { + id: 1, + fullPath: '/path/to/repo', + } + getRepoByIdMock.mockReturnValue(mockRepo as any) + executeCommandMock.mockResolvedValue('') + + const result = await service.stageFiles(1, ['file1.ts', 'file2.ts'], database) + + expect(getRepoByIdMock).toHaveBeenCalledWith(database, 1) + expect(executeCommandMock).toHaveBeenCalledWith( + ['git', '-C', mockRepo.fullPath, 'add', '--', 'file1.ts', 'file2.ts'], + { env: expect.any(Object) } + ) + expect(result).toBe('') + }) + + it('returns early when no files to stage', async () => { + const result = await service.stageFiles(1, [], database) + + expect(executeCommandMock).not.toHaveBeenCalled() + expect(result).toBe('') + }) + }) + + describe('unstageFiles', () => { + it('unstages files', async () => { + const mockRepo = { + id: 1, + fullPath: '/path/to/repo', + } + getRepoByIdMock.mockReturnValue(mockRepo as any) + executeCommandMock.mockResolvedValue('') + + const result = await service.unstageFiles(1, ['file1.ts', 'file2.ts'], database) + + expect(getRepoByIdMock).toHaveBeenCalledWith(database, 1) + expect(executeCommandMock).toHaveBeenCalledWith( + ['git', '-C', mockRepo.fullPath, 'restore', '--staged', '--', 'file1.ts', 'file2.ts'], + { env: expect.any(Object) } + ) + expect(result).toBe('') + }) + }) + + describe('resetToCommit', () => { + it('resets to specific commit', async () => { + const mockRepo = { + id: 1, + fullPath: '/path/to/repo', + } + getRepoByIdMock.mockReturnValue(mockRepo as any) + executeCommandMock.mockResolvedValue('HEAD is now at abc123') + + const result = await service.resetToCommit(1, 'abc123', database) + + expect(getRepoByIdMock).toHaveBeenCalledWith(database, 1) + expect(executeCommandMock).toHaveBeenCalledWith( + ['git', '-C', mockRepo.fullPath, 'reset', '--hard', 'abc123'], + { env: expect.any(Object) } + ) + expect(result).toBe('HEAD is now at abc123') + }) + }) + + describe('push', () => { + it('pushes changes to remote', async () => { + const mockRepo = { + id: 1, + fullPath: '/path/to/repo', + } + getRepoByIdMock.mockReturnValue(mockRepo) + executeCommandMock.mockResolvedValue('Everything up-to-date') + + const result = await service.push(1, {}, database) + + expect(getRepoById).toHaveBeenCalledWith(database, 1) + expect(executeCommand).toHaveBeenCalledWith( + ['git', '-C', '/path/to/repo', 'push'], + { env: expect.any(Object) } + ) + expect(result).toBe('Everything up-to-date') + }) + + it('respects setUpstream option', async () => { + const mockRepo = { + id: 1, + fullPath: '/path/to/repo', + } + getRepoByIdMock.mockReturnValue(mockRepo) + executeCommandMock.mockResolvedValueOnce('main\n').mockResolvedValueOnce('') + + await service.push(1, { setUpstream: true }, database) + + expect(executeCommand).toHaveBeenNthCalledWith( + 2, + ['git', '-C', '/path/to/repo', 'push', '--set-upstream', 'origin', 'main'], + expect.any(Object) + ) + }) + }) + + describe('fetch', () => { + it('fetches from remote', async () => { + const mockRepo = { id: 1, fullPath: '/path/to/repo' } + getRepoByIdMock.mockReturnValue(mockRepo) + executeCommandMock.mockResolvedValue('') + + const result = await service.fetch(1, database) + + expect(result).toBe('') + }) + }) + + describe('pull', () => { + it('pulls from remote', async () => { + const mockRepo = { id: 1, fullPath: '/path/to/repo' } + getRepoByIdMock.mockReturnValue(mockRepo) + executeCommandMock.mockResolvedValue('') + + const result = await service.pull(1, database) + + expect(result).toBe('') + }) + }) + + describe('getBranches', () => { + it('returns list of local branches', async () => { + const mockRepo = { id: 1, fullPath: '/path/to/repo' } + getRepoByIdMock.mockReturnValue(mockRepo as any) + executeCommandMock.mockImplementation((args) => { + if (args.includes('rev-parse')) return Promise.resolve('main') + if (args.includes('branch')) return Promise.resolve('* main abc123 [origin/main] Initial commit\n feature def456 [origin/feature] Feature work') + if (args.includes('rev-list')) return Promise.resolve('0 0') + return Promise.resolve('') + }) + + const result = await service.getBranches(1, database) + + expect(result).toHaveLength(2) + expect(result[0]).toMatchObject({ name: 'main', type: 'local', current: true }) + expect(result[1]).toMatchObject({ name: 'feature', type: 'local', current: false }) + }) + }) + + describe('getBranchStatus', () => { + it('returns correct ahead/behind counts', async () => { + const mockRepo = { id: 1, fullPath: '/path/to/repo' } + getRepoByIdMock.mockReturnValue(mockRepo as any) + executeCommandMock.mockResolvedValue('3\t5') + + const result = await service.getBranchStatus(1, database) + + expect(result).toEqual({ ahead: 3, behind: 5 }) + }) + + it('returns zeros when no upstream', async () => { + const mockRepo = { id: 1, fullPath: '/path/to/repo' } + getRepoByIdMock.mockReturnValue(mockRepo as any) + executeCommandMock.mockRejectedValue(new Error('No upstream branch')) + + const result = await service.getBranchStatus(1, database) + + expect(result).toEqual({ ahead: 0, behind: 0 }) + }) + }) + + describe('createBranch', () => { + it('creates and switches to new branch', async () => { + const mockRepo = { id: 1, fullPath: '/path/to/repo' } + getRepoByIdMock.mockReturnValue(mockRepo as any) + executeCommandMock.mockResolvedValue("Switched to a new branch 'feature-branch'") + + const result = await service.createBranch(1, 'feature-branch', database) + + expect(executeCommandMock).toHaveBeenCalledWith( + ['git', '-C', expect.stringContaining('/path/to/repo'), 'checkout', '-b', 'feature-branch'], + { env: expect.any(Object) } + ) + expect(result).toBe("Switched to a new branch 'feature-branch'") + }) + }) + + describe('switchBranch', () => { + it('switches to existing branch', async () => { + const mockRepo = { id: 1, fullPath: '/path/to/repo' } + getRepoByIdMock.mockReturnValue(mockRepo as any) + executeCommandMock.mockResolvedValue("Switched to branch 'main'") + + const result = await service.switchBranch(1, 'main', database) + + expect(executeCommandMock).toHaveBeenCalledWith( + ['git', '-C', expect.stringContaining('/path/to/repo'), 'checkout', 'main'], + { env: expect.any(Object) } + ) + expect(result).toBe("Switched to branch 'main'") + }) + }) +}) diff --git a/backend/test/services/git/GitStatusService.test.ts b/backend/test/services/git/GitStatusService.test.ts deleted file mode 100644 index ce7659a6..00000000 --- a/backend/test/services/git/GitStatusService.test.ts +++ /dev/null @@ -1,341 +0,0 @@ -import { describe, it, expect, vi, beforeEach, type MockedFunction } from 'vitest' -import type { Database } from 'bun:sqlite' -import type { GitAuthService } from '../../../src/services/git-auth' - -vi.mock('bun:sqlite', () => ({ - Database: vi.fn() -})) - -vi.mock('../../../src/services/settings', () => ({ - SettingsService: vi.fn() -})) - -vi.mock('../../../src/utils/process', () => ({ - executeCommand: vi.fn(), -})) - -vi.mock('../../../src/db/queries', () => ({ - getRepoById: vi.fn(), -})) - -vi.mock('../../../src/utils/git-auth', () => ({ - createSilentGitEnv: vi.fn(), -})) - -import { GitStatusService } from '../../../src/services/git/GitStatusService' -import { executeCommand } from '../../../src/utils/process' -import { getRepoById } from '../../../src/db/queries' - -const executeCommandMock = executeCommand as MockedFunction -const getRepoByIdMock = getRepoById as MockedFunction - -describe('GitStatusService', () => { - let service: GitStatusService - let database: Database - let mockGitAuthService: GitAuthService - - beforeEach(() => { - vi.clearAllMocks() - database = {} as Database - mockGitAuthService = { - getGitEnvironment: vi.fn().mockReturnValue({}), - } as unknown as GitAuthService - service = new GitStatusService(mockGitAuthService) - }) - - describe('getStatus', () => { - it('returns empty status for clean repository', async () => { - const mockRepo = { - id: 1, - fullPath: '/path/to/repo', - } - getRepoByIdMock.mockReturnValue(mockRepo as any) - executeCommandMock.mockImplementation((args) => { - if (args.includes('rev-parse')) return Promise.resolve('main') - if (args.includes('rev-list')) return Promise.resolve('0 0') - if (args.includes('status')) return Promise.resolve('') - return Promise.resolve('') - }) - - const result = await service.getStatus(1, database) - - expect(result.branch).toBe('main') - expect(result.ahead).toBe(0) - expect(result.behind).toBe(0) - expect(result.files).toEqual([]) - expect(result.hasChanges).toBe(false) - }) - - it('parses modified files correctly', async () => { - const mockRepo = { - id: 1, - fullPath: '/path/to/repo', - } - getRepoByIdMock.mockReturnValue(mockRepo as any) - executeCommandMock.mockImplementation((args) => { - if (args.includes('rev-parse')) return Promise.resolve('main') - if (args.includes('rev-list')) return Promise.resolve('0 0') - if (args.includes('status')) return Promise.resolve('MM file.ts') - return Promise.resolve('') - }) - - const result = await service.getStatus(1, database) - - expect(result.files).toHaveLength(1) - expect(result.files[0]).toEqual({ path: 'file.ts', status: 'modified', staged: true }) - expect(result.hasChanges).toBe(true) - }) - - it('parses staged files correctly', async () => { - const mockRepo = { - id: 1, - fullPath: '/path/to/repo', - } - getRepoByIdMock.mockReturnValue(mockRepo as any) - executeCommandMock.mockImplementation((args) => { - if (args.includes('rev-parse')) return Promise.resolve('main') - if (args.includes('rev-list')) return Promise.resolve('0 0') - if (args.includes('status')) return Promise.resolve('M file1.ts\nA file2.ts') - return Promise.resolve('') - }) - - const result = await service.getStatus(1, database) - - expect(result.files).toHaveLength(2) - expect(result.files[0]).toEqual({ path: 'file1.ts', status: 'modified', staged: true }) - expect(result.files[1]).toEqual({ path: 'file2.ts', status: 'added', staged: true }) - }) - - it('parses deleted files correctly', async () => { - const mockRepo = { - id: 1, - fullPath: '/path/to/repo', - } - getRepoByIdMock.mockReturnValue(mockRepo as any) - executeCommandMock.mockImplementation((args) => { - if (args.includes('rev-parse')) return Promise.resolve('main') - if (args.includes('rev-list')) return Promise.resolve('0 0') - if (args.includes('status')) return Promise.resolve('D file1.ts\n D file2.ts') - return Promise.resolve('') - }) - - const result = await service.getStatus(1, database) - - expect(result.files).toHaveLength(2) - expect(result.files[0]).toEqual({ path: 'file1.ts', status: 'deleted', staged: true }) - expect(result.files[1]).toEqual({ path: 'file2.ts', status: 'deleted', staged: false }) - }) - - it('parses renamed files correctly with arrow notation', async () => { - const mockRepo = { - id: 1, - fullPath: '/path/to/repo', - } - getRepoByIdMock.mockReturnValue(mockRepo as any) - executeCommandMock.mockImplementation((args) => { - if (args.includes('rev-parse')) return Promise.resolve('main') - if (args.includes('rev-list')) return Promise.resolve('0 0') - if (args.includes('status')) return Promise.resolve('R old.ts -> new.ts') - return Promise.resolve('') - }) - - const result = await service.getStatus(1, database) - - expect(result.files).toHaveLength(1) - expect(result.files[0]).toEqual({ path: 'new.ts', oldPath: 'old.ts', status: 'renamed', staged: true }) - }) - - it('parses untracked files correctly', async () => { - const mockRepo = { - id: 1, - fullPath: '/path/to/repo', - } - getRepoByIdMock.mockReturnValue(mockRepo as any) - executeCommandMock.mockImplementation((args) => { - if (args.includes('rev-parse')) return Promise.resolve('main') - if (args.includes('rev-list')) return Promise.resolve('0 0') - if (args.includes('status')) return Promise.resolve('?? newfile.ts') - return Promise.resolve('') - }) - - const result = await service.getStatus(1, database) - - expect(result.files).toHaveLength(1) - expect(result.files[0]).toEqual({ path: 'newfile.ts', status: 'untracked', staged: false }) - }) - - it('parses copied files correctly', async () => { - const mockRepo = { - id: 1, - fullPath: '/path/to/repo', - } - getRepoByIdMock.mockReturnValue(mockRepo as any) - executeCommandMock.mockImplementation((args) => { - if (args.includes('rev-parse')) return Promise.resolve('main') - if (args.includes('rev-list')) return Promise.resolve('0 0') - if (args.includes('status')) return Promise.resolve('C original.ts') - return Promise.resolve('') - }) - - const result = await service.getStatus(1, database) - - expect(result.files).toHaveLength(1) - expect(result.files[0]).toEqual({ path: 'original.ts', status: 'copied', staged: true }) - }) - - it('parses mixed status output', async () => { - const mockRepo = { - id: 1, - fullPath: '/path/to/repo', - } - getRepoByIdMock.mockReturnValue(mockRepo as any) - executeCommandMock.mockImplementation((args) => { - if (args.includes('rev-parse')) return Promise.resolve('main') - if (args.includes('rev-list')) return Promise.resolve('0 0') - if (args.includes('status')) return Promise.resolve('MM file1.ts\nA file2.ts\n?? file3.ts\nD file4.ts') - return Promise.resolve('') - }) - - const result = await service.getStatus(1, database) - - expect(result.files).toHaveLength(4) - expect(result.files).toContainEqual({ path: 'file1.ts', status: 'modified', staged: true }) - expect(result.files).toContainEqual({ path: 'file2.ts', status: 'added', staged: true }) - expect(result.files).toContainEqual({ path: 'file3.ts', status: 'untracked', staged: false }) - expect(result.files).toContainEqual({ path: 'file4.ts', status: 'deleted', staged: true }) - }) - - it('returns branch status with ahead/behind', async () => { - const mockRepo = { - id: 1, - fullPath: '/path/to/repo', - } - getRepoByIdMock.mockReturnValue(mockRepo as any) - executeCommandMock.mockImplementation((args) => { - if (args.includes('rev-parse')) return Promise.resolve('feature-branch') - if (args.includes('rev-list')) return Promise.resolve('2 3') - if (args.includes('status')) return Promise.resolve('') - return Promise.resolve('') - }) - - const result = await service.getStatus(1, database) - - expect(result.branch).toBe('feature-branch') - expect(result.ahead).toBe(2) - expect(result.behind).toBe(3) - }) - - it('handles branch status command failure gracefully', async () => { - const mockRepo = { - id: 1, - fullPath: '/path/to/repo', - } - getRepoByIdMock.mockReturnValue(mockRepo as any) - executeCommandMock.mockImplementation((args) => { - if (args.includes('rev-parse')) return Promise.reject(new Error('No upstream')) - if (args.includes('rev-list')) return Promise.reject(new Error('No upstream')) - if (args.includes('status')) return Promise.resolve('') - return Promise.resolve('') - }) - - const result = await service.getStatus(1, database) - - expect(result.branch).toBe('') - expect(result.ahead).toBe(0) - expect(result.behind).toBe(0) - }) - - it('throws error when repository not found', async () => { - getRepoByIdMock.mockReturnValue(null) - - await expect(service.getStatus(999, database)).rejects.toThrow('Repository not found') - }) - - it('throws error when status command fails', async () => { - const mockRepo = { - id: 1, - fullPath: '/path/to/repo', - } - getRepoByIdMock.mockReturnValue(mockRepo as any) - executeCommandMock.mockRejectedValue(new Error('Not a git repository')) - - await expect(service.getStatus(1, database)).rejects.toThrow('Not a git repository') - }) - - it('parses copied files correctly with arrow notation', async () => { - const mockRepo = { - id: 1, - fullPath: '/path/to/repo', - } - getRepoByIdMock.mockReturnValue(mockRepo as any) - executeCommandMock.mockImplementation((args) => { - if (args.includes('rev-parse')) return Promise.resolve('main') - if (args.includes('rev-list')) return Promise.resolve('0 0') - if (args.includes('status')) return Promise.resolve('C source.ts -> copy.ts') - return Promise.resolve('') - }) - - const result = await service.getStatus(1, database) - - expect(result.files).toHaveLength(1) - expect(result.files[0]).toEqual({ path: 'copy.ts', oldPath: 'source.ts', status: 'copied', staged: true }) - }) - - it('preserves spaces in filenames without trimming', async () => { - const mockRepo = { - id: 1, - fullPath: '/path/to/repo', - } - getRepoByIdMock.mockReturnValue(mockRepo as any) - executeCommandMock.mockImplementation((args) => { - if (args.includes('rev-parse')) return Promise.resolve('main') - if (args.includes('rev-list')) return Promise.resolve('0 0') - if (args.includes('status')) return Promise.resolve('M path with spaces/file name.ts') - return Promise.resolve('') - }) - - const result = await service.getStatus(1, database) - - expect(result.files).toHaveLength(1) - expect(result.files[0]).toEqual({ path: 'path with spaces/file name.ts', status: 'modified', staged: true }) - }) - - it('handles tab-separated ahead/behind output', async () => { - const mockRepo = { - id: 1, - fullPath: '/path/to/repo', - } - getRepoByIdMock.mockReturnValue(mockRepo as any) - executeCommandMock.mockImplementation((args) => { - if (args.includes('rev-parse')) return Promise.resolve('main') - if (args.includes('rev-list')) return Promise.resolve('5\t0') - if (args.includes('status')) return Promise.resolve('') - return Promise.resolve('') - }) - - const result = await service.getStatus(1, database) - - expect(result.ahead).toBe(5) - expect(result.behind).toBe(0) - }) - - it('handles only behind count correctly', async () => { - const mockRepo = { - id: 1, - fullPath: '/path/to/repo', - } - getRepoByIdMock.mockReturnValue(mockRepo as any) - executeCommandMock.mockImplementation((args) => { - if (args.includes('rev-parse')) return Promise.resolve('main') - if (args.includes('rev-list')) return Promise.resolve('0 7') - if (args.includes('status')) return Promise.resolve('') - return Promise.resolve('') - }) - - const result = await service.getStatus(1, database) - - expect(result.ahead).toBe(0) - expect(result.behind).toBe(7) - }) - }) -}) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index df5f0de4..38da32ae 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -9,7 +9,6 @@ import { Login } from './pages/Login' import { Register } from './pages/Register' import { Setup } from './pages/Setup' import { SettingsDialog } from './components/settings/SettingsDialog' -import { useSettingsDialog } from './hooks/useSettingsDialog' import { useTheme } from './hooks/useTheme' import { TTSProvider } from './contexts/TTSContext' import { AuthProvider } from './contexts/AuthContext' @@ -49,7 +48,6 @@ function PermissionDialogWrapper() { } function AppShell() { - const { isOpen, close } = useSettingsDialog() useTheme() useEffect(() => { @@ -66,7 +64,7 @@ function AppShell() { - + void -} +import { useSettingsDialog } from '@/hooks/useSettingsDialog' type SettingsView = 'menu' | 'general' | 'git' | 'shortcuts' | 'opencode' | 'providers' | 'account' | 'voice' | 'notifications' -export function SettingsDialog({ open, onOpenChange }: SettingsDialogProps) { +export function SettingsDialog() { + const { isOpen, close, activeTab, setActiveTab } = useSettingsDialog() const [mobileView, setMobileView] = useState('menu') const contentRef = useRef(null) const handleSwipeBack = useCallback(() => { if (mobileView === 'menu') { setMobileView('menu') - onOpenChange(false) + close() } else { setMobileView('menu') } - }, [mobileView, onOpenChange]) + }, [mobileView, close]) const { bind: bindSwipe, swipeStyles } = useSwipeBack(handleSwipeBack, { - enabled: open, + enabled: isOpen, }) useEffect(() => { return bindSwipe(contentRef.current) }, [bindSwipe]) + useEffect(() => { + if (!isOpen) return + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') close() + } + document.addEventListener('keydown', handleKeyDown) + return () => document.removeEventListener('keydown', handleKeyDown) + }, [isOpen, close]) + const menuItems = [ { id: 'account', icon: User, label: 'Account', description: 'Profile, passkeys, and sign out' }, { id: 'general', icon: Settings2, label: 'General Settings', description: 'App preferences and behavior' }, @@ -52,26 +58,33 @@ export function SettingsDialog({ open, onOpenChange }: SettingsDialogProps) { { id: 'providers', icon: Key, label: 'Providers', description: 'Manage AI provider API keys' }, ] - const handleClose = () => { - setMobileView('menu') - onOpenChange(false) + const handleTabChange = (tab: string) => { + setActiveTab(tab as SettingsView) } - return ( - - - -
-
-

- Settings -

-
- + return ( + + +
+
+

+ Settings +

+ +
+
@@ -117,31 +130,31 @@ export function SettingsDialog({ open, onOpenChange }: SettingsDialogProps) {
-
-
- {mobileView !== 'menu' && ( - - )} -

- {mobileView === 'menu' ? 'Settings' : menuItems.find(item => item.id === mobileView)?.label} -

-
- -
+
+
+ {mobileView !== 'menu' && ( + + )} +

+ {mobileView === 'menu' ? 'Settings' : menuItems.find(item => item.id === mobileView)?.label} +

+
+ +
{mobileView === 'menu' && ( @@ -149,7 +162,10 @@ export function SettingsDialog({ open, onOpenChange }: SettingsDialogProps) { {menuItems.map((item) => (