From b6473fc8216fee394997d4d2d046abb5b17cdbca Mon Sep 17 00:00:00 2001 From: Chris Scott <99081550+chriswritescode-dev@users.noreply.github.com> Date: Fri, 6 Feb 2026 17:49:27 -0500 Subject: [PATCH 1/6] refactor: consolidate git services into single GitService class Merges GitBranchService, GitCommitService, GitDiffService, GitFetchPullService, GitLogService, GitPushService, and GitStatusService into one cohesive GitService that simplifies architecture and follows SOLID principles. Updates all route handlers and tests to use the new consolidated service. Moves GitBranch interface to shared types location. --- backend/src/routes/repo-git.ts | 60 +- backend/src/services/git/GitBranchService.ts | 153 ---- backend/src/services/git/GitCommitService.ts | 116 --- backend/src/services/git/GitDiffService.ts | 145 ---- .../src/services/git/GitFetchPullService.ts | 33 - backend/src/services/git/GitLogService.ts | 143 ---- backend/src/services/git/GitPushService.ts | 70 -- backend/src/services/git/GitService.ts | 693 ++++++++++++++++++ backend/src/services/git/GitStatusService.ts | 136 ---- backend/src/services/git/interfaces.ts | 11 - backend/src/types/git.ts | 10 + backend/test/routes/repo-git.test.ts | 4 +- .../services/git/GitBranchService.test.ts | 444 ----------- .../services/git/GitCommitService.test.ts | 233 ------ .../test/services/git/GitDiffService.test.ts | 440 ----------- .../test/services/git/GitLogService.test.ts | 379 ---------- .../test/services/git/GitPushService.test.ts | 199 ----- backend/test/services/git/GitService.test.ts | 523 +++++++++++++ .../services/git/GitStatusService.test.ts | 341 --------- 19 files changed, 1253 insertions(+), 2880 deletions(-) delete mode 100644 backend/src/services/git/GitBranchService.ts delete mode 100644 backend/src/services/git/GitCommitService.ts delete mode 100644 backend/src/services/git/GitDiffService.ts delete mode 100644 backend/src/services/git/GitFetchPullService.ts delete mode 100644 backend/src/services/git/GitLogService.ts delete mode 100644 backend/src/services/git/GitPushService.ts create mode 100644 backend/src/services/git/GitService.ts delete mode 100644 backend/src/services/git/GitStatusService.ts delete mode 100644 backend/src/services/git/interfaces.ts delete mode 100644 backend/test/services/git/GitBranchService.test.ts delete mode 100644 backend/test/services/git/GitCommitService.test.ts delete mode 100644 backend/test/services/git/GitDiffService.test.ts delete mode 100644 backend/test/services/git/GitLogService.test.ts delete mode 100644 backend/test/services/git/GitPushService.test.ts create mode 100644 backend/test/services/git/GitService.test.ts delete mode 100644 backend/test/services/git/GitStatusService.test.ts 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) - }) - }) -}) From b7315daa7e6817f97ef4bc62270a4f73e62a35e1 Mon Sep 17 00:00:00 2001 From: Chris Scott <99081550+chriswritescode-dev@users.noreply.github.com> Date: Fri, 6 Feb 2026 21:56:28 -0500 Subject: [PATCH 2/6] Refactor settings dialog to use URL-based state management with tab synchronization --- frontend/src/App.tsx | 4 +- .../components/settings/SettingsDialog.tsx | 124 ++++++++++-------- frontend/src/components/ui/dialog.tsx | 4 +- frontend/src/components/ui/header.tsx | 8 +- frontend/src/contexts/AuthContext.tsx | 2 - frontend/src/hooks/useSettingsDialog.ts | 16 +-- frontend/src/hooks/useSettingsDialogUrl.ts | 60 +++++++++ 7 files changed, 140 insertions(+), 78 deletions(-) create mode 100644 frontend/src/hooks/useSettingsDialogUrl.ts 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) => ( -
+ +
+ +
+ + +
+
+ +
+ + setFormData({ ...formData, host: e.target.value })} disabled={isSaving} - className="flex-1" - > - - SSH Key - + autoComplete="off" + />
-
-
- - setFormData({ ...formData, host: e.target.value })} - disabled={isSaving} - autoComplete="off" - /> -
+ {formData.type === 'pat' ? ( + <> +
+ + { + setTokenEdited(true) + setFormData({ ...formData, token: e.target.value }) + }} + disabled={isSaving} + autoComplete="new-password" + /> + {credential?.token && !tokenEdited && ( +

+ Leave empty to keep existing token +

+ )} +
- {formData.type === 'pat' ? ( - <> -
- - { - setTokenEdited(true) - setFormData({ ...formData, token: e.target.value }) - }} - disabled={isSaving} - autoComplete="new-password" - /> - {credential?.token && !tokenEdited && ( +
+ + setFormData({ ...formData, username: e.target.value })} + disabled={isSaving} + autoComplete="off" + /> +
+ + ) : ( + <> +
+ +