From 53238a63249e616f0b36069d5a5298d03da08395 Mon Sep 17 00:00:00 2001 From: Chris Scott <99081550+chriswritescode-dev@users.noreply.github.com> Date: Sat, 3 Oct 2026 21:59:00 -0400 Subject: [PATCH] feat(git): extend source control with worktrees, stashes, conflicts and identities - Create a worktree from a new or existing branch - Integrate a worktree branch into a target branch by merge commit or cherry-pick, with continue/abort on conflicts - Delete a worktree with keep, delete-local, or delete-local-and-remote branch options, using safe branch deletion for unmerged branches - Add a stash tab (list, push, apply, pop, drop) shared across a repository and its worktrees - Rename and delete branches, optionally deleting the remote branch and updating scheduled base branches - Detect in-progress merge, rebase, cherry-pick and revert operations and hand conflicts to an agent session - Generate commit messages from staged changes with the default model - Add a default git identity and saved per-repository identities written to git config - Unify git error mapping behind a typed error and one route helper; consolidate git mutations and cache invalidation - Update repository docs --- backend/src/db/queries.ts | 5 +- backend/src/db/schedules.ts | 5 + backend/src/index.ts | 5 +- .../src/routes/internal/git-credentials.ts | 3 +- backend/src/routes/repo-git.ts | 627 +++++---- backend/src/routes/repos.test.ts | 92 ++ backend/src/routes/repos.ts | 100 +- backend/src/routes/settings.ts | 15 +- backend/src/services/credential-provider.ts | 7 + backend/src/services/git-auth.ts | 4 +- backend/src/services/git-identity.ts | 208 +++ backend/src/services/git/GitService.ts | 655 ++++++++-- .../src/services/git/commit-message-prompt.ts | 49 + .../src/services/opencode-single-server.ts | 11 +- .../src/services/project-id-resolver.test.ts | 16 +- backend/src/services/repo.ts | 23 +- backend/src/services/sandbox/runtime.ts | 6 +- backend/src/services/schedule-worktree.ts | 15 +- backend/src/types/git.ts | 3 + backend/src/utils/git-auth.ts | 11 +- backend/src/utils/git-errors.ts | 88 +- backend/src/utils/github.ts | 3 +- backend/src/utils/process.ts | 19 +- backend/src/utils/route-helpers.ts | 34 + backend/test/helpers/git-fixtures.ts | 61 + .../test/integration/ssh-integration.test.ts | 1 + .../routes/internal/git-credentials.test.ts | 14 + backend/test/routes/repo-git.test.ts | 775 +++++++++++- .../test/routes/repos-git-identity.test.ts | 222 ++++ backend/test/routes/settings.test.ts | 33 +- .../test/services/credential-provider.test.ts | 21 + backend/test/services/git-identity.test.ts | 225 ++++ .../test/services/git/GitService.real.test.ts | 1127 +++++++++++++++++ backend/test/services/git/GitService.test.ts | 121 +- .../git/commit-message-prompt.test.ts | 80 ++ backend/test/services/repo-git.test.ts | 76 +- backend/test/services/sandbox/runtime.test.ts | 27 + .../test/services/schedule-worktree.test.ts | 11 - backend/test/utils/git-errors.test.ts | 48 +- backend/test/utils/process.test.ts | 16 + docs/features/assistant-internal-api.md | 2 + docs/features/git.md | 62 +- docs/features/overview.md | 7 +- docs/features/server-health.md | 2 +- frontend/src/api/git.ts | 93 +- frontend/src/api/opencode.test.ts | 43 + frontend/src/api/opencode.ts | 15 +- frontend/src/api/repos.ts | 27 +- frontend/src/api/types/settings.ts | 2 +- .../repo/CreateWorktreeDialog.test.tsx | 142 +++ .../components/repo/CreateWorktreeDialog.tsx | 241 +++- .../src/components/repo/RepoCard.test.tsx | 2 + frontend/src/components/repo/RepoList.tsx | 56 +- .../components/repo/repo-list-state.test.ts | 1 + .../components/settings/GitSettings.test.tsx | 247 ++++ .../src/components/settings/GitSettings.tsx | 144 ++- .../source-control/BranchesTab.test.tsx | 75 ++ .../components/source-control/BranchesTab.tsx | 218 +++- .../components/source-control/ChangesTab.tsx | 54 +- .../GitOperationBanner.test.tsx | 114 ++ .../source-control/GitOperationBanner.tsx | 117 ++ .../IntegrateBranchDialog.test.tsx | 179 +++ .../source-control/IntegrateBranchDialog.tsx | 200 +++ .../RepoGitIdentitySelect.test.tsx | 146 +++ .../source-control/RepoGitIdentitySelect.tsx | 89 ++ .../source-control/SourceControlPanel.tsx | 13 +- .../source-control/StashTab.test.tsx | 95 ++ .../components/source-control/StashTab.tsx | 172 +++ .../ui/confirm-destructive-dialog.tsx | 4 + frontend/src/components/ui/delete-dialog.tsx | 8 +- .../src/components/ui/radio-option-group.tsx | 42 + frontend/src/hooks/useGit.test.tsx | 318 ++++- frontend/src/hooks/useGit.ts | 207 ++- .../useResolveConflictsWithAgent.test.tsx | 100 ++ .../src/hooks/useResolveConflictsWithAgent.ts | 44 + frontend/src/lib/git-conflict-prompt.test.ts | 59 + frontend/src/lib/git-conflict-prompt.ts | 26 + frontend/src/lib/git-identity.test.ts | 24 + frontend/src/lib/git-identity.ts | 15 + frontend/src/lib/queryInvalidation.test.ts | 25 + frontend/src/lib/queryInvalidation.ts | 16 + frontend/src/types/git.ts | 3 + shared/src/schemas/git.ts | 73 ++ shared/src/schemas/index.ts | 1 + shared/src/schemas/repo.ts | 14 + shared/src/schemas/settings.ts | 7 + shared/src/types/errors.ts | 10 + shared/src/types/index.ts | 33 +- shared/src/utils/repo.ts | 14 + 89 files changed, 7687 insertions(+), 776 deletions(-) create mode 100644 backend/src/services/git-identity.ts create mode 100644 backend/src/services/git/commit-message-prompt.ts create mode 100644 backend/test/helpers/git-fixtures.ts create mode 100644 backend/test/routes/repos-git-identity.test.ts create mode 100644 backend/test/services/git-identity.test.ts create mode 100644 backend/test/services/git/GitService.real.test.ts create mode 100644 backend/test/services/git/commit-message-prompt.test.ts create mode 100644 frontend/src/components/repo/CreateWorktreeDialog.test.tsx create mode 100644 frontend/src/components/settings/GitSettings.test.tsx create mode 100644 frontend/src/components/source-control/BranchesTab.test.tsx create mode 100644 frontend/src/components/source-control/GitOperationBanner.test.tsx create mode 100644 frontend/src/components/source-control/GitOperationBanner.tsx create mode 100644 frontend/src/components/source-control/IntegrateBranchDialog.test.tsx create mode 100644 frontend/src/components/source-control/IntegrateBranchDialog.tsx create mode 100644 frontend/src/components/source-control/RepoGitIdentitySelect.test.tsx create mode 100644 frontend/src/components/source-control/RepoGitIdentitySelect.tsx create mode 100644 frontend/src/components/source-control/StashTab.test.tsx create mode 100644 frontend/src/components/source-control/StashTab.tsx create mode 100644 frontend/src/components/ui/radio-option-group.tsx create mode 100644 frontend/src/hooks/useResolveConflictsWithAgent.test.tsx create mode 100644 frontend/src/hooks/useResolveConflictsWithAgent.ts create mode 100644 frontend/src/lib/git-conflict-prompt.test.ts create mode 100644 frontend/src/lib/git-conflict-prompt.ts create mode 100644 frontend/src/lib/git-identity.test.ts create mode 100644 frontend/src/lib/git-identity.ts create mode 100644 shared/src/schemas/git.ts diff --git a/backend/src/db/queries.ts b/backend/src/db/queries.ts index c02e57ac8..06835483f 100644 --- a/backend/src/db/queries.ts +++ b/backend/src/db/queries.ts @@ -3,6 +3,7 @@ import type { Repo, CreateRepoInput } from '../types/repo' import { getReposPath } from '@opencode-manager/shared/config/env' import { ASSISTANT_REPO_ID, ASSISTANT_REPO_PATH, getRepoDisplayName } from '@opencode-manager/shared/utils' import { getErrorMessage } from '../utils/error-utils' +import { canonicalPathSync } from '../utils/fs-safe' import path from 'path' interface RepoRow { @@ -93,12 +94,12 @@ export function setRepoSandboxGitCredentials(db: Database, repoId: number, allow } export function getRepoByDirectory(db: Database, directory: string): Repo | null { - const resolvedDirectory = path.resolve(directory) + const resolvedDirectory = canonicalPathSync(path.resolve(directory)) const repos = listRepos(db) return repos .filter((repo) => { - const resolvedRepoPath = path.resolve(repo.fullPath) + const resolvedRepoPath = canonicalPathSync(path.resolve(repo.fullPath)) const relativePath = path.relative(resolvedRepoPath, resolvedDirectory) return relativePath === '' || (!!relativePath && !relativePath.startsWith('..') && !path.isAbsolute(relativePath)) }) diff --git a/backend/src/db/schedules.ts b/backend/src/db/schedules.ts index b9c34da24..b7b869d2f 100644 --- a/backend/src/db/schedules.ts +++ b/backend/src/db/schedules.ts @@ -343,6 +343,11 @@ export function updateScheduleJobRunState(db: Database, repoId: number, jobId: n stmt.run(values.lastRunAt, values.nextRunAt ?? null, Date.now(), repoId, jobId) } +export function updateScheduleJobsBranch(db: Database, repoId: number, from: string, to: string): number { + const stmt = db.prepare('UPDATE schedule_jobs SET branch = ?, updated_at = ? WHERE repo_id = ? AND branch = ?') + return stmt.run(to, Date.now(), repoId, from).changes +} + export function createScheduleRun( db: Database, input: { diff --git a/backend/src/index.ts b/backend/src/index.ts index 88cfe0f1b..43270f107 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -48,7 +48,6 @@ import { opencodeServerManager } from './services/opencode-single-server' import { createOpenCodeClient } from './services/opencode/client' import { NotificationService } from './services/notification' import { ScheduleRunner, ScheduleService } from './services/schedules' -import { CredentialProvider } from './services/credential-provider' import { ScheduleWorktreeManager } from './services/schedule-worktree' import { migrateGlobalSkills } from './services/skills' import { installAssistantWorkspace } from './services/assistant-mode' @@ -220,9 +219,7 @@ try { logger.error('Failed to initialize workspace:', error) } -const settingsServiceForSchedules = new SettingsService(db) -const credentialProvider = new CredentialProvider(db) -const scheduleWorktreeManager = new ScheduleWorktreeManager(gitAuthService, settingsServiceForSchedules, credentialProvider, db) +const scheduleWorktreeManager = new ScheduleWorktreeManager(gitAuthService, db) const scheduleService = new ScheduleService(db, openCodeClient, scheduleWorktreeManager) const scheduleRunnerInstance = new ScheduleRunner(scheduleService) diff --git a/backend/src/routes/internal/git-credentials.ts b/backend/src/routes/internal/git-credentials.ts index 53a1c470e..830e4aca5 100644 --- a/backend/src/routes/internal/git-credentials.ts +++ b/backend/src/routes/internal/git-credentials.ts @@ -7,7 +7,8 @@ export function createInternalGitCredentialsRoutes(db: Database) { app.get('/gh-env', (c) => { const provider = new CredentialProvider(db) - return c.json(provider.getGhCliEnv({ cwd: c.req.query('cwd') })) + const cwd = c.req.query('cwd') + return c.json(provider.getGhCliEnv({ cwd })) }) return app diff --git a/backend/src/routes/repo-git.ts b/backend/src/routes/repo-git.ts index b85be5abf..edb7c2192 100644 --- a/backend/src/routes/repo-git.ts +++ b/backend/src/routes/repo-git.ts @@ -1,42 +1,71 @@ import { Hono } from 'hono' +import type { Context } from 'hono' import type { Database } from 'bun:sqlite' -import type { ContentfulStatusCode } from 'hono/utils/http-status' import { getRepoById } from '../db/queries' import { logger } from '../utils/logger' -import { parseGitError } from '../utils/git-errors' -import { GitService } from '../services/git/GitService' -import type { GitAuthService } from '../services/git-auth' -import { SettingsService } from '../services/settings' -import { CredentialProvider } from '../services/credential-provider' +import { parseJsonBody, respondWithGitError } from '../utils/route-helpers' +import { buildCommitMessagePrompt, normalizeGeneratedCommitMessage } from '../services/git/commit-message-prompt' +import type { CommitMessageContext } from '../services/git/commit-message-prompt' +import { RenameBranchRequestSchema, DeleteBranchRequestSchema, StashPushRequestSchema, StashApplyRequestSchema, StashDropRequestSchema, IntegrateBranchRequestSchema } from '@opencode-manager/shared' +import type { GitService } from '../services/git/GitService' +import type { OpenCodeClient } from '../services/opencode/client' import type { GitStatusResponse } from '../types/git' +import type { Repo } from '../types/repo' +import { getErrorMessage } from '../utils/error-utils' -export function createRepoGitRoutes(database: Database, gitAuthService: GitAuthService) { - const app = new Hono() - const settingsService = new SettingsService(database) - const credentialProvider = new CredentialProvider(database) - const git = new GitService(gitAuthService, settingsService, credentialProvider) +const DEFAULT_COMMIT_MESSAGE_TIMEOUT_MS = 30_000 - app.get('/:id/git/status', async (c) => { - try { - const id = parseInt(c.req.param('id')) - const repo = getRepoById(database, id) +export interface RepoGitRouteOptions { + commitMessageTimeoutMs?: number +} - if (!repo) { - return c.json({ error: 'Repo not found' }, 404) - } +class CommitMessageTimeoutError extends Error { + constructor() { + super('Commit message generation timed out') + this.name = 'CommitMessageTimeoutError' + } +} - const status = await git.getStatus(id, database) +function parseStashIndex(raw: string | undefined): number | null { + if (raw === undefined || !/^\d+$/.test(raw)) { + return null + } + return Number(raw) +} - return c.json(status) +export function createRepoGitRoutes( + database: Database, + git: GitService, + openCodeClient: OpenCodeClient, + options: RepoGitRouteOptions = {}, +) { + const app = new Hono() + const commitMessageTimeoutMs = options.commitMessageTimeoutMs ?? DEFAULT_COMMIT_MESSAGE_TIMEOUT_MS + + async function withRepo( + c: Context, + logMessage: string, + handler: (repo: Repo, id: number) => Promise, + ): Promise { + const id = parseInt(c.req.param('id')) + const repo = getRepoById(database, id) + + if (!repo) { + return c.json({ error: 'Repo not found' }, 404) + } + + try { + return await handler(repo, id) } catch (error: unknown) { - logger.error('Failed to get git status:', error) - const gitError = parseGitError(error) - return c.json( - { error: gitError.summary, detail: gitError.detail, code: gitError.code }, - gitError.statusCode as ContentfulStatusCode - ) + return respondWithGitError(c, error, logMessage) } - }) + } + + app.get('/:id/git/status', (c) => withRepo(c, 'Failed to get git status:', async (_repo, id) => { + const status = await git.getStatus(id, database) + + return c.json(status) + })) app.post('/git-status-batch', async (c) => { try { @@ -65,414 +94,336 @@ export function createRepoGitRoutes(database: Database, gitAuthService: GitAuthS results.push(...batchResults) } - const statuses = results - const resultMap: Record = {} - for (const entry of statuses) { + for (const entry of results) { if (entry) { const [id, status] = entry resultMap[id] = status } } - return c.json(resultMap) } catch (error: unknown) { - logger.error('Failed to get batch git status:', error) - const gitError = parseGitError(error) - return c.json( - { error: gitError.summary, detail: gitError.detail, code: gitError.code }, - gitError.statusCode as ContentfulStatusCode - ) + return respondWithGitError(c, error, 'Failed to get batch git status:') } }) - app.get('/:id/git/diff', async (c) => { - try { - const id = parseInt(c.req.param('id')) - const filePath = c.req.query('path') - - if (!filePath) { - return c.json({ error: 'path query parameter is required' }, 400) - } - - const repo = getRepoById(database, id) + app.get('/:id/git/diff', (c) => { + const filePath = c.req.query('path') - if (!repo) { - return c.json({ error: 'Repo not found' }, 404) - } + if (!filePath) { + return c.json({ error: 'path query parameter is required' }, 400) + } + return withRepo(c, 'Failed to get file diff:', async (_repo, id) => { const diff = await git.getDiff(id, filePath, database) return c.json(diff) - } catch (error: unknown) { - logger.error('Failed to get file diff:', error) - const gitError = parseGitError(error) - return c.json( - { error: gitError.summary, detail: gitError.detail, code: gitError.code }, - gitError.statusCode as ContentfulStatusCode - ) - } + }) }) - // Add new endpoint for full diff details - app.get('/:id/git/diff-full', async (c) => { - try { - const id = parseInt(c.req.param('id')) - const filePath = c.req.query('path') - const includeStaged = c.req.query('includeStaged') === 'true' - - if (!filePath) { - return c.json({ error: 'path query parameter is required' }, 400) - } + app.get('/:id/git/diff-full', (c) => { + const filePath = c.req.query('path') + const includeStaged = c.req.query('includeStaged') === 'true' - const repo = getRepoById(database, id) - if (!repo) { - return c.json({ error: 'Repo not found' }, 404) - } + if (!filePath) { + return c.json({ error: 'path query parameter is required' }, 400) + } + return withRepo(c, 'Failed to get full file diff:', async (_repo, id) => { 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) - const gitError = parseGitError(error) - return c.json( - { error: gitError.summary, detail: gitError.detail, code: gitError.code }, - gitError.statusCode as ContentfulStatusCode - ) - } + }) }) - app.post('/:id/git/fetch', async (c) => { - try { - const id = parseInt(c.req.param('id')) - const repo = getRepoById(database, id) + app.post('/:id/git/fetch', (c) => withRepo(c, 'Failed to fetch git:', async (_repo, id) => { + await git.fetch(id, database) - if (!repo) { - return c.json({ error: 'Repo not found' }, 404) - } + const status = await git.getStatus(id, database) + return c.json(status) + })) - await git.fetch(id, database) + app.post('/:id/git/pull', (c) => withRepo(c, 'Failed to pull git:', async (_repo, id) => { + await git.pull(id, database) - const status = await git.getStatus(id, database) - return c.json(status) - } catch (error: unknown) { - logger.error('Failed to fetch git:', error) - const gitError = parseGitError(error) - return c.json( - { error: gitError.summary, detail: gitError.detail, code: gitError.code }, - gitError.statusCode as ContentfulStatusCode - ) - } - }) + const status = await git.getStatus(id, database) + return c.json(status) + })) - app.post('/:id/git/pull', async (c) => { - try { - const id = parseInt(c.req.param('id')) - const repo = getRepoById(database, id) + app.post('/:id/git/commit', (c) => withRepo(c, 'Failed to commit git:', async (_repo, id) => { + const body = await c.req.json() + const { message, stagedPaths } = body - if (!repo) { - return c.json({ error: 'Repo not found' }, 404) - } + if (!message) { + return c.json({ error: 'message is required' }, 400) + } - await git.pull(id, database) + await git.commit(id, message, database, stagedPaths) - const status = await git.getStatus(id, database) - return c.json(status) - } catch (error: unknown) { - logger.error('Failed to pull git:', error) - const gitError = parseGitError(error) - return c.json( - { error: gitError.summary, detail: gitError.detail, code: gitError.code }, - gitError.statusCode as ContentfulStatusCode - ) - } - }) + const status = await git.getStatus(id, database) + return c.json(status) + })) - app.post('/:id/git/commit', async (c) => { + app.post('/:id/git/commit-message', (c) => withRepo(c, 'Failed to generate commit message:', async (_repo, id) => { + let context: CommitMessageContext try { - const id = parseInt(c.req.param('id')) - const repo = getRepoById(database, id) + context = await git.getCommitMessageContext(id, database) + } catch (error: unknown) { + logger.error('Failed to build commit message context:', error) + return c.json({ error: getErrorMessage(error) }, 400) + } - if (!repo) { - return c.json({ error: 'Repo not found' }, 404) - } + const controller = new AbortController() + let timedOut = false + let timer: ReturnType | undefined + const timeout = new Promise((_resolve, reject) => { + timer = setTimeout(() => { + timedOut = true + controller.abort() + reject(new CommitMessageTimeoutError()) + }, commitMessageTimeoutMs) + }) - const body = await c.req.json() - const { message, stagedPaths } = body + try { + const { text } = await Promise.race([ + openCodeClient.api.generate.text( + { prompt: buildCommitMessagePrompt(context) }, + { signal: controller.signal }, + ), + timeout, + ]) + const message = normalizeGeneratedCommitMessage(text) if (!message) { - return c.json({ error: 'message is required' }, 400) + return c.json({ error: 'Model returned an empty commit message' }, 502) } - await git.commit(id, message, database, stagedPaths) - - const status = await git.getStatus(id, database) - return c.json(status) + return c.json({ message }) } catch (error: unknown) { - logger.error('Failed to commit git:', error) - const gitError = parseGitError(error) - return c.json( - { error: gitError.summary, detail: gitError.detail, code: gitError.code }, - gitError.statusCode as ContentfulStatusCode - ) + if (timedOut) { + return c.json({ error: 'Commit message generation timed out' }, 502) + } + logger.error('Failed to generate commit message:', error) + return c.json({ error: getErrorMessage(error) }, 502) + } finally { + if (timer) clearTimeout(timer) } - }) + })) - app.post('/:id/git/push', async (c) => { - try { - const id = parseInt(c.req.param('id')) - const repo = getRepoById(database, id) + app.post('/:id/git/push', (c) => withRepo(c, 'Failed to push git:', async (_repo, id) => { + const body = await c.req.json() + const { setUpstream } = body - if (!repo) { - return c.json({ error: 'Repo not found' }, 404) - } + await git.push(id, { setUpstream: setUpstream || false }, database) - const body = await c.req.json() - const { setUpstream } = body + const status = await git.getStatus(id, database) + return c.json(status) + })) - await git.push(id, { setUpstream: setUpstream || false }, database) + app.post('/:id/git/stage', (c) => withRepo(c, 'Failed to stage files:', async (_repo, id) => { + const body = await c.req.json() + const { paths } = body - const status = await git.getStatus(id, database) - return c.json(status) - } catch (error: unknown) { - logger.error('Failed to push git:', error) - const gitError = parseGitError(error) - return c.json( - { error: gitError.summary, detail: gitError.detail, code: gitError.code }, - gitError.statusCode as ContentfulStatusCode - ) + if (!paths || !Array.isArray(paths)) { + return c.json({ error: 'paths is required and must be an array' }, 400) } - }) - app.post('/:id/git/stage', async (c) => { - try { - const id = parseInt(c.req.param('id')) - const repo = getRepoById(database, id) + await git.stageFiles(id, paths, database) - if (!repo) { - return c.json({ error: 'Repo not found' }, 404) - } + const status = await git.getStatus(id, database) + return c.json(status) + })) - const body = await c.req.json() - const { paths } = body + app.post('/:id/git/unstage', (c) => withRepo(c, 'Failed to unstage files:', async (_repo, id) => { + const body = await c.req.json() + const { paths } = body - if (!paths || !Array.isArray(paths)) { - return c.json({ error: 'paths is required and must be an array' }, 400) - } + if (!paths || !Array.isArray(paths)) { + return c.json({ error: 'paths is required and must be an array' }, 400) + } - await git.stageFiles(id, paths, database) + await git.unstageFiles(id, paths, database) - const status = await git.getStatus(id, database) - return c.json(status) - } catch (error: unknown) { - logger.error('Failed to stage files:', error) - const gitError = parseGitError(error) - return c.json( - { error: gitError.summary, detail: gitError.detail, code: gitError.code }, - gitError.statusCode as ContentfulStatusCode - ) - } - }) + const status = await git.getStatus(id, database) + return c.json(status) + })) - app.post('/:id/git/unstage', async (c) => { - try { - const id = parseInt(c.req.param('id')) - const repo = getRepoById(database, id) + app.post('/:id/git/discard', (c) => withRepo(c, 'Failed to discard changes:', async (_repo, id) => { + const body = await c.req.json() + const { paths, staged } = body - if (!repo) { - return c.json({ error: 'Repo not found' }, 404) - } + if (!paths || !Array.isArray(paths)) { + return c.json({ error: 'paths is required and must be an array' }, 400) + } - const body = await c.req.json() - const { paths } = body + await git.discardChanges(id, paths, staged ?? false, database) - if (!paths || !Array.isArray(paths)) { - return c.json({ error: 'paths is required and must be an array' }, 400) - } + const status = await git.getStatus(id, database) + return c.json(status) + })) - await git.unstageFiles(id, paths, database) + app.get('/:id/git/commit/:hash', (c) => withRepo(c, 'Failed to get commit details:', async (_repo, id) => { + const hash = c.req.param('hash') - const status = await git.getStatus(id, database) - return c.json(status) - } catch (error: unknown) { - logger.error('Failed to unstage files:', error) - const gitError = parseGitError(error) - return c.json( - { error: gitError.summary, detail: gitError.detail, code: gitError.code }, - gitError.statusCode as ContentfulStatusCode - ) + if (!hash) { + return c.json({ error: 'hash is required' }, 400) } - }) - app.post('/:id/git/discard', async (c) => { - try { - const id = parseInt(c.req.param('id')) - const repo = getRepoById(database, id) + const commitDetails = await git.getCommitDetails(id, hash, database) - if (!repo) { - return c.json({ error: 'Repo not found' }, 404) - } - - const body = await c.req.json() - const { paths, staged } = body + if (!commitDetails) { + return c.json({ error: 'Commit not found' }, 404) + } - if (!paths || !Array.isArray(paths)) { - return c.json({ error: 'paths is required and must be an array' }, 400) - } + return c.json(commitDetails) + })) - await git.discardChanges(id, paths, staged ?? false, database) + app.get('/:id/git/commit/:hash/diff', (c) => withRepo(c, 'Failed to get commit diff:', async (_repo, id) => { + const hash = c.req.param('hash') + const filePath = c.req.query('path') - const status = await git.getStatus(id, database) - return c.json(status) - } catch (error: unknown) { - logger.error('Failed to discard changes:', error) - const gitError = parseGitError(error) - return c.json( - { error: gitError.summary, detail: gitError.detail, code: gitError.code }, - gitError.statusCode as ContentfulStatusCode - ) + if (!hash) { + return c.json({ error: 'hash is required' }, 400) } - }) - app.get('/:id/git/commit/:hash', async (c) => { - try { - const id = parseInt(c.req.param('id')) - const hash = c.req.param('hash') - const repo = getRepoById(database, id) + if (!filePath) { + return c.json({ error: 'path query parameter is required' }, 400) + } - if (!repo) { - return c.json({ error: 'Repo not found' }, 404) - } + const diff = await git.getCommitDiff(id, hash, filePath, database) + return c.json(diff) + })) - if (!hash) { - return c.json({ error: 'hash is required' }, 400) - } + app.get('/:id/git/log', (c) => withRepo(c, 'Failed to get git log:', async (_repo, id) => { + const limit = parseInt(c.req.query('limit') || '10', 10) + const branch = c.req.query('branch') || undefined + const commits = await git.getLog(id, database, limit, branch) - const commitDetails = await git.getCommitDetails(id, hash, database) + return c.json({ commits }) + })) - if (!commitDetails) { - return c.json({ error: 'Commit not found' }, 404) - } + app.post('/:id/git/reset', (c) => withRepo(c, 'Failed to reset to commit:', async (_repo, id) => { + const body = await c.req.json() + const { commitHash } = body - return c.json(commitDetails) - } catch (error: unknown) { - logger.error('Failed to get commit details:', error) - const gitError = parseGitError(error) - return c.json( - { error: gitError.summary, detail: gitError.detail, code: gitError.code }, - gitError.statusCode as ContentfulStatusCode - ) + if (!commitHash) { + return c.json({ error: 'commitHash is required' }, 400) } - }) - app.get('/:id/git/commit/:hash/diff', async (c) => { - try { - const id = parseInt(c.req.param('id')) - const hash = c.req.param('hash') - const filePath = c.req.query('path') - const repo = getRepoById(database, id) + await git.resetToCommit(id, commitHash, database) - if (!repo) { - return c.json({ error: 'Repo not found' }, 404) - } + const status = await git.getStatus(id, database) + return c.json(status) + })) - if (!hash) { - return c.json({ error: 'hash is required' }, 400) - } + app.get('/:id/git/branches', (c) => withRepo(c, 'Failed to get branches:', async (_repo, id) => { + const branches = await git.getBranches(id, database) + const status = await git.getBranchStatus(id, database) - if (!filePath) { - return c.json({ error: 'path query parameter is required' }, 400) - } + return c.json({ branches, status }) + })) - const diff = await git.getCommitDiff(id, hash, filePath, database) - return c.json(diff) - } catch (error: unknown) { - logger.error('Failed to get commit diff:', error) - const gitError = parseGitError(error) - return c.json( - { error: gitError.summary, detail: gitError.detail, code: gitError.code }, - gitError.statusCode as ContentfulStatusCode - ) + app.post('/:id/git/branches/rename', (c) => withRepo(c, 'Failed to rename branch:', async (_repo, id) => { + const parsed = await parseJsonBody(c, RenameBranchRequestSchema) + if (parsed instanceof Response) { + return parsed } - }) - app.get('/:id/git/log', async (c) => { - try { - const id = parseInt(c.req.param('id')) - const repo = getRepoById(database, id) + await git.renameBranch(id, parsed.from, parsed.to, database) - if (!repo) { - return c.json({ error: 'Repo not found' }, 404) - } + const status = await git.getStatus(id, database) + return c.json(status) + })) - const limit = parseInt(c.req.query('limit') || '10', 10) - const branch = c.req.query('branch') || undefined - const commits = await git.getLog(id, database, limit, branch) + app.delete('/:id/git/branches', (c) => withRepo(c, 'Failed to delete branch:', async (_repo, id) => { + const parsed = await parseJsonBody(c, DeleteBranchRequestSchema) + if (parsed instanceof Response) { + return parsed + } - return c.json({ commits }) - } catch (error: unknown) { - logger.error('Failed to get git log:', error) - const gitError = parseGitError(error) - return c.json( - { error: gitError.summary, detail: gitError.detail, code: gitError.code }, - gitError.statusCode as ContentfulStatusCode - ) + const result = await git.deleteBranch(id, parsed, database) + + const status = await git.getStatus(id, database) + return c.json({ ...result, status }) + })) + + app.post('/:id/git/integrate', (c) => withRepo(c, 'Failed to integrate branch:', async (_repo, id) => { + const parsed = await parseJsonBody(c, IntegrateBranchRequestSchema) + if (parsed instanceof Response) { + return parsed } - }) - app.post('/:id/git/reset', async (c) => { - try { - const id = parseInt(c.req.param('id')) - const repo = getRepoById(database, id) + const result = await git.integrateBranch(id, parsed, database) - if (!repo) { - return c.json({ error: 'Repo not found' }, 404) - } + const targetStatus = await git.getStatus(result.targetRepoId, database) + return c.json({ ...result, targetStatus }) + })) - const body = await c.req.json() - const { commitHash } = body + app.get('/:id/git/stash', (c) => withRepo(c, 'Failed to list stashes:', async (_repo, id) => { + const stashes = await git.listStashes(id, database) + return c.json({ stashes }) + })) - if (!commitHash) { - return c.json({ error: 'commitHash is required' }, 400) - } + app.post('/:id/git/stash', (c) => withRepo(c, 'Failed to push stash:', async (_repo, id) => { + const parsed = await parseJsonBody(c, StashPushRequestSchema) + if (parsed instanceof Response) { + return parsed + } - await git.resetToCommit(id, commitHash, database) + await git.pushStash(id, parsed, database) - const status = await git.getStatus(id, database) - return c.json(status) - } catch (error: unknown) { - logger.error('Failed to reset to commit:', error) - const gitError = parseGitError(error) - return c.json( - { error: gitError.summary, detail: gitError.detail, code: gitError.code }, - gitError.statusCode as ContentfulStatusCode - ) + const status = await git.getStatus(id, database) + return c.json(status) + })) + + app.post('/:id/git/stash/:index/apply', (c) => withRepo(c, 'Failed to apply stash:', async (_repo, id) => { + const index = parseStashIndex(c.req.param('index')) + if (index === null) { + return c.json({ error: 'index must be a non-negative integer' }, 400) } - }) - app.get('/:id/git/branches', async (c) => { - try { - const id = parseInt(c.req.param('id')) - const repo = getRepoById(database, id) + const parsed = await parseJsonBody(c, StashApplyRequestSchema) + if (parsed instanceof Response) { + return parsed + } - if (!repo) { - return c.json({ error: 'Repo not found' }, 404) - } + await git.applyStash(id, index, parsed.hash, parsed.pop, database) - const branches = await git.getBranches(id, database) - const status = await git.getBranchStatus(id, database) + const status = await git.getStatus(id, database) + return c.json(status) + })) - return c.json({ branches, status }) - } catch (error: unknown) { - logger.error('Failed to get branches:', error) - const gitError = parseGitError(error) - return c.json( - { error: gitError.summary, detail: gitError.detail, code: gitError.code }, - gitError.statusCode as ContentfulStatusCode - ) + app.delete('/:id/git/stash/:index', (c) => withRepo(c, 'Failed to drop stash:', async (_repo, id) => { + const index = parseStashIndex(c.req.param('index')) + if (index === null) { + return c.json({ error: 'index must be a non-negative integer' }, 400) } - }) + + const parsed = await parseJsonBody(c, StashDropRequestSchema) + if (parsed instanceof Response) { + return parsed + } + + await git.dropStash(id, index, parsed.hash, database) + + const status = await git.getStatus(id, database) + return c.json(status) + })) + + app.post('/:id/git/operation/continue', (c) => withRepo(c, 'Failed to continue git operation:', async (_repo, id) => { + await git.continueOperation(id, database) + + const status = await git.getStatus(id, database) + return c.json(status) + })) + + app.post('/:id/git/operation/abort', (c) => withRepo(c, 'Failed to abort git operation:', async (_repo, id) => { + await git.abortOperation(id, database) + + const status = await git.getStatus(id, database) + return c.json(status) + })) return app } diff --git a/backend/src/routes/repos.test.ts b/backend/src/routes/repos.test.ts index eb9061120..4f87a31eb 100644 --- a/backend/src/routes/repos.test.ts +++ b/backend/src/routes/repos.test.ts @@ -11,6 +11,8 @@ import type { OpenCodeClient } from '../services/opencode/client' import type { Repo } from '@opencode-manager/shared/types' import { getReposPath } from '@opencode-manager/shared/config/env' import path from 'path' +import { mkdirSync, mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' beforeEach(() => { mock.module('../services/project-id-resolver', () => ({ @@ -574,3 +576,93 @@ describe('PATCH /api/repos/:id', () => { expect(res.status).toBe(500) }) }) + +describe('DELETE /api/repos/:id', () => { + let db: Database + let app: Hono + let workspacePath: string + const originalWorkspacePath = process.env.WORKSPACE_PATH + + beforeEach(() => { + workspacePath = mkdtempSync(path.join(tmpdir(), 'repos-delete-')) + process.env.WORKSPACE_PATH = workspacePath + mkdirSync(path.join(workspacePath, 'repos'), { recursive: true }) + db = createTestDb() + app = createTestApp(db) + }) + + afterEach(() => { + db.close() + rmSync(workspacePath, { recursive: true, force: true }) + if (originalWorkspacePath === undefined) { + delete process.env.WORKSPACE_PATH + } else { + process.env.WORKSPACE_PATH = originalWorkspacePath + } + }) + + it('rejects deleteBranch for a non-worktree repo', async () => { + createRepo(db, { localPath: 'repo-a', defaultBranch: 'main', cloneStatus: 'ready', clonedAt: Date.now(), isLocal: true }) + + const res = await app.request('/repos/1', { + method: 'DELETE', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ deleteBranch: 'local' }), + }) + + expect(res.status).toBe(400) + const data = await res.json() as { error: string } + expect(data.error).toBe('Only worktrees can delete their branch') + expect(getRepoById(db, 1)).not.toBeNull() + }) + + it('deletes a repo with no body and keeps the previous response shape', async () => { + createRepo(db, { localPath: 'repo-a', defaultBranch: 'main', cloneStatus: 'ready', clonedAt: Date.now(), isLocal: true }) + + const res = await app.request('/repos/1', { method: 'DELETE' }) + + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ success: true }) + expect(getRepoById(db, 1)).toBeNull() + }) + + it('returns 200 with branch.error when branch cleanup fails after the worktree removal', async () => { + createRepo(db, { + localPath: 'wt-missing', + branch: 'feature', + defaultBranch: 'main', + cloneStatus: 'ready', + clonedAt: Date.now(), + isLocal: true, + isWorktree: true, + }) + + const res = await app.request('/repos/1', { + method: 'DELETE', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ deleteBranch: 'local' }), + }) + + expect(res.status).toBe(200) + const data = await res.json() as { success: boolean; branch?: { name: string; deleted: boolean; remoteDeleted: boolean; error?: string } } + expect(data.success).toBe(true) + expect(data.branch?.name).toBe('feature') + expect(data.branch?.deleted).toBe(false) + expect(data.branch?.remoteDeleted).toBe(false) + expect(data.branch?.error).toBeTruthy() + expect(getRepoById(db, 1)).toBeNull() + }) + + it('returns 400 for an invalid deleteBranch value', async () => { + createRepo(db, { localPath: 'repo-a', defaultBranch: 'main', cloneStatus: 'ready', clonedAt: Date.now(), isLocal: true }) + + const res = await app.request('/repos/1', { + method: 'DELETE', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ deleteBranch: 'remote-only' }), + }) + + expect(res.status).toBe(400) + expect(getRepoById(db, 1)).not.toBeNull() + }) +}) diff --git a/backend/src/routes/repos.ts b/backend/src/routes/repos.ts index 065af5479..1e687cf3d 100644 --- a/backend/src/routes/repos.ts +++ b/backend/src/routes/repos.ts @@ -2,12 +2,13 @@ import { Hono } from 'hono' import { z } from 'zod' import type { ContentfulStatusCode } from 'hono/utils/http-status' import type { Database } from 'bun:sqlite' -import type { Repo } from '@opencode-manager/shared/types' -import { DiscoverReposRequestSchema, AssistantModeInitRequestSchema, UpdateRepoRequestSchema } from '@opencode-manager/shared/schemas' +import type { DeleteRepoResult, Repo } from '@opencode-manager/shared/types' +import { DiscoverReposRequestSchema, AssistantModeInitRequestSchema, UpdateRepoRequestSchema, DeleteRepoRequestSchema } from '@opencode-manager/shared/schemas' import { listRepos, getRepoById, updateLastAccessed, getRepoGitCredentialId, setRepoGitCredentialId, updateRepoName } from '../db/queries' import * as repoService from '../services/repo' import * as archiveService from '../services/archive' import { SettingsService } from '../services/settings' +import { getEffectiveGitIdentity, setRepoGitIdentity } from '../services/git-identity' import type { OpenCodeClient } from '../services/opencode/client' import { logger } from '../utils/logger' import { getErrorMessage, getStatusCode } from '../utils/error-utils' @@ -15,6 +16,7 @@ import { handleOpenCodeError } from '../utils/route-helpers' import { ASSISTANT_REPO_ID, isWorktreeSibling } from '@opencode-manager/shared/utils' import { isWorktreeError, openCodeLocation } from '@opencode-manager/shared/opencode' import { createRepoGitRoutes } from './repo-git' +import { createGitService } from '../services/git/GitService' import { createScheduleRoutes } from './schedules' import type { GitAuthService } from '../services/git-auth' import { ScheduleService } from '../services/schedules' @@ -44,8 +46,9 @@ export function createRepoRoutes( openCodeClient: OpenCodeClient, ) { const app = new Hono() + const git = createGitService(gitAuthService) - app.route('/', createRepoGitRoutes(database, gitAuthService)) + app.route('/', createRepoGitRoutes(database, git, openCodeClient)) app.route('/:id/schedules', createScheduleRoutes(scheduleService)) app.post('/', async (c) => { @@ -230,6 +233,54 @@ app.get('/', async (c) => { } }) + app.get('/:id/git-identity', async (c) => { + try { + const id = parseInt(c.req.param('id')) + const repo = getRepoById(database, id) + + if (!repo) { + return c.json({ error: 'Repo not found' }, 404) + } + + return c.json(await getEffectiveGitIdentity(repo.fullPath, database)) + } catch (error: unknown) { + logger.error('Failed to read repo git identity:', error) + return c.json({ error: getErrorMessage(error) }, 500) + } + }) + + app.patch('/:id/git-identity', async (c) => { + try { + const id = parseInt(c.req.param('id')) + const repo = getRepoById(database, id) + + if (!repo) { + return c.json({ error: 'Repo not found' }, 404) + } + + const body = await c.req.json() + const identityId = typeof body.identityId === 'string' && body.identityId.trim() !== '' + ? body.identityId.trim() + : null + + let preset: { name: string; email: string } | null = null + if (identityId) { + const settingsService = new SettingsService(database) + const settings = settingsService.getSettings() + preset = (settings.preferences.gitIdentities || []).find((identity) => identity.id === identityId) ?? null + if (!preset) { + return c.json({ error: 'Identity not found' }, 400) + } + } + + await setRepoGitIdentity(repo.fullPath, preset ? { name: preset.name, email: preset.email } : null) + return c.json(await getEffectiveGitIdentity(repo.fullPath, database)) + } catch (error: unknown) { + logger.error('Failed to update repo git identity:', error) + return c.json({ error: getErrorMessage(error) }, 500) + } + }) + app.patch('/:id', async (c) => { try { const id = parseInt(c.req.param('id')) @@ -327,16 +378,45 @@ app.get('/', async (c) => { } const repo = getRepoById(database, id) - + if (!repo) { return c.json({ error: 'Repo not found' }, 404) } - - scheduleService.prepareRepoDelete(id) - - await repoService.deleteRepoFiles(database, id) - - return c.json({ success: true }) + + const body = await c.req.json().catch(() => null) + const parsed = DeleteRepoRequestSchema.safeParse(body ?? {}) + if (!parsed.success) { + return c.json({ error: 'Invalid request' }, 400) + } + const { deleteBranch } = parsed.data + + if (deleteBranch !== 'none' && !repo.isWorktree) { + return c.json({ error: 'Only worktrees can delete their branch' }, 400) + } + + const removeWorktree = async () => { + scheduleService.prepareRepoDelete(id) + await repoService.deleteRepoFiles(database, id) + } + + const result: DeleteRepoResult = { success: true } + + if (deleteBranch === 'none') { + await removeWorktree() + } else { + result.branch = await git.deleteWorktreeBranch( + repo, + { deleteRemote: deleteBranch === 'local-and-remote' }, + removeWorktree, + database, + ) + + if (result.branch.error) { + logger.warn(`Deleted worktree repo ${id} but failed to delete branch '${result.branch.name}': ${result.branch.error}`) + } + } + + return c.json(result) } catch (error: unknown) { logger.error('Failed to delete repo:', error) return c.json({ error: getErrorMessage(error) }, 500) diff --git a/backend/src/routes/settings.ts b/backend/src/routes/settings.ts index 45e93675c..1ffc9eaf6 100644 --- a/backend/src/routes/settings.ts +++ b/backend/src/routes/settings.ts @@ -28,6 +28,7 @@ import { discoverModelsCached, } from '../utils/discovery-cache' import { opencodeServerManager } from '../services/opencode-single-server' +import { syncManagerGitIdentityConfig } from '../services/git-identity' import { getOrCreateInternalToken, rotateInternalToken } from '../services/internal-token' import { sseAggregator } from '../services/sse-aggregator' import type { OpenCodeSupervisor } from '../services/opencode-supervisor' @@ -248,11 +249,15 @@ function listOpenCodeRestartReasons(previous: UserPreferences, next: Partial typeof reason === 'string') } +function gitIdentityConfigChanged(previous: UserPreferences, next: Partial): boolean { + return preferenceChanged(previous.gitIdentity ?? {}, next.gitIdentity) + || preferenceChanged(previous.gitCredentials ?? [], next.gitCredentials) +} + function markOpenCodeRestartPendingFor(reasons: string[]): boolean { if (reasons.length === 0) return false logger.info(`${reasons.join(', ')} changed, marking OpenCode server restart as pending`) @@ -385,6 +390,10 @@ export function createSettingsRoutes(db: Database, gitAuthService: GitAuthServic const settings = settingsService.updateSettings(validated.preferences, userId) + if (gitIdentityConfigChanged(currentSettings.preferences, validated.preferences)) { + await syncManagerGitIdentityConfig(db) + } + const restartRequired = markOpenCodeRestartPendingFor( listOpenCodeRestartReasons(currentSettings.preferences, validated.preferences), ) @@ -408,6 +417,10 @@ export function createSettingsRoutes(db: Database, gitAuthService: GitAuthServic const currentSettings = settingsService.getSettings(userId) const settings = settingsService.resetSettings(userId) + if (gitIdentityConfigChanged(currentSettings.preferences, settings.preferences)) { + await syncManagerGitIdentityConfig(db) + } + const restartRequired = markOpenCodeRestartPendingFor( listOpenCodeRestartReasons(currentSettings.preferences, settings.preferences), ) diff --git a/backend/src/services/credential-provider.ts b/backend/src/services/credential-provider.ts index 611b34b26..9098125ab 100644 --- a/backend/src/services/credential-provider.ts +++ b/backend/src/services/credential-provider.ts @@ -7,6 +7,8 @@ import { createGitEnv, createGhCliEnv, findGitHubCredential, + resolveGitIdentity, + type GitIdentity, type ResolvedGitCredential, } from '../utils/git-auth' import { limitForwardedGitConfigs, SANDBOX_MAX_FORWARDED_GIT_CONFIGS } from './sandbox/shell-shim' @@ -95,6 +97,11 @@ export class CredentialProvider { return this.getGhCliEnvForContext(this.resolveContext(options)) } + async resolveDefaultGitIdentity(): Promise { + const preferences = this.getPreferences() + return resolveGitIdentity(preferences.gitIdentity, this.getCredentials(preferences)) + } + private resolveContext(options: CredentialResolutionOptions, repo = this.resolveRepo(options)): CredentialResolutionContext { const preferences = this.getPreferences() return { diff --git a/backend/src/services/git-auth.ts b/backend/src/services/git-auth.ts index 9d8b65aa2..c26f3f67c 100644 --- a/backend/src/services/git-auth.ts +++ b/backend/src/services/git-auth.ts @@ -5,6 +5,7 @@ import { SSHHostKeyHandler } from '../ipc/sshHostKeyHandler' import { writeTemporarySSHKey, buildSSHCommand, buildSSHCommandWithKnownHosts, cleanupSSHKey, parseSSHHost } from '../utils/ssh-key-manager' import { decryptSecret } from '../utils/crypto' import { isSSHUrl, normalizeSSHUrl, extractHostFromSSHUrl } from '@opencode-manager/shared/utils' +import { getOpenCodeConfigHome } from '@opencode-manager/shared/config/env' import type { GitCredential } from '@opencode-manager/shared' import { logger } from '../utils/logger' import { CredentialProvider } from './credential-provider' @@ -84,7 +85,7 @@ export class GitAuthService { } } - async setupSSHForRepoUrl(repoUrl: string | undefined, database: Database, skipSSHVerification: boolean = false): Promise { + async setupSSHForRepoUrl(repoUrl: string | undefined, database?: Database, skipSSHVerification: boolean = false): Promise { if (!repoUrl || !isSSHUrl(repoUrl)) { return false } @@ -159,6 +160,7 @@ export class GitAuthService { GIT_TERMINAL_PROMPT: '0', LANG: 'en_US.UTF-8', LC_ALL: 'en_US.UTF-8', + XDG_CONFIG_HOME: getOpenCodeConfigHome(), } if (silent) { diff --git a/backend/src/services/git-identity.ts b/backend/src/services/git-identity.ts new file mode 100644 index 000000000..c21663aee --- /dev/null +++ b/backend/src/services/git-identity.ts @@ -0,0 +1,208 @@ +import path from 'path' +import { existsSync } from 'node:fs' +import type { Database } from 'bun:sqlite' +import { getOpenCodeConfigHome } from '@opencode-manager/shared/config/env' +import type { GitIdentity, RepoGitIdentity } from '@opencode-manager/shared' +import { executeCommand } from '../utils/process' +import { canonicalPathSync, mkdirSyncSafe } from '../utils/fs-safe' +import { logger } from '../utils/logger' +import { createGitIdentityEnv } from '../utils/git-auth' +import { CredentialProvider } from './credential-provider' +import { SettingsService } from './settings' + +export interface SyncManagerGitIdentityResult { + identity: GitIdentity | null + error: string | null +} + +interface GitConfigValue { + scope: string + origin: string + value: string +} + +interface ExecuteCommandResult { + exitCode: number + stdout: string + stderr: string +} + +let managerConfigWriteChain: Promise = Promise.resolve() + +function serializeManagerConfigWrite(run: () => Promise): Promise { + const next = managerConfigWriteChain.then(run, run) + managerConfigWriteChain = next.catch(() => undefined) + return next +} + +export function getManagerGitConfigPath(): string { + return path.join(getOpenCodeConfigHome(), 'git', 'config') +} + +function gitConfigResult(result: string | ExecuteCommandResult): ExecuteCommandResult { + if (typeof result === 'string') return { exitCode: 0, stdout: result, stderr: '' } + if (result !== null && typeof result === 'object' && typeof result.exitCode === 'number') return result + return { exitCode: 1, stdout: '', stderr: '' } +} + +function parseGitConfigValue(output: string): GitConfigValue | null { + const line = output.trim() + if (!line) return null + const scopeSeparator = line.indexOf('\t') + if (scopeSeparator < 0) return null + const scope = line.slice(0, scopeSeparator) + const remainder = line.slice(scopeSeparator + 1) + const originSeparator = remainder.indexOf('\t') + if (originSeparator < 0) return null + return { + scope, + origin: remainder.slice(0, originSeparator), + value: remainder.slice(originSeparator + 1), + } +} + +function originFilePath(origin: string): string | null { + return origin.startsWith('file:') ? origin.slice('file:'.length) : null +} + +function isRepositoryScope(scope: string): boolean { + return scope === 'local' || scope === 'worktree' +} + +async function readGitConfigValue(directory: string, key: string): Promise { + const result = gitConfigResult(await executeCommand( + ['git', '-C', directory, 'config', '--show-scope', '--show-origin', '--get', key], + { ignoreExitCode: true, silent: true, env: { XDG_CONFIG_HOME: getOpenCodeConfigHome() } }, + )) + if (result.exitCode !== 0) return null + return parseGitConfigValue(result.stdout) +} + +async function writeManagerConfigValue(key: string, value: string): Promise { + await executeCommand(['git', 'config', '--file', getManagerGitConfigPath(), key, value]) +} + +async function unsetManagerConfigValue(key: string): Promise { + if (!existsSync(getManagerGitConfigPath())) return + const result = gitConfigResult(await executeCommand( + ['git', 'config', '--file', getManagerGitConfigPath(), '--unset', key], + { ignoreExitCode: true, silent: true }, + )) + if (result.exitCode !== 0 && result.exitCode !== 5) { + throw new Error(`git config --unset ${key} failed with code ${result.exitCode}: ${result.stderr}`) + } +} + +async function unsetLocalConfigValue(repoPath: string, key: string): Promise { + const result = gitConfigResult(await executeCommand( + ['git', '-C', repoPath, 'config', '--local', '--unset', key], + { ignoreExitCode: true, silent: true }, + )) + if (result.exitCode !== 0 && result.exitCode !== 5) { + throw new Error(`git config --local --unset ${key} failed with code ${result.exitCode}: ${result.stderr}`) + } +} + +function findPresetId(database: Database, name: string | null, email: string | null): string | null { + if (name === null || email === null) return null + const identities = new SettingsService(database).getSettings('default').preferences.gitIdentities ?? [] + return identities.find((identity) => identity.name === name && identity.email === email)?.id ?? null +} + +/** + * Resolves the Manager default identity and mirrors it into the Manager-owned + * global git config file so every Manager git command and the OpenCode process + * resolve the same default. Writes are serialized to avoid git's config lock, + * and failures are reported rather than thrown so callers can stay best-effort. + */ +export async function syncManagerGitIdentityConfig(database: Database): Promise { + let identity: GitIdentity | null = null + try { + const resolved = await new CredentialProvider(database).resolveDefaultGitIdentity() + identity = resolved && (resolved.name || resolved.email) ? resolved : null + const configured = identity + await serializeManagerConfigWrite(async () => { + mkdirSyncSafe(path.dirname(getManagerGitConfigPath())) + if (configured?.name) { + await writeManagerConfigValue('user.name', configured.name) + } else { + await unsetManagerConfigValue('user.name') + } + if (configured) { + await writeManagerConfigValue('user.email', configured.email) + } else { + await unsetManagerConfigValue('user.email') + } + }) + + if (identity) { + logger.info(`Manager git identity configured: ${identity.name} <${identity.email}>`) + } else { + logger.info('Manager git identity cleared: no default identity configured') + } + + return { identity, error: null } + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + logger.warn(`Failed to sync the Manager git identity config: ${message}`) + return { identity, error: message } + } +} + +/** + * Resolves the effective commit identity for a directory using git itself, so a + * repository-local identity set by the user is respected. Classifies where the + * values come from and surfaces the matching saved preset when the values are + * repository-scoped. + */ +export async function getEffectiveGitIdentity(directory: string, database: Database): Promise { + const [nameValue, emailValue] = await Promise.all([ + readGitConfigValue(directory, 'user.name'), + readGitConfigValue(directory, 'user.email'), + ]) + + const name = nameValue?.value ?? null + const email = emailValue?.value ?? null + const values = [nameValue, emailValue].filter((value): value is GitConfigValue => value !== null) + const managerPath = canonicalPathSync(getManagerGitConfigPath()) + const managerMatch = values.some((value) => { + const origin = originFilePath(value.origin) + return origin !== null && canonicalPathSync(origin) === managerPath + }) + + const scope: RepoGitIdentity['scope'] = + values.some((value) => isRepositoryScope(value.scope)) ? 'repository' + : managerMatch ? 'default' + : name !== null || email !== null ? 'global' + : 'none' + + return { + name, + email, + scope, + presetId: scope === 'repository' ? findPresetId(database, name, email) : null, + } +} + +export async function setRepoGitIdentity( + repoPath: string, + identity: { name: string; email: string } | null, +): Promise { + if (identity === null) { + await unsetLocalConfigValue(repoPath, 'user.name') + await unsetLocalConfigValue(repoPath, 'user.email') + return + } + + await executeCommand(['git', '-C', repoPath, 'config', '--local', 'user.name', identity.name]) + await executeCommand(['git', '-C', repoPath, 'config', '--local', 'user.email', identity.email]) +} + +export async function getGitIdentityEnvForDirectory( + directory: string, + database: Database, +): Promise> { + const identity = await getEffectiveGitIdentity(directory, database) + if (!identity.name) return {} + return createGitIdentityEnv({ name: identity.name, email: identity.email ?? '' }) +} diff --git a/backend/src/services/git/GitService.ts b/backend/src/services/git/GitService.ts index 7f49284e4..fabc102c3 100644 --- a/backend/src/services/git/GitService.ts +++ b/backend/src/services/git/GitService.ts @@ -2,22 +2,62 @@ 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 { isSSHUrl } from '@opencode-manager/shared/utils' -import { isNoUpstreamError, parseBranchNameFromError } from '../../utils/git-errors' -import { SettingsService } from '../settings' -import { CredentialProvider } from '../credential-provider' +import { getRepoById, getRepoByDirectory, updateRepoBranch, listRepos } from '../../db/queries' +import { updateScheduleJobsBranch } from '../../db/schedules' +import { isSSHUrl, getBranchNameError } from '@opencode-manager/shared/utils' +import { GitOperationError, isNoUpstreamError, parseGitError } from '../../utils/git-errors' +import { resolveMainCheckoutPath } from '../repo' +import { MAX_COMMIT_PROMPT_DIFF_CHARS, type CommitMessageContext } from './commit-message-prompt' import type { Database } from 'bun:sqlite' +import type { DeleteBranchResult, GitOperationKind, GitOperationState, GitStashEntry, IntegrateBranchRequest, IntegrateBranchResult, StashPushRequest } from '@opencode-manager/shared' import type { GitBranch, GitCommit, FileDiffResponse, GitDiffOptions, GitStatusResponse, GitFileStatus, GitFileStatusType, CommitDetails, CommitFile } from '../../types/git' +import { canonicalPathSync } from '../../utils/fs-safe' import path from 'path' +import { existsSync } from 'node:fs' -export class GitService { +function assertValidBranchName(name: string): void { + const error = getBranchNameError(name) + if (error) { + throw new Error(error) + } +} + +function assertValidStashIndex(index: number): void { + if (!Number.isInteger(index) || index < 0) { + throw new Error(`Invalid stash index: ${index}`) + } +} + +export class GitIntegrationConflictError extends GitOperationError { + constructor( + readonly targetRepoId: number, + readonly operation: GitOperationState + ) { + super('MERGE_CONFLICT', 'Integration stopped on conflicts', { targetRepoId, operation }) + this.name = 'GitIntegrationConflictError' + } +} + +export class BranchDeleteError extends Error { constructor( - private gitAuthService: GitAuthService, - private settingsService: SettingsService, - private credentialProvider: CredentialProvider - ) {} + message: string, + readonly localDeleted: boolean, + readonly remoteDeleted: boolean + ) { + super(message) + this.name = 'BranchDeleteError' + } +} + +export interface WorktreeBranchDeletionResult { + name: string + deleted: boolean + remoteDeleted: boolean + error?: string +} + +export class GitService { + constructor(private gitAuthService: GitAuthService) {} async getStatus(repoId: number, database: Database): Promise { try { @@ -29,10 +69,11 @@ export class GitService { const repoPath = repo.fullPath const env = this.gitAuthService.getGitEnvironment() - const [branch, branchStatus, porcelainOutput] = await Promise.all([ + const [branch, branchStatus, porcelainOutput, operation] = await Promise.all([ this.getCurrentBranch(repoPath, env), this.getBranchStatusFromPath(repoPath, env), - executeCommand(['git', '-C', repoPath, 'status', '--porcelain'], { env }) + executeCommand(['git', '-C', repoPath, 'status', '--porcelain'], { env }), + this.getOperationState(repoPath, env) ]) const files = this.parsePorcelainOutput(porcelainOutput) @@ -43,7 +84,8 @@ export class GitService { ahead: branchStatus.ahead, behind: branchStatus.behind, files, - hasChanges + hasChanges, + operation } } catch (error: unknown) { logger.error(`Failed to get status for repo ${repoId}:`, error) @@ -51,6 +93,47 @@ export class GitService { } } + private async getOperationState(repoPath: string, env: Record): Promise { + const output = await executeCommand( + [ + 'git', '-C', repoPath, 'rev-parse', + '--git-path', 'rebase-merge', + '--git-path', 'rebase-apply', + '--git-path', 'MERGE_HEAD', + '--git-path', 'CHERRY_PICK_HEAD', + '--git-path', 'REVERT_HEAD', + ], + { env, silent: true } + ) + + const markerPaths = output.split('\n').map((entry) => entry.trim()) + const markerExists = (index: number): boolean => { + const gitPath = markerPaths[index] + if (!gitPath) return false + const resolved = path.isAbsolute(gitPath) ? gitPath : path.resolve(repoPath, gitPath) + return existsSync(resolved) + } + + const kind: GitOperationKind | null = + markerExists(0) || markerExists(1) ? 'rebase' + : markerExists(2) ? 'merge' + : markerExists(3) ? 'cherry-pick' + : markerExists(4) ? 'revert' + : null + + if (!kind) { + return null + } + + const conflictOutput = await executeCommand( + ['git', '-C', repoPath, 'diff', '--name-only', '--diff-filter=U', '-z'], + { env, silent: true } + ) + const conflictedFiles = conflictOutput.split('\0').filter((entry) => entry.length > 0) + + return { kind, conflictedFiles } + } + async getFileDiff(repoId: number, filePath: string, database: Database, options?: GitDiffOptions & { includeStaged?: boolean }): Promise { const repo = getRepoById(database, repoId) if (!repo) { @@ -198,14 +281,7 @@ export class GitService { } const repoPath = repo.fullPath - const authEnv = this.getEnvironmentForRepo(repo) - - const settings = this.settingsService.getSettings('default') - const gitCredentials = this.credentialProvider.getGitCredentials() - const identity = await resolveGitIdentity(settings.preferences.gitIdentity, gitCredentials) - const identityEnv = identity ? createGitIdentityEnv(identity) : {} - - const env = { ...authEnv, ...identityEnv } + const env = this.getEnvironmentForRepo(repo) const args = ['git', '-C', repoPath, 'commit', '-m', message] @@ -223,6 +299,48 @@ export class GitService { } } + async getCommitMessageContext(repoId: number, database: Database): Promise { + const repo = getRepoById(database, repoId) + if (!repo) { + throw new Error('Repository not found') + } + + const repoPath = path.resolve(repo.fullPath) + const env = this.gitAuthService.getGitEnvironment() + + const stagedStat = await executeCommand( + ['git', '-C', repoPath, 'diff', '--cached', '--stat'], + { env } + ) + + if (!stagedStat.trim()) { + throw new Error('No staged changes') + } + + const [stagedDiff, recentSubjects] = await Promise.all([ + executeCommand(['git', '-C', repoPath, 'diff', '--cached'], { env, maxOutputChars: MAX_COMMIT_PROMPT_DIFF_CHARS + 1 }), + this.getRecentCommitSubjects(repoPath, env), + ]) + + return { stagedStat, stagedDiff, recentSubjects } + } + + private async getRecentCommitSubjects(repoPath: string, env: Record): Promise { + if (!(await this.hasCommits(repoPath))) { + return [] + } + + try { + const output = await executeCommand( + ['git', '-C', repoPath, 'log', '-n', '10', '--format=%s'], + { env, silent: true } + ) + return output.split('\n').map(line => line.trim()).filter(Boolean) + } catch { + return [] + } + } + async stageFiles(repoId: number, paths: string[], database: Database): Promise { try { const repo = getRepoById(database, repoId) @@ -517,7 +635,7 @@ export class GitService { return 'modified' } - private async setupSSHIfNeeded(repoUrl: string | undefined, database: Database): Promise { + private async setupSSHIfNeeded(repoUrl: string | undefined, database?: Database): Promise { await this.gitAuthService.setupSSHForRepoUrl(repoUrl, database) } @@ -546,6 +664,20 @@ export class GitService { return { ...baseEnv, ...sshEnv } } + private async withRemoteAuth( + repo: { id?: number; repoUrl?: string; fullPath: string }, + run: (env: Record) => Promise, + options: { database?: Database; silent?: boolean } = {} + ): Promise { + await this.setupSSHIfNeeded(repo.repoUrl, options.database) + try { + const env = this.getEnvironmentForRepo(repo, options.silent ?? false) + return await run(env) + } finally { + await this.cleanupSSHForRepo() + } + } + async resetToCommit(repoId: number, commitHash: string, database: Database): Promise { try { const repo = getRepoById(database, repoId) @@ -574,12 +706,9 @@ export class GitService { const fullPath = path.resolve(repo.fullPath) - await this.setupSSHIfNeeded(repo.repoUrl, database) - - try { - const env = this.getEnvironmentForRepo(repo) + return this.withRemoteAuth(repo, async (env) => { if (options.setUpstream) { - return await this.pushWithUpstream(repoId, fullPath, env) + return this.pushWithUpstream(repoId, fullPath, env) } try { @@ -587,13 +716,11 @@ export class GitService { return await executeCommand(args, { env }) } catch (error) { if (isNoUpstreamError(error as Error)) { - return await this.pushWithUpstream(repoId, fullPath, env) + return this.pushWithUpstream(repoId, fullPath, env) } throw error } - } finally { - await this.cleanupSSHForRepo() - } + }, { database }) } async fetch(repoId: number, database: Database): Promise { @@ -604,14 +731,11 @@ export class GitService { const fullPath = path.resolve(repo.fullPath) - await this.setupSSHIfNeeded(repo.repoUrl, database) - - try { - const env = this.getEnvironmentForRepo(repo, true) - return await executeCommand(['git', '-C', fullPath, 'fetch', '--all', '--prune'], { env }) - } finally { - await this.cleanupSSHForRepo() - } + return this.withRemoteAuth( + repo, + (env) => executeCommand(['git', '-C', fullPath, 'fetch', '--all', '--prune'], { env }), + { database, silent: true } + ) } async pull(repoId: number, database: Database): Promise { @@ -622,14 +746,11 @@ export class GitService { const fullPath = path.resolve(repo.fullPath) - await this.setupSSHIfNeeded(repo.repoUrl, database) - - try { - const env = this.getEnvironmentForRepo(repo, false) - return await executeCommand(['git', '-C', fullPath, 'pull'], { env }) - } finally { - await this.cleanupSSHForRepo() - } + return this.withRemoteAuth( + repo, + (env) => executeCommand(['git', '-C', fullPath, 'pull'], { env }), + { database } + ) } async getBranches(repoId: number, database: Database): Promise { @@ -641,13 +762,7 @@ export class GitService { 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 currentBranch = await this.resolveHeadBranch(fullPath, env) const stdout = await executeCommand(['git', '-C', fullPath, 'branch', '-vv', '-a'], { env, silent: true }) const lines = stdout.split('\n').filter(line => line.trim()) @@ -674,7 +789,7 @@ export class GitService { const branch: GitBranch = { name: branchName, type: branchName.startsWith('remotes/') ? 'remote' : 'local', - current: isCurrent && (branchName === currentBranch || branchName === `remotes/${currentBranch}`), + current: isCurrent && (branchName === currentBranch || (currentBranch !== null && branchName === `remotes/${currentBranch}`)), isWorktree } @@ -728,32 +843,413 @@ export class GitService { } } - async createBranch(repoId: number, branchName: string, database: Database): Promise { + async renameBranch(repoId: number, from: string, to: string, database: Database): Promise { const repo = getRepoById(database, repoId) if (!repo) { throw new Error(`Repository not found`) } + assertValidBranchName(from) + assertValidBranchName(to) + const fullPath = path.resolve(repo.fullPath) const env = this.gitAuthService.getGitEnvironment() - const result = await executeCommand(['git', '-C', fullPath, 'checkout', '-b', branchName], { env }) + const currentBranch = await this.getCurrentBranch(fullPath, env) + if (currentBranch !== from && await this.isBranchCheckedOutInWorktree(fullPath, from, env)) { + throw new GitOperationError('BRANCH_IN_OTHER_WORKTREE', `Branch '${from}' is checked out in another worktree and cannot be renamed here`) + } + + const result = await executeCommand(['git', '-C', fullPath, 'branch', '-m', '--', from, to], { env }) + + const renamedMainCheckout = await resolveMainCheckoutPath(fullPath) + const renamedMainCheckoutCanonical = renamedMainCheckout ? canonicalPathSync(renamedMainCheckout) : null + + const relatedRepoIds: number[] = [] + if (renamedMainCheckoutCanonical) { + for (const candidate of listRepos(database)) { + if (candidate.cloneStatus !== 'ready') continue + const candidateMainCheckout = await resolveMainCheckoutPath(candidate.fullPath) + if (candidateMainCheckout && canonicalPathSync(candidateMainCheckout) === renamedMainCheckoutCanonical) { + relatedRepoIds.push(candidate.id) + } + } + } + + database.transaction(() => { + if (repo.branch === from) { + updateRepoBranch(database, repoId, to) + } + for (const relatedRepoId of relatedRepoIds) { + updateScheduleJobsBranch(database, relatedRepoId, from, to) + } + })() return result } - async switchBranch(repoId: number, branchName: string, database: Database): Promise { + async getMainCheckoutPath(worktreePath: string): Promise { + const mainCheckoutPath = await resolveMainCheckoutPath(worktreePath) + if (!mainCheckoutPath) { + throw new Error(`Could not determine the main checkout for worktree at ${worktreePath}`) + } + return mainCheckoutPath + } + + async deleteWorktreeBranch( + repo: { id: number; fullPath: string; repoUrl?: string; branch?: string }, + options: { deleteRemote: boolean }, + removeWorktree: () => Promise, + database: Database + ): Promise { + let name: string | null = null + let baseTarget: { fullPath: string; repoUrl?: string; id?: number } | null = null + let prepareError: string | null = null + + try { + const env = this.getEnvironmentForRepo(repo) + name = await this.resolveHeadBranch(repo.fullPath, env) ?? repo.branch ?? null + if (!name) { + throw new Error('Could not determine the worktree branch') + } + + const basePath = await this.getMainCheckoutPath(repo.fullPath) + const baseRepo = getRepoByDirectory(database, basePath) + baseTarget = baseRepo + ? { fullPath: baseRepo.fullPath, repoUrl: baseRepo.repoUrl, id: baseRepo.id } + : { fullPath: basePath, repoUrl: repo.repoUrl } + } catch (error: unknown) { + prepareError = getErrorMessage(error) + } + + await removeWorktree() + + if (prepareError || !name || !baseTarget) { + return { + name: name ?? repo.branch ?? '', + deleted: false, + remoteDeleted: false, + error: prepareError ?? 'Could not determine the worktree branch', + } + } + + try { + const { remoteDeleted } = await this.deleteBranchAtPath(baseTarget, name, { + force: false, + deleteRemote: options.deleteRemote, + }) + return { name, deleted: true, remoteDeleted } + } catch (error: unknown) { + const partial = error instanceof BranchDeleteError ? error : null + return { + name, + deleted: partial?.localDeleted ?? false, + remoteDeleted: partial?.remoteDeleted ?? false, + error: getErrorMessage(error), + } + } + } + + async deleteBranchAtPath( + target: { fullPath: string; repoUrl?: string; id?: number }, + name: string, + options: { force: boolean; deleteRemote: boolean } + ): Promise { + assertValidBranchName(name) + + const fullPath = path.resolve(target.fullPath) + const env = this.getEnvironmentForRepo(target) + + const currentBranch = await this.getCurrentBranch(fullPath, env) + if (currentBranch === name) { + throw new GitOperationError('BRANCH_CHECKED_OUT', `Cannot delete branch '${name}' because it is currently checked out`) + } + + let upstreamRemote: string | null = null + let upstreamBranch: string | null = null + if (options.deleteRemote) { + try { + const upstream = await executeCommand( + ['git', '-C', fullPath, 'rev-parse', '--abbrev-ref', `${name}@{upstream}`], + { env, silent: true } + ) + const separator = upstream.trim().indexOf('/') + if (separator > 0) { + upstreamRemote = upstream.trim().slice(0, separator) + upstreamBranch = upstream.trim().slice(separator + 1) + } + } catch { + upstreamRemote = null + upstreamBranch = null + } + } + + const deleteFlag = options.force ? '-D' : '-d' + try { + await executeCommand(['git', '-C', fullPath, 'branch', deleteFlag, '--', name], { env }) + } catch (error: unknown) { + if (parseGitError(error).code === 'BRANCH_NOT_MERGED') { + throw new GitOperationError('BRANCH_NOT_MERGED', `Branch '${name}' was kept because it has unmerged commits.`) + } + throw error + } + + let remoteDeleted = false + if (upstreamRemote && upstreamBranch) { + try { + await this.withRemoteAuth(target, async (remoteEnv) => { + await executeCommand( + ['git', '-C', fullPath, 'push', upstreamRemote, '--delete', upstreamBranch], + { env: remoteEnv } + ) + }) + remoteDeleted = true + } catch (error: unknown) { + throw new BranchDeleteError(getErrorMessage(error), true, false) + } + } + + return { remoteDeleted } + } + + async deleteBranch( + repoId: number, + request: { name: string; force: boolean; deleteRemote: boolean }, + database: Database + ): Promise { const repo = getRepoById(database, repoId) if (!repo) { throw new Error(`Repository not found`) } - const fullPath = path.resolve(repo.fullPath) + return this.deleteBranchAtPath( + { fullPath: repo.fullPath, repoUrl: repo.repoUrl, id: repo.id }, + request.name, + { force: request.force, deleteRemote: request.deleteRemote } + ) + } + + private async listWorktreeCheckouts(repoPath: string, env: Record | undefined): Promise> { + try { + const output = await executeCommand(['git', '-C', repoPath, 'worktree', 'list', '--porcelain'], { env, silent: true }) + const checkouts: Array<{ path: string; branch: string | null }> = [] + let current: { path: string; branch: string | null } | null = null + + for (const line of output.split('\n')) { + if (line.startsWith('worktree ')) { + if (current) checkouts.push(current) + current = { path: line.slice('worktree '.length).trim(), branch: null } + } else if (line.startsWith('branch refs/heads/') && current) { + current.branch = line.slice('branch refs/heads/'.length).trim() + } + } + if (current) checkouts.push(current) + + return checkouts + } catch { + return [] + } + } + + private async findCheckoutPath(repoPath: string, branch: string, env: Record | undefined): Promise { + const checkouts = await this.listWorktreeCheckouts(repoPath, env) + return checkouts.find((checkout) => checkout.branch === branch)?.path ?? null + } + + async integrateBranch(repoId: number, request: IntegrateBranchRequest, database: Database): Promise { + const repo = getRepoById(database, repoId) + if (!repo) { + throw new Error('Repository not found') + } + + const sourcePath = path.resolve(repo.fullPath) + const sourceEnv = this.getEnvironmentForRepo(repo) + + const sourceBranch = await this.resolveHeadBranch(sourcePath, sourceEnv) + if (!sourceBranch) { + throw new GitOperationError('INTEGRATE_DETACHED_HEAD', 'Cannot integrate from a detached HEAD') + } + + const sourceRef = `refs/heads/${sourceBranch}` + + const targetBranch = request.targetBranch + if (targetBranch === sourceBranch) { + throw new GitOperationError('INTEGRATE_INTO_SELF', 'Cannot integrate a branch into itself') + } + + const targetRef = `refs/heads/${targetBranch}` + + const targetPath = await this.findCheckoutPath(sourcePath, targetBranch, sourceEnv) + if (!targetPath) { + throw new GitOperationError('INTEGRATE_TARGET_NOT_CHECKED_OUT', 'Target branch is not checked out in any worktree') + } + + const targetRepo = getRepoByDirectory(database, targetPath) + if (!targetRepo || canonicalPathSync(path.resolve(targetRepo.fullPath)) !== canonicalPathSync(path.resolve(targetPath))) { + throw new GitOperationError('INTEGRATE_TARGET_NOT_MANAGED', 'Target checkout is not a managed repository') + } + + const targetEnv = this.getEnvironmentForRepo(targetRepo) + const [porcelain, targetOperation] = await Promise.all([ + executeCommand(['git', '-C', targetPath, 'status', '--porcelain'], { env: targetEnv }), + this.getOperationState(targetPath, targetEnv), + ]) + + const hasTrackedChanges = this.parsePorcelainOutput(porcelain).some((file) => file.status !== 'untracked') + if (hasTrackedChanges || targetOperation) { + throw new GitOperationError('UNCOMMITTED_CHANGES', 'You have uncommitted changes. Commit or stash them first.') + } + + const countOutput = await executeCommand( + ['git', '-C', sourcePath, 'rev-list', '--count', `${targetRef}..${sourceRef}`], + { env: sourceEnv, silent: true } + ) + const integratedCommits = Number.parseInt(countOutput.trim(), 10) + if (!integratedCommits) { + throw new GitOperationError('INTEGRATE_NOTHING_TO_INTEGRATE', 'Nothing to integrate') + } + + const commandEnv = { + ...targetEnv, + GIT_EDITOR: 'true', + } + + const args = request.strategy === 'cherry-pick' + ? ['git', '-C', targetPath, 'cherry-pick', `${targetRef}..${sourceRef}`] + : ['git', '-C', targetPath, 'merge', '--no-ff', '--no-edit', sourceRef] + + try { + await executeCommand(args, { env: commandEnv }) + } catch (error: unknown) { + const operation = await this.getOperationState(targetPath, targetEnv) + if (operation) { + throw new GitIntegrationConflictError(targetRepo.id, operation) + } + throw error + } + + return { targetRepoId: targetRepo.id, integratedCommits } + } + + async listStashes(repoId: number, database: Database): Promise { + const repo = getRepoById(database, repoId) + if (!repo) { + throw new Error(`Repository not found`) + } + + const repoPath = path.resolve(repo.fullPath) const env = this.gitAuthService.getGitEnvironment() + const output = await executeCommand( + ['git', '-C', repoPath, 'stash', 'list', '--format=%gd%x1f%H%x1f%gs%x1f%cI'], + { env, silent: true } + ) - const result = await executeCommand(['git', '-C', fullPath, 'checkout', branchName], { env }) + const stashes: GitStashEntry[] = [] - return result + for (const line of output.split('\n')) { + if (!line.trim()) continue + + const [ref, hash, subject, date] = line.split('\x1f') + if (!ref) continue + + const indexMatch = ref.match(/^stash@\{(\d+)\}$/) + if (!indexMatch) continue + + const subjectMatch = subject?.match(/^(?:WIP on|On) ([^:]+): ?(.*)$/) + stashes.push({ + index: Number(indexMatch[1]), + ref, + hash: hash ?? '', + message: subjectMatch ? (subjectMatch[2] ?? '') : (subject ?? ''), + branch: subjectMatch ? (subjectMatch[1] ?? null) : null, + date: date ?? '' + }) + } + + return stashes + } + + async pushStash(repoId: number, request: StashPushRequest, database: Database): Promise { + const repo = getRepoById(database, repoId) + if (!repo) { + throw new Error(`Repository not found`) + } + + const repoPath = path.resolve(repo.fullPath) + const env = this.getEnvironmentForRepo(repo) + + const args = ['git', '-C', repoPath, 'stash', 'push'] + if (request.includeUntracked) { + args.push('-u') + } + if (request.message) { + args.push('-m', request.message) + } + + return executeCommand(args, { env }) + } + + async applyStash(repoId: number, index: number, hash: string, pop: boolean, database: Database): Promise { + return this.runStashCommand(repoId, index, hash, [pop ? 'pop' : 'apply'], database) + } + + async dropStash(repoId: number, index: number, hash: string, database: Database): Promise { + return this.runStashCommand(repoId, index, hash, ['drop'], database) + } + + private async runStashCommand(repoId: number, index: number, hash: string, action: string[], database: Database): Promise { + assertValidStashIndex(index) + + const repo = getRepoById(database, repoId) + if (!repo) { + throw new Error(`Repository not found`) + } + + const repoPath = path.resolve(repo.fullPath) + const env = this.gitAuthService.getGitEnvironment() + const currentHash = await this.resolveStashHash(repoPath, index, env) + if (!currentHash || currentHash !== hash.trim()) { + throw new GitOperationError('STASH_CHANGED', 'The stash list changed. Refresh and try again.') + } + + const args = ['git', '-C', repoPath, 'stash', ...action, `stash@{${index}}`] + + return executeCommand(args, { env }) + } + + private async resolveStashHash(repoPath: string, index: number, env: Record): Promise { + try { + const output = await executeCommand(['git', '-C', repoPath, 'rev-parse', `stash@{${index}}`], { env, silent: true }) + const hash = output.trim() + return hash || null + } catch { + return null + } + } + + async continueOperation(repoId: number, database: Database): Promise { + return this.runOperationCommand(repoId, database, '--continue') + } + + async abortOperation(repoId: number, database: Database): Promise { + return this.runOperationCommand(repoId, database, '--abort') + } + + private async runOperationCommand(repoId: number, database: Database, action: '--continue' | '--abort'): Promise { + const repo = getRepoById(database, repoId) + if (!repo) { + throw new Error(`Repository not found`) + } + + const repoPath = path.resolve(repo.fullPath) + const env = this.getEnvironmentForRepo(repo) + const operation = await this.getOperationState(repoPath, env) + if (!operation) { + throw new GitOperationError('NO_OPERATION_IN_PROGRESS', 'No operation in progress') + } + + const commandEnv = action === '--continue' ? { ...env, GIT_EDITOR: 'true' } : env + return executeCommand(['git', '-C', repoPath, operation.kind, action], { env: commandEnv }) } private async getCurrentBranch(repoPath: string, env: Record | undefined): Promise { @@ -765,6 +1261,20 @@ export class GitService { } } + private async resolveHeadBranch(repoPath: string, env: Record | undefined): Promise { + try { + const ref = await executeCommand(['git', '-C', repoPath, 'symbolic-ref', '--quiet', 'HEAD'], { env, silent: true }) + const trimmed = ref.trim() + return trimmed.startsWith('refs/heads/') ? trimmed.slice('refs/heads/'.length) : null + } catch { + return null + } + } + + private async isBranchCheckedOutInWorktree(repoPath: string, branch: string, env: Record | undefined): Promise { + return (await this.findCheckoutPath(repoPath, branch, env)) !== null + } + 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 }) @@ -981,21 +1491,8 @@ export class GitService { } } - 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) - } + private async pushWithUpstream(_repoId: number, fullPath: string, env: Record): Promise { + const branchName = await this.resolveHeadBranch(fullPath, env) if (!branchName) { throw new Error('Unable to detect current branch. Ensure you are on a branch before pushing with --set-upstream.') @@ -1005,3 +1502,7 @@ export class GitService { return executeCommand(args, { env }) } } + +export function createGitService(gitAuthService: GitAuthService): GitService { + return new GitService(gitAuthService) +} diff --git a/backend/src/services/git/commit-message-prompt.ts b/backend/src/services/git/commit-message-prompt.ts new file mode 100644 index 000000000..15ab5bc37 --- /dev/null +++ b/backend/src/services/git/commit-message-prompt.ts @@ -0,0 +1,49 @@ +export const MAX_COMMIT_PROMPT_DIFF_CHARS = 60_000 + +export interface CommitMessageContext { + stagedStat: string + stagedDiff: string + recentSubjects: string[] +} + +function truncateDiff(diff: string): string { + if (diff.length <= MAX_COMMIT_PROMPT_DIFF_CHARS) { + return diff + } + return `${diff.slice(0, MAX_COMMIT_PROMPT_DIFF_CHARS)}\n[diff truncated]` +} + +export function buildCommitMessagePrompt({ stagedStat, stagedDiff, recentSubjects }: CommitMessageContext): string { + const subjects = recentSubjects.length > 0 + ? recentSubjects.map(subject => `- ${subject}`).join('\n') + : '(no recent commits)' + + return [ + 'Write a single commit message for the staged changes below.', + 'Match the style of the recent commit subjects when they establish a convention, such as a conventional-commit prefix.', + 'Use a subject line of 72 characters or fewer, followed by an optional short body separated by a blank line.', + 'Return only the commit message text. Do not wrap it in code fences and do not prefix it with a label.', + '', + 'Recent commit subjects:', + subjects, + '', + 'Staged changes (stat):', + stagedStat.trim(), + '', + 'Staged diff:', + truncateDiff(stagedDiff), + ].join('\n') +} + +const COMMIT_MESSAGE_LABEL = /^(?:commit message|message)\s*:\s*/i + +export function normalizeGeneratedCommitMessage(text: string): string { + let normalized = text.trim().replace(COMMIT_MESSAGE_LABEL, '') + + const fenced = normalized.match(/^```[^\n]*\n?([\s\S]*?)\n?```$/) + if (fenced) { + normalized = (fenced[1] ?? '').trim() + } + + return normalized.replace(COMMIT_MESSAGE_LABEL, '').trim() +} diff --git a/backend/src/services/opencode-single-server.ts b/backend/src/services/opencode-single-server.ts index e64dd4a91..9f0d60507 100644 --- a/backend/src/services/opencode-single-server.ts +++ b/backend/src/services/opencode-single-server.ts @@ -4,7 +4,6 @@ import os from 'os' import { StringDecoder } from 'node:string_decoder' import { promises as fs, accessSync, constants } from 'fs' import { logger } from '../utils/logger' -import { createGitIdentityEnv, resolveGitIdentity } from '../utils/git-auth' import { buildSSHCommandWithKnownHosts, buildSSHCommandWithConfig, @@ -43,6 +42,7 @@ import { restoreQuarantinedOpenCodePlugins } from './opencode-plugin-quarantine' import { resolveProcessIdentityProvider } from './opencode/process-identity' import { SandboxRuntimeService } from './sandbox/runtime' import { CredentialProvider } from './credential-provider' +import { syncManagerGitIdentityConfig } from './git-identity' import { mkdirSafe, writeFileAtomic } from '../utils/fs-safe' import { createProcessLogForwarder } from '../utils/log-buffer' import { OPENCODE_SERVICE_SERVE_ARGS, prepareOpenCodeServiceLaunch } from './opencode-service-mode' @@ -435,14 +435,12 @@ class OpenCodeServerManager { const openCodeServerHost = getOpenCodeServerHost() let credentialProvider: CredentialProvider | null = null - let gitIdentityEnv: Record = {} let userEnvVars: Record = {} if (this.db) { try { credentialProvider = new CredentialProvider(this.db) const settingsService = new SettingsService(this.db) const settings = settingsService.getSettings('default') - const gitCredentials = credentialProvider.getGitCredentials() const rawEnvVars = settings.preferences.serverEnvVars || [] if (rawEnvVars.length > 0) { userEnvVars = Object.fromEntries( @@ -460,11 +458,7 @@ class OpenCodeServerManager { logger.info(`Injecting ${Object.keys(userEnvVars).length} custom server env vars`) } - const identity = await resolveGitIdentity(settings.preferences.gitIdentity, gitCredentials) - if (identity) { - gitIdentityEnv = createGitIdentityEnv(identity) - logger.info(`Git identity resolved: ${identity.name} <${identity.email}>`) - } + await syncManagerGitIdentityConfig(this.db) } catch (error) { logger.warn('Failed to get git settings:', error) } @@ -641,7 +635,6 @@ class OpenCodeServerManager { ...userEnvVars, ...microsandboxEnv, ...gitEnv, - ...gitIdentityEnv, ...(this.db ? { OCM_INTERNAL_API_URL: `http://localhost:${ENV.SERVER.PORT}/api/internal`, diff --git a/backend/src/services/project-id-resolver.test.ts b/backend/src/services/project-id-resolver.test.ts index d7e773513..1c5403c69 100644 --- a/backend/src/services/project-id-resolver.test.ts +++ b/backend/src/services/project-id-resolver.test.ts @@ -6,13 +6,7 @@ import { execSync } from 'child_process' import { gitRemoteProjectId } from '@opencode-manager/shared/project-id' import { isGitMainCheckout, resolveProjectId } from './project-id-resolver' -const GIT_ENV = { - ...process.env, - GIT_AUTHOR_NAME: 't', - GIT_AUTHOR_EMAIL: 't@t', - GIT_COMMITTER_NAME: 't', - GIT_COMMITTER_EMAIL: 't@t', -} +const COMMIT_IDENTITY_ARGS = ['-c', 'user.name=t', '-c', 'user.email=t@t'] describe('resolveProjectId', () => { let base: string @@ -33,7 +27,7 @@ describe('resolveProjectId', () => { it('prefers the normalized origin remote hash', async () => { const dir = mkdtempSync(path.join(base, 'remote-')) execSync(`git init -q "${dir}"`) - execSync(`git -C "${dir}" commit -q --allow-empty -m init`, { env: GIT_ENV }) + execSync(`git -C "${dir}" ${COMMIT_IDENTITY_ARGS.join(' ')} commit -q --allow-empty -m init`) execSync(`git -C "${dir}" remote add origin git@github.com:Acme/App.git`) expect(await resolveProjectId(dir)).toBe(gitRemoteProjectId('git@github.com:Acme/App.git')) @@ -42,7 +36,7 @@ describe('resolveProjectId', () => { it('falls back to the sorted first root commit when there is no remote', async () => { const dir = mkdtempSync(path.join(base, 'root-')) execSync(`git init -q "${dir}"`) - execSync(`git -C "${dir}" commit -q --allow-empty -m init`, { env: GIT_ENV }) + execSync(`git -C "${dir}" ${COMMIT_IDENTITY_ARGS.join(' ')} commit -q --allow-empty -m init`) const rootCommit = execSync(`git -C "${dir}" rev-list --max-parents=0 HEAD`).toString().trim() expect(await resolveProjectId(dir)).toBe(rootCommit) @@ -59,9 +53,7 @@ describe('isGitMainCheckout', () => { mainRepo = path.join(base, 'main') worktree = path.join(base, 'wt') execSync(`git init -q "${mainRepo}"`) - execSync(`git -C "${mainRepo}" commit -q --allow-empty -m init`, { - env: { ...process.env, GIT_AUTHOR_NAME: 't', GIT_AUTHOR_EMAIL: 't@t', GIT_COMMITTER_NAME: 't', GIT_COMMITTER_EMAIL: 't@t' }, - }) + execSync(`git -C "${mainRepo}" ${COMMIT_IDENTITY_ARGS.join(' ')} commit -q --allow-empty -m init`) execSync(`git -C "${mainRepo}" worktree add -q "${worktree}" -b feature`) }) diff --git a/backend/src/services/repo.ts b/backend/src/services/repo.ts index 6a9268686..00b997a1c 100644 --- a/backend/src/services/repo.ts +++ b/backend/src/services/repo.ts @@ -930,6 +930,19 @@ export async function pullRepo( } } +export async function resolveMainCheckoutPath(worktreePath: string): Promise { + try { + const commonDir = await executeCommand( + ['git', '-C', path.resolve(worktreePath), 'rev-parse', '--path-format=absolute', '--git-common-dir'], + { silent: true } + ) + const trimmed = commonDir.trim() + return trimmed ? path.dirname(trimmed) : null + } catch { + return null + } +} + export async function deleteRepoFiles(database: Database, repoId: number): Promise { const repo = getRepoById(database, repoId) if (!repo) { @@ -938,11 +951,13 @@ export async function deleteRepoFiles(database: Database, repoId: number): Promi const fullPath = path.resolve(getReposPath(), repo.localPath) - if (repo.isWorktree && repo.repoUrl) { - const { name: repoName } = normalizeRepoUrl(repo.repoUrl) - const baseRepoPath = path.resolve(getReposPath(), repoName) + if (repo.isWorktree) { + const baseRepoPath = await resolveMainCheckoutPath(fullPath) + ?? (repo.repoUrl ? path.resolve(getReposPath(), normalizeRepoUrl(repo.repoUrl).name) : null) - await removeWorktree(baseRepoPath, fullPath) + if (baseRepoPath) { + await removeWorktree(baseRepoPath, fullPath) + } } await executeCommand(['rm', '-rf', repo.localPath], getReposPath()) diff --git a/backend/src/services/sandbox/runtime.ts b/backend/src/services/sandbox/runtime.ts index 8b9764042..b7ce75b71 100644 --- a/backend/src/services/sandbox/runtime.ts +++ b/backend/src/services/sandbox/runtime.ts @@ -7,6 +7,7 @@ import { mkdirSafe } from '../../utils/fs-safe' import { logger } from '../../utils/logger' import { SettingsService } from '../settings' import { CredentialProvider } from '../credential-provider' +import { getGitIdentityEnvForDirectory } from '../git-identity' import { getProcessIdentityAttestationError } from '../opencode/process-identity' import { detectSandboxCapability } from './capability' import { @@ -879,7 +880,10 @@ export class SandboxRuntimeService { } try { await ensureWorkspaceSandbox() - const env = new CredentialProvider(this.db).getSandboxGitEnv({ cwd: directory }) + const env = { + ...new CredentialProvider(this.db).getSandboxGitEnv({ cwd: directory }), + ...await getGitIdentityEnvForDirectory(directory, this.db), + } return Object.keys(env).length > 0 ? { mode: 'sandbox', workdir: workDirectory, env } : { mode: 'sandbox', workdir: workDirectory } diff --git a/backend/src/services/schedule-worktree.ts b/backend/src/services/schedule-worktree.ts index fe9b8d6a4..ec1fa7ce5 100644 --- a/backend/src/services/schedule-worktree.ts +++ b/backend/src/services/schedule-worktree.ts @@ -5,9 +5,6 @@ import { getScheduleWorktreesPath } from '@opencode-manager/shared/config/env' import { ASSISTANT_REPO_ID } from '@opencode-manager/shared/utils' import type { Repo } from '../types/repo' import type { GitAuthService } from './git-auth' -import type { SettingsService } from './settings' -import type { CredentialProvider } from './credential-provider' -import { resolveGitIdentity, createGitIdentityEnv } from '../utils/git-auth' import { isSSHUrl } from '@opencode-manager/shared/utils' import { executeCommand } from '../utils/process' import { resolveDefaultBranch, createWorktreeSafely, removeWorktree } from './repo' @@ -34,8 +31,6 @@ export function buildRepoEnvForRepo(repo: { id?: number; fullPath: string }): Re export class ScheduleWorktreeManager { constructor( private readonly gitAuthService: GitAuthService, - private readonly settingsService: SettingsService, - private readonly credentialProvider: CredentialProvider, private readonly db: Database, ) {} @@ -191,14 +186,6 @@ export class ScheduleWorktreeManager { private async buildGitEnv(repo: Repo, sshSetup: boolean, silent: boolean): Promise> { const baseEnv = this.gitAuthService.getGitEnvironment(silent) const sshEnv = sshSetup ? this.gitAuthService.getSSHEnvironment() : {} - const identityEnv = await this.buildIdentityEnv() - return { ...baseEnv, ...buildRepoEnvForRepo(repo), ...sshEnv, ...identityEnv } - } - - private async buildIdentityEnv(): Promise> { - const settings = this.settingsService.getSettings() - const gitCredentials = this.credentialProvider.getGitCredentials() - const identity = await resolveGitIdentity(settings.preferences.gitIdentity, gitCredentials) - return identity ? createGitIdentityEnv(identity) : {} + return { ...baseEnv, ...buildRepoEnvForRepo(repo), ...sshEnv } } } diff --git a/backend/src/types/git.ts b/backend/src/types/git.ts index effe8fd15..979969a42 100644 --- a/backend/src/types/git.ts +++ b/backend/src/types/git.ts @@ -1,3 +1,5 @@ +import type { GitOperationState } from '@opencode-manager/shared' + export type GitFileStatusType = 'modified' | 'added' | 'deleted' | 'renamed' | 'untracked' | 'copied' export interface GitFileStatus { @@ -22,6 +24,7 @@ export interface GitStatusResponse { behind: number files: GitFileStatus[] hasChanges: boolean + operation: GitOperationState | null } export interface FileDiffResponse { diff --git a/backend/src/utils/git-auth.ts b/backend/src/utils/git-auth.ts index e2909b2f4..238583e15 100644 --- a/backend/src/utils/git-auth.ts +++ b/backend/src/utils/git-auth.ts @@ -1,5 +1,8 @@ import type { GitCredential } from '@opencode-manager/shared' import { githubFetch } from './github' +import { logger } from './logger' + +const GITHUB_USER_INFO_TIMEOUT_MS = 10_000 export function isGitHubHttpsUrl(repoUrl: string): boolean { const url = normalizeGitCredentialUrl(repoUrl) @@ -210,9 +213,10 @@ export function createGitIdentityEnv(identity: GitIdentity): Record { try { + const signal = AbortSignal.timeout(GITHUB_USER_INFO_TIMEOUT_MS) const [userResponse, emailsResponse] = await Promise.all([ - githubFetch('https://api.github.com/user', { token, apiVersion: '2022-11-28' }), - githubFetch('https://api.github.com/user/emails', { token, apiVersion: '2022-11-28' }), + githubFetch('https://api.github.com/user', { token, apiVersion: '2022-11-28', signal }), + githubFetch('https://api.github.com/user/emails', { token, apiVersion: '2022-11-28', signal }), ]) if (!userResponse.ok) return null @@ -236,7 +240,8 @@ export async function fetchGitHubUserInfo(token: string): Promise pattern.test(error.message)) } -export function parseBranchNameFromError(error: Error): string | null { - const match = error.message.match(/The current branch (.+) has no upstream branch/i) - return match?.[1]?.trim() ?? null +export class GitOperationError extends Error { + constructor( + readonly code: GitErrorCode, + message?: string, + readonly details?: Record + ) { + super(message) + this.name = 'GitOperationError' + } } export interface GitErrorInfo { @@ -22,6 +28,7 @@ export interface GitErrorInfo { summary: string detail: string statusCode: number + details?: Record } interface ErrorPattern { @@ -154,6 +161,34 @@ const ERROR_PATTERNS: ErrorPattern[] = [ /reference.*not found/i, ], }, + { + code: 'BRANCH_CHECKED_OUT', + summary: 'This branch is currently checked out and cannot be deleted.', + statusCode: 409, + patterns: [ + /cannot delete branch .*checked out/i, + ], + }, + { + code: 'BRANCH_IN_OTHER_WORKTREE', + summary: 'This branch is checked out in another worktree and cannot be modified here.', + statusCode: 409, + patterns: [], + }, + { + code: 'BRANCH_NOT_MERGED', + summary: 'This branch has unmerged commits. Merge it or delete it with force.', + statusCode: 409, + patterns: [ + /is not fully merged/i, + ], + }, + { + code: 'STASH_CHANGED', + summary: 'The stash list changed. Refresh and try again.', + statusCode: 409, + patterns: [], + }, { code: 'UNCOMMITTED_CHANGES', summary: 'You have uncommitted changes. Commit or stash them first.', @@ -164,6 +199,42 @@ const ERROR_PATTERNS: ErrorPattern[] = [ /would lose uncommitted changes/i, ], }, + { + code: 'NO_OPERATION_IN_PROGRESS', + summary: 'No merge, rebase, cherry-pick, or revert is in progress.', + statusCode: 409, + patterns: [], + }, + { + code: 'INTEGRATE_DETACHED_HEAD', + summary: 'Cannot integrate from a detached HEAD', + statusCode: 400, + patterns: [], + }, + { + code: 'INTEGRATE_INTO_SELF', + summary: 'Cannot integrate a branch into itself', + statusCode: 400, + patterns: [], + }, + { + code: 'INTEGRATE_TARGET_NOT_CHECKED_OUT', + summary: 'Target branch is not checked out in any worktree', + statusCode: 409, + patterns: [], + }, + { + code: 'INTEGRATE_TARGET_NOT_MANAGED', + summary: 'Target checkout is not a managed repository', + statusCode: 409, + patterns: [], + }, + { + code: 'INTEGRATE_NOTHING_TO_INTEGRATE', + summary: 'Nothing to integrate', + statusCode: 400, + patterns: [], + }, ] function stripCommandFailedPrefix(message: string): string { @@ -194,6 +265,17 @@ export function parseGitError(error: unknown): GitErrorInfo { const message = stripCommandFailedPrefix(rawMessage) const cleanedMessage = cleanGitProgressLines(message) + if (error instanceof GitOperationError) { + const entry = ERROR_PATTERNS.find((errorPattern) => errorPattern.code === error.code) + return { + code: error.code, + summary: entry?.summary ?? 'A git operation failed.', + detail: cleanedMessage || message, + statusCode: entry?.statusCode ?? 500, + ...(error.details ? { details: error.details } : {}), + } + } + for (const errorPattern of ERROR_PATTERNS) { for (const pattern of errorPattern.patterns) { if (pattern.test(message) || pattern.test(cleanedMessage)) { diff --git a/backend/src/utils/github.ts b/backend/src/utils/github.ts index d69928e39..974393a2f 100644 --- a/backend/src/utils/github.ts +++ b/backend/src/utils/github.ts @@ -4,6 +4,7 @@ interface GithubRequestOptions { token?: string apiVersion?: string accept?: string + signal?: AbortSignal } export function githubFetch( @@ -17,7 +18,7 @@ export function githubFetch( } if (options.token) headers.Authorization = `Bearer ${options.token}` if (options.apiVersion) headers['X-GitHub-Api-Version'] = options.apiVersion - return fetchFn(url, { headers }) + return fetchFn(url, { headers, signal: options.signal }) } export async function githubFetchJson( diff --git a/backend/src/utils/process.ts b/backend/src/utils/process.ts index ed39b9318..0d2b4e6e1 100644 --- a/backend/src/utils/process.ts +++ b/backend/src/utils/process.ts @@ -7,6 +7,7 @@ interface ExecuteCommandOptions { env?: Record ignoreExitCode?: boolean timeout?: number + maxOutputChars?: number } export async function executeCommand( @@ -50,6 +51,7 @@ export async function executeCommand( let stdout = '' let stderr = '' let isResolved = false + let outputCapped = false const timeoutId = options.timeout ? setTimeout(() => { if (!isResolved) { @@ -60,7 +62,14 @@ export async function executeCommand( }, options.timeout) : undefined proc.stdout?.on('data', (data: Buffer) => { + if (outputCapped) return + stdout += data.toString() + if (options.maxOutputChars !== undefined && stdout.length > options.maxOutputChars) { + outputCapped = true + stdout = stdout.slice(0, options.maxOutputChars) + proc.kill('SIGKILL') + } }) proc.stderr?.on('data', (data: Buffer) => { @@ -88,7 +97,15 @@ export async function executeCommand( const exitCode = code === null ? 1 : code const failureDetail = terminatedBySignal ? `signal ${signal}` : `code ${code}` - if (options.ignoreExitCode) { + if (outputCapped) { + resolve(options.ignoreExitCode + ? { + exitCode, + stdout, + stderr: terminatedBySignal ? `${stderr}Command terminated by signal ${signal}` : stderr, + } + : stdout) + } else if (options.ignoreExitCode) { resolve({ exitCode, stdout, diff --git a/backend/src/utils/route-helpers.ts b/backend/src/utils/route-helpers.ts index d9eb81992..2dfa65c21 100644 --- a/backend/src/utils/route-helpers.ts +++ b/backend/src/utils/route-helpers.ts @@ -3,6 +3,7 @@ import type { ContentfulStatusCode } from 'hono/utils/http-status' import { ClientError, openCodeErrorStatus } from '@opencode-manager/shared/opencode' import { isOAuthErrorCode } from '@opencode-manager/shared/schemas' import { getErrorMessage } from './error-utils' +import { parseGitError } from './git-errors' import { logger } from './logger' export function parseId(value: string | undefined, label?: string, ErrorClass?: new (message: string, status: number) => Error): number { @@ -43,6 +44,39 @@ export function handleServiceError( return c.json({ error: getErrorMessage(error) }, 500) } +export function respondWithGitError(c: Context, error: unknown, logMessage: string) { + logger.error(logMessage, error) + const gitError = parseGitError(error) + return c.json( + { + error: gitError.summary, + detail: gitError.detail, + code: gitError.code, + ...(gitError.details ? { details: gitError.details } : {}), + }, + gitError.statusCode as ContentfulStatusCode + ) +} + +interface JsonBodySchema { + safeParse(data: unknown): { success: true; data: T } | { success: false; error: { issues: Array<{ message?: string }> } } +} + +export async function parseJsonBody(c: Context, schema: JsonBodySchema): Promise { + let body: unknown + try { + body = await c.req.json() + } catch { + return c.json({ error: 'Invalid JSON' }, 400) + } + + const parsed = schema.safeParse(body) + if (!parsed.success) { + return c.json({ error: parsed.error.issues[0]?.message || 'Invalid request' }, 400) + } + return parsed.data +} + export function handleOpenCodeError(c: Context, error: unknown, fallback: string) { if (error instanceof Error) { const tag = (error as { _tag?: unknown })._tag diff --git a/backend/test/helpers/git-fixtures.ts b/backend/test/helpers/git-fixtures.ts new file mode 100644 index 000000000..2c57c1239 --- /dev/null +++ b/backend/test/helpers/git-fixtures.ts @@ -0,0 +1,61 @@ +import { execFileSync } from 'node:child_process' +import { mkdirSync } from 'node:fs' +import type { GitAuthService } from '../../src/services/git-auth' + +export function git(args: string[], cwd?: string): string { + return execFileSync('git', args, { + cwd, + encoding: 'utf-8', + env: { ...process.env, GIT_TERMINAL_PROMPT: '0', GIT_CONFIG_NOSYSTEM: '1' }, + }).trim() +} + +export function createCommittedRepo(repoPath: string, branch = 'main'): void { + mkdirSync(repoPath, { recursive: true }) + git(['init', '-b', branch], repoPath) + git(['config', 'user.email', 'test@test.com'], repoPath) + git(['config', 'user.name', 'Test'], repoPath) + git(['commit', '--allow-empty', '-m', 'init'], repoPath) +} + +export function createOrigin(originPath: string, workPath: string, extraBranches: string[] = []): void { + mkdirSync(originPath, { recursive: true }) + git(['init', '--bare', originPath]) + mkdirSync(workPath, { recursive: true }) + git(['init', '-b', 'main'], workPath) + git(['config', 'user.email', 'test@test.com'], workPath) + git(['config', 'user.name', 'Test'], workPath) + git(['commit', '--allow-empty', '-m', 'init'], workPath) + git(['remote', 'add', 'origin', originPath], workPath) + git(['push', 'origin', 'main'], workPath) + git(['symbolic-ref', 'HEAD', 'refs/heads/main'], originPath) + + for (const branch of extraBranches) { + git(['checkout', '-b', branch], workPath) + git(['commit', '--allow-empty', '-m', branch], workPath) + git(['push', 'origin', branch], workPath) + git(['checkout', 'main'], workPath) + } +} + +export function cloneOrigin(originPath: string, clonePath: string): void { + git(['clone', originPath, clonePath]) + git(['config', 'user.email', 'test@test.com'], clonePath) + git(['config', 'user.name', 'Test'], clonePath) +} + +let seq = 0 + +export function uniqueName(prefix: string): string { + seq += 1 + return `${prefix}-${seq}` +} + +export function createGitAuthService(env: Record = {}): GitAuthService { + return { + getGitEnvironment: () => env, + getSSHEnvironment: () => ({}), + setupSSHForRepoUrl: async () => false, + cleanupSSHKey: async () => {}, + } as unknown as GitAuthService +} diff --git a/backend/test/integration/ssh-integration.test.ts b/backend/test/integration/ssh-integration.test.ts index 9ff89d38c..fe051501a 100644 --- a/backend/test/integration/ssh-integration.test.ts +++ b/backend/test/integration/ssh-integration.test.ts @@ -12,6 +12,7 @@ vi.mock('@opencode-manager/shared/config/env', () => ({ getAgentsMdPath: vi.fn(() => '/tmp/test-workspace/AGENTS.md'), getDatabasePath: vi.fn(() => ':memory:'), getConfigPath: vi.fn(() => '/tmp/test-workspace/config'), + getOpenCodeConfigHome: vi.fn(() => '/tmp/test-workspace/.config'), ENV: { SERVER: { PORT: 5003, HOST: '0.0.0.0', NODE_ENV: 'test' }, AUTH: { TRUSTED_ORIGINS: 'http://localhost:5173', SECRET: 'test-secret-for-encryption-key-32c' }, diff --git a/backend/test/routes/internal/git-credentials.test.ts b/backend/test/routes/internal/git-credentials.test.ts index 4ae326be5..8b8a2a053 100644 --- a/backend/test/routes/internal/git-credentials.test.ts +++ b/backend/test/routes/internal/git-credentials.test.ts @@ -37,4 +37,18 @@ describe('internal git-credentials routes', () => { expect(res.status).toBe(200) expect(await res.json()).toEqual({}) }) + + it('GET /gh-env returns only GH env for a repo cwd', async () => { + settingsService.updateSettings({ + gitCredentials: [ + { name: 'github', host: 'github.com', type: 'pat', token: 'ghp_repo_token' } as GitCredential, + ], + gitIdentities: [{ id: 'work', name: 'Work User', email: 'work@example.com' }], + }) + + const res = await app.request('/gh-env?cwd=/tmp/any-repo') + + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ GH_TOKEN: 'ghp_repo_token', GITHUB_TOKEN: 'ghp_repo_token' }) + }) }) diff --git a/backend/test/routes/repo-git.test.ts b/backend/test/routes/repo-git.test.ts index 2ec70ee1a..0ffa43628 100644 --- a/backend/test/routes/repo-git.test.ts +++ b/backend/test/routes/repo-git.test.ts @@ -1,8 +1,10 @@ -import { describe, it, expect, vi, beforeEach, type MockedFunction } from 'vitest' +import { describe, it, expect, vi, beforeEach, afterEach, type MockedFunction } from 'vitest' import { Hono } from 'hono' import type { Database } from 'bun:sqlite' import type { GitAuthService } from '../../src/services/git-auth' +import type { OpenCodeClient } from '../../src/services/opencode/client' import { createRepoGitRoutes } from '../../src/routes/repo-git' +import { createGitService, GitService } from '../../src/services/git/GitService' import * as db from '../../src/db/queries' vi.mock('bun:sqlite', () => ({ @@ -19,6 +21,9 @@ vi.mock('../../src/utils/logger', () => ({ vi.mock('../../src/db/queries', () => ({ getRepoById: vi.fn(), + getRepoByDirectory: vi.fn(), + updateRepoBranch: vi.fn(), + listRepos: vi.fn(() => []), })) vi.mock('../../src/utils/process', () => ({ @@ -55,6 +60,8 @@ describe('Repo Git Routes', () => { let app: Hono let mockDatabase: Database let mockGitAuthService: GitAuthService + let mockOpenCodeClient: OpenCodeClient + let generateTextMock: MockedFunction<(input: { prompt: string }) => Promise<{ text: string }>> beforeEach(() => { vi.clearAllMocks() @@ -68,14 +75,28 @@ describe('Repo Git Routes', () => { values: vi.fn(), })), exec: vi.fn(), - query: vi.fn(), + query: vi.fn(() => ({ + get: vi.fn(() => undefined), + all: vi.fn(() => []), + run: vi.fn(), + })), inTransaction: vi.fn(), + transaction: vi.fn((fn: () => void) => fn), close: vi.fn(), } as unknown as Database mockGitAuthService = { getGitEnvironment: vi.fn().mockReturnValue({}), } as unknown as GitAuthService - app = createRepoGitRoutes(mockDatabase, mockGitAuthService) + generateTextMock = vi.fn() + mockOpenCodeClient = { + api: { generate: { text: generateTextMock } }, + } as unknown as OpenCodeClient + app = createRepoGitRoutes( + mockDatabase, + createGitService(mockGitAuthService), + mockOpenCodeClient, + { commitMessageTimeoutMs: 50 }, + ) }) describe('POST /git-status-batch', () => { @@ -354,6 +375,134 @@ describe('Repo Git Routes', () => { }) }) + describe('POST /:id/git/commit-message', () => { + afterEach(async () => { + const { executeCommand } = await import('../../src/utils/process') + ;(executeCommand as MockedFunction).mockReset() + }) + + it('returns 404 when repo does not exist', async () => { + getRepoByIdMock.mockReturnValue(null) + const response = await app.request('/999/git/commit-message', { method: 'POST' }) + + expect(response.status).toBe(404) + expect(await response.json()).toHaveProperty('error', 'Repo not found') + }) + + it('returns 400 when there are no staged changes', async () => { + const { executeCommand } = await import('../../src/utils/process') + const executeCommandMock = executeCommand as MockedFunction + + getRepoByIdMock.mockReturnValue({ id: 1, fullPath: '/path/to/repo' } as any) + executeCommandMock.mockResolvedValue('') + + const response = await app.request('/1/git/commit-message', { method: 'POST' }) + + expect(response.status).toBe(400) + expect(await response.json()).toHaveProperty('error', 'No staged changes') + expect(generateTextMock).not.toHaveBeenCalled() + }) + + it('returns the normalized message on success', async () => { + const { executeCommand } = await import('../../src/utils/process') + const executeCommandMock = executeCommand as MockedFunction + + getRepoByIdMock.mockReturnValue({ id: 1, fullPath: '/path/to/repo' } as any) + executeCommandMock.mockImplementation((args) => { + if (args.includes('--stat')) return Promise.resolve(' src/index.ts | 2 +-') + if (args.includes('log')) return Promise.resolve('feat: prior change') + if (args.includes('rev-parse')) return Promise.resolve('abc123') + return Promise.resolve('+added line') + }) + generateTextMock.mockResolvedValue({ text: '```\nfeat: add thing\n```' }) + + const response = await app.request('/1/git/commit-message', { method: 'POST' }) + const body = await response.json() + + expect(response.status).toBe(200) + expect(body).toEqual({ message: 'feat: add thing' }) + expect(generateTextMock).toHaveBeenCalledWith( + expect.objectContaining({ prompt: expect.stringContaining('src/index.ts | 2 +-') }), + expect.objectContaining({ signal: expect.anything() }), + ) + }) + + it('returns 502 when the model returns an empty message', async () => { + const { executeCommand } = await import('../../src/utils/process') + const executeCommandMock = executeCommand as MockedFunction + + getRepoByIdMock.mockReturnValue({ id: 1, fullPath: '/path/to/repo' } as any) + executeCommandMock.mockImplementation((args) => { + if (args.includes('--stat')) return Promise.resolve(' src/index.ts | 2 +-') + if (args.includes('rev-parse')) return Promise.resolve('abc123') + return Promise.resolve('+added line') + }) + generateTextMock.mockResolvedValue({ text: ' ' }) + + const response = await app.request('/1/git/commit-message', { method: 'POST' }) + + expect(response.status).toBe(502) + expect(await response.json()).toHaveProperty('error', 'Model returned an empty commit message') + }) + + it('returns 502 when the model returns a labeled empty fence', async () => { + const { executeCommand } = await import('../../src/utils/process') + const executeCommandMock = executeCommand as MockedFunction + + getRepoByIdMock.mockReturnValue({ id: 1, fullPath: '/path/to/repo' } as any) + executeCommandMock.mockImplementation((args) => { + if (args.includes('--stat')) return Promise.resolve(' src/index.ts | 2 +-') + if (args.includes('rev-parse')) return Promise.resolve('abc123') + return Promise.resolve('+added line') + }) + generateTextMock.mockResolvedValue({ text: 'Commit message:\n```\n```' }) + + const response = await app.request('/1/git/commit-message', { method: 'POST' }) + const body = await response.json() + + expect(response.status).toBe(502) + expect(body).toHaveProperty('error', 'Model returned an empty commit message') + expect(body).not.toHaveProperty('message') + }) + + it('returns 502 when the client throws', async () => { + const { executeCommand } = await import('../../src/utils/process') + const executeCommandMock = executeCommand as MockedFunction + + getRepoByIdMock.mockReturnValue({ id: 1, fullPath: '/path/to/repo' } as any) + executeCommandMock.mockImplementation((args) => { + if (args.includes('--stat')) return Promise.resolve(' src/index.ts | 2 +-') + if (args.includes('rev-parse')) return Promise.resolve('abc123') + return Promise.resolve('+added line') + }) + generateTextMock.mockRejectedValue(new Error('Model unavailable')) + + const response = await app.request('/1/git/commit-message', { method: 'POST' }) + + expect(response.status).toBe(502) + expect(await response.json()).toHaveProperty('error', 'Model unavailable') + }) + + it('returns 502 when generation times out', async () => { + const { executeCommand } = await import('../../src/utils/process') + const executeCommandMock = executeCommand as MockedFunction + + getRepoByIdMock.mockReturnValue({ id: 1, fullPath: '/path/to/repo' } as any) + executeCommandMock.mockImplementation((args) => { + if (args.includes('--stat')) return Promise.resolve(' src/index.ts | 2 +-') + if (args.includes('log')) return Promise.resolve('feat: prior change') + if (args.includes('rev-parse')) return Promise.resolve('abc123') + return Promise.resolve('+added line') + }) + generateTextMock.mockImplementation(() => new Promise(() => {})) + + const response = await app.request('/1/git/commit-message', { method: 'POST' }) + + expect(response.status).toBe(502) + expect(await response.json()).toHaveProperty('error', 'Commit message generation timed out') + }) + }) + describe('GET /:id/git/commit/:hash', () => { it('should return 404 when repo does not exist', async () => { ;(db.getRepoById as MockedFunction).mockReturnValue(null) @@ -416,4 +565,624 @@ describe('Repo Git Routes', () => { expect(body).toHaveProperty('error') }) }) + + describe('POST /:id/git/branches/rename', () => { + it('returns 404 when repo does not exist', async () => { + getRepoByIdMock.mockReturnValue(null) + const response = await app.request('/999/git/branches/rename', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ from: 'main', to: 'renamed' }), + }) + + expect(response.status).toBe(404) + expect(await response.json()).toHaveProperty('error', 'Repo not found') + }) + + it('returns 400 when the request body is invalid', async () => { + getRepoByIdMock.mockReturnValue({ id: 1, fullPath: '/path/to/repo' } as any) + const response = await app.request('/1/git/branches/rename', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ from: 'main' }), + }) + + expect(response.status).toBe(400) + expect(await response.json()).toHaveProperty('error') + }) + + it('renames the branch and returns the refreshed status', async () => { + const { executeCommand } = await import('../../src/utils/process') + const executeCommandMock = executeCommand as MockedFunction + + getRepoByIdMock.mockReturnValue({ id: 1, fullPath: '/path/to/repo', branch: 'main' } as any) + executeCommandMock.mockImplementation((args) => { + if (args.includes('rev-parse')) return Promise.resolve('renamed') + if (args.includes('rev-list')) return Promise.resolve('0 0') + return Promise.resolve('') + }) + + const response = await app.request('/1/git/branches/rename', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ from: 'main', to: 'renamed' }), + }) + const body = await response.json() + + expect(response.status).toBe(200) + expect(body).toHaveProperty('branch', 'renamed') + expect(body).toHaveProperty('files') + expect(executeCommandMock).toHaveBeenCalledWith( + ['git', '-C', '/path/to/repo', 'branch', '-m', '--', 'main', 'renamed'], + { env: expect.any(Object) } + ) + }) + + it('returns 400 for an option-like source branch name', async () => { + getRepoByIdMock.mockReturnValue({ id: 1, fullPath: '/path/to/repo', branch: 'main' } as any) + const response = await app.request('/1/git/branches/rename', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ from: '--force', to: 'victim' }), + }) + + expect(response.status).toBe(400) + expect(await response.json()).toHaveProperty('error') + }) + + it('returns 409 when the branch is checked out in another worktree', async () => { + const { executeCommand } = await import('../../src/utils/process') + const executeCommandMock = executeCommand as MockedFunction + + getRepoByIdMock.mockReturnValue({ id: 1, fullPath: '/path/to/repo', branch: 'main' } as any) + executeCommandMock.mockImplementation((args) => { + if (args.includes('worktree')) { + return Promise.resolve('worktree /path/to/repo\nHEAD abc\nbranch refs/heads/main\n\nworktree /other\nHEAD def\nbranch refs/heads/feature\n') + } + if (args.includes('rev-parse')) return Promise.resolve('main') + return Promise.resolve('') + }) + + const response = await app.request('/1/git/branches/rename', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ from: 'feature', to: 'feature-renamed' }), + }) + const body = await response.json() + + expect(response.status).toBe(409) + expect(body).toHaveProperty('code', 'BRANCH_IN_OTHER_WORKTREE') + }) + }) + + describe('DELETE /:id/git/branches', () => { + it('returns 404 when repo does not exist', async () => { + getRepoByIdMock.mockReturnValue(null) + const response = await app.request('/999/git/branches', { + method: 'DELETE', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: 'feature' }), + }) + + expect(response.status).toBe(404) + expect(await response.json()).toHaveProperty('error', 'Repo not found') + }) + + it('returns 400 when the request body is invalid', async () => { + getRepoByIdMock.mockReturnValue({ id: 1, fullPath: '/path/to/repo' } as any) + const response = await app.request('/1/git/branches', { + method: 'DELETE', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({}), + }) + + expect(response.status).toBe(400) + expect(await response.json()).toHaveProperty('error') + }) + + it('deletes the branch and returns remoteDeleted with the refreshed status', async () => { + const { executeCommand } = await import('../../src/utils/process') + const executeCommandMock = executeCommand as MockedFunction + + getRepoByIdMock.mockReturnValue({ id: 1, fullPath: '/path/to/repo', branch: 'main' } as any) + executeCommandMock.mockImplementation((args) => { + if (args.includes('rev-parse')) return Promise.resolve('main') + if (args.includes('rev-list')) return Promise.resolve('0 0') + return Promise.resolve('') + }) + + const response = await app.request('/1/git/branches', { + method: 'DELETE', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: 'feature', force: false, deleteRemote: false }), + }) + const body = await response.json() as { remoteDeleted: boolean; status: { branch: string } } + + expect(response.status).toBe(200) + expect(body).toHaveProperty('remoteDeleted', false) + expect(body).toHaveProperty('status') + expect(body.status).toHaveProperty('branch', 'main') + }) + + it('returns 409 when deleting the checked-out branch', async () => { + const { executeCommand } = await import('../../src/utils/process') + const executeCommandMock = executeCommand as MockedFunction + + getRepoByIdMock.mockReturnValue({ id: 1, fullPath: '/path/to/repo', branch: 'main' } as any) + executeCommandMock.mockImplementation((args) => { + if (args.includes('rev-parse')) return Promise.resolve('main') + return Promise.resolve('') + }) + + const response = await app.request('/1/git/branches', { + method: 'DELETE', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: 'main', force: true, deleteRemote: false }), + }) + const body = await response.json() + + expect(response.status).toBe(409) + expect(body).toHaveProperty('code', 'BRANCH_CHECKED_OUT') + }) + + it('returns 400 for an option-like branch name', async () => { + getRepoByIdMock.mockReturnValue({ id: 1, fullPath: '/path/to/repo', branch: 'main' } as any) + const response = await app.request('/1/git/branches', { + method: 'DELETE', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: '--force', force: true }), + }) + + expect(response.status).toBe(400) + expect(await response.json()).toHaveProperty('error') + }) + }) + + describe('POST /:id/git/integrate', () => { + it('returns 404 when repo does not exist', async () => { + getRepoByIdMock.mockReturnValue(null) + const response = await app.request('/999/git/integrate', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ targetBranch: 'main' }), + }) + + expect(response.status).toBe(404) + expect(await response.json()).toHaveProperty('error', 'Repo not found') + }) + + it('returns 400 when the request body is invalid', async () => { + getRepoByIdMock.mockReturnValue({ id: 1, fullPath: '/path/to/repo' } as any) + const response = await app.request('/1/git/integrate', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ strategy: 'merge' }), + }) + + expect(response.status).toBe(400) + expect(await response.json()).toHaveProperty('error') + }) + + it('returns 409 with the conflict body shape when the merge stops on conflicts', async () => { + const { executeCommand } = await import('../../src/utils/process') + const executeCommandMock = executeCommand as MockedFunction + const getRepoByDirectoryMock = db.getRepoByDirectory as MockedFunction + + getRepoByIdMock.mockReturnValue({ id: 1, fullPath: '/source', branch: 'feature' } as any) + getRepoByDirectoryMock.mockReturnValue({ id: 2, fullPath: '/target', branch: 'main' } as any) + + let mergeAttempted = false + executeCommandMock.mockImplementation((args) => { + if (args.includes('worktree')) { + return Promise.resolve('worktree /source\nHEAD aaa\nbranch refs/heads/feature\n\nworktree /target\nHEAD bbb\nbranch refs/heads/main\n') + } + if (args.includes('--git-path')) { + return Promise.resolve([ + '/nonexistent/rebase-merge', + '/nonexistent/rebase-apply', + mergeAttempted ? process.cwd() : '/nonexistent/MERGE_HEAD', + '/nonexistent/CHERRY_PICK_HEAD', + '/nonexistent/REVERT_HEAD', + ].join('\n')) + } + if (args.includes('symbolic-ref')) return Promise.resolve('refs/heads/feature') + if (args.includes('rev-parse')) return Promise.resolve('feature') + if (args.includes('status')) return Promise.resolve('') + if (args.includes('rev-list')) return Promise.resolve('1') + if (args.includes('merge')) { + mergeAttempted = true + return Promise.reject(new Error('Command failed with code 1: CONFLICT (content): Merge conflict in file.txt')) + } + if (args.includes('diff')) return Promise.resolve('file.txt') + return Promise.resolve('') + }) + + const response = await app.request('/1/git/integrate', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ targetBranch: 'main', strategy: 'merge' }), + }) + const body = await response.json() + + expect(response.status).toBe(409) + expect(body).toEqual({ + error: 'Merge conflict detected. Resolve the conflicts before continuing.', + detail: 'Integration stopped on conflicts', + code: 'MERGE_CONFLICT', + details: { targetRepoId: 2, operation: { kind: 'merge', conflictedFiles: ['file.txt'] } }, + }) + }) + + it('returns the target status for the integrated repo', async () => { + getRepoByIdMock.mockReturnValue({ id: 1, fullPath: '/path/to/repo', branch: 'feature' } as any) + const integrateSpy = vi.spyOn(GitService.prototype, 'integrateBranch').mockResolvedValue({ + targetRepoId: 2, + integratedCommits: 3, + }) + const statusSpy = vi.spyOn(GitService.prototype, 'getStatus').mockResolvedValue({ + branch: 'main', + ahead: 0, + behind: 0, + files: [], + hasChanges: false, + operation: null, + }) + + try { + const response = await app.request('/1/git/integrate', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ targetBranch: 'main', strategy: 'merge' }), + }) + const body = await response.json() + + expect(response.status).toBe(200) + expect(body).toEqual({ + targetRepoId: 2, + integratedCommits: 3, + targetStatus: { branch: 'main', ahead: 0, behind: 0, files: [], hasChanges: false, operation: null }, + }) + expect(statusSpy).toHaveBeenCalledWith(2, mockDatabase) + } finally { + integrateSpy.mockRestore() + statusSpy.mockRestore() + } + }) + }) + + describe('GET /:id/git/stash', () => { + it('returns 404 when repo does not exist', async () => { + getRepoByIdMock.mockReturnValue(null) + const response = await app.request('/999/git/stash') + + expect(response.status).toBe(404) + expect(await response.json()).toHaveProperty('error', 'Repo not found') + }) + + it('returns parsed stashes', async () => { + const { executeCommand } = await import('../../src/utils/process') + const executeCommandMock = executeCommand as MockedFunction + + getRepoByIdMock.mockReturnValue({ id: 1, fullPath: '/path/to/repo' } as any) + executeCommandMock.mockImplementation((args) => { + if (args.includes('stash') && args.includes('list')) { + return Promise.resolve('stash@{0}\x1fabc123\x1fOn main: wip note\x1f2024-01-01T00:00:00+00:00') + } + return Promise.resolve('') + }) + + const response = await app.request('/1/git/stash') + const body = await response.json() as { stashes: Array> } + + expect(response.status).toBe(200) + expect(body.stashes).toHaveLength(1) + expect(body.stashes[0]).toMatchObject({ index: 0, ref: 'stash@{0}', hash: 'abc123', message: 'wip note', branch: 'main' }) + }) + }) + + describe('POST /:id/git/stash', () => { + it('returns 404 when repo does not exist', async () => { + getRepoByIdMock.mockReturnValue(null) + const response = await app.request('/999/git/stash', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({}), + }) + + expect(response.status).toBe(404) + expect(await response.json()).toHaveProperty('error', 'Repo not found') + }) + + it('returns 400 when the request body is invalid', async () => { + getRepoByIdMock.mockReturnValue({ id: 1, fullPath: '/path/to/repo' } as any) + const response = await app.request('/1/git/stash', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ message: 123 }), + }) + + expect(response.status).toBe(400) + expect(await response.json()).toHaveProperty('error') + }) + + it('pushes a stash and returns the refreshed status', async () => { + const { executeCommand } = await import('../../src/utils/process') + const executeCommandMock = executeCommand as MockedFunction + + getRepoByIdMock.mockReturnValue({ id: 1, fullPath: '/path/to/repo' } as any) + executeCommandMock.mockImplementation((args) => { + if (args.includes('rev-parse')) return Promise.resolve('main') + if (args.includes('rev-list')) return Promise.resolve('0 0') + return Promise.resolve('') + }) + + const response = await app.request('/1/git/stash', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ message: 'wip note', includeUntracked: true }), + }) + const body = await response.json() + + expect(response.status).toBe(200) + expect(body).toHaveProperty('branch', 'main') + expect(executeCommandMock).toHaveBeenCalledWith( + expect.arrayContaining(['stash', 'push', '-u', '-m', 'wip note']), + expect.any(Object) + ) + }) + }) + + describe('POST /:id/git/stash/:index/apply', () => { + it('returns 404 when repo does not exist', async () => { + getRepoByIdMock.mockReturnValue(null) + const response = await app.request('/999/git/stash/0/apply', { method: 'POST' }) + + expect(response.status).toBe(404) + expect(await response.json()).toHaveProperty('error', 'Repo not found') + }) + + it('returns 400 for a non-integer index', async () => { + getRepoByIdMock.mockReturnValue({ id: 1, fullPath: '/path/to/repo' } as any) + const response = await app.request('/1/git/stash/abc/apply', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ pop: false }), + }) + + expect(response.status).toBe(400) + expect(await response.json()).toHaveProperty('error', 'index must be a non-negative integer') + }) + + it('returns 400 when the hash is missing', async () => { + getRepoByIdMock.mockReturnValue({ id: 1, fullPath: '/path/to/repo' } as any) + const response = await app.request('/1/git/stash/2/apply', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ pop: true }), + }) + + expect(response.status).toBe(400) + expect(await response.json()).toHaveProperty('error') + }) + + it('applies the stash and returns the refreshed status', async () => { + const { executeCommand } = await import('../../src/utils/process') + const executeCommandMock = executeCommand as MockedFunction + + getRepoByIdMock.mockReturnValue({ id: 1, fullPath: '/path/to/repo' } as any) + executeCommandMock.mockImplementation((args) => { + if (args.includes('rev-parse')) return Promise.resolve('main') + if (args.includes('rev-list')) return Promise.resolve('0 0') + return Promise.resolve('') + }) + + const response = await app.request('/1/git/stash/2/apply', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ hash: 'main', pop: true }), + }) + const body = await response.json() + + expect(response.status).toBe(200) + expect(body).toHaveProperty('branch', 'main') + expect(executeCommandMock).toHaveBeenCalledWith( + ['git', '-C', '/path/to/repo', 'stash', 'pop', 'stash@{2}'], + expect.any(Object) + ) + }) + + it('returns 409 with STASH_CHANGED when the hash does not match', async () => { + const { executeCommand } = await import('../../src/utils/process') + const executeCommandMock = executeCommand as MockedFunction + + getRepoByIdMock.mockReturnValue({ id: 1, fullPath: '/path/to/repo' } as any) + executeCommandMock.mockImplementation((args) => { + if (args.includes('rev-parse')) return Promise.resolve('main') + if (args.includes('rev-list')) return Promise.resolve('0 0') + return Promise.resolve('') + }) + + const response = await app.request('/1/git/stash/0/apply', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ hash: 'stale', pop: false }), + }) + const body = await response.json() + + expect(response.status).toBe(409) + expect(body).toHaveProperty('code', 'STASH_CHANGED') + expect(executeCommandMock).not.toHaveBeenCalledWith( + expect.arrayContaining(['stash', 'apply']), + expect.any(Object) + ) + }) + }) + + describe('DELETE /:id/git/stash/:index', () => { + it('returns 404 when repo does not exist', async () => { + getRepoByIdMock.mockReturnValue(null) + const response = await app.request('/999/git/stash/0', { method: 'DELETE' }) + + expect(response.status).toBe(404) + expect(await response.json()).toHaveProperty('error', 'Repo not found') + }) + + it('returns 400 for a non-integer index', async () => { + getRepoByIdMock.mockReturnValue({ id: 1, fullPath: '/path/to/repo' } as any) + const response = await app.request('/1/git/stash/1.5', { method: 'DELETE' }) + + expect(response.status).toBe(400) + expect(await response.json()).toHaveProperty('error', 'index must be a non-negative integer') + }) + + it('returns 400 when the hash is missing', async () => { + getRepoByIdMock.mockReturnValue({ id: 1, fullPath: '/path/to/repo' } as any) + const response = await app.request('/1/git/stash/0', { method: 'DELETE' }) + + expect(response.status).toBe(400) + expect(await response.json()).toHaveProperty('error') + }) + + it('drops the stash and returns the refreshed status', async () => { + const { executeCommand } = await import('../../src/utils/process') + const executeCommandMock = executeCommand as MockedFunction + + getRepoByIdMock.mockReturnValue({ id: 1, fullPath: '/path/to/repo' } as any) + executeCommandMock.mockImplementation((args) => { + if (args.includes('rev-parse')) return Promise.resolve('main') + if (args.includes('rev-list')) return Promise.resolve('0 0') + return Promise.resolve('') + }) + + const response = await app.request('/1/git/stash/0', { + method: 'DELETE', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ hash: 'main' }), + }) + const body = await response.json() + + expect(response.status).toBe(200) + expect(body).toHaveProperty('branch', 'main') + expect(executeCommandMock).toHaveBeenCalledWith( + ['git', '-C', '/path/to/repo', 'stash', 'drop', 'stash@{0}'], + expect.any(Object) + ) + }) + + it('returns 409 with STASH_CHANGED when the hash does not match', async () => { + const { executeCommand } = await import('../../src/utils/process') + const executeCommandMock = executeCommand as MockedFunction + + getRepoByIdMock.mockReturnValue({ id: 1, fullPath: '/path/to/repo' } as any) + executeCommandMock.mockImplementation((args) => { + if (args.includes('rev-parse')) return Promise.resolve('main') + if (args.includes('rev-list')) return Promise.resolve('0 0') + return Promise.resolve('') + }) + + const response = await app.request('/1/git/stash/0', { + method: 'DELETE', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ hash: 'stale' }), + }) + const body = await response.json() + + expect(response.status).toBe(409) + expect(body).toHaveProperty('code', 'STASH_CHANGED') + }) + }) + + describe('POST /:id/git/operation/continue', () => { + it('returns 404 when repo does not exist', async () => { + getRepoByIdMock.mockReturnValue(null) + const response = await app.request('/999/git/operation/continue', { method: 'POST' }) + + expect(response.status).toBe(404) + expect(await response.json()).toHaveProperty('error', 'Repo not found') + }) + + it('returns 409 when no operation is in progress', async () => { + const { executeCommand } = await import('../../src/utils/process') + const executeCommandMock = executeCommand as MockedFunction + + getRepoByIdMock.mockReturnValue({ id: 1, fullPath: '/path/to/repo' } as any) + executeCommandMock.mockResolvedValue('') + + const response = await app.request('/1/git/operation/continue', { method: 'POST' }) + const body = await response.json() + + expect(response.status).toBe(409) + expect(body).toHaveProperty('code', 'NO_OPERATION_IN_PROGRESS') + }) + + it('continues the operation and returns the refreshed status', async () => { + const { executeCommand } = await import('../../src/utils/process') + const executeCommandMock = executeCommand as MockedFunction + + getRepoByIdMock.mockReturnValue({ id: 1, fullPath: '/path/to/repo' } as any) + executeCommandMock.mockImplementation((args) => { + if (args.includes('--git-path')) return Promise.resolve(process.cwd()) + if (args.includes('rev-parse')) return Promise.resolve('main') + if (args.includes('rev-list')) return Promise.resolve('0 0') + return Promise.resolve('') + }) + + const response = await app.request('/1/git/operation/continue', { method: 'POST' }) + const body = await response.json() + + expect(response.status).toBe(200) + expect(body).toHaveProperty('branch', 'main') + expect(executeCommandMock).toHaveBeenCalledWith( + ['git', '-C', '/path/to/repo', 'rebase', '--continue'], + { env: expect.objectContaining({ GIT_EDITOR: 'true' }) } + ) + }) + }) + + describe('POST /:id/git/operation/abort', () => { + it('returns 404 when repo does not exist', async () => { + getRepoByIdMock.mockReturnValue(null) + const response = await app.request('/999/git/operation/abort', { method: 'POST' }) + + expect(response.status).toBe(404) + expect(await response.json()).toHaveProperty('error', 'Repo not found') + }) + + it('returns 409 when no operation is in progress', async () => { + const { executeCommand } = await import('../../src/utils/process') + const executeCommandMock = executeCommand as MockedFunction + + getRepoByIdMock.mockReturnValue({ id: 1, fullPath: '/path/to/repo' } as any) + executeCommandMock.mockResolvedValue('') + + const response = await app.request('/1/git/operation/abort', { method: 'POST' }) + const body = await response.json() + + expect(response.status).toBe(409) + expect(body).toHaveProperty('code', 'NO_OPERATION_IN_PROGRESS') + }) + + it('aborts the operation and returns the refreshed status', async () => { + const { executeCommand } = await import('../../src/utils/process') + const executeCommandMock = executeCommand as MockedFunction + + getRepoByIdMock.mockReturnValue({ id: 1, fullPath: '/path/to/repo' } as any) + executeCommandMock.mockImplementation((args) => { + if (args.includes('--git-path')) return Promise.resolve(process.cwd()) + if (args.includes('rev-parse')) return Promise.resolve('main') + if (args.includes('rev-list')) return Promise.resolve('0 0') + return Promise.resolve('') + }) + + const response = await app.request('/1/git/operation/abort', { method: 'POST' }) + const body = await response.json() + + expect(response.status).toBe(200) + expect(body).toHaveProperty('branch', 'main') + expect(executeCommandMock).toHaveBeenCalledWith( + ['git', '-C', '/path/to/repo', 'rebase', '--abort'], + expect.any(Object) + ) + }) + }) }) diff --git a/backend/test/routes/repos-git-identity.test.ts b/backend/test/routes/repos-git-identity.test.ts new file mode 100644 index 000000000..d29b8500d --- /dev/null +++ b/backend/test/routes/repos-git-identity.test.ts @@ -0,0 +1,222 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { execFileSync } from 'node:child_process' +import { afterAll, afterEach, beforeEach, describe, expect, it } from 'vitest' +import { Hono } from 'hono' +import { Database } from 'bun:sqlite' +import { getOpenCodeConfigHome } from '@opencode-manager/shared/config/env' +import { migrate } from '../../src/db/migration-runner' +import { allMigrations } from '../../src/db/migrations' +import { createRepo } from '../../src/db/queries' +import { SettingsService } from '../../src/services/settings' +import { createRepoRoutes } from '../../src/routes/repos' +import { createWorktreeSafely } from '../../src/services/repo' +import { syncManagerGitIdentityConfig } from '../../src/services/git-identity' +import { createGitAuthService, cloneOrigin, createOrigin, git, uniqueName } from '../helpers/git-fixtures' +import { createStubOpenCodeClient } from '../helpers/stub-opencode-client' + +const workspaceRoot = mkdtempSync(path.join(tmpdir(), 'repos-git-identity-')) +const homeDir = path.join(workspaceRoot, 'home') +process.env.WORKSPACE_PATH = workspaceRoot +process.env.GIT_CONFIG_NOSYSTEM = '1' +delete process.env.GIT_CONFIG_GLOBAL +process.env.HOME = homeDir +process.env.XDG_CONFIG_HOME = getOpenCodeConfigHome() +mkdirSync(homeDir, { recursive: true }) + +const reposPath = path.join(workspaceRoot, 'repos') + +function readLocalConfig(repoPath: string, key: string): string | null { + try { + return execFileSync('git', ['-C', repoPath, 'config', '--local', '--get', key], { + encoding: 'utf-8', + env: { ...process.env, GIT_TERMINAL_PROMPT: '0', GIT_CONFIG_NOSYSTEM: '1' }, + }).trim() + } catch { + return null + } +} + +describe('repo git identity routes', () => { + let db: Database + let app: Hono + + beforeEach(() => { + db = new Database(':memory:') + migrate(db, allMigrations) + rmSync(reposPath, { recursive: true, force: true }) + mkdirSync(reposPath, { recursive: true }) + const scheduleService = { prepareRepoDelete: () => {} } as unknown as Parameters[2] + app = new Hono() + app.route('/repos', createRepoRoutes(db, createGitAuthService(), scheduleService, createStubOpenCodeClient())) + }) + + afterEach(() => { + db.close() + }) + + afterAll(() => { + rmSync(workspaceRoot, { recursive: true, force: true }) + }) + + function setupClone(prefix: string): { repoId: number; repoPath: string; origin: string } { + const origin = path.join(workspaceRoot, uniqueName(`${prefix}-origin.git`)) + const work = path.join(workspaceRoot, uniqueName(`${prefix}-work`)) + createOrigin(origin, work) + const repoPath = path.join(reposPath, uniqueName(`${prefix}-clone`)) + cloneOrigin(origin, repoPath) + const repo = createRepo(db, { + repoUrl: origin, + localPath: path.basename(repoPath), + branch: 'main', + defaultBranch: 'main', + cloneStatus: 'ready', + clonedAt: Date.now(), + }) + return { repoId: repo.id, repoPath, origin } + } + + async function commitViaRoute(repoId: number, message: string): Promise { + return app.request(`/repos/${repoId}/git/commit`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ message }), + }) + } + + it('writes local config from a preset and a commit records that author', async () => { + const { repoId, repoPath } = setupClone('identity-commit') + new SettingsService(db).updateSettings({ + gitIdentities: [{ id: 'work', name: 'Work User', email: 'work@example.com' }], + }) + + const patch = await app.request(`/repos/${repoId}/git-identity`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ identityId: 'work' }), + }) + + expect(patch.status).toBe(200) + expect(await patch.json()).toEqual({ + name: 'Work User', + email: 'work@example.com', + scope: 'repository', + presetId: 'work', + }) + expect(readLocalConfig(repoPath, 'user.name')).toBe('Work User') + expect(readLocalConfig(repoPath, 'user.email')).toBe('work@example.com') + + writeFileSync(path.join(repoPath, 'file.txt'), 'hello\n') + git(['add', 'file.txt'], repoPath) + + const res = await commitViaRoute(repoId, 'identity commit') + + expect(res.status).toBe(200) + expect(git(['log', '-1', '--format=%an <%ae>'], repoPath)).toBe('Work User ') + }) + + it('commits with the same identity in a linked worktree', async () => { + const { repoId, repoPath } = setupClone('identity-worktree') + new SettingsService(db).updateSettings({ + gitIdentities: [{ id: 'work', name: 'Work User', email: 'work@example.com' }], + }) + await app.request(`/repos/${repoId}/git-identity`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ identityId: 'work' }), + }) + + const worktreePath = path.join(reposPath, uniqueName('identity-worktree-feature')) + await createWorktreeSafely(repoPath, worktreePath, 'feature', {}, 'main') + writeFileSync(path.join(worktreePath, 'feature.txt'), 'feature\n') + git(['add', 'feature.txt'], worktreePath) + git(['commit', '-m', 'worktree commit'], worktreePath) + + expect(git(['log', '-1', '--format=%an <%ae>'], worktreePath)).toBe('Work User ') + }) + + it('applies the Manager default identity to a commit when the repo has no local identity', async () => { + const { repoId, repoPath } = setupClone('identity-default') + new SettingsService(db).updateSettings({ gitIdentity: { name: 'Manager User', email: 'manager@example.com' } }) + await syncManagerGitIdentityConfig(db) + + git(['config', '--local', '--unset', 'user.name'], repoPath) + git(['config', '--local', '--unset', 'user.email'], repoPath) + + writeFileSync(path.join(repoPath, 'file.txt'), 'hello\n') + git(['add', 'file.txt'], repoPath) + + const res = await commitViaRoute(repoId, 'default identity commit') + + expect(res.status).toBe(200) + expect(git(['log', '-1', '--format=%an <%ae>'], repoPath)).toBe('Manager User ') + }) + + it('removes the local keys when the identity is unset', async () => { + const { repoId, repoPath } = setupClone('identity-unset') + new SettingsService(db).updateSettings({ + gitIdentities: [{ id: 'work', name: 'Work User', email: 'work@example.com' }], + }) + await app.request(`/repos/${repoId}/git-identity`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ identityId: 'work' }), + }) + + const res = await app.request(`/repos/${repoId}/git-identity`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ identityId: null }), + }) + + expect(res.status).toBe(200) + expect(readLocalConfig(repoPath, 'user.name')).toBeNull() + expect(readLocalConfig(repoPath, 'user.email')).toBeNull() + }) + + it('returns the effective identity from GET', async () => { + const { repoId } = setupClone('identity-get') + new SettingsService(db).updateSettings({ + gitIdentities: [{ id: 'work', name: 'Work User', email: 'work@example.com' }], + }) + await app.request(`/repos/${repoId}/git-identity`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ identityId: 'work' }), + }) + + const res = await app.request(`/repos/${repoId}/git-identity`) + + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ + name: 'Work User', + email: 'work@example.com', + scope: 'repository', + presetId: 'work', + }) + }) + + it('returns 400 for an unknown identity id', async () => { + const { repoId } = setupClone('identity-unknown') + + const res = await app.request(`/repos/${repoId}/git-identity`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ identityId: 'missing-identity' }), + }) + + expect(res.status).toBe(400) + expect(await res.json()).toEqual({ error: 'Identity not found' }) + }) + + it('returns 404 for a missing repo', async () => { + const res = await app.request('/repos/9999/git-identity', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ identityId: 'work' }), + }) + + expect(res.status).toBe(404) + }) +}) diff --git a/backend/test/routes/settings.test.ts b/backend/test/routes/settings.test.ts index 248198ba1..60299644f 100644 --- a/backend/test/routes/settings.test.ts +++ b/backend/test/routes/settings.test.ts @@ -142,6 +142,14 @@ vi.mock('../../src/utils/discovery-cache', () => ({ discoverModelsCached: mockDiscoverModelsCached, })) +const { mockSyncManagerGitIdentityConfig } = vi.hoisted(() => ({ + mockSyncManagerGitIdentityConfig: vi.fn(), +})) + +vi.mock('../../src/services/git-identity', () => ({ + syncManagerGitIdentityConfig: mockSyncManagerGitIdentityConfig, +})) + const { mockValidateSSHPrivateKey } = vi.hoisted(() => ({ mockValidateSSHPrivateKey: vi.fn(), })) @@ -150,6 +158,20 @@ vi.mock('../../src/utils/ssh-validation', () => ({ validateSSHPrivateKey: mockValidateSSHPrivateKey, })) +const { mockWriteTemporarySSHKey, mockCleanupSSHKey } = vi.hoisted(() => ({ + mockWriteTemporarySSHKey: vi.fn(), + mockCleanupSSHKey: vi.fn(), +})) + +vi.mock('../../src/utils/ssh-key-manager', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + writeTemporarySSHKey: mockWriteTemporarySSHKey, + cleanupSSHKey: mockCleanupSSHKey, + } +}) + vi.mock('../../src/services/file-operations', () => ({ writeFileContent: vi.fn(), readFileContent: vi.fn(), @@ -337,6 +359,8 @@ describe('Settings Routes - OpenCode Upgrade', () => { mockArchiveBrokenOpenCodeConfigFile.mockReset() mockRestoreOpenCodeConfigSnapshot.mockReset() mockApplyOpenCodeConfigUpdate.mockReset() + mockSyncManagerGitIdentityConfig.mockReset() + mockSyncManagerGitIdentityConfig.mockResolvedValue({ identity: null, error: null }) mockDetectSandboxCapability.mockReset() mockDetectSandboxCapability.mockReturnValue({ available: true, msbVersion: 'msb 1.0.0' }) mockInstallOpenCodeVersion.mockReset() @@ -1399,7 +1423,7 @@ describe('Settings Routes - OpenCode Upgrade', () => { expect(opencodeServerManager.markRestartPending).toHaveBeenCalledTimes(1) }) - it('requires a restart when the git identity changes', async () => { + it('does not require a restart when only the git identity changes and syncs the identity config', async () => { mockGetSettings.mockReturnValue({ preferences: { gitIdentity: { name: 'Old', email: 'old@example.com' } }, updatedAt: 1, @@ -1418,8 +1442,9 @@ describe('Settings Routes - OpenCode Upgrade', () => { const json = await res.json() as Record expect(res.status).toBe(200) - expect(json.restartRequired).toBe(true) - expect(opencodeServerManager.markRestartPending).toHaveBeenCalledTimes(1) + expect(json.restartRequired).toBeUndefined() + expect(opencodeServerManager.markRestartPending).not.toHaveBeenCalled() + expect(mockSyncManagerGitIdentityConfig).toHaveBeenCalledTimes(1) }) it('requires a restart when server environment variables change', async () => { @@ -1709,6 +1734,8 @@ describe('Settings Routes - versions, directory files, skills, MCP and maintenan mockClearStartupError.mockReturnValue(undefined) mockSpawnSync.mockReturnValue({ status: 0, stdout: '', stderr: '' }) mockValidateSSHPrivateKey.mockResolvedValue({ valid: true, hasPassphrase: false }) + mockWriteTemporarySSHKey.mockReset().mockResolvedValue('/tmp/test-ssh-key') + mockCleanupSSHKey.mockReset().mockResolvedValue(undefined) mockGetOpenCodeServerPasswordSource.mockReturnValue('managed') mockGetStoredOpenCodeServerPasswordState.mockReturnValue(null) }) diff --git a/backend/test/services/credential-provider.test.ts b/backend/test/services/credential-provider.test.ts index 14096f959..2703c2fd7 100644 --- a/backend/test/services/credential-provider.test.ts +++ b/backend/test/services/credential-provider.test.ts @@ -316,4 +316,25 @@ describe('CredentialProvider', () => { } }) }) + + describe('resolveDefaultGitIdentity', () => { + const globalIdentity = { name: 'Global Agent', email: 'global@example.com' } + const workIdentity = { id: 'work', name: 'Work Agent', email: 'work@example.com' } + + it('returns the manual identity when both fields are set', async () => { + settingsService.updateSettings({ gitIdentity: globalIdentity }) + + expect(await provider.resolveDefaultGitIdentity()).toEqual(globalIdentity) + }) + + it('falls back to the built-in default identity when nothing is configured', async () => { + expect(await provider.resolveDefaultGitIdentity()).toEqual({ name: 'OpenCode Agent', email: '' }) + }) + + it('does not consult a repo-assigned preset', async () => { + settingsService.updateSettings({ gitIdentity: globalIdentity, gitIdentities: [workIdentity] }) + + expect(await provider.resolveDefaultGitIdentity()).toEqual(globalIdentity) + }) + }) }) diff --git a/backend/test/services/git-identity.test.ts b/backend/test/services/git-identity.test.ts new file mode 100644 index 000000000..49ba456ab --- /dev/null +++ b/backend/test/services/git-identity.test.ts @@ -0,0 +1,225 @@ +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { execFileSync } from 'node:child_process' +import { afterAll, afterEach, beforeEach, describe, expect, it } from 'vitest' +import { Database } from 'bun:sqlite' +import { getOpenCodeConfigHome } from '@opencode-manager/shared/config/env' +import { migrate } from '../../src/db/migration-runner' +import { allMigrations } from '../../src/db/migrations' +import { SettingsService } from '../../src/services/settings' +import { + getEffectiveGitIdentity, + getGitIdentityEnvForDirectory, + getManagerGitConfigPath, + setRepoGitIdentity, + syncManagerGitIdentityConfig, +} from '../../src/services/git-identity' +import { createCommittedRepo, git, uniqueName } from '../helpers/git-fixtures' + +const workspaceRoot = mkdtempSync(path.join(tmpdir(), 'git-identity-test-')) +const homeDir = path.join(workspaceRoot, 'home') +process.env.WORKSPACE_PATH = workspaceRoot +process.env.GIT_CONFIG_NOSYSTEM = '1' +delete process.env.GIT_CONFIG_GLOBAL +process.env.HOME = homeDir +process.env.XDG_CONFIG_HOME = getOpenCodeConfigHome() +mkdirSync(homeDir, { recursive: true }) + +function readLocalConfig(repoPath: string, key: string): string | null { + try { + return execFileSync('git', ['-C', repoPath, 'config', '--local', '--get', key], { + encoding: 'utf-8', + env: { ...process.env, GIT_TERMINAL_PROMPT: '0', GIT_CONFIG_NOSYSTEM: '1' }, + }).trim() + } catch { + return null + } +} + +function initRepoWithoutIdentity(repoPath: string): void { + mkdirSync(repoPath, { recursive: true }) + git(['init', '-b', 'main'], repoPath) + git(['-c', 'user.name=Init', '-c', 'user.email=init@test.com', 'commit', '--allow-empty', '-m', 'init'], repoPath) +} + +describe('git-identity', () => { + let db: Database + let settingsService: SettingsService + + beforeEach(() => { + db = new Database(':memory:') + migrate(db, allMigrations) + settingsService = new SettingsService(db) + rmSync(getManagerGitConfigPath(), { force: true }) + rmSync(path.join(homeDir, '.gitconfig'), { force: true }) + }) + + afterEach(() => { + db.close() + }) + + afterAll(() => { + rmSync(workspaceRoot, { recursive: true, force: true }) + }) + + describe('setRepoGitIdentity', () => { + it('writes both local keys and unsets them when given null', async () => { + const repoPath = path.join(workspaceRoot, uniqueName('set-identity')) + createCommittedRepo(repoPath) + + await setRepoGitIdentity(repoPath, { name: 'Work User', email: 'work@example.com' }) + + expect(readLocalConfig(repoPath, 'user.name')).toBe('Work User') + expect(readLocalConfig(repoPath, 'user.email')).toBe('work@example.com') + + await setRepoGitIdentity(repoPath, null) + + expect(readLocalConfig(repoPath, 'user.name')).toBeNull() + expect(readLocalConfig(repoPath, 'user.email')).toBeNull() + }) + }) + + describe('getEffectiveGitIdentity', () => { + it('reports repository scope and the matching preset id for a local identity', async () => { + const repoPath = path.join(workspaceRoot, uniqueName('effective-repo')) + createCommittedRepo(repoPath) + settingsService.updateSettings({ + gitIdentities: [{ id: 'work', name: 'Work User', email: 'work@example.com' }], + }) + await setRepoGitIdentity(repoPath, { name: 'Work User', email: 'work@example.com' }) + + const identity = await getEffectiveGitIdentity(repoPath, db) + + expect(identity).toEqual({ + name: 'Work User', + email: 'work@example.com', + scope: 'repository', + presetId: 'work', + }) + }) + + it('reports default scope when only the Manager config file supplies the identity', async () => { + const repoPath = path.join(workspaceRoot, uniqueName('effective-default')) + initRepoWithoutIdentity(repoPath) + settingsService.updateSettings({ gitIdentity: { name: 'Manager User', email: 'manager@example.com' } }) + + const sync = await syncManagerGitIdentityConfig(db) + const identity = await getEffectiveGitIdentity(repoPath, db) + + expect(sync.error).toBeNull() + expect(identity).toEqual({ + name: 'Manager User', + email: 'manager@example.com', + scope: 'default', + presetId: null, + }) + }) + + it('reports global scope for a user ~/.gitconfig and none when unset', async () => { + const repoPath = path.join(workspaceRoot, uniqueName('effective-global')) + initRepoWithoutIdentity(repoPath) + + expect(await getEffectiveGitIdentity(repoPath, db)).toEqual({ + name: null, + email: null, + scope: 'none', + presetId: null, + }) + + writeFileSync(path.join(homeDir, '.gitconfig'), '[user]\n\tname = Global User\n\temail = global@example.com\n') + + expect(await getEffectiveGitIdentity(repoPath, db)).toEqual({ + name: 'Global User', + email: 'global@example.com', + scope: 'global', + presetId: null, + }) + }) + + it('lets a user-set local identity win over the Manager default', async () => { + const repoPath = path.join(workspaceRoot, uniqueName('effective-local-wins')) + initRepoWithoutIdentity(repoPath) + settingsService.updateSettings({ gitIdentity: { name: 'Manager User', email: 'manager@example.com' } }) + await syncManagerGitIdentityConfig(db) + await setRepoGitIdentity(repoPath, { name: 'Local User', email: 'local@example.com' }) + + const identity = await getEffectiveGitIdentity(repoPath, db) + + expect(identity.scope).toBe('repository') + expect(identity.name).toBe('Local User') + expect(identity.email).toBe('local@example.com') + }) + }) + + describe('getGitIdentityEnvForDirectory', () => { + it('returns the identity env when both name and email resolve', async () => { + const repoPath = path.join(workspaceRoot, uniqueName('env-identity')) + createCommittedRepo(repoPath) + await setRepoGitIdentity(repoPath, { name: 'Env User', email: 'env@example.com' }) + + expect(await getGitIdentityEnvForDirectory(repoPath, db)).toEqual({ + GIT_AUTHOR_NAME: 'Env User', + GIT_AUTHOR_EMAIL: 'env@example.com', + GIT_COMMITTER_NAME: 'Env User', + GIT_COMMITTER_EMAIL: 'env@example.com', + }) + }) + + it('returns an empty env when no identity resolves', async () => { + const repoPath = path.join(workspaceRoot, uniqueName('env-none')) + initRepoWithoutIdentity(repoPath) + + expect(await getGitIdentityEnvForDirectory(repoPath, db)).toEqual({}) + }) + }) + + describe('syncManagerGitIdentityConfig', () => { + it('writes and unsets keys without touching other content of the file', async () => { + const managerPath = getManagerGitConfigPath() + mkdirSync(path.dirname(managerPath), { recursive: true }) + writeFileSync(managerPath, '[credential]\n\thelper = store\n') + + settingsService.updateSettings({ gitIdentity: { name: 'Manager User', email: 'manager@example.com' } }) + const written = await syncManagerGitIdentityConfig(db) + + expect(written.error).toBeNull() + const afterWrite = readFileSync(managerPath, 'utf-8') + expect(afterWrite).toContain('[credential]') + expect(afterWrite).toContain('helper = store') + expect(afterWrite).toContain('name = Manager User') + expect(afterWrite).toContain('email = manager@example.com') + + settingsService.updateSettings({ gitIdentity: { name: '', email: '' } }) + const cleared = await syncManagerGitIdentityConfig(db) + + expect(cleared.identity).toBeNull() + expect(cleared.error).toBeNull() + const afterUnset = readFileSync(managerPath, 'utf-8') + expect(afterUnset).toContain('[credential]') + expect(afterUnset).toContain('helper = store') + expect(afterUnset).not.toContain('name =') + expect(afterUnset).not.toContain('email =') + }) + + it('keeps a default identity with an empty email so commits still have an author', async () => { + settingsService.updateSettings({ gitIdentity: { name: 'OpenCode Agent', email: '' } }) + const result = await syncManagerGitIdentityConfig(db) + + expect(result.error).toBeNull() + expect(result.identity).toEqual({ name: 'OpenCode Agent', email: '' }) + + const repoPath = path.join(workspaceRoot, uniqueName('default-empty-email')) + initRepoWithoutIdentity(repoPath) + git(['-c', 'user.useConfigOnly=true', 'commit', '--allow-empty', '-m', 'agent'], repoPath) + + expect(git(['log', '-1', '--format=%an <%ae>'], repoPath)).toBe('OpenCode Agent <>') + expect(await getGitIdentityEnvForDirectory(repoPath, db)).toEqual({ + GIT_AUTHOR_NAME: 'OpenCode Agent', + GIT_AUTHOR_EMAIL: '', + GIT_COMMITTER_NAME: 'OpenCode Agent', + GIT_COMMITTER_EMAIL: '', + }) + }) + }) +}) diff --git a/backend/test/services/git/GitService.real.test.ts b/backend/test/services/git/GitService.real.test.ts new file mode 100644 index 000000000..b2cf17889 --- /dev/null +++ b/backend/test/services/git/GitService.real.test.ts @@ -0,0 +1,1127 @@ +import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { afterAll, afterEach, beforeEach, describe, expect, it } from 'vitest' +import { Hono } from 'hono' +import { Database } from 'bun:sqlite' +import { migrate } from '../../../src/db/migration-runner' +import { allMigrations } from '../../../src/db/migrations' +import { createRepo, getRepoByDirectory, getRepoById } from '../../../src/db/queries' +import { createScheduleJob, listScheduleJobsByRepo } from '../../../src/db/schedules' +import { createGitService, GitIntegrationConflictError, type GitService } from '../../../src/services/git/GitService' +import { createWorktreeSafely } from '../../../src/services/repo' +import type { ScheduleJobPersistenceInput } from '../../../src/services/schedule-config' +import { createRepoRoutes } from '../../../src/routes/repos' +import { createStubOpenCodeClient } from '../../helpers/stub-opencode-client' +import { cloneOrigin, createCommittedRepo, createGitAuthService, createOrigin, git, uniqueName } from '../../helpers/git-fixtures' +import { GitOperationError, parseGitError } from '../../../src/utils/git-errors' +import type { GitAuthService } from '../../../src/services/git-auth' +import type { Repo } from '../../../src/types/repo' + +const workspaceRoot = mkdtempSync(path.join(tmpdir(), 'git-service-real-')) +process.env.WORKSPACE_PATH = workspaceRoot +process.env.GIT_CONFIG_NOSYSTEM = '1' +process.env.GIT_CONFIG_GLOBAL = '/dev/null' + +const reposPath = path.join(workspaceRoot, 'repos') + +function scheduleJobInput(name: string, branch: string): ScheduleJobPersistenceInput { + return { + name, + description: null, + enabled: true, + scheduleMode: 'interval', + intervalMinutes: 60, + cronExpression: null, + timezone: null, + agentSlug: null, + prompt: 'do work', + model: null, + skillMetadata: null, + permissionConfig: null, + mcpServers: [], + branch, + nextRunAt: null, + } +} + +describe('GitService real git', () => { + let db: Database + let gitAuth: GitAuthService + let service: GitService + + beforeEach(() => { + db = new Database(':memory:') + migrate(db, allMigrations) + gitAuth = createGitAuthService() + service = createGitService(gitAuth) + rmSync(reposPath, { recursive: true, force: true }) + mkdirSync(reposPath, { recursive: true }) + }) + + afterEach(() => { + db.close() + }) + + afterAll(() => { + rmSync(workspaceRoot, { recursive: true, force: true }) + }) + + function registerClone(origin: string, repoPath: string, branch = 'main'): Repo { + return createRepo(db, { + repoUrl: origin, + localPath: path.basename(repoPath), + branch, + defaultBranch: branch, + cloneStatus: 'ready', + clonedAt: Date.now(), + }) + } + + describe('getRepoByDirectory', () => { + it('matches a repo row through a symlinked path', () => { + const realPath = path.join(workspaceRoot, uniqueName('symlink-real')) + mkdirSync(realPath, { recursive: true }) + const repo = createRepo(db, { + isLocal: true, + localPath: path.basename(realPath), + sourcePath: realPath, + branch: 'main', + defaultBranch: 'main', + cloneStatus: 'ready', + clonedAt: Date.now(), + }) + + const aliasPath = path.join(workspaceRoot, uniqueName('symlink-alias')) + symlinkSync(realPath, aliasPath) + + expect(getRepoByDirectory(db, aliasPath)?.id).toBe(repo.id) + }) + }) + + describe('renameBranch', () => { + it('renames the checked-out branch and updates the repo row', async () => { + const origin = path.join(workspaceRoot, uniqueName('rename-origin.git')) + const work = path.join(workspaceRoot, uniqueName('rename-work')) + createOrigin(origin, work) + const repoPath = path.join(reposPath, uniqueName('rename-clone')) + cloneOrigin(origin, repoPath) + const repo = registerClone(origin, repoPath) + + await service.renameBranch(repo.id, 'main', 'renamed', db) + + expect(git(['rev-parse', '--abbrev-ref', 'HEAD'], repoPath)).toBe('renamed') + expect(git(['branch', '--list', 'renamed'], repoPath)).toContain('renamed') + expect(getRepoById(db, repo.id)?.branch).toBe('renamed') + }) + + it('leaves the repo row unchanged when renaming another branch', async () => { + const origin = path.join(workspaceRoot, uniqueName('rename-other-origin.git')) + const work = path.join(workspaceRoot, uniqueName('rename-other-work')) + createOrigin(origin, work) + const repoPath = path.join(reposPath, uniqueName('rename-other-clone')) + cloneOrigin(origin, repoPath) + git(['branch', 'feature'], repoPath) + const repo = registerClone(origin, repoPath) + + await service.renameBranch(repo.id, 'feature', 'feature-renamed', db) + + expect(git(['branch', '--list', 'feature-renamed'], repoPath)).toContain('feature-renamed') + expect(getRepoById(db, repo.id)?.branch).toBe('main') + }) + + it('rejects an option-like source branch and preserves refs', async () => { + const origin = path.join(workspaceRoot, uniqueName('rename-force-origin.git')) + const work = path.join(workspaceRoot, uniqueName('rename-force-work')) + createOrigin(origin, work) + const repoPath = path.join(reposPath, uniqueName('rename-force-clone')) + cloneOrigin(origin, repoPath) + git(['branch', 'victim'], repoPath) + const victimBefore = git(['rev-parse', 'victim'], repoPath) + const repo = registerClone(origin, repoPath) + + const error = await service + .renameBranch(repo.id, '--force', 'victim', db) + .catch((caught: unknown) => caught) + + expect(error).toBeInstanceOf(Error) + expect(git(['rev-parse', '--abbrev-ref', 'HEAD'], repoPath)).toBe('main') + expect(git(['branch', '--list', 'main'], repoPath)).toContain('main') + expect(git(['branch', '--list', 'victim'], repoPath)).toContain('victim') + expect(git(['rev-parse', 'victim'], repoPath)).toBe(victimBefore) + expect(getRepoById(db, repo.id)?.branch).toBe('main') + }) + + it('rejects renaming a branch checked out in another worktree', async () => { + const origin = path.join(workspaceRoot, uniqueName('rename-wt-origin.git')) + const work = path.join(workspaceRoot, uniqueName('rename-wt-work')) + createOrigin(origin, work) + const baseRepoPath = path.join(reposPath, uniqueName('rename-wt-base')) + cloneOrigin(origin, baseRepoPath) + const baseRepo = registerClone(origin, baseRepoPath) + + const worktreeName = uniqueName('rename-wt-feature') + const worktreePath = path.join(reposPath, worktreeName) + git(['worktree', 'add', '-b', 'feature', worktreePath], baseRepoPath) + const worktreeRepo = createRepo(db, { + repoUrl: origin, + localPath: worktreeName, + branch: 'feature', + defaultBranch: 'main', + cloneStatus: 'ready', + clonedAt: Date.now(), + isWorktree: true, + }) + + const error = await service + .renameBranch(baseRepo.id, 'feature', 'feature-renamed', db) + .catch((caught: unknown) => caught) + + expect(error).toBeInstanceOf(Error) + const parsed = parseGitError(error) + expect(parsed.statusCode).toBe(409) + expect(parsed.code).toBe('BRANCH_IN_OTHER_WORKTREE') + expect(git(['branch', '--list', 'feature'], baseRepoPath)).toContain('feature') + expect(git(['branch', '--list', 'feature-renamed'], baseRepoPath)).toBe('') + expect(getRepoById(db, worktreeRepo.id)?.branch).toBe('feature') + }) + + it('renames a branch from the worktree that owns it and updates its row', async () => { + const origin = path.join(workspaceRoot, uniqueName('rename-wt-own-origin.git')) + const work = path.join(workspaceRoot, uniqueName('rename-wt-own-work')) + createOrigin(origin, work) + const baseRepoPath = path.join(reposPath, uniqueName('rename-wt-own-base')) + cloneOrigin(origin, baseRepoPath) + registerClone(origin, baseRepoPath) + + const worktreeName = uniqueName('rename-wt-own-feature') + const worktreePath = path.join(reposPath, worktreeName) + git(['worktree', 'add', '-b', 'feature', worktreePath], baseRepoPath) + const worktreeRepo = createRepo(db, { + repoUrl: origin, + localPath: worktreeName, + branch: 'feature', + defaultBranch: 'main', + cloneStatus: 'ready', + clonedAt: Date.now(), + isWorktree: true, + }) + + await service.renameBranch(worktreeRepo.id, 'feature', 'feature-renamed', db) + + expect(git(['rev-parse', '--abbrev-ref', 'HEAD'], worktreePath)).toBe('feature-renamed') + expect(git(['branch', '--list', 'feature-renamed'], worktreePath)).toContain('feature-renamed') + expect(getRepoById(db, worktreeRepo.id)?.branch).toBe('feature-renamed') + }) + + it('updates schedule base branches across worktrees of the same repository only', async () => { + const origin = path.join(workspaceRoot, uniqueName('rename-sched-origin.git')) + const work = path.join(workspaceRoot, uniqueName('rename-sched-work')) + createOrigin(origin, work) + const baseRepoPath = path.join(reposPath, uniqueName('rename-sched-base')) + cloneOrigin(origin, baseRepoPath) + const baseRepo = registerClone(origin, baseRepoPath) + + const worktreeName = uniqueName('rename-sched-feature') + const worktreePath = path.join(reposPath, worktreeName) + git(['worktree', 'add', '-b', 'feature', worktreePath], baseRepoPath) + const worktreeRepo = createRepo(db, { + repoUrl: origin, + localPath: worktreeName, + branch: 'feature', + defaultBranch: 'main', + cloneStatus: 'ready', + clonedAt: Date.now(), + isWorktree: true, + }) + + const unrelatedPath = path.join(workspaceRoot, uniqueName('rename-sched-unrelated')) + createCommittedRepo(unrelatedPath) + const unrelatedRepo = createRepo(db, { + isLocal: true, + localPath: path.basename(unrelatedPath), + sourcePath: unrelatedPath, + branch: 'feature', + defaultBranch: 'main', + cloneStatus: 'ready', + clonedAt: Date.now(), + }) + + createScheduleJob(db, baseRepo.id, scheduleJobInput('base schedule', 'feature')) + createScheduleJob(db, worktreeRepo.id, scheduleJobInput('worktree schedule', 'feature')) + createScheduleJob(db, unrelatedRepo.id, scheduleJobInput('unrelated schedule', 'feature')) + + await service.renameBranch(worktreeRepo.id, 'feature', 'renamed', db) + + expect(listScheduleJobsByRepo(db, baseRepo.id).map((job) => job.branch)).toEqual(['renamed']) + expect(listScheduleJobsByRepo(db, worktreeRepo.id).map((job) => job.branch)).toEqual(['renamed']) + expect(listScheduleJobsByRepo(db, unrelatedRepo.id).map((job) => job.branch)).toEqual(['feature']) + }) + }) + + describe('deleteBranch', () => { + it('deletes a merged local branch', async () => { + const origin = path.join(workspaceRoot, uniqueName('delete-merged-origin.git')) + const work = path.join(workspaceRoot, uniqueName('delete-merged-work')) + createOrigin(origin, work) + const repoPath = path.join(reposPath, uniqueName('delete-merged-clone')) + cloneOrigin(origin, repoPath) + git(['branch', 'feature'], repoPath) + const repo = registerClone(origin, repoPath) + + const result = await service.deleteBranch(repo.id, { name: 'feature', force: false, deleteRemote: false }, db) + + expect(result.remoteDeleted).toBe(false) + expect(git(['branch', '--list', 'feature'], repoPath)).toBe('') + }) + + it('refuses to delete an unmerged branch without force and succeeds with it', async () => { + const origin = path.join(workspaceRoot, uniqueName('delete-unmerged-origin.git')) + const work = path.join(workspaceRoot, uniqueName('delete-unmerged-work')) + createOrigin(origin, work) + const repoPath = path.join(reposPath, uniqueName('delete-unmerged-clone')) + cloneOrigin(origin, repoPath) + git(['checkout', '-b', 'feature'], repoPath) + git(['commit', '--allow-empty', '-m', 'unmerged'], repoPath) + git(['checkout', 'main'], repoPath) + const repo = registerClone(origin, repoPath) + + await expect( + service.deleteBranch(repo.id, { name: 'feature', force: false, deleteRemote: false }, db) + ).rejects.toThrow() + expect(git(['branch', '--list', 'feature'], repoPath)).toContain('feature') + + const result = await service.deleteBranch(repo.id, { name: 'feature', force: true, deleteRemote: false }, db) + + expect(result.remoteDeleted).toBe(false) + expect(git(['branch', '--list', 'feature'], repoPath)).toBe('') + }) + + it('deletes the upstream remote branch and reports remoteDeleted', async () => { + const origin = path.join(workspaceRoot, uniqueName('delete-remote-origin.git')) + const work = path.join(workspaceRoot, uniqueName('delete-remote-work')) + createOrigin(origin, work, ['feature']) + const repoPath = path.join(reposPath, uniqueName('delete-remote-clone')) + cloneOrigin(origin, repoPath) + git(['checkout', '-b', 'feature', 'origin/feature'], repoPath) + git(['checkout', 'main'], repoPath) + const repo = registerClone(origin, repoPath) + + const result = await service.deleteBranch(repo.id, { name: 'feature', force: false, deleteRemote: true }, db) + + expect(result.remoteDeleted).toBe(true) + expect(git(['branch', '--list', 'feature'], repoPath)).toBe('') + expect(git(['branch', '--list', 'feature'], origin)).toBe('') + }) + + it('reports remoteDeleted false when the branch has no upstream', async () => { + const origin = path.join(workspaceRoot, uniqueName('delete-no-upstream-origin.git')) + const work = path.join(workspaceRoot, uniqueName('delete-no-upstream-work')) + createOrigin(origin, work) + const repoPath = path.join(reposPath, uniqueName('delete-no-upstream-clone')) + cloneOrigin(origin, repoPath) + git(['branch', 'feature'], repoPath) + const repo = registerClone(origin, repoPath) + + const result = await service.deleteBranch(repo.id, { name: 'feature', force: false, deleteRemote: true }, db) + + expect(result.remoteDeleted).toBe(false) + expect(git(['branch', '--list', 'feature'], repoPath)).toBe('') + }) + + it('rejects deleting the checked-out branch with a conflict error', async () => { + const origin = path.join(workspaceRoot, uniqueName('delete-current-origin.git')) + const work = path.join(workspaceRoot, uniqueName('delete-current-work')) + createOrigin(origin, work) + const repoPath = path.join(reposPath, uniqueName('delete-current-clone')) + cloneOrigin(origin, repoPath) + const repo = registerClone(origin, repoPath) + + const error = await service + .deleteBranch(repo.id, { name: 'main', force: true, deleteRemote: false }, db) + .catch((caught: unknown) => caught) + + expect(error).toBeInstanceOf(Error) + const parsed = parseGitError(error) + expect(parsed.statusCode).toBe(409) + expect(parsed.code).toBe('BRANCH_CHECKED_OUT') + expect(git(['branch', '--list', 'main'], repoPath)).toContain('main') + }) + + it('rejects deleting a branch of an unknown repo', async () => { + await expect( + service.deleteBranch(9999, { name: 'main', force: false, deleteRemote: false }, db) + ).rejects.toThrow(/Repository not found/) + }) + + it('rejects an option-like branch name without altering branches', async () => { + const origin = path.join(workspaceRoot, uniqueName('delete-option-origin.git')) + const work = path.join(workspaceRoot, uniqueName('delete-option-work')) + createOrigin(origin, work) + const repoPath = path.join(reposPath, uniqueName('delete-option-clone')) + cloneOrigin(origin, repoPath) + const repo = registerClone(origin, repoPath) + + await expect( + service.deleteBranch(repo.id, { name: '--force', force: true, deleteRemote: false }, db) + ).rejects.toThrow(/must not start with/) + + expect(git(['branch', '--list', 'main'], repoPath)).toContain('main') + }) + }) + + describe('stash', () => { + function setupStashRepo(prefix: string): { repo: Repo; repoPath: string } { + const origin = path.join(workspaceRoot, uniqueName(`${prefix}-origin.git`)) + const work = path.join(workspaceRoot, uniqueName(`${prefix}-work`)) + createOrigin(origin, work) + const repoPath = path.join(reposPath, uniqueName(`${prefix}-clone`)) + cloneOrigin(origin, repoPath) + const repo = registerClone(origin, repoPath) + return { repo, repoPath } + } + + async function stashHash(repo: Repo, index = 0): Promise { + const stashes = await service.listStashes(repo.id, db) + return stashes[index]!.hash + } + + it('pushes an untracked file into a stash and lists it with the message and branch', async () => { + const { repo, repoPath } = setupStashRepo('stash-push') + writeFileSync(path.join(repoPath, 'note.txt'), 'hello\n') + + await service.pushStash(repo.id, { message: 'wip note', includeUntracked: true }, db) + + expect(existsSync(path.join(repoPath, 'note.txt'))).toBe(false) + const stashes = await service.listStashes(repo.id, db) + expect(stashes).toHaveLength(1) + expect(stashes[0]).toMatchObject({ index: 0, ref: 'stash@{0}', message: 'wip note', branch: 'main' }) + expect(stashes[0]?.hash).toMatch(/^[0-9a-f]{40}$/) + }) + + it('applies a stash, restoring files and keeping the entry', async () => { + const { repo, repoPath } = setupStashRepo('stash-apply') + writeFileSync(path.join(repoPath, 'note.txt'), 'hello\n') + await service.pushStash(repo.id, { message: 'wip note', includeUntracked: true }, db) + + await service.applyStash(repo.id, 0, await stashHash(repo), false, db) + + expect(readFileSync(path.join(repoPath, 'note.txt'), 'utf-8')).toBe('hello\n') + expect(await service.listStashes(repo.id, db)).toHaveLength(1) + }) + + it('pops a stash, restoring files and removing the entry', async () => { + const { repo, repoPath } = setupStashRepo('stash-pop') + writeFileSync(path.join(repoPath, 'note.txt'), 'hello\n') + await service.pushStash(repo.id, { message: 'wip note', includeUntracked: true }, db) + + await service.applyStash(repo.id, 0, await stashHash(repo), true, db) + + expect(readFileSync(path.join(repoPath, 'note.txt'), 'utf-8')).toBe('hello\n') + expect(await service.listStashes(repo.id, db)).toHaveLength(0) + }) + + it('drops a stash without restoring files', async () => { + const { repo, repoPath } = setupStashRepo('stash-drop') + writeFileSync(path.join(repoPath, 'note.txt'), 'hello\n') + await service.pushStash(repo.id, { message: 'wip note', includeUntracked: true }, db) + + await service.dropStash(repo.id, 0, await stashHash(repo), db) + + expect(existsSync(path.join(repoPath, 'note.txt'))).toBe(false) + expect(await service.listStashes(repo.id, db)).toHaveLength(0) + }) + + it('rejects a non-integer or negative stash index', async () => { + const { repo } = setupStashRepo('stash-invalid') + + await expect(service.applyStash(repo.id, 1.5, 'hash', false, db)).rejects.toThrow(/Invalid stash index/) + await expect(service.dropStash(repo.id, -1, 'hash', db)).rejects.toThrow(/Invalid stash index/) + }) + + it('rejects a stale stash hash and leaves both stashes intact', async () => { + const { repo, repoPath } = setupStashRepo('stash-stale') + writeFileSync(path.join(repoPath, 'first.txt'), 'first\n') + await service.pushStash(repo.id, { message: 'first', includeUntracked: true }, db) + const staleHash = await stashHash(repo) + + writeFileSync(path.join(repoPath, 'second.txt'), 'second\n') + await service.pushStash(repo.id, { message: 'second', includeUntracked: true }, db) + + const applyError = await service.applyStash(repo.id, 0, staleHash, false, db).catch((caught: unknown) => caught) + expect(parseGitError(applyError).code).toBe('STASH_CHANGED') + expect(parseGitError(applyError).statusCode).toBe(409) + + const dropError = await service.dropStash(repo.id, 0, staleHash, db).catch((caught: unknown) => caught) + expect(parseGitError(dropError).code).toBe('STASH_CHANGED') + + expect(await service.listStashes(repo.id, db)).toHaveLength(2) + expect(existsSync(path.join(repoPath, 'first.txt'))).toBe(false) + expect(existsSync(path.join(repoPath, 'second.txt'))).toBe(false) + }) + + it('rejects applying a stash index that no longer exists', async () => { + const { repo } = setupStashRepo('stash-missing') + + const error = await service.applyStash(repo.id, 0, 'deadbeef', false, db).catch((caught: unknown) => caught) + expect(parseGitError(error).code).toBe('STASH_CHANGED') + }) + + function prepareConflictingStash(repoPath: string): void { + writeFileSync(path.join(repoPath, 'file.txt'), 'original\n') + git(['add', 'file.txt'], repoPath) + git(['commit', '-m', 'add file'], repoPath) + writeFileSync(path.join(repoPath, 'file.txt'), 'stashed change\n') + } + + it('surfaces a merge conflict and keeps the stash when applying over a competing commit', async () => { + const { repo, repoPath } = setupStashRepo('stash-conflict-apply') + prepareConflictingStash(repoPath) + await service.pushStash(repo.id, { message: 'wip', includeUntracked: false }, db) + const hash = await stashHash(repo) + writeFileSync(path.join(repoPath, 'file.txt'), 'competing change\n') + git(['add', 'file.txt'], repoPath) + git(['commit', '-m', 'competing'], repoPath) + + let error: unknown + try { + await service.applyStash(repo.id, 0, hash, false, db) + } catch (caught) { + error = caught + } + + expect(error).toBeInstanceOf(Error) + expect(parseGitError(error).code).toBe('MERGE_CONFLICT') + expect(git(['status', '--porcelain'], repoPath)).toContain('UU file.txt') + expect(await service.listStashes(repo.id, db)).toHaveLength(1) + }) + + it('surfaces a merge conflict and keeps the stash when a pop fails', async () => { + const { repo, repoPath } = setupStashRepo('stash-conflict-pop') + prepareConflictingStash(repoPath) + await service.pushStash(repo.id, { message: 'wip', includeUntracked: false }, db) + const hash = await stashHash(repo) + writeFileSync(path.join(repoPath, 'file.txt'), 'competing change\n') + git(['add', 'file.txt'], repoPath) + git(['commit', '-m', 'competing'], repoPath) + + let error: unknown + try { + await service.applyStash(repo.id, 0, hash, true, db) + } catch (caught) { + error = caught + } + + expect(error).toBeInstanceOf(Error) + expect(parseGitError(error).code).toBe('MERGE_CONFLICT') + expect(await service.listStashes(repo.id, db)).toHaveLength(1) + }) + }) + + describe('operations', () => { + function setupOperationRepo(prefix: string): { repo: Repo; repoPath: string } { + const origin = path.join(workspaceRoot, uniqueName(`${prefix}-origin.git`)) + const work = path.join(workspaceRoot, uniqueName(`${prefix}-work`)) + createOrigin(origin, work) + const repoPath = path.join(reposPath, uniqueName(`${prefix}-clone`)) + cloneOrigin(origin, repoPath) + const repo = registerClone(origin, repoPath) + return { repo, repoPath } + } + + function commitConflictingChange(repoPath: string): string { + writeFileSync(path.join(repoPath, 'file.txt'), 'base\n') + git(['add', 'file.txt'], repoPath) + git(['commit', '-m', 'add file'], repoPath) + git(['checkout', '-b', 'feature'], repoPath) + writeFileSync(path.join(repoPath, 'file.txt'), 'feature\n') + git(['commit', '-am', 'feature change'], repoPath) + const featureHash = git(['rev-parse', 'HEAD'], repoPath) + git(['checkout', 'main'], repoPath) + writeFileSync(path.join(repoPath, 'file.txt'), 'main change\n') + git(['commit', '-am', 'main change'], repoPath) + return featureHash + } + + it('reports no operation for a clean repository', async () => { + const { repo } = setupOperationRepo('op-clean') + + const status = await service.getStatus(repo.id, db) + + expect(status.operation).toBeNull() + }) + + it('reports a merge with its conflicted files after a conflicting merge', async () => { + const { repo, repoPath } = setupOperationRepo('op-merge') + commitConflictingChange(repoPath) + try { + git(['merge', 'feature'], repoPath) + } catch { + void 0 + } + + const status = await service.getStatus(repo.id, db) + + expect(status.operation).toEqual({ kind: 'merge', conflictedFiles: ['file.txt'] }) + }) + + it('aborts a conflicting merge and clears the operation state', async () => { + const { repo, repoPath } = setupOperationRepo('op-abort') + commitConflictingChange(repoPath) + try { + git(['merge', 'feature'], repoPath) + } catch { + void 0 + } + + await service.abortOperation(repo.id, db) + + expect((await service.getStatus(repo.id, db)).operation).toBeNull() + expect(git(['status', '--porcelain'], repoPath)).toBe('') + }) + + it('continues a merge after the conflict is resolved and staged', async () => { + const { repo, repoPath } = setupOperationRepo('op-continue') + commitConflictingChange(repoPath) + try { + git(['merge', 'feature'], repoPath) + } catch { + void 0 + } + writeFileSync(path.join(repoPath, 'file.txt'), 'resolved\n') + git(['add', 'file.txt'], repoPath) + + await service.continueOperation(repo.id, db) + + expect((await service.getStatus(repo.id, db)).operation).toBeNull() + expect(git(['log', '-1', '--format=%P'], repoPath).split(' ')).toHaveLength(2) + }) + + it('reports the next conflict when a rebase continue stops on a later commit', async () => { + const { repo, repoPath } = setupOperationRepo('op-rebase-multi') + writeFileSync(path.join(repoPath, 'file.txt'), 'base\n') + writeFileSync(path.join(repoPath, 'other.txt'), 'base\n') + git(['add', 'file.txt', 'other.txt'], repoPath) + git(['commit', '-m', 'add files'], repoPath) + git(['checkout', '-b', 'feature'], repoPath) + writeFileSync(path.join(repoPath, 'file.txt'), 'feature\n') + git(['commit', '-am', 'feature file'], repoPath) + writeFileSync(path.join(repoPath, 'other.txt'), 'feature\n') + git(['commit', '-am', 'feature other'], repoPath) + git(['checkout', 'main'], repoPath) + writeFileSync(path.join(repoPath, 'file.txt'), 'main\n') + writeFileSync(path.join(repoPath, 'other.txt'), 'main\n') + git(['commit', '-am', 'main change'], repoPath) + + git(['checkout', 'feature'], repoPath) + try { + git(['rebase', 'main'], repoPath) + } catch { + void 0 + } + + expect((await service.getStatus(repo.id, db)).operation).toEqual({ + kind: 'rebase', + conflictedFiles: ['file.txt'], + }) + + writeFileSync(path.join(repoPath, 'file.txt'), 'resolved\n') + git(['add', 'file.txt'], repoPath) + + const error = await service.continueOperation(repo.id, db).catch((caught: unknown) => caught) + expect(error).toBeInstanceOf(Error) + + expect((await service.getStatus(repo.id, db)).operation).toEqual({ + kind: 'rebase', + conflictedFiles: ['other.txt'], + }) + }) + + it('reports a cherry-pick with its conflicted files', async () => { + const { repo, repoPath } = setupOperationRepo('op-cherry-pick') + const featureHash = commitConflictingChange(repoPath) + try { + git(['cherry-pick', featureHash], repoPath) + } catch { + void 0 + } + + const status = await service.getStatus(repo.id, db) + + expect(status.operation).toEqual({ kind: 'cherry-pick', conflictedFiles: ['file.txt'] }) + }) + + it('reports exact conflicted paths for unicode and special-character file names', async () => { + const { repo, repoPath } = setupOperationRepo('op-filenames') + const unicodeName = 'café.txt' + const specialName = 'a b"c.txt' + writeFileSync(path.join(repoPath, unicodeName), 'base\n') + writeFileSync(path.join(repoPath, specialName), 'base\n') + git(['add', unicodeName, specialName], repoPath) + git(['commit', '-m', 'add files'], repoPath) + git(['checkout', '-b', 'feature'], repoPath) + writeFileSync(path.join(repoPath, unicodeName), 'feature\n') + writeFileSync(path.join(repoPath, specialName), 'feature\n') + git(['commit', '-am', 'feature change'], repoPath) + git(['checkout', 'main'], repoPath) + writeFileSync(path.join(repoPath, unicodeName), 'main\n') + writeFileSync(path.join(repoPath, specialName), 'main\n') + git(['commit', '-am', 'main change'], repoPath) + try { + git(['merge', 'feature'], repoPath) + } catch { + void 0 + } + + const status = await service.getStatus(repo.id, db) + + expect(status.operation?.kind).toBe('merge') + expect(status.operation?.conflictedFiles).toEqual( + expect.arrayContaining([unicodeName, specialName]) + ) + expect(status.operation?.conflictedFiles).toHaveLength(2) + }) + + it('rejects continue and abort when no operation is in progress', async () => { + const { repo } = setupOperationRepo('op-none') + + const continueError = await service.continueOperation(repo.id, db).catch((caught: unknown) => caught) + const abortError = await service.abortOperation(repo.id, db).catch((caught: unknown) => caught) + + expect(parseGitError(continueError).code).toBe('NO_OPERATION_IN_PROGRESS') + expect(parseGitError(continueError).statusCode).toBe(409) + expect(parseGitError(abortError).code).toBe('NO_OPERATION_IN_PROGRESS') + }) + }) + + describe('integrateBranch', () => { + interface WorktreeFixture { + origin: string + base: Repo + basePath: string + worktree: Repo + worktreePath: string + } + + async function setupWorktreeRepo(prefix: string): Promise { + const origin = path.join(workspaceRoot, uniqueName(`${prefix}-origin.git`)) + const work = path.join(workspaceRoot, uniqueName(`${prefix}-work`)) + createOrigin(origin, work) + const basePath = path.join(reposPath, uniqueName(`${prefix}-base`)) + cloneOrigin(origin, basePath) + const base = registerClone(origin, basePath) + + const worktreeName = uniqueName(`${prefix}-feature`) + const worktreePath = path.join(reposPath, worktreeName) + await createWorktreeSafely(basePath, worktreePath, 'feature', {}, 'main') + const worktree = createRepo(db, { + repoUrl: origin, + localPath: worktreeName, + branch: 'feature', + defaultBranch: 'main', + cloneStatus: 'ready', + clonedAt: Date.now(), + isWorktree: true, + }) + + return { origin, base, basePath, worktree, worktreePath } + } + + it('merges the worktree branch into the target and counts integrated commits', async () => { + const { base, basePath, worktree, worktreePath } = await setupWorktreeRepo('integrate-merge') + writeFileSync(path.join(worktreePath, 'a.txt'), 'a\n') + git(['add', 'a.txt'], worktreePath) + git(['commit', '-m', 'add a'], worktreePath) + writeFileSync(path.join(worktreePath, 'b.txt'), 'b\n') + git(['add', 'b.txt'], worktreePath) + git(['commit', '-m', 'add b'], worktreePath) + + const result = await service.integrateBranch(worktree.id, { targetBranch: 'main', strategy: 'merge' }, db) + + expect(result).toEqual({ targetRepoId: base.id, integratedCommits: 2 }) + expect(existsSync(path.join(basePath, 'a.txt'))).toBe(true) + expect(existsSync(path.join(basePath, 'b.txt'))).toBe(true) + expect(git(['log', '-1', '--format=%P'], basePath).split(' ')).toHaveLength(2) + }) + + it('cherry-picks the worktree commits linearly into the target', async () => { + const { basePath, worktree, worktreePath } = await setupWorktreeRepo('integrate-cherry') + writeFileSync(path.join(worktreePath, 'a.txt'), 'a\n') + git(['add', 'a.txt'], worktreePath) + git(['commit', '-m', 'add a'], worktreePath) + writeFileSync(path.join(worktreePath, 'b.txt'), 'b\n') + git(['add', 'b.txt'], worktreePath) + git(['commit', '-m', 'add b'], worktreePath) + + const result = await service.integrateBranch(worktree.id, { targetBranch: 'main', strategy: 'cherry-pick' }, db) + + expect(result.integratedCommits).toBe(2) + expect(git(['log', '-2', '--format=%s'], basePath).split('\n')).toEqual(['add b', 'add a']) + expect(git(['log', '-1', '--format=%P'], basePath).split(' ')).toHaveLength(1) + }) + + it('rejects a target with tracked changes', async () => { + const { basePath, worktree, worktreePath } = await setupWorktreeRepo('integrate-dirty') + writeFileSync(path.join(basePath, 'tracked.txt'), 'base\n') + git(['add', 'tracked.txt'], basePath) + git(['commit', '-m', 'tracked'], basePath) + writeFileSync(path.join(worktreePath, 'feature.txt'), 'feature\n') + git(['add', 'feature.txt'], worktreePath) + git(['commit', '-m', 'feature'], worktreePath) + writeFileSync(path.join(basePath, 'tracked.txt'), 'modified\n') + + await expect( + service.integrateBranch(worktree.id, { targetBranch: 'main', strategy: 'merge' }, db) + ).rejects.toThrow(/uncommitted changes/i) + }) + + it('rejects a target branch that is not checked out in any worktree', async () => { + const { basePath, worktree } = await setupWorktreeRepo('integrate-no-checkout') + git(['branch', 'unchecked'], basePath) + + await expect( + service.integrateBranch(worktree.id, { targetBranch: 'unchecked', strategy: 'merge' }, db) + ).rejects.toThrow(/not checked out in any worktree/i) + }) + + it('rejects integrating a branch into itself', async () => { + const { worktree } = await setupWorktreeRepo('integrate-self') + + await expect( + service.integrateBranch(worktree.id, { targetBranch: 'feature', strategy: 'merge' }, db) + ).rejects.toThrow(/into itself/i) + }) + + it('throws a conflict error for a conflicting merge and reports the operation on the target', async () => { + const { base, basePath, worktree, worktreePath } = await setupWorktreeRepo('integrate-conflict-merge') + writeFileSync(path.join(basePath, 'file.txt'), 'base\n') + git(['add', 'file.txt'], basePath) + git(['commit', '-m', 'base'], basePath) + writeFileSync(path.join(worktreePath, 'file.txt'), 'feature\n') + git(['add', 'file.txt'], worktreePath) + git(['commit', '-m', 'feature change'], worktreePath) + writeFileSync(path.join(basePath, 'file.txt'), 'main change\n') + git(['commit', '-am', 'main change'], basePath) + + const error = await service + .integrateBranch(worktree.id, { targetBranch: 'main', strategy: 'merge' }, db) + .catch((caught: unknown) => caught) + + expect(error).toBeInstanceOf(GitIntegrationConflictError) + const conflict = error as GitIntegrationConflictError + expect(conflict.targetRepoId).toBe(base.id) + expect(conflict.operation.kind).toBe('merge') + expect(conflict.operation.conflictedFiles).toContain('file.txt') + + const status = await service.getStatus(base.id, db) + expect(status.operation).toEqual({ kind: 'merge', conflictedFiles: ['file.txt'] }) + }) + + it('throws a conflict error for a conflicting cherry-pick', async () => { + const { base, basePath, worktree, worktreePath } = await setupWorktreeRepo('integrate-conflict-cherry') + writeFileSync(path.join(basePath, 'file.txt'), 'base\n') + git(['add', 'file.txt'], basePath) + git(['commit', '-m', 'base'], basePath) + writeFileSync(path.join(worktreePath, 'file.txt'), 'feature\n') + git(['add', 'file.txt'], worktreePath) + git(['commit', '-m', 'feature change'], worktreePath) + writeFileSync(path.join(basePath, 'file.txt'), 'main change\n') + git(['commit', '-am', 'main change'], basePath) + + const error = await service + .integrateBranch(worktree.id, { targetBranch: 'main', strategy: 'cherry-pick' }, db) + .catch((caught: unknown) => caught) + + expect(error).toBeInstanceOf(GitIntegrationConflictError) + const conflict = error as GitIntegrationConflictError + expect(conflict.targetRepoId).toBe(base.id) + expect(conflict.operation.kind).toBe('cherry-pick') + + const status = await service.getStatus(base.id, db) + expect(status.operation?.kind).toBe('cherry-pick') + }) + + it('merges the selected heads when tags collide with the source and target branch names', async () => { + const { base, basePath, worktree, worktreePath } = await setupWorktreeRepo('integrate-tag-merge') + writeFileSync(path.join(worktreePath, 'feature.txt'), 'feature\n') + git(['add', 'feature.txt'], worktreePath) + git(['commit', '-m', 'add feature'], worktreePath) + writeFileSync(path.join(worktreePath, 'feature2.txt'), 'feature2\n') + git(['add', 'feature2.txt'], worktreePath) + git(['commit', '-m', 'add feature2'], worktreePath) + + const baseSha = git(['rev-parse', 'refs/heads/main'], basePath) + const firstFeatureSha = git(['rev-parse', 'HEAD~1'], worktreePath) + git(['tag', 'feature', baseSha], basePath) + git(['tag', 'main', firstFeatureSha], basePath) + + const result = await service.integrateBranch(worktree.id, { targetBranch: 'main', strategy: 'merge' }, db) + + expect(result).toEqual({ targetRepoId: base.id, integratedCommits: 2 }) + expect(existsSync(path.join(basePath, 'feature.txt'))).toBe(true) + expect(existsSync(path.join(basePath, 'feature2.txt'))).toBe(true) + }) + + it('cherry-picks the selected heads when tags collide with the source and target branch names', async () => { + const { basePath, worktree, worktreePath } = await setupWorktreeRepo('integrate-tag-cherry') + writeFileSync(path.join(worktreePath, 'feature.txt'), 'feature\n') + git(['add', 'feature.txt'], worktreePath) + git(['commit', '-m', 'add feature'], worktreePath) + writeFileSync(path.join(worktreePath, 'feature2.txt'), 'feature2\n') + git(['add', 'feature2.txt'], worktreePath) + git(['commit', '-m', 'add feature2'], worktreePath) + + const baseSha = git(['rev-parse', 'refs/heads/main'], basePath) + const firstFeatureSha = git(['rev-parse', 'HEAD~1'], worktreePath) + git(['tag', 'feature', baseSha], basePath) + git(['tag', 'main', firstFeatureSha], basePath) + + const result = await service.integrateBranch(worktree.id, { targetBranch: 'main', strategy: 'cherry-pick' }, db) + + expect(result.integratedCommits).toBe(2) + expect(git(['log', '-2', '--format=%s'], basePath).split('\n')).toEqual(['add feature2', 'add feature']) + expect(existsSync(path.join(basePath, 'feature.txt'))).toBe(true) + expect(existsSync(path.join(basePath, 'feature2.txt'))).toBe(true) + }) + + it('rejects a target worktree nested inside a managed repo without its own row', async () => { + const { basePath, worktree, worktreePath } = await setupWorktreeRepo('integrate-unmanaged-nested') + const nestedPath = path.join(basePath, 'nested') + git(['worktree', 'add', '-b', 'nested', nestedPath], basePath) + writeFileSync(path.join(worktreePath, 'feature.txt'), 'feature\n') + git(['add', 'feature.txt'], worktreePath) + git(['commit', '-m', 'feature work'], worktreePath) + + const nestedHeadBefore = git(['rev-parse', 'HEAD'], nestedPath) + const nestedStatusBefore = git(['status', '--porcelain'], nestedPath) + + const error = await service + .integrateBranch(worktree.id, { targetBranch: 'nested', strategy: 'merge' }, db) + .catch((caught: unknown) => caught) + + expect(error).toBeInstanceOf(GitOperationError) + expect(parseGitError(error).statusCode).toBe(409) + expect((error as GitOperationError).message).toMatch(/not a managed repository/i) + expect(git(['rev-parse', 'HEAD'], nestedPath)).toBe(nestedHeadBefore) + expect(git(['status', '--porcelain'], nestedPath)).toBe(nestedStatusBefore) + }) + + it('integrates into a nested target once it has its own managed row', async () => { + const { origin, basePath, worktree, worktreePath } = await setupWorktreeRepo('integrate-managed-nested') + const nestedPath = path.join(basePath, 'nested') + git(['worktree', 'add', '-b', 'nested', nestedPath], basePath) + const nestedRepo = createRepo(db, { + repoUrl: origin, + localPath: `${path.basename(basePath)}/nested`, + branch: 'nested', + defaultBranch: 'main', + cloneStatus: 'ready', + clonedAt: Date.now(), + isWorktree: true, + }) + + writeFileSync(path.join(worktreePath, 'feature.txt'), 'feature\n') + git(['add', 'feature.txt'], worktreePath) + git(['commit', '-m', 'feature work'], worktreePath) + + const result = await service.integrateBranch(worktree.id, { targetBranch: 'nested', strategy: 'merge' }, db) + + expect(result.targetRepoId).toBe(nestedRepo.id) + expect(result.integratedCommits).toBe(1) + expect(existsSync(path.join(nestedPath, 'feature.txt'))).toBe(true) + }) + }) + + describe('DELETE /repos/:id worktree branch cleanup', () => { + interface DeleteWorktreeFixture { + origin: string + basePath: string + worktree: Repo + worktreePath: string + } + + async function setupDeleteWorktree(prefix: string): Promise { + const origin = path.join(workspaceRoot, uniqueName(`${prefix}-origin.git`)) + const work = path.join(workspaceRoot, uniqueName(`${prefix}-work`)) + createOrigin(origin, work) + const baseName = uniqueName(`${prefix}-base`) + const basePath = path.join(reposPath, baseName) + cloneOrigin(origin, basePath) + const repoUrl = `https://example.com/owner/${baseName}.git` + createRepo(db, { repoUrl, localPath: baseName, branch: 'main', defaultBranch: 'main', cloneStatus: 'ready', clonedAt: Date.now() }) + + const worktreeName = uniqueName(`${prefix}-feature`) + const worktreePath = path.join(reposPath, worktreeName) + await createWorktreeSafely(basePath, worktreePath, 'feature', {}, 'main') + const worktree = createRepo(db, { + repoUrl, + localPath: worktreeName, + branch: 'feature', + defaultBranch: 'main', + cloneStatus: 'ready', + clonedAt: Date.now(), + isWorktree: true, + }) + + writeFileSync(path.join(worktreePath, 'feature.txt'), 'feature\n') + git(['add', 'feature.txt'], worktreePath) + git(['commit', '-m', 'feature work'], worktreePath) + + return { origin, basePath, worktree, worktreePath } + } + + function mergeFeatureIntoMain(basePath: string): void { + git(['merge', 'feature'], basePath) + } + + function pushFeatureWithUpstream(worktreePath: string): void { + git(['push', '-u', 'origin', 'feature'], worktreePath) + } + + function createDeleteApp(): Hono { + const app = new Hono() + const scheduleService = { prepareRepoDelete: () => {} } as unknown as Parameters[2] + app.route('/repos', createRepoRoutes(db, gitAuth, scheduleService, createStubOpenCodeClient())) + return app + } + + function rejectPushes(origin: string): void { + const hooksDir = path.join(origin, 'hooks') + mkdirSync(hooksDir, { recursive: true }) + const hookPath = path.join(hooksDir, 'pre-receive') + writeFileSync(hookPath, '#!/bin/sh\necho "rejected by test hook" >&2\nexit 1\n') + chmodSync(hookPath, 0o755) + } + + async function setupLocalWorktree(prefix: string): Promise<{ basePath: string; worktree: Repo; worktreePath: string }> { + const baseName = uniqueName(`${prefix}-local-base`) + const basePath = path.join(reposPath, baseName) + mkdirSync(basePath, { recursive: true }) + git(['init', '-b', 'main'], basePath) + git(['config', 'user.email', 'test@test.com'], basePath) + git(['config', 'user.name', 'Test'], basePath) + git(['commit', '--allow-empty', '-m', 'init'], basePath) + + const worktreeName = uniqueName(`${prefix}-local-feature`) + const worktreePath = path.join(reposPath, worktreeName) + await createWorktreeSafely(basePath, worktreePath, 'feature', {}, 'main') + const worktree = createRepo(db, { + localPath: worktreeName, + branch: 'feature', + defaultBranch: 'main', + cloneStatus: 'ready', + clonedAt: Date.now(), + isLocal: true, + isWorktree: true, + }) + + return { basePath, worktree, worktreePath } + } + + it('deletes the worktree directory and its local branch when the branch is merged', async () => { + const { basePath, worktree, worktreePath } = await setupDeleteWorktree('delete-wt-local') + mergeFeatureIntoMain(basePath) + + const res = await createDeleteApp().request(`/repos/${worktree.id}`, { + method: 'DELETE', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ deleteBranch: 'local' }), + }) + + expect(res.status).toBe(200) + const data = await res.json() as { success: boolean; branch?: { name: string; deleted: boolean; remoteDeleted: boolean; error?: string } } + expect(data.success).toBe(true) + expect(data.branch).toEqual({ name: 'feature', deleted: true, remoteDeleted: false }) + expect(existsSync(worktreePath)).toBe(false) + expect(git(['branch', '--list', 'feature'], basePath)).toBe('') + expect(getRepoById(db, worktree.id)).toBeNull() + }) + + it('keeps an unmerged branch and reports why the worktree branch was not deleted', async () => { + const { basePath, worktree, worktreePath } = await setupDeleteWorktree('delete-wt-unmerged') + + const res = await createDeleteApp().request(`/repos/${worktree.id}`, { + method: 'DELETE', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ deleteBranch: 'local' }), + }) + + expect(res.status).toBe(200) + const data = await res.json() as { success: boolean; branch?: { name: string; deleted: boolean; remoteDeleted: boolean; error?: string } } + expect(data.success).toBe(true) + expect(data.branch).toEqual({ + name: 'feature', + deleted: false, + remoteDeleted: false, + error: "Branch 'feature' was kept because it has unmerged commits.", + }) + expect(existsSync(worktreePath)).toBe(false) + expect(git(['branch', '--list', 'feature'], basePath)).toContain('feature') + expect(getRepoById(db, worktree.id)).toBeNull() + }) + + it('also deletes the pushed origin branch with local-and-remote', async () => { + const { origin, basePath, worktree, worktreePath } = await setupDeleteWorktree('delete-wt-remote') + pushFeatureWithUpstream(worktreePath) + + const res = await createDeleteApp().request(`/repos/${worktree.id}`, { + method: 'DELETE', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ deleteBranch: 'local-and-remote' }), + }) + + expect(res.status).toBe(200) + const data = await res.json() as { success: boolean; branch?: { name: string; deleted: boolean; remoteDeleted: boolean; error?: string } } + expect(data.success).toBe(true) + expect(data.branch?.deleted).toBe(true) + expect(data.branch?.remoteDeleted).toBe(true) + expect(existsSync(worktreePath)).toBe(false) + expect(git(['branch', '--list', 'feature'], basePath)).toBe('') + expect(git(['branch', '--list', 'feature'], origin)).toBe('') + }) + + it('deletes a local worktree without a repoUrl together with its branch', async () => { + const { basePath, worktree, worktreePath } = await setupLocalWorktree('delete-wt-local-no-url') + mergeFeatureIntoMain(basePath) + + const res = await createDeleteApp().request(`/repos/${worktree.id}`, { + method: 'DELETE', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ deleteBranch: 'local' }), + }) + + expect(res.status).toBe(200) + const data = await res.json() as { success: boolean; branch?: { name: string; deleted: boolean; remoteDeleted: boolean; error?: string } } + expect(data.success).toBe(true) + expect(data.branch).toEqual({ name: 'feature', deleted: true, remoteDeleted: false }) + expect(existsSync(worktreePath)).toBe(false) + expect(git(['branch', '--list', 'feature'], basePath)).toBe('') + expect(git(['worktree', 'list', '--porcelain'], basePath)).not.toContain(worktreePath) + expect(getRepoById(db, worktree.id)).toBeNull() + }) + + it('reports local deletion as complete when the remote rejects the branch deletion', async () => { + const { origin, basePath, worktree, worktreePath } = await setupDeleteWorktree('delete-wt-reject') + pushFeatureWithUpstream(worktreePath) + rejectPushes(origin) + + const res = await createDeleteApp().request(`/repos/${worktree.id}`, { + method: 'DELETE', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ deleteBranch: 'local-and-remote' }), + }) + + expect(res.status).toBe(200) + const data = await res.json() as { success: boolean; branch?: { name: string; deleted: boolean; remoteDeleted: boolean; error?: string } } + expect(data.success).toBe(true) + expect(data.branch?.name).toBe('feature') + expect(data.branch?.deleted).toBe(true) + expect(data.branch?.remoteDeleted).toBe(false) + expect(data.branch?.error).toBeTruthy() + expect(existsSync(worktreePath)).toBe(false) + expect(git(['branch', '--list', 'feature'], basePath)).toBe('') + expect(git(['branch', '--list', 'feature'], origin)).toContain('feature') + expect(getRepoById(db, worktree.id)).toBeNull() + }) + }) +}) diff --git a/backend/test/services/git/GitService.test.ts b/backend/test/services/git/GitService.test.ts index 2d00012ac..219d0b579 100644 --- a/backend/test/services/git/GitService.test.ts +++ b/backend/test/services/git/GitService.test.ts @@ -6,35 +6,25 @@ 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(), - filterGitCredentials: vi.fn().mockReturnValue([]), + updateRepoBranch: vi.fn(), + listRepos: vi.fn(() => []), })) vi.mock('../../../src/utils/git-errors', () => ({ isNoUpstreamError: vi.fn().mockReturnValue(false), - parseBranchNameFromError: vi.fn().mockReturnValue(null), + parseGitError: vi.fn(() => ({ code: 'UNKNOWN', statusCode: 500 })), + GitOperationError: class GitOperationError extends Error { + constructor(readonly code: string, message?: string, readonly details?: Record) { + super(message) + this.name = 'GitOperationError' + } + }, })) import { GitService } from '../../../src/services/git/GitService' @@ -48,8 +38,6 @@ describe('GitService', () => { let service: GitService let database: Database let mockGitAuthService: GitAuthService - let mockSettingsService: any - let mockCredentialProvider: any beforeEach(() => { vi.clearAllMocks() @@ -63,18 +51,7 @@ describe('GitService', () => { setSSHPort: vi.fn(), setupSSHForRepoUrl: vi.fn().mockResolvedValue(false), } as unknown as GitAuthService - mockSettingsService = { - getSettings: vi.fn().mockReturnValue({ - preferences: { - gitIdentity: null, - gitCredentials: [], - }, - }), - } - mockCredentialProvider = { - getGitCredentials: vi.fn().mockReturnValue([]), - } - service = new GitService(mockGitAuthService, mockSettingsService, mockCredentialProvider) + service = new GitService(mockGitAuthService) }) describe('getStatus', () => { @@ -98,6 +75,7 @@ describe('GitService', () => { expect(result.behind).toBe(0) expect(result.files).toEqual([]) expect(result.hasChanges).toBe(false) + expect(result.operation).toBeNull() }) it('parses modified files correctly', async () => { @@ -712,7 +690,7 @@ describe('GitService', () => { clonedAt: 123456, } getRepoByIdMock.mockReturnValue(mockRepo) - executeCommandMock.mockResolvedValueOnce('main\n').mockResolvedValueOnce('') + executeCommandMock.mockResolvedValueOnce('refs/heads/main\n').mockResolvedValueOnce('') await service.push(1, { setUpstream: true }, database) @@ -775,6 +753,7 @@ describe('GitService', () => { const mockRepo = { id: 1, fullPath: '/path/to/repo' } getRepoByIdMock.mockReturnValue(mockRepo as any) executeCommandMock.mockImplementation((args) => { + if (args.includes('symbolic-ref')) return Promise.resolve('refs/heads/main') 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') @@ -811,38 +790,6 @@ describe('GitService', () => { }) }) - 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'") - }) - }) - describe('discardChanges', () => { it('discards staged changes using restore --staged --worktree', async () => { const mockRepo = { @@ -1462,4 +1409,46 @@ index abc123..def456 100644 expect(result.diff).toContain('... (diff truncated due to size)') }) }) + + describe('operations', () => { + it('continues the in-progress operation with an editor that accepts defaults', async () => { + getRepoByIdMock.mockReturnValue({ id: 1, fullPath: '/path/to/repo' } as any) + vi.spyOn(service as any, 'getOperationState').mockResolvedValue({ kind: 'merge', conflictedFiles: ['file.txt'] }) + executeCommandMock.mockResolvedValue('') + + await service.continueOperation(1, database) + + expect(executeCommandMock).toHaveBeenCalledWith( + ['git', '-C', '/path/to/repo', 'merge', '--continue'], + { env: expect.objectContaining({ GIT_EDITOR: 'true' }) } + ) + }) + + it('aborts the in-progress operation without setting an editor', async () => { + getRepoByIdMock.mockReturnValue({ id: 1, fullPath: '/path/to/repo' } as any) + vi.spyOn(service as any, 'getOperationState').mockResolvedValue({ kind: 'cherry-pick', conflictedFiles: [] }) + executeCommandMock.mockResolvedValue('') + + await service.abortOperation(1, database) + + expect(executeCommandMock).toHaveBeenCalledWith( + ['git', '-C', '/path/to/repo', 'cherry-pick', '--abort'], + { env: expect.not.objectContaining({ GIT_EDITOR: 'true' }) } + ) + }) + + it('throws when continuing with no operation in progress', async () => { + getRepoByIdMock.mockReturnValue({ id: 1, fullPath: '/path/to/repo' } as any) + vi.spyOn(service as any, 'getOperationState').mockResolvedValue(null) + + await expect(service.continueOperation(1, database)).rejects.toThrow('No operation in progress') + }) + + it('throws when aborting with no operation in progress', async () => { + getRepoByIdMock.mockReturnValue({ id: 1, fullPath: '/path/to/repo' } as any) + vi.spyOn(service as any, 'getOperationState').mockResolvedValue(null) + + await expect(service.abortOperation(1, database)).rejects.toThrow('No operation in progress') + }) + }) }) diff --git a/backend/test/services/git/commit-message-prompt.test.ts b/backend/test/services/git/commit-message-prompt.test.ts new file mode 100644 index 000000000..9fb4d1462 --- /dev/null +++ b/backend/test/services/git/commit-message-prompt.test.ts @@ -0,0 +1,80 @@ +import { describe, it, expect } from 'vitest' +import { + buildCommitMessagePrompt, + normalizeGeneratedCommitMessage, + MAX_COMMIT_PROMPT_DIFF_CHARS, +} from '../../../src/services/git/commit-message-prompt' + +describe('buildCommitMessagePrompt', () => { + it('includes the recent subjects and the staged stat', () => { + const prompt = buildCommitMessagePrompt({ + stagedStat: ' src/index.ts | 4 ++--', + stagedDiff: '+added line', + recentSubjects: ['feat: add source control', 'fix: correct status parsing'], + }) + + expect(prompt).toContain('feat: add source control') + expect(prompt).toContain('fix: correct status parsing') + expect(prompt).toContain('src/index.ts | 4 ++--') + }) + + it('truncates the staged diff above the limit and marks it', () => { + const diff = 'a'.repeat(MAX_COMMIT_PROMPT_DIFF_CHARS + 500) + const prompt = buildCommitMessagePrompt({ stagedStat: 'stat', stagedDiff: diff, recentSubjects: [] }) + + expect(prompt).toContain('[diff truncated]') + expect(prompt).toContain('a'.repeat(MAX_COMMIT_PROMPT_DIFF_CHARS)) + expect(prompt).not.toContain(diff) + }) + + it('leaves a diff under the limit intact', () => { + const diff = 'b'.repeat(100) + const prompt = buildCommitMessagePrompt({ stagedStat: 'stat', stagedDiff: diff, recentSubjects: [] }) + + expect(prompt).toContain(diff) + expect(prompt).not.toContain('[diff truncated]') + }) + + it('handles an empty subject list', () => { + const prompt = buildCommitMessagePrompt({ stagedStat: 'stat', stagedDiff: 'diff', recentSubjects: [] }) + + expect(prompt).toContain('(no recent commits)') + }) +}) + +describe('normalizeGeneratedCommitMessage', () => { + it('strips a surrounding code fence', () => { + expect(normalizeGeneratedCommitMessage('```\nfeat: add thing\n```')).toBe('feat: add thing') + }) + + it('strips a fence with a language hint', () => { + expect(normalizeGeneratedCommitMessage('```text\nfeat: add thing\n```')).toBe('feat: add thing') + }) + + it('strips a leading commit message label', () => { + expect(normalizeGeneratedCommitMessage('Commit message: feat: add thing')).toBe('feat: add thing') + }) + + it('strips a label placed outside a surrounding fence', () => { + expect(normalizeGeneratedCommitMessage('Commit message:\n```text\nfeat: add thing\n```')).toBe('feat: add thing') + expect(normalizeGeneratedCommitMessage('Commit message:\n```\nfeat: add thing\n```')).toBe('feat: add thing') + }) + + it('strips a label placed inside a surrounding fence', () => { + expect(normalizeGeneratedCommitMessage('```\nCommit message: feat: add thing\n```')).toBe('feat: add thing') + }) + + it('normalizes a labeled empty fence to an empty string', () => { + expect(normalizeGeneratedCommitMessage('Commit message:\n```\n```')).toBe('') + expect(normalizeGeneratedCommitMessage('Commit message:\n```text\n```')).toBe('') + }) + + it('preserves a body separated from the subject', () => { + expect(normalizeGeneratedCommitMessage('feat: add thing\n\nExplain the change')).toBe('feat: add thing\n\nExplain the change') + }) + + it('returns an empty string when nothing remains', () => { + expect(normalizeGeneratedCommitMessage(' \n ')).toBe('') + expect(normalizeGeneratedCommitMessage('```\n```')).toBe('') + }) +}) diff --git a/backend/test/services/repo-git.test.ts b/backend/test/services/repo-git.test.ts index 2839ef40b..d9768327a 100644 --- a/backend/test/services/repo-git.test.ts +++ b/backend/test/services/repo-git.test.ts @@ -11,6 +11,7 @@ import { createRepo, getRepoById, getRepoByLocalPath } from '../../src/db/querie import { resolveOpenCodeProjectId } from '@opencode-manager/shared/project-id' import { isWorktreeSibling } from '@opencode-manager/shared/utils' import { getReposPath, getScheduleWorktreesPath } from '@opencode-manager/shared/config/env' +import { cloneOrigin, createCommittedRepo, createGitAuthService, createOrigin, git, uniqueName } from '../helpers/git-fixtures' import type { GitAuthService } from '../../src/services/git-auth' import type { OpenCodeClient } from '../../src/services/opencode/client' import type { Repo } from '../../src/types/repo' @@ -29,64 +30,6 @@ const certPath = path.join(certDir, 'cert.pem') let certKey: Buffer let certPem: Buffer -function git(args: string[], cwd?: string): string { - return execFileSync('git', args, { - cwd, - encoding: 'utf-8', - env: { ...process.env, GIT_TERMINAL_PROMPT: '0', GIT_CONFIG_NOSYSTEM: '1' }, - }).trim() -} - -function createCommittedRepo(repoPath: string, branch = 'main'): void { - mkdirSync(repoPath, { recursive: true }) - git(['init', '-b', branch], repoPath) - git(['config', 'user.email', 'test@test.com'], repoPath) - git(['config', 'user.name', 'Test'], repoPath) - git(['commit', '--allow-empty', '-m', 'init'], repoPath) -} - -function createOrigin(originPath: string, workPath: string, extraBranches: string[] = []): void { - mkdirSync(originPath, { recursive: true }) - git(['init', '--bare', originPath]) - mkdirSync(workPath, { recursive: true }) - git(['init', '-b', 'main'], workPath) - git(['config', 'user.email', 'test@test.com'], workPath) - git(['config', 'user.name', 'Test'], workPath) - git(['commit', '--allow-empty', '-m', 'init'], workPath) - git(['remote', 'add', 'origin', originPath], workPath) - git(['push', 'origin', 'main'], workPath) - git(['symbolic-ref', 'HEAD', 'refs/heads/main'], originPath) - - for (const branch of extraBranches) { - git(['checkout', '-b', branch], workPath) - git(['commit', '--allow-empty', '-m', branch], workPath) - git(['push', 'origin', branch], workPath) - git(['checkout', 'main'], workPath) - } -} - -function cloneOrigin(originPath: string, clonePath: string): void { - git(['clone', originPath, clonePath]) - git(['config', 'user.email', 'test@test.com'], clonePath) - git(['config', 'user.name', 'Test'], clonePath) -} - -let seq = 0 - -function uniqueName(prefix: string): string { - seq += 1 - return `${prefix}-${seq}` -} - -function createGitAuthService(env: Record = {}): GitAuthService { - return { - getGitEnvironment: () => env, - getSSHEnvironment: () => ({}), - setupSSHForRepoUrl: async () => false, - cleanupSSHKey: async () => {}, - } as unknown as GitAuthService -} - async function withTlsServer(status: number, fn: (port: number) => Promise): Promise { const server = createServer({ key: certKey, cert: certPem }, (_req, res) => { res.writeHead(status, { 'WWW-Authenticate': 'Basic realm="test"' }) @@ -625,6 +568,23 @@ describe('repo service real git', () => { expect(git(['rev-parse', '--abbrev-ref', 'HEAD'], path.join(reposPath, `${baseName}-${branchName}`))).toBe(branchName) }) + it('creates a worktree for an existing remote-only branch', async () => { + const { cloneRepo } = await import('../../src/services/repo') + const origin = path.join(workspaceRoot, uniqueName('clone-wt-remote-origin.git')) + const work = path.join(workspaceRoot, uniqueName('clone-wt-remote-work')) + createOrigin(origin, work, ['wt-remote']) + const baseName = uniqueName('clone-wt-remote-base') + await cloneRepo(db, gitAuth, origin, { directoryName: baseName }) + + const repo = await cloneRepo(db, gitAuth, origin, { directoryName: baseName, branch: 'wt-remote', useWorktree: true }) + + const worktreePath = path.join(reposPath, `${baseName}-wt-remote`) + expect(repo.isWorktree).toBe(true) + expect(existsSync(worktreePath)).toBe(true) + expect(git(['rev-parse', '--abbrev-ref', 'HEAD'], worktreePath)).toBe('wt-remote') + expect(git(['rev-parse', '--abbrev-ref', 'wt-remote@{upstream}'], worktreePath)).toBe('origin/wt-remote') + }) + it('reuses a valid existing base repo directory and checks out a local branch', async () => { const { cloneRepo } = await import('../../src/services/repo') const origin = path.join(workspaceRoot, uniqueName('clone-reuse-origin.git')) diff --git a/backend/test/services/sandbox/runtime.test.ts b/backend/test/services/sandbox/runtime.test.ts index 132315464..64a9f9c68 100644 --- a/backend/test/services/sandbox/runtime.test.ts +++ b/backend/test/services/sandbox/runtime.test.ts @@ -318,6 +318,33 @@ describe('SandboxRuntimeService', () => { expect(plan).toEqual({ mode: 'sandbox', workdir: directory }) }) + it('includes the resolved git identity in the sandbox plan env for a directory with a local identity', async () => { + enableEnforcement() + mockExecuteCommand.mockImplementation(async (args: string[]) => { + if (args.includes('inspect')) return { exitCode: 0, stdout: runningInspectOutput(realInspectConfig()), stderr: '' } + if (args.includes('config') && args.includes('user.name')) { + return { exitCode: 0, stdout: 'local\tfile:/repo/.git/config\tLocal User\n', stderr: '' } + } + if (args.includes('config') && args.includes('user.email')) { + return { exitCode: 0, stdout: 'local\tfile:/repo/.git/config\tlocal@example.com\n', stderr: '' } + } + return { exitCode: 0, stdout: '[]', stderr: '' } + }) + + const plan = await service.planShell(repoADir) + + expect(plan).toEqual({ + mode: 'sandbox', + workdir: repoADir, + env: { + GIT_AUTHOR_NAME: 'Local User', + GIT_AUTHOR_EMAIL: 'local@example.com', + GIT_COMMITTER_NAME: 'Local User', + GIT_COMMITTER_EMAIL: 'local@example.com', + }, + }) + }) + it('starts an existing stopped sandbox instead of recreating it', async () => { enableEnforcement() let inspectCalls = 0 diff --git a/backend/test/services/schedule-worktree.test.ts b/backend/test/services/schedule-worktree.test.ts index ac6c29fc7..b44e5349a 100644 --- a/backend/test/services/schedule-worktree.test.ts +++ b/backend/test/services/schedule-worktree.test.ts @@ -61,15 +61,6 @@ describe('ScheduleWorktreeManager', () => { setupSSHForRepoUrl: vi.fn().mockResolvedValue(false), cleanupSSHKey: vi.fn().mockResolvedValue(undefined), } - const mockSettingsService = { - getSettings: vi.fn(() => ({ - preferences: { gitIdentity: undefined }, - updatedAt: Date.now(), - })), - } - const mockCredentialProvider = { - getGitCredentials: vi.fn(() => []), - } const mockDb = {} as any beforeAll(() => { @@ -110,8 +101,6 @@ describe('ScheduleWorktreeManager', () => { const { ScheduleWorktreeManager } = await import('../../src/services/schedule-worktree') return new ScheduleWorktreeManager( mockGitAuthService as any, - mockSettingsService as any, - mockCredentialProvider as any, mockDb, ) } diff --git a/backend/test/utils/git-errors.test.ts b/backend/test/utils/git-errors.test.ts index e2c995140..c88f8804e 100644 --- a/backend/test/utils/git-errors.test.ts +++ b/backend/test/utils/git-errors.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest' -import { parseGitError } from '../../src/utils/git-errors' +import { GitOperationError, parseGitError } from '../../src/utils/git-errors' describe('parseGitError', () => { describe('AUTH_FAILED', () => { @@ -183,6 +183,28 @@ describe('parseGitError', () => { }) }) + describe('BRANCH_NOT_MERGED', () => { + it('matches git refusing a safe branch delete', () => { + const error = new Error( + "Command failed with code 1: error: the branch 'feature' is not fully merged\n" + + "error: If you are sure you want to delete it, run 'git branch -D feature'" + ) + const result = parseGitError(error) + expect(result.code).toBe('BRANCH_NOT_MERGED') + expect(result.statusCode).toBe(409) + }) + }) + + describe('STASH_CHANGED', () => { + it('maps the typed stale stash error to its summary', () => { + const error = new GitOperationError('STASH_CHANGED', 'The stash list changed. Refresh and try again.') + const result = parseGitError(error) + expect(result.code).toBe('STASH_CHANGED') + expect(result.statusCode).toBe(409) + expect(result.summary).toBe('The stash list changed. Refresh and try again.') + }) + }) + describe('UNKNOWN', () => { it('falls back for unrecognized errors', () => { const error = new Error('Command failed with code 1: something completely unexpected happened') @@ -269,6 +291,30 @@ describe('parseGitError', () => { }) }) + describe('GitOperationError', () => { + it('maps a typed error by code to the table summary and status', () => { + const error = new GitOperationError('BRANCH_IN_OTHER_WORKTREE', 'custom message') + const result = parseGitError(error) + + expect(result.code).toBe('BRANCH_IN_OTHER_WORKTREE') + expect(result.summary).toBe('This branch is checked out in another worktree and cannot be modified here.') + expect(result.statusCode).toBe(409) + expect(result.detail).toBe('custom message') + }) + + it('exposes typed error details', () => { + const error = new GitOperationError('MERGE_CONFLICT', 'Integration stopped on conflicts', { + targetRepoId: 2, + operation: { kind: 'merge', conflictedFiles: [] }, + }) + const result = parseGitError(error) + + expect(result.code).toBe('MERGE_CONFLICT') + expect(result.statusCode).toBe(409) + expect(result.details).toEqual({ targetRepoId: 2, operation: { kind: 'merge', conflictedFiles: [] } }) + }) + }) + describe('GitService plain Error passthrough', () => { it('handles GitService "Repository not found" throw', () => { const error = new Error('Repository not found') diff --git a/backend/test/utils/process.test.ts b/backend/test/utils/process.test.ts index af46399a8..1d89e74aa 100644 --- a/backend/test/utils/process.test.ts +++ b/backend/test/utils/process.test.ts @@ -24,4 +24,20 @@ describe('executeCommand signal handling', () => { expect(result).toEqual({ exitCode: 0, stdout: '', stderr: '' }) }) + + it('truncates stdout at maxOutputChars and resolves instead of rejecting', async () => { + const result = await executeCommand( + ['sh', '-c', 'i=0; while [ $i -lt 1000 ]; do printf "0123456789"; i=$((i+1)); done'], + { maxOutputChars: 100, silent: true }, + ) + + expect(typeof result).toBe('string') + expect(result).toHaveLength(100) + }) + + it('returns the full stdout when it stays within maxOutputChars', async () => { + const result = await executeCommand(['sh', '-c', 'printf "hello"'], { maxOutputChars: 100, silent: true }) + + expect(result).toBe('hello') + }) }) diff --git a/docs/features/assistant-internal-api.md b/docs/features/assistant-internal-api.md index 3b1a8f194..228baf9ea 100644 --- a/docs/features/assistant-internal-api.md +++ b/docs/features/assistant-internal-api.md @@ -167,6 +167,7 @@ Retrieve the user's full settings and preferences. repoSortMode: 'recent' | 'manual' | 'name', gitCredentials?: [...], // Read-only gitIdentity?: {...}, // Read-only + gitIdentities?: [...], // Read-only tts?: {...}, // Read-only stt?: {...}, // Read-only }, @@ -193,6 +194,7 @@ The following preference keys can be modified: The following keys are **NOT** allowed and will be rejected: - `gitCredentials` - Git credentials must be managed via the full UI - `gitIdentity` - Git identity must be managed via the full UI +- `gitIdentities` - Git identity presets must be managed via the full UI - `tts.apiKey` - TTS credentials must be managed via the full UI - `tts.endpoint` - TTS endpoint must be managed via the full UI - `stt.apiKey` - STT credentials must be managed via the full UI diff --git a/docs/features/git.md b/docs/features/git.md index 06b736521..b64c9e13b 100644 --- a/docs/features/git.md +++ b/docs/features/git.md @@ -41,7 +41,7 @@ Work on multiple branches simultaneously without switching: 1. Select a repository 2. Click **Create Worktree** -3. Enter branch name +3. Choose **New branch** (enter a name and pick a base branch) or **Existing branch** (pick a local or remote branch to check out) 4. A new workspace is created with that branch checked out Worktrees share the same git history but have independent working directories. This is useful for: @@ -50,6 +50,26 @@ Worktrees share the same git history but have independent working directories. T - Working on a feature while keeping main branch accessible - Testing changes without disrupting your main work +### Integrating a Worktree Branch + +From a worktree's Branches tab, click **Integrate** to bring its commits into another branch: + +1. Choose the target branch. It must be checked out cleanly in another managed worktree, with no uncommitted changes or operation already in progress. +2. Choose a strategy: **Merge commit** integrates all commits with a merge commit, while **Cherry-pick commits** replays each commit onto the target. +3. Click **Integrate**. + +If the target stops on conflicts, the operation banner appears in the dialog so you can resolve, continue, or abort the integration. + +### Deleting a Worktree + +Deleting a worktree offers three branch options: + +- **Keep branch** - Leave the branch in the parent repository. +- **Delete local branch** - Remove the branch from the parent repository. +- **Delete local and remote branch** - Also delete the branch from origin. + +**Delete local branch** and **Delete local and remote branch** use a safe delete: if the branch has unmerged commits it is kept (and the remote branch is not deleted either), and a warning names it. Force-delete it from the Branches tab if intended. + ## Source Control Panel Access comprehensive git operations via the source control button. @@ -77,10 +97,48 @@ Access comprehensive git operations via the source control button. - List all local and remote branches - Create new branches from current HEAD - Switch branches (checks out the branch) -- Delete local branches +- Rename a local branch from its actions menu. Renaming also updates the base branch of schedules in every checkout of the same git repository that used the old name. A worktree's folder keeps the old branch name; `ocm` mirroring looks for a folder named after the new branch, so mirroring the renamed branch fails while that worktree exists. +- Delete local branches, optionally force-deleting an unmerged branch and, when the branch tracks a remote, also deleting the remote branch. The current branch and branches checked out in another worktree cannot be deleted. ![Branches Tab](../images/git/git-branch.png){ .phone } +### Stash Tab + +Stash work in progress and restore it later: + +- Enter an optional message and choose whether to include untracked files +- **Stash changes** saves the working tree and index +- Each stash shows its branch and date, with **Apply** (restore and keep the stash), **Pop** (restore and remove the stash), and **Drop** (delete the stash) +- The stash list is shared by a repository and all its worktrees. Apply, Pop, and Drop verify the stash is still the one shown and ask you to refresh if the list changed. + +### Merge, Rebase and Cherry-pick Conflicts + +When a merge, rebase, or cherry-pick stops on conflicts, an operation banner appears at the top of the source control panel: + +- Shows the operation kind and the list of conflicted files +- **Continue** finishes the operation once every conflict is resolved +- **Abort** cancels the operation and restores the previous state +- **Resolve with agent** opens a new session in the repository, primed with the conflicted files, so an agent can resolve them + +### Committing + +Commit staged changes with a message. Press ⌘/Ctrl+Enter to commit without clicking the button. + +#### AI Commit Messages + +Use the sparkle button in the commit box to generate a commit message from the staged changes using OpenCode's default model. Only staged changes are used, and the generated message replaces the current text after confirmation. + +#### Commit Identities + +Configure identities in **Settings > Git**: + +- **Default identity** - Written to Manager's own git config file (`.config/git/config` in the workspace). When the name or email is empty and a GitHub token credential is configured, the missing parts come from the GitHub account. It applies to Manager commits, agent shells, terminals, and scheduled runs without restarting OpenCode. Because it is a global-level git config file, a repository's own identity and, on non-Docker installs, your `~/.gitconfig` identity take precedence over it. +- **Saved identities** - Presets that can be applied to individual repositories. + +Each repository has a **Commit as** selector in the commit box. Choosing a saved preset writes it into the repository's local git config (`user.name` / `user.email`), which git shares with all worktrees of the repository (Manager worktrees, OpenCode workspaces, and scheduled-run worktrees). Choosing **Default** removes the repository's local identity. An identity you set yourself in the repository's local git config is respected and shown as **Custom**; the selector asks before replacing it. The selector shows the effective identity and where it comes from (repository, Manager default, git global config, or not configured). Removing a saved preset does not change repositories that use it. + +Sandboxed shells receive the resolved identity for their directory. + ## Diff Viewer View file changes with a unified diff format: diff --git a/docs/features/overview.md b/docs/features/overview.md index b62c3f669..f2b76a047 100644 --- a/docs/features/overview.md +++ b/docs/features/overview.md @@ -8,9 +8,12 @@ OpenCode Manager provides a comprehensive web interface for managing OpenCode AI - **Multi-Repository Support** - Clone and manage multiple git repos with private repo support via GitHub PAT - **SSH Authentication** - SSH key authentication for git repositories -- **Git Worktrees** - Work on multiple branches simultaneously -- **Source Control Panel** - View changes, commits, and branches in a unified interface +- **Git Worktrees** - Work on multiple branches simultaneously, integrate a worktree branch into another branch, and delete worktrees with optional local and remote branch cleanup +- **Source Control Panel** - View changes, commits, branches, and stashes in a unified interface - **Diff Viewer** - Unified diffs with line numbers and change counts +- **Conflict Resolution** - Continue or abort merge, rebase, and cherry-pick operations, or hand conflicts to an agent session +- **AI Commit Messages** - Generate commit messages from staged changes using OpenCode's default model +- **Per-Repository Identities** - A default git identity plus saved presets, written to each repository's git config (shared by its worktrees) for Manager commits and agent shells [Learn more →](git.md) diff --git a/docs/features/server-health.md b/docs/features/server-health.md index a68bfc5b6..afea14bc2 100644 --- a/docs/features/server-health.md +++ b/docs/features/server-health.md @@ -96,6 +96,6 @@ Besides the explicit **Restart** button, the server is automatically restarted w - **Config import completes** — Importing a standalone OpenCode config into the workspace - **Version upgrade** — After installing a new OpenCode version -Saving the OpenCode configuration never restarts the server. A change to the merged configuration is written to disk and applied immediately with an OpenCode location reload, which re-reads the configuration, agents, commands, skills, plugins, and `AGENTS.md` for every loaded location without interrupting running sessions. Comment-only edits do nothing, and changes limited to the `mcp` section are applied by the Settings UI through the MCP API. The change is flagged as **restart required** only when the reload fails, for example because the server is unavailable. Changes to how the server process is launched still need a restart: server environment variables, Git credentials and identity, sandbox enforcement, manager token rotation, the server password, and OpenCode version installs. Saving a provider credential, or completing a provider OAuth flow, applies to the running server immediately without a restart, because OpenCode 2 stores credentials in its database and broadcasts the change. +Saving the OpenCode configuration never restarts the server. A change to the merged configuration is written to disk and applied immediately with an OpenCode location reload, which re-reads the configuration, agents, commands, skills, plugins, and `AGENTS.md` for every loaded location without interrupting running sessions. Comment-only edits do nothing, and changes limited to the `mcp` section are applied by the Settings UI through the MCP API. The change is flagged as **restart required** only when the reload fails, for example because the server is unavailable. Changes to how the server process is launched still need a restart: server environment variables, Git credentials, sandbox enforcement, manager token rotation, the server password, and OpenCode version installs. The default git identity applies without a restart: it is written to Manager's own git config file, which the running OpenCode process and its agent shells already read. Saving a provider credential, or completing a provider OAuth flow, applies to the running server immediately without a restart, because OpenCode 2 stores credentials in its database and broadcasts the change. A save is rejected with `409` when the files changed since you loaded them (stale revision), or when it would remove a value that is defined only in a lower-priority source file — removing it from the preferred file would leave the inherited value in effect. diff --git a/frontend/src/api/git.ts b/frontend/src/api/git.ts index d6afc2d83..aebd70249 100644 --- a/frontend/src/api/git.ts +++ b/frontend/src/api/git.ts @@ -1,6 +1,7 @@ import { useQuery } from '@tanstack/react-query' import { fetchWrapper, FetchError } from './fetchWrapper' import { API_BASE_URL } from '@/config' +import type { DeleteBranchRequest, DeleteBranchResult, GitStashEntry, IntegrateBranchRequest, IntegrateBranchResult, RenameBranchRequest, StashApplyRequest, StashDropRequest, StashPushRequest } from '@opencode-manager/shared' import type { GitStatusResponse, FileDiffResponse, GitCommit, CommitDetails } from '@/types/git' export async function fetchGitStatus(repoId: number): Promise { @@ -73,6 +74,12 @@ export async function gitCommit(repoId: number, message: string, stagedPaths?: s }) } +export async function gitGenerateCommitMessage(repoId: number): Promise<{ message: string }> { + return fetchWrapper(`${API_BASE_URL}/api/repos/${repoId}/git/commit-message`, { + method: 'POST', + }) +} + export async function gitStageFiles(repoId: number, paths: string[]): Promise { return fetchWrapper(`${API_BASE_URL}/api/repos/${repoId}/git/stage`, { method: 'POST', @@ -105,12 +112,94 @@ export async function gitReset(repoId: number, commitHash: string): Promise { + return fetchWrapper(`${API_BASE_URL}/api/repos/${repoId}/git/branches/rename`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(request), + }) +} + +export async function gitDeleteBranch( + repoId: number, + request: DeleteBranchRequest +): Promise { + return fetchWrapper(`${API_BASE_URL}/api/repos/${repoId}/git/branches`, { + method: 'DELETE', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(request), + }) +} + +async function fetchGitStashes(repoId: number): Promise<{ stashes: GitStashEntry[] }> { + return fetchWrapper(`${API_BASE_URL}/api/repos/${repoId}/git/stash`) +} + +export async function gitStashPush(repoId: number, request: StashPushRequest): Promise { + return fetchWrapper(`${API_BASE_URL}/api/repos/${repoId}/git/stash`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(request), + }) +} + +export async function gitStashApply(repoId: number, index: number, request: StashApplyRequest): Promise { + return fetchWrapper(`${API_BASE_URL}/api/repos/${repoId}/git/stash/${index}/apply`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(request), + }) +} + +export async function gitStashDrop(repoId: number, index: number, request: StashDropRequest): Promise { + return fetchWrapper(`${API_BASE_URL}/api/repos/${repoId}/git/stash/${index}`, { + method: 'DELETE', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(request), + }) +} + +export async function gitContinueOperation(repoId: number): Promise { + return fetchWrapper(`${API_BASE_URL}/api/repos/${repoId}/git/operation/continue`, { + method: 'POST', + }) +} + +export async function gitAbortOperation(repoId: number): Promise { + return fetchWrapper(`${API_BASE_URL}/api/repos/${repoId}/git/operation/abort`, { + method: 'POST', + }) +} + +export async function gitIntegrateBranch( + repoId: number, + request: IntegrateBranchRequest +): Promise { + return fetchWrapper(`${API_BASE_URL}/api/repos/${repoId}/git/integrate`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(request), + }) +} + +interface GitStatusQueryOptions { + refetchInterval?: number | false +} + +export function useGitStatus(repoId: number | undefined, options: GitStatusQueryOptions = {}) { return useQuery({ queryKey: ['gitStatus', repoId], queryFn: () => repoId ? fetchGitStatus(repoId) : Promise.reject(new Error('No repo ID')), enabled: !!repoId, - refetchInterval: false, + refetchInterval: options.refetchInterval ?? false, + }) +} + +export function useGitStashes(repoId: number | undefined) { + return useQuery({ + queryKey: ['gitStashes', repoId], + queryFn: () => repoId ? fetchGitStashes(repoId) : Promise.reject(new Error('No repo ID')), + enabled: !!repoId, }) } diff --git a/frontend/src/api/opencode.test.ts b/frontend/src/api/opencode.test.ts index 20c1fc7b6..87bb967c4 100644 --- a/frontend/src/api/opencode.test.ts +++ b/frontend/src/api/opencode.test.ts @@ -9,6 +9,7 @@ import { compactSession, createSession, createSessionWithContext, + createSessionWithPrompt, deleteSession, findFiles, forkSession, @@ -215,6 +216,48 @@ describe('OpenCode facade', () => { expect((deleteInit as RequestInit).method).toBe('DELETE') }) + it('creates a session in a directory and sends a prompt', async () => { + fetchMock + .mockResolvedValueOnce(jsonResponse({ data: sessionInfo('ses_new', '/abs/repos/my-repo') })) + .mockResolvedValueOnce( + jsonResponse({ + data: { + id: 'msg_1', + sessionID: 'ses_new', + time: { created: 1000 }, + type: 'user', + payload: { text: 'Fix conflicts' }, + }, + }), + ) + + const session = await createSessionWithPrompt( + { directory: '/abs/repos/my-repo', title: 'Resolve merge conflicts' }, + 'Fix conflicts', + ) + + expect(session.id).toBe('ses_new') + const [createUrl] = fetchMock.mock.calls[0] ?? [] + expect(String(createUrl)).toBe('http://localhost/api/opencode/api/session') + const [promptUrl, promptInit] = fetchMock.mock.calls[1] ?? [] + expect(String(promptUrl)).toBe('http://localhost/api/opencode/api/session/ses_new/prompt') + expect((promptInit as RequestInit).method).toBe('POST') + expect((promptInit as RequestInit).body).toBe(JSON.stringify({ text: 'Fix conflicts' })) + }) + + it('deletes the new session when sending the prompt fails', async () => { + fetchMock + .mockResolvedValueOnce(jsonResponse({ data: sessionInfo('ses_new', '/abs/repos/my-repo') })) + .mockResolvedValueOnce(new Response('nope', { status: 400 })) + .mockResolvedValueOnce(new Response(null, { status: 204 })) + + await expect(createSessionWithPrompt({ directory: '/abs/repos/my-repo' }, 'Fix conflicts')).rejects.toBeInstanceOf(FetchError) + + const [deleteUrl, deleteInit] = fetchMock.mock.calls[2] ?? [] + expect(String(deleteUrl)).toBe('http://localhost/api/opencode/api/session/ses_new') + expect((deleteInit as RequestInit).method).toBe('DELETE') + }) + it('finds files through the V2 filesystem route and returns paths', async () => { fetchMock.mockResolvedValue( jsonResponse({ diff --git a/frontend/src/api/opencode.ts b/frontend/src/api/opencode.ts index c74564842..17210119e 100644 --- a/frontend/src/api/opencode.ts +++ b/frontend/src/api/opencode.ts @@ -140,10 +140,13 @@ export async function addSessionContext(sessionID: string, text: string): Promis await callOpenCode((api) => api.session.synthetic({ sessionID, text, resume: false })) } -export async function createSessionWithContext(input: CreateSessionInput, context: string): Promise { +async function createSessionThen( + input: CreateSessionInput, + step: (session: SessionInfo) => Promise, +): Promise { const session = await createSession(input) try { - await addSessionContext(session.id, context) + await step(session) } catch (error) { await deleteSession(session.id).catch(() => undefined) throw error @@ -151,6 +154,14 @@ export async function createSessionWithContext(input: CreateSessionInput, contex return session } +export async function createSessionWithContext(input: CreateSessionInput, context: string): Promise { + return createSessionThen(input, (session) => addSessionContext(session.id, context)) +} + +export async function createSessionWithPrompt(input: CreateSessionInput, text: string): Promise { + return createSessionThen(input, (session) => sendPrompt({ sessionID: session.id, text })) +} + export async function switchSessionModel(sessionID: string, model: ModelRef): Promise { await callOpenCode((api) => api.session.switchModel({ sessionID, model })) } diff --git a/frontend/src/api/repos.ts b/frontend/src/api/repos.ts index 5a273c857..b97eddc2d 100644 --- a/frontend/src/api/repos.ts +++ b/frontend/src/api/repos.ts @@ -2,7 +2,7 @@ import type { Repo } from './types' import { FetchError, fetchWrapper, fetchWrapperVoid, fetchWrapperBlob } from './fetchWrapper' import { API_BASE_URL } from '@/config' import { saveFile } from '@/lib/download' -import type { DiscoverReposResponse, AssistantModeStatus, AssistantModeInitRequest } from '@opencode-manager/shared/types' +import type { DiscoverReposResponse, AssistantModeStatus, AssistantModeInitRequest, DeleteRepoRequest, DeleteRepoResult, RepoGitIdentity } from '@opencode-manager/shared/types' export interface CreateRepoOptions { repoUrl?: string @@ -69,9 +69,18 @@ export async function createRepoWorkspace(repoId: number): Promise }) } -export async function deleteRepo(id: number): Promise { - return fetchWrapperVoid(`${API_BASE_URL}/api/repos/${id}`, { +export type DeleteBranchChoice = NonNullable + +export interface DeleteRepoOptions { + deleteBranch?: DeleteBranchChoice +} + +export async function deleteRepo(id: number, options?: DeleteRepoOptions): Promise { + return fetchWrapper(`${API_BASE_URL}/api/repos/${id}`, { method: 'DELETE', + ...(options + ? { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(options) } + : {}), }) } @@ -83,6 +92,18 @@ export async function updateRepoGitCredential(id: number, credentialId?: string) }) } +export async function getRepoGitIdentity(id: number): Promise { + return fetchWrapper(`${API_BASE_URL}/api/repos/${id}/git-identity`) +} + +export async function updateRepoGitIdentity(id: number, identityId: string | null): Promise { + return fetchWrapper(`${API_BASE_URL}/api/repos/${id}/git-identity`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ identityId }), + }) +} + export async function renameRepo(id: number, name: string | null): Promise { return fetchWrapper(`${API_BASE_URL}/api/repos/${id}`, { method: 'PATCH', diff --git a/frontend/src/api/types/settings.ts b/frontend/src/api/types/settings.ts index d1f25e67b..9893a7041 100644 --- a/frontend/src/api/types/settings.ts +++ b/frontend/src/api/types/settings.ts @@ -27,7 +27,7 @@ import type { NotificationPreferences } from '@opencode-manager/shared/types' import { saveFile } from '@/lib/download' export type { TTSConfig, STTConfig, OpenCodeConfigFile, OpenCodeConfigSourceFile, OpenCodeConfigSourceName, UpdateOpenCodeConfigRequest, ModelConfig, ProviderConfig, SandboxPreferences, NotificationPreferences, SkillFileInfo, CreateSkillRequest, UpdateSkillRequest, SkillScope, InstallSkillFromGithubRequest, InstallSkillResponse } -export type { UserPreferences, UpdateSettingsRequest, CustomCommand, GitCredential, GitIdentity } from '@opencode-manager/shared' +export type { UserPreferences, UpdateSettingsRequest, CustomCommand, GitCredential, GitIdentity, GitIdentityProfile } from '@opencode-manager/shared' export { DEFAULT_TTS_CONFIG, DEFAULT_STT_CONFIG, DEFAULT_KEYBOARD_SHORTCUTS, DEFAULT_USER_PREFERENCES, DEFAULT_LEADER_KEY, BLOCKED_SERVER_ENV_KEYS } export { isOpenCodeConfigSourceName } from '@opencode-manager/shared' diff --git a/frontend/src/components/repo/CreateWorktreeDialog.test.tsx b/frontend/src/components/repo/CreateWorktreeDialog.test.tsx new file mode 100644 index 000000000..604e9cfcc --- /dev/null +++ b/frontend/src/components/repo/CreateWorktreeDialog.test.tsx @@ -0,0 +1,142 @@ +import { describe, it, expect, vi, beforeAll, beforeEach } from 'vitest' +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { CreateWorktreeDialog } from './CreateWorktreeDialog' +import { createRepo, listBranches } from '@/api/repos' + +vi.mock('@/api/repos', () => ({ + createRepo: vi.fn(), + listBranches: vi.fn(), +})) + +vi.mock('@/lib/toast', () => ({ + showToast: { success: vi.fn(), error: vi.fn(), info: vi.fn(), warning: vi.fn() }, +})) + +const repoUrl = 'git@example.com:org/repo.git' + +function renderDialog() { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }) + return render( + + + , + ) +} + +describe('CreateWorktreeDialog', () => { + beforeAll(() => { + Element.prototype.hasPointerCapture ??= () => false + Element.prototype.setPointerCapture ??= () => {} + Element.prototype.releasePointerCapture ??= () => {} + Element.prototype.scrollIntoView ??= () => {} + }) + + beforeEach(() => { + vi.clearAllMocks() + vi.mocked(listBranches).mockResolvedValue({ + branches: [ + { name: 'main', type: 'local', current: true }, + { name: 'feature', type: 'local', current: false }, + { name: 'wt-branch', type: 'local', current: false, isWorktree: true }, + { name: 'remotes/origin/feature', type: 'remote', current: false }, + { name: 'remotes/origin/release', type: 'remote', current: false }, + ], + status: { ahead: 0, behind: 0 }, + }) + vi.mocked(createRepo).mockResolvedValue({} as never) + }) + + it('lists only non-current, non-worktree local branches and remote-only branches in existing mode', async () => { + const user = userEvent.setup() + renderDialog() + + await user.click(await screen.findByRole('button', { name: 'Existing branch' })) + await user.click(screen.getByRole('combobox', { name: 'Branch to check out' })) + + expect(screen.getByRole('option', { name: 'feature' })).toBeInTheDocument() + expect(screen.getByRole('option', { name: /release/ })).toBeInTheDocument() + expect(screen.queryByRole('option', { name: /main/ })).not.toBeInTheDocument() + expect(screen.queryByRole('option', { name: /wt-branch/ })).not.toBeInTheDocument() + }) + + it('creates a worktree for an existing remote-only branch without a base branch', async () => { + const user = userEvent.setup() + renderDialog() + + await user.click(await screen.findByRole('button', { name: 'Existing branch' })) + await user.click(screen.getByRole('combobox', { name: 'Branch to check out' })) + await user.click(screen.getByRole('option', { name: /release/ })) + await user.click(screen.getByRole('button', { name: 'Create Worktree' })) + + await waitFor(() => expect(createRepo).toHaveBeenCalledTimes(1)) + expect(createRepo).toHaveBeenCalledWith({ repoUrl, branch: 'release', useWorktree: true }) + expect(vi.mocked(createRepo).mock.calls[0][0]).not.toHaveProperty('baseBranch') + }) + + it('does not offer remote-only branches from non-origin remotes', async () => { + vi.mocked(listBranches).mockResolvedValue({ + branches: [ + { name: 'main', type: 'local', current: true }, + { name: 'remotes/upstream/release', type: 'remote', current: false }, + ], + status: { ahead: 0, behind: 0 }, + }) + const user = userEvent.setup() + renderDialog() + + await user.click(await screen.findByRole('button', { name: 'Existing branch' })) + await user.click(screen.getByRole('combobox', { name: 'Branch to check out' })) + + expect(screen.queryByRole('option', { name: /release/ })).not.toBeInTheDocument() + }) + + it('deduplicates remote candidates that share a short name across remotes', async () => { + vi.mocked(listBranches).mockResolvedValue({ + branches: [ + { name: 'main', type: 'local', current: true }, + { name: 'remotes/origin/release', type: 'remote', current: false }, + { name: 'remotes/upstream/release', type: 'remote', current: false }, + ], + status: { ahead: 0, behind: 0 }, + }) + const user = userEvent.setup() + renderDialog() + + await user.click(await screen.findByRole('button', { name: 'Existing branch' })) + await user.click(screen.getByRole('combobox', { name: 'Branch to check out' })) + + expect(screen.getAllByRole('option', { name: /release/ })).toHaveLength(1) + }) + + it('creates a worktree for a new branch with the selected base branch', async () => { + const user = userEvent.setup() + renderDialog() + + await user.type(screen.getByPlaceholderText('feature/my-branch'), 'my-new-branch') + await user.click(screen.getByRole('combobox')) + await user.click(screen.getByRole('option', { name: /main/ })) + await user.click(screen.getByRole('button', { name: 'Create Worktree' })) + + await waitFor(() => expect(createRepo).toHaveBeenCalledTimes(1)) + expect(createRepo).toHaveBeenCalledWith({ + repoUrl, + branch: 'my-new-branch', + useWorktree: true, + baseBranch: 'main', + }) + }) + + it('prompts to use existing mode when a new branch name already exists', async () => { + const user = userEvent.setup() + renderDialog() + + await user.type(screen.getByPlaceholderText('feature/my-branch'), 'feature') + + expect(await screen.findByText('Use Existing branch instead')).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Create Worktree' })).toBeDisabled() + }) +}) diff --git a/frontend/src/components/repo/CreateWorktreeDialog.tsx b/frontend/src/components/repo/CreateWorktreeDialog.tsx index 748632f8f..af858655d 100644 --- a/frontend/src/components/repo/CreateWorktreeDialog.tsx +++ b/frontend/src/components/repo/CreateWorktreeDialog.tsx @@ -5,7 +5,7 @@ import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' import { AlertCircle, GitBranch, Loader2 } from 'lucide-react' -import { createRepo, listBranches } from '@/api/repos' +import { createRepo, listBranches, type CreateRepoOptions } from '@/api/repos' import { showToast } from '@/lib/toast' import { invalidateRepoGitCaches } from '@/lib/queryInvalidation' @@ -18,6 +18,13 @@ interface CreateWorktreeDialogProps { onCreated?: () => void } +type WorktreeMode = 'new' | 'existing' + +interface WorktreePayload { + branch: string + base?: string +} + export function CreateWorktreeDialog({ open, onOpenChange, @@ -27,8 +34,10 @@ export function CreateWorktreeDialog({ onCreated, }: CreateWorktreeDialogProps) { const queryClient = useQueryClient() + const [mode, setMode] = useState('new') const [branchName, setBranchName] = useState('') const [baseBranch, setBaseBranch] = useState('') + const [existingBranch, setExistingBranch] = useState('') const [error, setError] = useState(null) const canCreate = Boolean(repoUrl) @@ -41,15 +50,41 @@ export function CreateWorktreeDialog({ }) const localBranches = (branchesData?.branches ?? []).filter((b) => b.type === 'local') + + const seenRemoteNames = new Set() const remoteBranches = (branchesData?.branches ?? []) - .filter((b) => b.type === 'remote') - .map((b) => ({ ...b, shortName: b.name.replace(/^remotes\/[^/]+\//, '') })) + .filter((b) => b.type === 'remote' && b.name.startsWith('remotes/origin/')) + .map((b) => ({ ...b, shortName: b.name.slice('remotes/origin/'.length) })) + .filter((b) => b.shortName.length > 0) .filter((b) => !localBranches.some((lb) => lb.name === b.shortName)) + .filter((b) => { + if (seenRemoteNames.has(b.shortName)) return false + seenRemoteNames.add(b.shortName) + return true + }) + + const checkoutCandidates = [ + ...localBranches + .filter((b) => !b.current && !b.isWorktree) + .map((b) => ({ name: b.name, remote: false })), + ...remoteBranches.map((b) => ({ name: b.shortName, remote: true })), + ] + + const existingBranchNames = new Set([ + ...localBranches.map((b) => b.name), + ...remoteBranches.map((b) => b.shortName), + ]) + + const trimmedBranchName = branchName.trim() + const newBranchConflict = + mode === 'new' && trimmedBranchName.length > 0 && existingBranchNames.has(trimmedBranchName) useEffect(() => { if (!open) { + setMode('new') setBranchName('') setBaseBranch('') + setExistingBranch('') setError(null) return } @@ -59,13 +94,17 @@ export function CreateWorktreeDialog({ }, [open, defaultBaseBranch]) const worktreeMutation = useMutation({ - mutationFn: (payload: { branch: string; base: string }) => - createRepo({ + mutationFn: (payload: WorktreePayload) => { + const options: CreateRepoOptions = { repoUrl: repoUrl || undefined, branch: payload.branch, useWorktree: true, - baseBranch: payload.base, - }), + } + if (payload.base) { + options.baseBranch = payload.base + } + return createRepo(options) + }, onSuccess: () => { invalidateRepoGitCaches(queryClient, repoId) showToast.success('Worktree created') @@ -78,19 +117,44 @@ export function CreateWorktreeDialog({ }) const handleCreate = () => { - const trimmed = branchName.trim() - if (!trimmed) { + if (mode === 'existing') { + if (!existingBranch) { + setError('Select a branch to check out') + return + } + setError(null) + worktreeMutation.mutate({ branch: existingBranch }) + return + } + + if (!trimmedBranchName) { setError('Branch name is required') return } + if (newBranchConflict) { + setError('Use Existing branch instead') + return + } if (!baseBranch) { setError('Base branch is required') return } setError(null) - worktreeMutation.mutate({ branch: trimmed, base: baseBranch }) + worktreeMutation.mutate({ branch: trimmedBranchName, base: baseBranch }) + } + + const selectMode = (nextMode: WorktreeMode) => { + setMode(nextMode) + setError(null) } + const canSubmit = + canCreate && + !worktreeMutation.isPending && + (mode === 'new' + ? Boolean(trimmedBranchName) && Boolean(baseBranch) && !newBranchConflict + : Boolean(existingBranch)) + return ( @@ -100,7 +164,7 @@ export function CreateWorktreeDialog({ Create Worktree - Create a separate workspace for a new branch. The worktree is managed as its own repo entry. + Create a separate workspace for a new or existing branch. The worktree is managed as its own repo entry. @@ -114,56 +178,121 @@ export function CreateWorktreeDialog({ ) : ( <> -
- - setBranchName(e.target.value)} - autoFocus - onKeyDown={(e) => { - if (e.key === 'Enter' && !worktreeMutation.isPending) handleCreate() - }} - /> +
+ +
-
- - setBranchName(e.target.value)} + autoFocus + onKeyDown={(e) => { + if (e.key === 'Enter' && !worktreeMutation.isPending) handleCreate() + }} + /> + {newBranchConflict && ( +

Use Existing branch instead

+ )} +
+ +
+ + -

- The new branch will be created from this branch. Ignored if the branch name already exists locally or on the remote. -

-
+ + +

+ The new branch will be created from this branch. +

+
+ + ) : ( +
+ + +

+ The worktree will check out the selected branch. Remote branches are limited to origin. +

+
+ )} )} @@ -184,7 +313,7 @@ export function CreateWorktreeDialog({ + ) : null, +})) + +function buildPreferences(overrides: Partial = {}): UserPreferences { + return { + theme: 'dark', + mode: 'build', + autoScroll: true, + expandDiffs: true, + expandToolCalls: false, + showReasoning: false, + simpleChatMode: false, + keyboardShortcuts: {}, + customCommands: [], + gitCredentials: [], + gitIdentity: { name: '', email: '' }, + gitIdentities: [], + ...overrides, + } +} + +let serverPreferences: UserPreferences + +function setupServer(initial: UserPreferences) { + serverPreferences = initial + getSettingsMock.mockImplementation(async () => ({ preferences: serverPreferences, updatedAt: Date.now() })) + updateSettingsMock.mockImplementation(async (request: { preferences: Partial }) => { + serverPreferences = { ...serverPreferences, ...request.preferences } + return { preferences: serverPreferences, updatedAt: Date.now() } + }) +} + +function renderGitSettings(repos: unknown[] = []) { + listReposMock.mockResolvedValue(repos) + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }) + function Wrapper({ children }: { children: ReactNode }) { + return {children} + } + return { queryClient, ...render(, { wrapper: Wrapper }) } +} + +function defaultIdentityNameInput() { + return screen.getByLabelText('Name', { selector: 'input#git-name' }) +} + +function savedIdentityNameInput(index = 0) { + return screen.getByLabelText('Name', { selector: `input#git-identity-${index}-name` }) +} + +describe('GitSettings draft preservation', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('keeps default identity edits when a saved identity is removed', async () => { + const user = userEvent.setup() + setupServer( + buildPreferences({ + gitIdentity: { name: 'Old Name', email: 'old@example.com' }, + gitIdentities: [{ id: 'a', name: 'Alpha', email: 'alpha@example.com' }], + }), + ) + renderGitSettings() + + const nameInput = await screen.findByLabelText('Name', { selector: 'input#git-name' }) + await user.clear(nameInput) + await user.type(nameInput, 'New Name') + expect(nameInput).toHaveValue('New Name') + + await user.click(screen.getByTitle('Delete')) + + await waitFor(() => expect(screen.queryByText('Alpha')).not.toBeInTheDocument()) + expect(defaultIdentityNameInput()).toHaveValue('New Name') + expect(screen.getByRole('button', { name: /save changes/i })).toBeInTheDocument() + }) + + it('keeps saved identity drafts when a credential is saved', async () => { + const user = userEvent.setup() + setupServer(buildPreferences()) + renderGitSettings() + + await screen.findByLabelText('Name', { selector: 'input#git-name' }) + + await user.click(screen.getAllByRole('button', { name: 'Add' })[1]) + const identityNameInput = await screen.findByLabelText('Name', { selector: 'input#git-identity-0-name' }) + await user.type(identityNameInput, 'Draft Person') + + await user.click(screen.getAllByRole('button', { name: 'Add' })[0]) + await user.click(await screen.findByRole('button', { name: 'Confirm credential save' })) + + await waitFor(() => expect(updateSettingsMock).toHaveBeenCalled()) + expect(savedIdentityNameInput()).toHaveValue('Draft Person') + expect(screen.getByRole('button', { name: /save changes/i })).toBeInTheDocument() + }) + + it('keeps saved identity drafts when a credential is deleted', async () => { + const user = userEvent.setup() + setupServer( + buildPreferences({ + gitCredentials: [{ id: 'cred-1', name: 'Work', host: 'github.com', type: 'pat', token: 'tok' }], + }), + ) + renderGitSettings() + + await screen.findByLabelText('Name', { selector: 'input#git-name' }) + + await user.click(screen.getAllByRole('button', { name: 'Add' })[1]) + const identityNameInput = await screen.findByLabelText('Name', { selector: 'input#git-identity-0-name' }) + await user.type(identityNameInput, 'Draft Person') + + await user.click(screen.getAllByTitle('Delete')[0]) + + await waitFor(() => expect(updateSettingsMock).toHaveBeenCalled()) + expect(savedIdentityNameInput()).toHaveValue('Draft Person') + expect(screen.getByRole('button', { name: /save changes/i })).toBeInTheDocument() + }) + + it('removes a saved identity without touching repositories', async () => { + const user = userEvent.setup() + const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true) + setupServer( + buildPreferences({ + gitIdentities: [{ id: 'a', name: 'Alpha', email: 'alpha@example.com' }], + }), + ) + renderGitSettings([ + { + id: 1, + localPath: 'repo-one', + fullPath: '/workspace/repo-one', + defaultBranch: 'main', + cloneStatus: 'ready', + clonedAt: Date.now(), + }, + ]) + + await screen.findByLabelText('Name', { selector: 'input#git-name' }) + await user.click(await screen.findByTitle('Delete')) + + await waitFor(() => expect(screen.queryByText('Alpha')).not.toBeInTheDocument()) + expect(confirmSpy).not.toHaveBeenCalled() + expect(updateRepoGitCredentialMock).not.toHaveBeenCalled() + expect(listReposMock).toHaveBeenCalledTimes(1) + }) + + it('invalidates the repo git identity cache after saving', async () => { + const user = userEvent.setup() + setupServer(buildPreferences()) + const { queryClient } = renderGitSettings() + const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries') + + const nameInput = await screen.findByLabelText('Name', { selector: 'input#git-name' }) + await user.type(nameInput, 'New Name') + await user.click(screen.getByRole('button', { name: /save changes/i })) + + await waitFor(() => expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['repoGitIdentity'] })) + }) + + it('retains identity drafts when saving fails', async () => { + const user = userEvent.setup() + setupServer(buildPreferences()) + updateSettingsMock.mockRejectedValueOnce(new Error('network down')) + renderGitSettings() + + const nameInput = await screen.findByLabelText('Name', { selector: 'input#git-name' }) + await user.type(nameInput, 'Draft Name') + + await user.click(screen.getByRole('button', { name: /save changes/i })) + + await waitFor(() => expect(showToastMock.error).toHaveBeenCalled()) + expect(defaultIdentityNameInput()).toHaveValue('Draft Name') + expect(screen.getByRole('button', { name: /save changes/i })).toBeInTheDocument() + }) +}) diff --git a/frontend/src/components/settings/GitSettings.tsx b/frontend/src/components/settings/GitSettings.tsx index 50689947b..f5f7ddcea 100644 --- a/frontend/src/components/settings/GitSettings.tsx +++ b/frontend/src/components/settings/GitSettings.tsx @@ -1,7 +1,7 @@ -import { useState, useEffect } from 'react' +import { useState, useEffect, useRef } from 'react' import { useQuery, useQueryClient } from '@tanstack/react-query' import { useSettings } from '@/hooks/useSettings' -import { Loader2, Plus, Trash2, Save, User, Key, Pencil } from 'lucide-react' +import { Loader2, Plus, Trash2, Save, User, Users, Key, Pencil } from 'lucide-react' import { showToast } from '@/lib/toast' import { GitCredentialDialog, type GitCredentialSaveOptions } from './GitCredentialDialog' import { RestartServerDialog } from './RestartServerDialog' @@ -9,7 +9,7 @@ import { Label } from '@/components/ui/label' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { randomId } from '@/lib/utils' -import type { GitCredential, GitIdentity, UserPreferences } from '@/api/types/settings' +import type { GitCredential, GitIdentity, GitIdentityProfile, UserPreferences } from '@/api/types/settings' import { listRepos, updateRepoGitCredential } from '@/api/repos' import { useOpenCodeServerActions } from '@/hooks/useOpenCodeServerActions' @@ -22,11 +22,13 @@ export function GitSettings() { const queryClient = useQueryClient() const [gitCredentials, setGitCredentials] = useState([]) const [gitIdentity, setGitIdentity] = useState({ name: '', email: '' }) + const [gitIdentities, setGitIdentities] = useState([]) const [defaultGitCredentialId, setDefaultGitCredentialId] = useState() const [isSaving, setIsSaving] = useState(false) const [hasChanges, setHasChanges] = useState(false) const [isCredentialDialogOpen, setIsCredentialDialogOpen] = useState(false) const [editingCredentialIndex, setEditingCredentialIndex] = useState(null) + const identityDirtyRef = useRef(false) const { data: repos = [] } = useQuery({ queryKey: ['repos'], @@ -58,16 +60,25 @@ export function GitSettings() { useEffect(() => { if (preferences) { setGitCredentials(preferences.gitCredentials || []) - setGitIdentity(preferences.gitIdentity || { name: '', email: '' }) setDefaultGitCredentialId(preferences.defaultGitCredentialId) - setHasChanges(false) + if (!identityDirtyRef.current) { + setGitIdentity(preferences.gitIdentity || { name: '', email: '' }) + setGitIdentities(preferences.gitIdentities || []) + setHasChanges(false) + } } }, [preferences]) - const checkForIdentityChanges = (newIdentity: GitIdentity) => { + const identityListsEqual = (a: GitIdentityProfile[], b: GitIdentityProfile[]) => + a.length === b.length && a.every((entry, index) => + entry.id === b[index]?.id && entry.name === b[index]?.name && entry.email === b[index]?.email) + + const checkForIdentityChanges = (newIdentity: GitIdentity, newIdentities: GitIdentityProfile[]) => { const currentIdentity = preferences?.gitIdentity || { name: '', email: '' } const identityChanged = currentIdentity.name !== newIdentity.name || currentIdentity.email !== newIdentity.email - setHasChanges(identityChanged) + const identitiesChanged = !identityListsEqual(preferences?.gitIdentities || [], newIdentities) + identityDirtyRef.current = identityChanged || identitiesChanged + setHasChanges(identityChanged || identitiesChanged) } const openAddCredentialDialog = () => { @@ -122,7 +133,7 @@ export function GitSettings() { try { await saveGitSettings( - { gitCredentials: newCredentials, defaultGitCredentialId: nextDefaultGitCredentialId, gitIdentity }, + { gitCredentials: newCredentials, defaultGitCredentialId: nextDefaultGitCredentialId }, { successMessage: 'Credential saved', afterSave: () => syncRepoAssignments(nextCredential.id!, options.repoIds), @@ -144,7 +155,7 @@ export function GitSettings() { try { await saveGitSettings( - { gitCredentials: newCredentials, defaultGitCredentialId: nextDefaultGitCredentialId, gitIdentity }, + { gitCredentials: newCredentials, defaultGitCredentialId: nextDefaultGitCredentialId }, { successMessage: 'Credential deleted', afterSave: removedCredentialId ? () => syncRepoAssignments(removedCredentialId, []) : undefined, @@ -158,7 +169,25 @@ export function GitSettings() { const updateIdentity = (field: keyof GitIdentity, value: string) => { const newIdentity = { ...gitIdentity, [field]: value } setGitIdentity(newIdentity) - checkForIdentityChanges(newIdentity) + checkForIdentityChanges(newIdentity, gitIdentities) + } + + const updateSavedIdentity = (index: number, field: keyof GitIdentity, value: string) => { + const newIdentities = gitIdentities.map((identity, i) => i === index ? { ...identity, [field]: value } : identity) + setGitIdentities(newIdentities) + checkForIdentityChanges(gitIdentity, newIdentities) + } + + const addIdentity = () => { + const newIdentities = [...gitIdentities, { id: randomId(), name: '', email: '' }] + setGitIdentities(newIdentities) + checkForIdentityChanges(gitIdentity, newIdentities) + } + + const removeIdentity = (index: number) => { + const newIdentities = gitIdentities.filter((_, i) => i !== index) + setGitIdentities(newIdentities) + checkForIdentityChanges(gitIdentity, newIdentities) } const saveAll = async () => { @@ -166,9 +195,14 @@ export function GitSettings() { try { showToast.loading('Saving git configuration...', { id: 'git-config' }) await saveGitSettings( - { gitCredentials, defaultGitCredentialId, gitIdentity }, - { successMessage: 'Git configuration saved', toastId: 'git-config' } + { gitCredentials, defaultGitCredentialId, gitIdentity, gitIdentities }, + { + successMessage: 'Git configuration saved', + toastId: 'git-config', + } ) + await queryClient.invalidateQueries({ queryKey: ['repoGitIdentity'] }) + identityDirtyRef.current = false setHasChanges(false) } catch { showToast.error('Failed to save git configuration', { id: 'git-config' }) @@ -212,7 +246,7 @@ export function GitSettings() {
- Identity + Default identity {gitIdentity.name || gitIdentity.email ? `${gitIdentity.name || 'No name'} <${gitIdentity.email || 'No email'}>` : 'Not configured'} @@ -220,7 +254,7 @@ export function GitSettings() {

- Author identity used for git commits. Leave empty to use system defaults. + Used for commits by Manager and agents in repositories that have no identity of their own. Applies without restarting.

@@ -346,6 +380,88 @@ export function GitSettings() {
+
+
+ + Saved identities + + {gitIdentities.length} saved + +
+ +
+
+

+ Presets you can apply to a repository from the commit box's "Commit as" selector. This writes the repository's local git config, shared by its worktrees. +

+ +
+ + {gitIdentities.length === 0 ? ( +
+

+ No saved identities. Click "Add" to create one. +

+
+ ) : ( +
+ {gitIdentities.map((identity, index) => ( +
+
+ Identity {index + 1} + +
+
+
+ + updateSavedIdentity(index, 'name', e.target.value)} + disabled={isSaving} + className="bg-background border-border text-foreground placeholder:text-muted-foreground" + /> +
+
+ + updateSavedIdentity(index, 'email', e.target.value)} + disabled={isSaving} + className="bg-background border-border text-foreground placeholder:text-muted-foreground" + /> +
+
+
+ ))} +
+ )} +
+
+ { + class GitAuthError extends Error {} + return { + GitAuthError, + listBranches: vi.fn(), + getRepo: vi.fn(), + switchBranch: vi.fn(), + } +}) + +vi.mock('@/api/git', () => ({ + fetchGitStatus: vi.fn(), + useGitStatus: () => ({ data: undefined, dataUpdatedAt: 0 }), +})) + +vi.mock('@/hooks/useGit', () => ({ + useGit: () => ({ + createBranch: { isPending: false, mutateAsync }, + switchBranch: { isPending: false, mutateAsync }, + renameBranch: { isPending: false, mutateAsync }, + deleteBranch: { isPending: false, mutateAsync }, + integrateBranch: { isPending: false, mutate: vi.fn() }, + }), +})) + +vi.mock('@/lib/toast', () => ({ + showToast: { success: vi.fn(), error: vi.fn(), info: vi.fn(), warning: vi.fn() }, +})) + +vi.mock('@/components/repo/CreateWorktreeDialog', () => ({ + CreateWorktreeDialog: () => null, +})) + +import { listBranches, getRepo } from '@/api/repos' + +const createWrapper = () => { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }) + return ({ children }: { children: React.ReactNode }) => ( + {children} + ) +} + +beforeEach(() => { + vi.clearAllMocks() + vi.mocked(listBranches).mockResolvedValue({ + branches: [ + { name: 'main', type: 'local', current: true }, + { name: 'feature', type: 'local', current: false }, + ], + status: { ahead: 0, behind: 0 }, + }) + vi.mocked(getRepo).mockResolvedValue({ repoUrl: 'git@example.com:org/repo.git', isWorktree: false } as never) +}) + +describe('BranchesTab', () => { + it('gives the inline rename textbox a descriptive accessible name', async () => { + const user = userEvent.setup() + render(, { wrapper: createWrapper() }) + + await user.click(await screen.findByRole('button', { name: 'Actions for feature' })) + await user.click(await screen.findByRole('menuitem', { name: 'Rename' })) + + expect(screen.getByRole('textbox', { name: 'New name for branch feature' })).toBeInTheDocument() + }) +}) diff --git a/frontend/src/components/source-control/BranchesTab.tsx b/frontend/src/components/source-control/BranchesTab.tsx index f1aeeefc3..187a41fd0 100644 --- a/frontend/src/components/source-control/BranchesTab.tsx +++ b/frontend/src/components/source-control/BranchesTab.tsx @@ -4,12 +4,16 @@ import { listBranches, switchBranch, GitAuthError, getRepo } from '@/api/repos' import { fetchGitStatus } from '@/api/git' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' -import { Loader2, GitBranch, GitBranchPlus, Check, Plus, AlertCircle, Globe } from 'lucide-react' +import { Checkbox } from '@/components/ui/checkbox' +import { ConfirmDestructiveDialog } from '@/components/ui/confirm-destructive-dialog' +import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu' +import { Loader2, GitBranch, GitBranchPlus, GitMerge, Check, Plus, AlertCircle, Globe, MoreHorizontal, Pencil, Trash2 } from 'lucide-react' import { cn } from '@/lib/utils' import { showToast } from '@/lib/toast' import { useGit } from '@/hooks/useGit' import { GIT_UI_COLORS } from '@/lib/git-status-styles' import { CreateWorktreeDialog } from '@/components/repo/CreateWorktreeDialog' +import { IntegrateBranchDialog } from '@/components/source-control/IntegrateBranchDialog' import { invalidateRepoGitCaches, setRepoGitStatusCaches } from '@/lib/queryInvalidation' interface BranchesTabProps { @@ -17,12 +21,23 @@ interface BranchesTabProps { currentBranch: string } +interface BranchTarget { + name: string + upstream?: string +} + export function BranchesTab({ repoId, currentBranch }: BranchesTabProps) { const queryClient = useQueryClient() const [newBranchName, setNewBranchName] = useState('') const [searchQuery, setSearchQuery] = useState('') const [isCreating, setIsCreating] = useState(false) const [worktreeDialogOpen, setWorktreeDialogOpen] = useState(false) + const [integrateDialogOpen, setIntegrateDialogOpen] = useState(false) + const [renamingBranch, setRenamingBranch] = useState(null) + const [renameValue, setRenameValue] = useState('') + const [branchToDelete, setBranchToDelete] = useState(null) + const [forceDelete, setForceDelete] = useState(false) + const [deleteRemote, setDeleteRemote] = useState(false) const git = useGit(repoId) const { data: branches, isLoading, error, refetch } = useQuery({ @@ -82,6 +97,58 @@ export function BranchesTab({ repoId, currentBranch }: BranchesTabProps) { } } + const startRename = (branch: BranchTarget) => { + setRenamingBranch(branch.name) + setRenameValue(branch.name) + } + + const cancelRename = () => { + setRenamingBranch(null) + setRenameValue('') + } + + const handleRename = async () => { + const nextName = renameValue.trim() + if (!renamingBranch || !nextName || nextName === renamingBranch) { + cancelRename() + return + } + + try { + await git.renameBranch.mutateAsync({ from: renamingBranch, to: nextName }) + cancelRename() + } catch { + // Error handled by mutation + } + } + + const startDelete = (branch: BranchTarget) => { + setBranchToDelete({ name: branch.name, upstream: branch.upstream }) + setForceDelete(false) + setDeleteRemote(false) + } + + const closeDelete = () => { + setBranchToDelete(null) + setForceDelete(false) + setDeleteRemote(false) + } + + const handleDelete = async () => { + if (!branchToDelete) return + + try { + await git.deleteBranch.mutateAsync({ + name: branchToDelete.name, + force: forceDelete, + deleteRemote: branchToDelete.upstream ? deleteRemote : false, + }) + closeDelete() + } catch { + // Error handled by mutation + } + } + if (isLoading) { return (
@@ -178,6 +245,18 @@ export function BranchesTab({ repoId, currentBranch }: BranchesTabProps) { Worktree )} + {isRepoWorktree && ( + + )}
)}
@@ -202,32 +281,91 @@ export function BranchesTab({ repoId, currentBranch }: BranchesTabProps) { switchBranchMutation.mutate(checkoutName) } + if (renamingBranch === branch.name) { + return ( +
+ setRenameValue(e.target.value)} + className="h-10 md:h-8 md:text-sm flex-1 min-w-0" + aria-label={`New name for branch ${renamingBranch}`} + autoFocus + onKeyDown={(e) => { + if (e.key === 'Enter') handleRename() + if (e.key === 'Escape') cancelRename() + }} + /> + + +
+ ) + } + return ( - + {!isRemote && ( + + + + + + startRename(branch)} disabled={isCheckedOutElsewhere}> + + Rename + + startDelete(branch)} + disabled={isCurrent || !!branch.isWorktree} + > + + Delete + + + )} - {branch.type === 'local' && !branch.upstream && !branch.isWorktree && !(isCurrent && isRepoWorktree) && ( - local - )} - {isCurrent && } - +
) })}
@@ -250,6 +388,46 @@ export function BranchesTab({ repoId, currentBranch }: BranchesTabProps) { repoUrl={repoUrl} defaultBaseBranch={activeBranch} /> + + + + { + if (!open) closeDelete() + }} + onConfirm={handleDelete} + onCancel={closeDelete} + title={`Delete branch "${branchToDelete?.name ?? ''}"?`} + description="This removes the local branch. This action cannot be undone." + confirmLabel="Delete" + pendingLabel="Deleting..." + isPending={git.deleteBranch.isPending} + > +
+ + {branchToDelete?.upstream && ( + + )} +
+
) } diff --git a/frontend/src/components/source-control/ChangesTab.tsx b/frontend/src/components/source-control/ChangesTab.tsx index 5d6584508..aa03019a6 100644 --- a/frontend/src/components/source-control/ChangesTab.tsx +++ b/frontend/src/components/source-control/ChangesTab.tsx @@ -2,11 +2,12 @@ import { useState } from 'react' import { useGitStatus } from '@/api/git' import { useGit } from '@/hooks/useGit' import { GitFlatFileList } from './GitFlatFileList' +import { RepoGitIdentitySelect } from './RepoGitIdentitySelect' import { Button } from '@/components/ui/button' import { Textarea } from '@/components/ui/textarea' import { FileDiffView } from '@/components/file-browser/FileDiffView' import { DiscardDialog } from '@/components/ui/discard-dialog' -import { Loader2, GitCommit, FileText, AlertCircle } from 'lucide-react' +import { Loader2, GitCommit, FileText, AlertCircle, Sparkles } from 'lucide-react' interface ChangesTabProps { repoId: number @@ -60,6 +61,16 @@ export function ChangesTab({ repoId, onFileSelect, onClearFileSelection, selecte setCommitMessage('') } + const handleGenerateMessage = () => { + git.generateCommitMessage.mutate(undefined, { + onSuccess: ({ message }) => { + if (commitMessage.trim() === '' || window.confirm('Replace the current commit message?')) { + setCommitMessage(message) + } + }, + }) + } + if (isLoading) { return (
@@ -129,17 +140,36 @@ export function ChangesTab({ repoId, onFileSelect, onClearFileSelection, selecte {status.hasChanges && (
-