From aeaf0dece48a6357472a50a6fae9057b013f2c97 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Wed, 20 May 2026 20:11:38 -0700 Subject: [PATCH] Create PRs from Source Control (#2478) * Create PRs directly from Source Control - Replace the modal flow with an inline PR composer in the sidebar - Keep PR creation state and validation scoped per worktree - Rename the recovery action to clarify it only pushes before creating PRs * Clean up fork PR remotes after worktree deletion - Track Orca-created push target remotes in worktree metadata - Reuse ownership markers when later worktrees share the same fork remote - Fetch only the selected PR base instead of every remote before drafting PRs - Mirror local branch cleanup for SSH worktree deletion * Stabilize pull request creation flow - Keep PR actions and composer fields locked while generation or creation is in flight - Refresh git status, branch comparison, and history after remote actions settle - Disable push-only actions on diverged branches so users sync first * Make PR context generation read-only - Stop rebasing or probing HEAD before collecting PR draft context - Allow git operations on known repo roots without refreshing worktree cache Co-authored-by: Orca * fix: address review findings --------- Co-authored-by: Orca --- src/main/git/remote.test.ts | 15 + src/main/git/remote.ts | 12 +- src/main/git/upstream.test.ts | 25 +- src/main/git/upstream.ts | 21 +- src/main/ipc/filesystem-auth.ts | 14 +- src/main/ipc/filesystem.test.ts | 12 + src/main/ipc/filesystem.ts | 9 +- src/main/ipc/worktree-remote.ts | 222 +++- src/main/ipc/worktrees.test.ts | 164 ++- src/main/ipc/worktrees.ts | 22 + src/main/providers/ssh-git-provider.test.ts | 11 + src/main/providers/ssh-git-provider.ts | 10 +- src/main/providers/types.ts | 7 +- src/main/repo-worktrees.ts | 5 + src/main/runtime/orca-runtime-git.ts | 11 +- src/main/runtime/orca-runtime.ts | 28 +- src/main/runtime/rpc/methods/git-params.ts | 1 + src/main/runtime/rpc/methods/git.test.ts | 24 +- src/main/runtime/rpc/methods/git.ts | 7 +- src/main/runtime/runtime-rpc.test.ts | 4 +- .../pull-request-context.test.ts | 229 +++- .../text-generation/pull-request-context.ts | 126 +- src/preload/api-types.ts | 1 + src/preload/index.ts | 1 + src/relay/git-handler-worktree-ops.test.ts | 83 ++ src/relay/git-handler-worktree-ops.ts | 60 + src/relay/git-handler.ts | 33 +- .../CommitArea.chevron-spinner.test.tsx | 9 +- .../CommitArea.primary-icons.test.tsx | 7 +- .../right-sidebar/CommitArea.test.tsx | 7 +- .../SourceControl.commit-drafts.test.ts | 36 + .../right-sidebar/SourceControl.tsx | 1031 +++++++++++++---- .../right-sidebar/git-status-refresh.test.ts | 28 +- .../right-sidebar/git-status-refresh.ts | 10 + .../source-control-dropdown-items.test.ts | 102 +- .../source-control-dropdown-items.ts | 120 +- .../source-control-primary-action.test.ts | 22 + .../source-control-primary-action.ts | 15 + .../right-sidebar/useGitStatusPolling.test.ts | 4 +- .../src/runtime/runtime-git-client.ts | 16 +- src/renderer/src/store/slices/editor.test.ts | 72 +- src/renderer/src/store/slices/editor.ts | 52 +- src/shared/git-status-types.ts | 4 + src/shared/git-upstream-status.ts | 20 + src/shared/types.ts | 2 + tests/e2e/source-control-create-pr.spec.ts | 36 +- 46 files changed, 2332 insertions(+), 418 deletions(-) create mode 100644 src/relay/git-handler-worktree-ops.test.ts create mode 100644 src/shared/git-upstream-status.ts diff --git a/src/main/git/remote.test.ts b/src/main/git/remote.test.ts index 0b9d6e655..3b45cff98 100644 --- a/src/main/git/remote.test.ts +++ b/src/main/git/remote.test.ts @@ -70,6 +70,21 @@ describe('git remote operations', () => { ]) }) + it('passes --force-with-lease when requested', async () => { + gitExecFileAsyncMock + .mockResolvedValueOnce({ stdout: 'feature\n', stderr: '' }) + .mockResolvedValueOnce({ stdout: 'origin\n', stderr: '' }) + .mockResolvedValueOnce({ stdout: 'refs/heads/feature\n', stderr: '' }) + .mockResolvedValueOnce({ stdout: '', stderr: '' }) + + await gitPush('/repo', false, undefined, { forceWithLease: true }) + + expect(gitExecFileAsyncMock).toHaveBeenLastCalledWith( + ['push', '--force-with-lease', '--set-upstream', 'origin', 'HEAD:feature'], + { cwd: '/repo' } + ) + }) + it('maps non-fast-forward push failures to an actionable message', async () => { gitExecFileAsyncMock .mockRejectedValueOnce(new Error('no branch')) diff --git a/src/main/git/remote.ts b/src/main/git/remote.ts index ce9f2c9a3..8735a8f41 100644 --- a/src/main/git/remote.ts +++ b/src/main/git/remote.ts @@ -42,7 +42,8 @@ function explicitPushTarget(target: GitPushTarget): { remote: string; refspec: s export async function gitPush( worktreePath: string, _publish = false, - pushTarget?: GitPushTarget + pushTarget?: GitPushTarget, + options: { forceWithLease?: boolean } = {} ): Promise { try { if (pushTarget) { @@ -61,9 +62,12 @@ export async function gitPush( const target = pushTarget ? explicitPushTarget(pushTarget) : await getConfiguredPushTarget(worktreePath) - const args = target - ? ['push', '--set-upstream', target.remote, target.refspec] - : ['push', '--set-upstream', 'origin', 'HEAD'] + const args = [ + 'push', + ...(options.forceWithLease ? ['--force-with-lease'] : []), + '--set-upstream', + ...(target ? [target.remote, target.refspec] : ['origin', 'HEAD']) + ] await gitExecFileAsync(args, { cwd: worktreePath }) } catch (error) { throw new Error(normalizeGitErrorMessage(error, 'push')) diff --git a/src/main/git/upstream.test.ts b/src/main/git/upstream.test.ts index 9f8a0ddc5..1ec56a434 100644 --- a/src/main/git/upstream.test.ts +++ b/src/main/git/upstream.test.ts @@ -25,6 +25,7 @@ describe('getUpstreamStatus', () => { gitExecFileAsyncMock .mockResolvedValueOnce({ stdout: 'origin/main\n' }) .mockResolvedValueOnce({ stdout: '2\t3\n' }) + .mockResolvedValueOnce({ stdout: '+ abc123 remote work\n' }) const result = await getUpstreamStatus('/repo') @@ -32,7 +33,29 @@ describe('getUpstreamStatus', () => { hasUpstream: true, upstreamName: 'origin/main', ahead: 2, - behind: 3 + behind: 3, + behindCommitsArePatchEquivalent: false + }) + }) + + it('marks diverged upstream commits as patch-equivalent after a rebase', async () => { + gitExecFileAsyncMock + .mockResolvedValueOnce({ stdout: 'origin/feature\n' }) + .mockResolvedValueOnce({ stdout: '14\t3\n' }) + .mockResolvedValueOnce({ + stdout: + '= ac503deae Stabilize pull request creation flow\n' + + '= 7dc0fc1a6 Clean up fork PR remotes after worktree deletion\n' + }) + + const result = await getUpstreamStatus('/repo') + + expect(result).toEqual({ + hasUpstream: true, + upstreamName: 'origin/feature', + ahead: 14, + behind: 3, + behindCommitsArePatchEquivalent: true }) }) diff --git a/src/main/git/upstream.ts b/src/main/git/upstream.ts index 4b2146edc..5c144b4f3 100644 --- a/src/main/git/upstream.ts +++ b/src/main/git/upstream.ts @@ -1,7 +1,22 @@ import type { GitUpstreamStatus } from '../../shared/types' +import { upstreamOnlyCommitsArePatchEquivalent } from '../../shared/git-upstream-status' import { isNoUpstreamError, normalizeGitErrorMessage } from '../../shared/git-remote-error' import { gitExecFileAsync } from './runner' +async function getBehindCommitsArePatchEquivalent(worktreePath: string): Promise { + try { + const { stdout } = await gitExecFileAsync( + ['log', '--oneline', '--cherry-mark', '--right-only', 'HEAD...@{u}', '--'], + { cwd: worktreePath } + ) + return upstreamOnlyCommitsArePatchEquivalent(stdout) + } catch { + // Why: patch-equivalence is an optimization for the rebase case. If the + // probe fails, keep the conservative pull-first behavior. + return false + } +} + export async function getUpstreamStatus(worktreePath: string): Promise { try { const { stdout: upstreamStdout } = await gitExecFileAsync( @@ -35,11 +50,15 @@ export async function getUpstreamStatus(worktreePath: string): Promise 0 && behind > 0 ? await getBehindCommitsArePatchEquivalent(worktreePath) : undefined + return { hasUpstream: true, upstreamName, ahead, - behind + behind, + ...(behindCommitsArePatchEquivalent !== undefined ? { behindCommitsArePatchEquivalent } : {}) } } catch (error) { // Why: we only swallow clearly-no-upstream signals — that's an expected diff --git a/src/main/ipc/filesystem-auth.ts b/src/main/ipc/filesystem-auth.ts index 216db515c..459fd8ac5 100644 --- a/src/main/ipc/filesystem-auth.ts +++ b/src/main/ipc/filesystem-auth.ts @@ -2,7 +2,7 @@ import { resolve, relative, dirname, basename, isAbsolute } from 'path' import { realpathSync } from 'fs' import { realpath } from 'fs/promises' import type { Store } from '../persistence' -import { listRepoWorktrees } from '../repo-worktrees' +import { isRepoRoot, listRepoWorktrees } from '../repo-worktrees' export const PATH_ACCESS_DENIED_MESSAGE = 'Access denied: path resolves outside allowed directories. If this blocks a legitimate workflow, please file a GitHub issue.' @@ -271,12 +271,9 @@ async function isPathAllowedIncludingRegisteredWorktrees( /** * Resolve and verify that a worktree path belongs to a registered repo. * - * Why this doesn't use resolveAuthorizedPath: linked worktrees can live - * anywhere on disk (e.g. ~/.codex/worktrees/), far outside the repo root - * and workspaceDir that resolveAuthorizedPath allows. The security boundary - * for git operations is *worktree registration* — the path must match a - * worktree reported by `git worktree list` for a known repo — not - * directory containment within allowed roots. + * Why this doesn't use resolveAuthorizedPath: linked worktrees can live outside + * repo/workspace roots. Git operations trust exact worktree registration from + * `git worktree list`, not directory containment. */ export async function resolveRegisteredWorktreePath( worktreePath: string, @@ -289,8 +286,7 @@ export async function resolveRegisteredWorktreePath( } const resolvedTarget = resolve(worktreePath) - - if (registeredWorktreeRoots.has(resolvedTarget)) { + if (registeredWorktreeRoots.has(resolvedTarget) || isRepoRoot(store.getRepos(), resolvedTarget)) { return resolvedTarget } diff --git a/src/main/ipc/filesystem.test.ts b/src/main/ipc/filesystem.test.ts index 6679e5cff..3615d8d43 100644 --- a/src/main/ipc/filesystem.test.ts +++ b/src/main/ipc/filesystem.test.ts @@ -510,6 +510,18 @@ describe('registerFilesystemHandlers', () => { expect(getStatusMock).toHaveBeenCalledWith(WORKTREE_FEATURE_PATH, { includeIgnored: false }) }) + it('allows git operations on the known repo root without rebuilding the worktree cache', async () => { + getStatusMock.mockResolvedValue({ entries: [] }) + + registerFilesystemHandlers(store as never) + + await handlers.get('git:status')!(null, { worktreePath: REPO_PATH }) + + expect(listWorktreesMock).not.toHaveBeenCalled() + expect(realpathMock).not.toHaveBeenCalledWith(REPO_PATH) + expect(getStatusMock).toHaveBeenCalledWith(REPO_PATH, { includeIgnored: false }) + }) + it('forwards includeIgnored through local and SSH git status IPC', async () => { registerWorktreeRootsForRepo(store as never, 'repo-1', [REPO_PATH, WORKTREE_FEATURE_PATH]) getStatusMock.mockResolvedValue({ entries: [], conflictOperation: 'unknown' }) diff --git a/src/main/ipc/filesystem.ts b/src/main/ipc/filesystem.ts index 0fc09dc71..fdf200717 100644 --- a/src/main/ipc/filesystem.ts +++ b/src/main/ipc/filesystem.ts @@ -930,6 +930,7 @@ export function registerFilesystemHandlers( args: { worktreePath: string publish?: boolean + forceWithLease?: boolean connectionId?: string pushTarget?: GitPushTarget } @@ -946,13 +947,17 @@ export function registerFilesystemHandlers( if (!provider) { throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) } - return provider.pushBranch(args.worktreePath, publish, args.pushTarget) + return provider.pushBranch(args.worktreePath, publish, args.pushTarget, { + forceWithLease: args.forceWithLease === true + }) } const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) if (args.pushTarget) { await validateGitPushTarget(worktreePath, args.pushTarget) } - await gitPush(worktreePath, publish, args.pushTarget) + await gitPush(worktreePath, publish, args.pushTarget, { + forceWithLease: args.forceWithLease === true + }) } ) diff --git a/src/main/ipc/worktree-remote.ts b/src/main/ipc/worktree-remote.ts index 9d2afdf96..aba7fd59c 100644 --- a/src/main/ipc/worktree-remote.ts +++ b/src/main/ipc/worktree-remote.ts @@ -44,6 +44,7 @@ import { mergeWorktree, areWorktreePathsEqual } from './worktree-logic' +import { getRepoIdFromWorktreeId } from '../../shared/worktree-id' import { invalidateAuthorizedRootsCache } from './filesystem-auth' import { createWorktreeSymlinks } from './worktree-symlinks' import { normalizeSparseDirectories } from './sparse-checkout-directories' @@ -140,17 +141,34 @@ async function ensureUniqueRemoteName(repoPath: string, preferred: string): Prom export async function prepareWorktreePushTarget( repoPath: string, - target: GitPushTarget + target: GitPushTarget, + store?: WorktreePushTargetStore, + repoId?: string ): Promise { await validateGitPushTarget(repoPath, target) + const { remoteCreated: _ignoredRemoteCreated, ...sanitizedTarget } = target let remoteName = target.remoteName + let remoteCreated = false if (target.remoteUrl) { const existingRemote = await findRemoteForUrl(repoPath, target.remoteUrl) if (existingRemote) { remoteName = existingRemote + // Why: if a later PR worktree reuses an Orca-created fork remote, it + // must inherit ownership so deleting the final user can remove it. + remoteCreated = store + ? isPushTargetRemoteCreatedByKnownWorktree( + store, + { + ...target, + remoteName: existingRemote + }, + repoId + ) + : false } else { remoteName = await ensureUniqueRemoteName(repoPath, target.remoteName) await gitExecFileAsync(['remote', 'add', remoteName, target.remoteUrl], { cwd: repoPath }) + remoteCreated = true } } @@ -163,8 +181,152 @@ export async function prepareWorktreePushTarget( { cwd: repoPath } ) return { - ...target, - remoteName + ...sanitizedTarget, + remoteName, + ...(remoteCreated ? { remoteCreated: true } : {}) + } +} + +type GitRemoteExec = (args: string[], cwd: string) => Promise<{ stdout: string; stderr?: string }> +type WorktreePushTargetStore = Pick + +function sameGitHubRemoteUrl(left: string, right: string): boolean { + if (left === right) { + return true + } + const parsedLeft = parseGitHubOwnerRepo(left) + const parsedRight = parseGitHubOwnerRepo(right) + return Boolean( + parsedLeft && + parsedRight && + parsedLeft.owner.toLowerCase() === parsedRight.owner.toLowerCase() && + parsedLeft.repo.toLowerCase() === parsedRight.repo.toLowerCase() + ) +} + +function isPushTargetUsedByAnotherWorktree( + store: WorktreePushTargetStore, + removedWorktreeId: string, + target: GitPushTarget +): boolean { + const removedRepoId = getRepoIdFromWorktreeId(removedWorktreeId) + return Object.entries(store.getAllWorktreeMeta()).some(([worktreeId, meta]) => { + // Why: git remotes are repo-local; matching metadata from another repo + // must not pin this repo's fork remote forever. + const belongsToSameRepo = getRepoIdFromWorktreeId(worktreeId) === removedRepoId + if (worktreeId === removedWorktreeId || !belongsToSameRepo || !meta.pushTarget) { + return false + } + const otherRemoteUrl = meta.pushTarget.remoteUrl + const targetRemoteUrl = target.remoteUrl + return ( + meta.pushTarget.remoteName === target.remoteName || + (typeof otherRemoteUrl === 'string' && + typeof targetRemoteUrl === 'string' && + sameGitHubRemoteUrl(otherRemoteUrl, targetRemoteUrl)) + ) + }) +} + +function isPushTargetRemoteCreatedByKnownWorktree( + store: WorktreePushTargetStore, + target: GitPushTarget, + repoId?: string +): boolean { + return Object.entries(store.getAllWorktreeMeta()).some(([worktreeId, meta]) => { + if (repoId && getRepoIdFromWorktreeId(worktreeId) !== repoId) { + return false + } + if (!meta.pushTarget?.remoteCreated) { + return false + } + const otherRemoteUrl = meta.pushTarget.remoteUrl + const targetRemoteUrl = target.remoteUrl + return ( + meta.pushTarget.remoteName === target.remoteName || + (typeof otherRemoteUrl === 'string' && + typeof targetRemoteUrl === 'string' && + sameGitHubRemoteUrl(otherRemoteUrl, targetRemoteUrl)) + ) + }) +} + +async function hasBranchConfigUsingRemote( + execGit: GitRemoteExec, + repoPath: string, + target: GitPushTarget +): Promise { + try { + const { stdout } = await execGit( + ['config', '--get-regexp', '^branch\\..*\\.(remote|pushRemote)$'], + repoPath + ) + return stdout + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean) + .some((line) => { + const value = line.split(/\s+/).slice(1).join(' ') + return value === target.remoteName || value === target.remoteUrl + }) + } catch { + return false + } +} + +async function cleanupUnusedWorktreePushTargetRemoteWithExec( + repoPath: string, + removedWorktreeId: string, + target: GitPushTarget | undefined, + store: WorktreePushTargetStore, + execGit: GitRemoteExec +): Promise { + if ( + !target?.remoteCreated || + !target.remoteUrl || + target.remoteName === 'origin' || + target.remoteName === 'upstream' + ) { + return + } + if (isPushTargetUsedByAnotherWorktree(store, removedWorktreeId, target)) { + return + } + if (await hasBranchConfigUsingRemote(execGit, repoPath, target)) { + return + } + + let configuredRemoteUrl: string + try { + configuredRemoteUrl = ( + await execGit(['remote', 'get-url', target.remoteName], repoPath) + ).stdout.trim() + } catch { + return + } + if (!sameGitHubRemoteUrl(configuredRemoteUrl, target.remoteUrl)) { + return + } + + await execGit(['remote', 'remove', target.remoteName], repoPath) +} + +export async function cleanupUnusedWorktreePushTargetRemote( + repoPath: string, + removedWorktreeId: string, + target: GitPushTarget | undefined, + store: WorktreePushTargetStore +): Promise { + try { + await cleanupUnusedWorktreePushTargetRemoteWithExec( + repoPath, + removedWorktreeId, + target, + store, + (args, cwd) => gitExecFileAsync(args, { cwd }) + ) + } catch (error) { + console.warn(`[worktrees] Failed to clean up fork PR remote for ${removedWorktreeId}`, error) } } @@ -244,18 +406,35 @@ async function ensureUniqueRemoteNameSsh( async function prepareWorktreePushTargetSsh( provider: SshGitProvider, repoPath: string, - target: GitPushTarget + target: GitPushTarget, + store?: WorktreePushTargetStore, + repoId?: string ): Promise { assertGitPushTargetShape(target) + const { remoteCreated: _ignoredRemoteCreated, ...sanitizedTarget } = target await provider.exec(['check-ref-format', '--branch', target.branchName], repoPath) let remoteName = target.remoteName + let remoteCreated = false if (target.remoteUrl) { const existingRemote = await findRemoteForUrlSsh(provider, repoPath, target.remoteUrl) if (existingRemote) { remoteName = existingRemote + // Why: if a later PR worktree reuses an Orca-created fork remote, it + // must inherit ownership so deleting the final user can remove it. + remoteCreated = store + ? isPushTargetRemoteCreatedByKnownWorktree( + store, + { + ...target, + remoteName: existingRemote + }, + repoId + ) + : false } else { remoteName = await ensureUniqueRemoteNameSsh(provider, repoPath, target.remoteName) await provider.exec(['remote', 'add', remoteName, target.remoteUrl], repoPath) + remoteCreated = true } } await provider.exec( @@ -266,7 +445,30 @@ async function prepareWorktreePushTargetSsh( ], repoPath ) - return { ...target, remoteName } + return { ...sanitizedTarget, remoteName, ...(remoteCreated ? { remoteCreated: true } : {}) } +} + +export async function cleanupUnusedWorktreePushTargetRemoteSsh( + provider: SshGitProvider, + repoPath: string, + removedWorktreeId: string, + target: GitPushTarget | undefined, + store: WorktreePushTargetStore +): Promise { + try { + await cleanupUnusedWorktreePushTargetRemoteWithExec( + repoPath, + removedWorktreeId, + target, + store, + (args, cwd) => provider.exec(args, cwd) + ) + } catch (error) { + console.warn( + `[worktrees] Failed to clean up remote fork PR remote for ${removedWorktreeId}`, + error + ) + } } async function configureCreatedWorktreePushTargetSsh( @@ -444,7 +646,13 @@ export async function createRemoteWorktree( if (args.pushTarget) { // Why: fork-PR SSH worktrees need the same contributor-remote setup as // local worktrees before creation, otherwise Push/Sync can target origin. - preparedPushTarget = await prepareWorktreePushTargetSsh(provider, repo.path, args.pushTarget) + preparedPushTarget = await prepareWorktreePushTargetSsh( + provider, + repo.path, + args.pushTarget, + store, + repo.id + ) } const mux = getActiveMultiplexer(repo.connectionId!) @@ -779,7 +987,7 @@ export async function createLocalWorktree( // Why: validate and fetch the contributor remote before creating the // worktree. If this fails, retrying won't hit branch/path conflicts from a // half-created worktree. - preparedPushTarget = await prepareWorktreePushTarget(repo.path, args.pushTarget) + preparedPushTarget = await prepareWorktreePushTarget(repo.path, args.pushTarget, store, repo.id) } await (sparseDirectories.length > 0 diff --git a/src/main/ipc/worktrees.test.ts b/src/main/ipc/worktrees.test.ts index 12d6033da..26e84f85d 100644 --- a/src/main/ipc/worktrees.test.ts +++ b/src/main/ipc/worktrees.test.ts @@ -621,11 +621,69 @@ describe('registerWorktreeHandlers', () => { expect(store.setWorktreeMeta).toHaveBeenCalledWith( 'repo-1::/workspace/improve-dashboard', expect.objectContaining({ - pushTarget: { + pushTarget: expect.objectContaining({ remoteName: 'pr-prateek-orca', branchName: 'prateek/fix-sidebar-agents-toggle', - remoteUrl: 'git@github.com:prateek/orca.git' - } + remoteUrl: 'git@github.com:prateek/orca.git', + remoteCreated: true + }) + }) + ) + }) + + it('keeps the Orca-created marker when a new worktree reuses an Orca-created fork remote', async () => { + listWorktreesMock.mockResolvedValue([ + { + path: '/workspace/improve-dashboard', + head: 'abc123', + branch: 'refs/heads/improve-dashboard', + isBare: false, + isMainWorktree: false + } + ]) + const existingPushTarget = { + remoteName: 'pr-contributor-orca', + branchName: 'contributor/previous-fix', + remoteUrl: 'https://github.com/contributor/orca.git', + remoteCreated: true + } + store.getAllWorktreeMeta.mockReturnValue({ + 'repo-1::/workspace/previous-fix': makeWorktreeMeta({ pushTarget: existingPushTarget }) + }) + store.setWorktreeMeta.mockImplementation((_worktreeId, meta) => meta) + gitExecFileAsyncMock.mockImplementation(async (args: string[]) => { + if (args[0] === 'remote' && args.length === 1) { + return { stdout: 'pr-contributor-orca\n', stderr: '' } + } + if (args[0] === 'remote' && args[1] === 'get-url') { + return { stdout: 'https://github.com/contributor/orca.git\n', stderr: '' } + } + return { stdout: '', stderr: '' } + }) + + await handlers['worktrees:create'](null, { + repoId: 'repo-1', + name: 'improve-dashboard', + pushTarget: { + remoteName: 'pr-contributor-orca', + branchName: 'contributor/new-fix', + remoteUrl: 'https://github.com/contributor/orca.git' + } + }) + + expect(gitExecFileAsyncMock).not.toHaveBeenCalledWith( + ['remote', 'add', expect.any(String), expect.any(String)], + expect.any(Object) + ) + expect(store.setWorktreeMeta).toHaveBeenCalledWith( + 'repo-1::/workspace/improve-dashboard', + expect.objectContaining({ + pushTarget: expect.objectContaining({ + remoteName: 'pr-contributor-orca', + branchName: 'contributor/new-fix', + remoteUrl: 'https://github.com/contributor/orca.git', + remoteCreated: true + }) }) ) }) @@ -2035,6 +2093,106 @@ describe('registerWorktreeHandlers', () => { ) }) + it('removes an unused Orca-created fork remote after deleting its worktree', async () => { + mockKnownFeatureWorktree() + removeWorktreeMock.mockResolvedValue(undefined) + const pushTarget = { + remoteName: 'pr-contributor-orca', + branchName: 'feature/from-fork', + remoteUrl: 'https://github.com/contributor/orca.git', + remoteCreated: true + } + store.getWorktreeMeta.mockReturnValue(makeWorktreeMeta({ pushTarget })) + store.getAllWorktreeMeta.mockReturnValue({ + 'repo-1::/workspace/feature-wt': makeWorktreeMeta({ pushTarget }) + }) + gitExecFileAsyncMock.mockImplementation(async (args: string[]) => { + if (args[0] === 'config') { + throw new Error('no branch config') + } + if (args[0] === 'remote' && args[1] === 'get-url') { + return { stdout: 'https://github.com/contributor/orca.git\n', stderr: '' } + } + return { stdout: '', stderr: '' } + }) + + await handlers['worktrees:remove'](null, { + worktreeId: 'repo-1::/workspace/feature-wt' + }) + + expect(gitExecFileAsyncMock).toHaveBeenCalledWith(['remote', 'remove', 'pr-contributor-orca'], { + cwd: '/workspace/repo' + }) + }) + + it('keeps an Orca-created fork remote while another worktree still uses it', async () => { + mockKnownFeatureWorktree() + removeWorktreeMock.mockResolvedValue(undefined) + const pushTarget = { + remoteName: 'pr-contributor-orca', + branchName: 'feature/from-fork', + remoteUrl: 'https://github.com/contributor/orca.git', + remoteCreated: true + } + store.getWorktreeMeta.mockReturnValue(makeWorktreeMeta({ pushTarget })) + store.getAllWorktreeMeta.mockReturnValue({ + 'repo-1::/workspace/feature-wt': makeWorktreeMeta({ pushTarget }), + 'repo-1::/workspace/other-wt': makeWorktreeMeta({ + pushTarget: { + ...pushTarget, + branchName: 'other-branch' + } + }) + }) + + await handlers['worktrees:remove'](null, { + worktreeId: 'repo-1::/workspace/feature-wt' + }) + + expect(gitExecFileAsyncMock).not.toHaveBeenCalledWith( + ['remote', 'remove', 'pr-contributor-orca'], + expect.any(Object) + ) + }) + + it('ignores matching push targets from other repos when deciding fork remote cleanup', async () => { + mockKnownFeatureWorktree() + removeWorktreeMock.mockResolvedValue(undefined) + const pushTarget = { + remoteName: 'pr-contributor-orca', + branchName: 'feature/from-fork', + remoteUrl: 'https://github.com/contributor/orca.git', + remoteCreated: true + } + store.getWorktreeMeta.mockReturnValue(makeWorktreeMeta({ pushTarget })) + store.getAllWorktreeMeta.mockReturnValue({ + 'repo-1::/workspace/feature-wt': makeWorktreeMeta({ pushTarget }), + 'repo-2::/workspace/other-wt': makeWorktreeMeta({ + pushTarget: { + ...pushTarget, + branchName: 'other-branch' + } + }) + }) + gitExecFileAsyncMock.mockImplementation(async (args: string[]) => { + if (args[0] === 'config') { + throw new Error('no branch config') + } + if (args[0] === 'remote' && args[1] === 'get-url') { + return { stdout: 'https://github.com/contributor/orca.git\n', stderr: '' } + } + return { stdout: '', stderr: '' } + }) + + await handlers['worktrees:remove'](null, { + worktreeId: 'repo-1::/workspace/feature-wt' + }) + + expect(gitExecFileAsyncMock).toHaveBeenCalledWith(['remote', 'remove', 'pr-contributor-orca'], { + cwd: '/workspace/repo' + }) + }) + it('rejects unregistered delete paths before teardown, hooks, or git removal', async () => { mockKnownFeatureWorktree('/workspace/real-feature') getEffectiveHooksMock.mockReturnValue({ diff --git a/src/main/ipc/worktrees.ts b/src/main/ipc/worktrees.ts index 829151bca..93801400e 100644 --- a/src/main/ipc/worktrees.ts +++ b/src/main/ipc/worktrees.ts @@ -51,6 +51,8 @@ import { joinWorktreeRelativePath } from '../runtime/runtime-relative-paths' import { createLocalWorktree, createRemoteWorktree, + cleanupUnusedWorktreePushTargetRemote, + cleanupUnusedWorktreePushTargetRemoteSsh, notifyWorktreesChanged } from './worktree-remote' import { @@ -603,9 +605,17 @@ export function registerWorktreeHandlers( worktreePath, registeredWorktrees ).path + const removedPushTarget = store.getWorktreeMeta(args.worktreeId)?.pushTarget if (repo.connectionId) { await provider!.removeWorktree(canonicalWorktreePath, args.force) + await cleanupUnusedWorktreePushTargetRemoteSsh( + provider!, + repo.path, + args.worktreeId, + removedPushTarget, + store + ) runtime.clearOptimisticReconcileToken(args.worktreeId) store.removeWorktreeMeta(args.worktreeId) deleteWorktreeHistoryDir(args.worktreeId) @@ -685,6 +695,12 @@ export function registerWorktreeHandlers( // list` continues to show the stale entry and the branch it had checked out // remains locked — other worktrees cannot check it out. await gitExecFileAsync(['worktree', 'prune'], { cwd: repo.path }).catch(() => {}) + await cleanupUnusedWorktreePushTargetRemote( + repo.path, + args.worktreeId, + removedPushTarget, + store + ) runtime.clearOptimisticReconcileToken(args.worktreeId) store.removeWorktreeMeta(args.worktreeId) deleteWorktreeHistoryDir(args.worktreeId) @@ -696,6 +712,12 @@ export function registerWorktreeHandlers( formatWorktreeRemovalError(error, canonicalWorktreePath, args.force ?? false) ) } + await cleanupUnusedWorktreePushTargetRemote( + repo.path, + args.worktreeId, + removedPushTarget, + store + ) runtime.clearOptimisticReconcileToken(args.worktreeId) store.removeWorktreeMeta(args.worktreeId) deleteWorktreeHistoryDir(args.worktreeId) diff --git a/src/main/providers/ssh-git-provider.test.ts b/src/main/providers/ssh-git-provider.test.ts index 4983f1429..32e71e97e 100644 --- a/src/main/providers/ssh-git-provider.test.ts +++ b/src/main/providers/ssh-git-provider.test.ts @@ -290,6 +290,17 @@ describe('SshGitProvider', () => { }) }) + it('pushBranch forwards force-with-lease mode', async () => { + await provider.pushBranch('/home/user/repo', false, undefined, { forceWithLease: true }) + + expect(mux.request).toHaveBeenCalledWith('git.push', { + worktreePath: '/home/user/repo', + publish: false, + pushTarget: undefined, + forceWithLease: true + }) + }) + it('pullBranch sends git.pull request', async () => { await provider.pullBranch('/home/user/repo') expect(mux.request).toHaveBeenCalledWith('git.pull', { diff --git a/src/main/providers/ssh-git-provider.ts b/src/main/providers/ssh-git-provider.ts index 513ae5312..859596aec 100644 --- a/src/main/providers/ssh-git-provider.ts +++ b/src/main/providers/ssh-git-provider.ts @@ -186,9 +186,15 @@ export class SshGitProvider implements IGitProvider { async pushBranch( worktreePath: string, publish = false, - pushTarget?: GitPushTarget + pushTarget?: GitPushTarget, + options: { forceWithLease?: boolean } = {} ): Promise { - await this.mux.request('git.push', { worktreePath, publish, pushTarget }) + await this.mux.request('git.push', { + worktreePath, + publish, + pushTarget, + ...(options.forceWithLease === true ? { forceWithLease: true } : {}) + }) } async pullBranch(worktreePath: string): Promise { diff --git a/src/main/providers/types.ts b/src/main/providers/types.ts index 143f7a99d..1e698ce2c 100644 --- a/src/main/providers/types.ts +++ b/src/main/providers/types.ts @@ -167,7 +167,12 @@ export type IGitProvider = { getBranchCompare(worktreePath: string, baseRef: string): Promise getCommitCompare(worktreePath: string, commitId: string): Promise getUpstreamStatus(worktreePath: string): Promise - pushBranch(worktreePath: string, publish?: boolean, pushTarget?: GitPushTarget): Promise + pushBranch( + worktreePath: string, + publish?: boolean, + pushTarget?: GitPushTarget, + options?: { forceWithLease?: boolean } + ): Promise pullBranch(worktreePath: string): Promise fetchRemote(worktreePath: string): Promise getBranchDiff( diff --git a/src/main/repo-worktrees.ts b/src/main/repo-worktrees.ts index 2469c4810..5a6b69254 100644 --- a/src/main/repo-worktrees.ts +++ b/src/main/repo-worktrees.ts @@ -1,8 +1,13 @@ import type { GitWorktreeInfo, Repo } from '../shared/types' +import { resolve } from 'path' import { listWorktrees } from './git/worktree' import { isFolderRepo } from '../shared/repo-kind' import { getSshGitProvider } from './providers/ssh-git-dispatch' +export function isRepoRoot(repos: Repo[], resolvedTarget: string): boolean { + return repos.some((repo) => !repo.connectionId && resolve(repo.path) === resolvedTarget) +} + export function createFolderWorktree(repo: Repo): GitWorktreeInfo { return { path: repo.path, diff --git a/src/main/runtime/orca-runtime-git.ts b/src/main/runtime/orca-runtime-git.ts index 2d6e90f19..4bb81ce18 100644 --- a/src/main/runtime/orca-runtime-git.ts +++ b/src/main/runtime/orca-runtime-git.ts @@ -239,7 +239,8 @@ export class RuntimeGitCommands { async pushRuntimeGit( worktreeSelector: string, publish?: boolean, - pushTarget?: GitPushTarget + pushTarget?: GitPushTarget, + forceWithLease?: boolean ): Promise<{ ok: true }> { const target = await this.host.resolveRuntimeGitTarget(worktreeSelector) const provider = target.connectionId ? getSshGitProvider(target.connectionId) : null @@ -247,10 +248,14 @@ export class RuntimeGitCommands { if (!provider) { throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) } - await provider.pushBranch(target.worktree.path, publish === true, pushTarget) + await provider.pushBranch(target.worktree.path, publish === true, pushTarget, { + forceWithLease: forceWithLease === true + }) return { ok: true } } - await gitPush(target.worktree.path, publish === true, pushTarget) + await gitPush(target.worktree.path, publish === true, pushTarget, { + forceWithLease: forceWithLease === true + }) return { ok: true } } diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index f34216677..0b762785e 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -269,6 +269,8 @@ import { DEFAULT_REPO_BADGE_COLOR, getDefaultVoiceSettings } from '../../shared/ import { listRepoWorktrees } from '../repo-worktrees' import { createWorktreeSymlinks } from '../ipc/worktree-symlinks' import { + cleanupUnusedWorktreePushTargetRemote, + cleanupUnusedWorktreePushTargetRemoteSsh, createRemoteWorktree, configureCreatedWorktreePushTarget, prepareWorktreePushTarget @@ -5989,7 +5991,12 @@ export class OrcaRuntimeService { // Why: fork-PR worktrees created through a remote runtime need the same // upstream target setup as local desktop creates, or Push would publish // to the wrong remote after the client/server split. - preparedPushTarget = await prepareWorktreePushTarget(repo.path, args.pushTarget) + preparedPushTarget = await prepareWorktreePushTarget( + repo.path, + args.pushTarget, + this.store, + repo.id + ) } await (sparseDirectories.length > 0 @@ -6818,6 +6825,13 @@ export class OrcaRuntimeService { if (repo.connectionId) { const provider = requireSshGitProvider(repo.connectionId) await provider.removeWorktree(worktree.path, force) + await cleanupUnusedWorktreePushTargetRemoteSsh( + provider, + repo.path, + worktree.id, + worktree.pushTarget, + this.store + ) this.clearOptimisticReconcileToken(worktree.id) this.store.removeWorktreeMeta(worktree.id) this.invalidateResolvedWorktreeCache() @@ -6894,6 +6908,12 @@ export class OrcaRuntimeService { // list` continues to show the stale entry and the branch it had checked out // remains locked — other worktrees cannot check it out. await gitExecFileAsync(['worktree', 'prune'], { cwd: repo.path }).catch(() => {}) + await cleanupUnusedWorktreePushTargetRemote( + repo.path, + worktree.id, + worktree.pushTarget, + this.store + ) this.clearOptimisticReconcileToken(worktree.id) this.store.removeWorktreeMeta(worktree.id) this.invalidateResolvedWorktreeCache() @@ -6906,6 +6926,12 @@ export class OrcaRuntimeService { throw new Error(formatWorktreeRemovalError(error, worktree.path, force)) } + await cleanupUnusedWorktreePushTargetRemote( + repo.path, + worktree.id, + worktree.pushTarget, + this.store + ) this.clearOptimisticReconcileToken(worktree.id) this.store.removeWorktreeMeta(worktree.id) this.invalidateResolvedWorktreeCache() diff --git a/src/main/runtime/rpc/methods/git-params.ts b/src/main/runtime/rpc/methods/git-params.ts index db6b5cbce..c614ac913 100644 --- a/src/main/runtime/rpc/methods/git-params.ts +++ b/src/main/runtime/rpc/methods/git-params.ts @@ -124,6 +124,7 @@ export const GitBulkPaths = WorktreeSelector.extend({ export const GitPush = WorktreeSelector.extend({ publish: z.boolean().optional(), + forceWithLease: z.boolean().optional(), pushTarget: z.unknown().optional() }) diff --git a/src/main/runtime/rpc/methods/git.test.ts b/src/main/runtime/rpc/methods/git.test.ts index 60d2c9542..efa6de5e5 100644 --- a/src/main/runtime/rpc/methods/git.test.ts +++ b/src/main/runtime/rpc/methods/git.test.ts @@ -237,10 +237,32 @@ describe('git RPC methods', () => { agentCmdOverrides: { cursor: 'cursor-agent' } }) expect(runtime.cancelRuntimeGenerateCommitMessage).toHaveBeenCalledWith('id:wt-1') - expect(runtime.pushRuntimeGit).toHaveBeenCalledWith('id:wt-1', true, { remote: 'origin' }) + expect(runtime.pushRuntimeGit).toHaveBeenCalledWith( + 'id:wt-1', + true, + { remote: 'origin' }, + undefined + ) expect(response).toMatchObject({ ok: true, result: 'https://example.com/file#L3' }) }) + it('forwards force-with-lease push mode to the runtime', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + pushRuntimeGit: vi.fn().mockResolvedValue({ ok: true }) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: GIT_METHODS }) + + await dispatcher.dispatch( + makeRequest('git.push', { + worktree: 'id:wt-1', + forceWithLease: true + }) + ) + + expect(runtime.pushRuntimeGit).toHaveBeenCalledWith('id:wt-1', undefined, undefined, true) + }) + it('forwards commit-message settings to the runtime', async () => { const commitMessageAi = { enabled: true, diff --git a/src/main/runtime/rpc/methods/git.ts b/src/main/runtime/rpc/methods/git.ts index 2fdc80f01..e6ae20899 100644 --- a/src/main/runtime/rpc/methods/git.ts +++ b/src/main/runtime/rpc/methods/git.ts @@ -91,7 +91,12 @@ export const GIT_METHODS: RpcMethod[] = [ name: 'git.push', params: GitPush, handler: async (params, { runtime }) => - runtime.pushRuntimeGit(params.worktree, params.publish, params.pushTarget as never) + runtime.pushRuntimeGit( + params.worktree, + params.publish, + params.pushTarget as never, + params.forceWithLease + ) }), defineMethod({ name: 'git.branchDiff', diff --git a/src/main/runtime/runtime-rpc.test.ts b/src/main/runtime/runtime-rpc.test.ts index 2ca478d67..9c367ea21 100644 --- a/src/main/runtime/runtime-rpc.test.ts +++ b/src/main/runtime/runtime-rpc.test.ts @@ -1016,7 +1016,7 @@ describe('OrcaRuntimeRpcServer', () => { expect(selectCodexAccount).toHaveBeenCalledWith(null) expect(readTerminal).toHaveBeenCalledWith('term-1', { cursor: undefined }) expect(getRuntimeGitStatus).toHaveBeenCalledWith('id:wt-1') - expect(pushRuntimeGit).toHaveBeenCalledWith('id:wt-1', true, undefined) + expect(pushRuntimeGit).toHaveBeenCalledWith('id:wt-1', true, undefined, undefined) expect(getRuntimeGitUpstreamStatus).toHaveBeenCalledWith('id:wt-1') expect(bulkStageRuntimeGitPaths).toHaveBeenCalledWith('id:wt-1', ['a.ts', 'b.ts']) expect(bulkUnstageRuntimeGitPaths).toHaveBeenCalledWith('id:wt-1', ['c.ts']) @@ -1099,7 +1099,7 @@ describe('OrcaRuntimeRpcServer', () => { ) expect(replies).toContainEqual(expect.objectContaining({ id: 'req_push', ok: true })) - expect(pushRuntimeGit).toHaveBeenCalledWith('id:wt-1', undefined, undefined) + expect(pushRuntimeGit).toHaveBeenCalledWith('id:wt-1', undefined, undefined, undefined) }) it('leaves the last published metadata in place when a runtime stops', async () => { diff --git a/src/main/text-generation/pull-request-context.test.ts b/src/main/text-generation/pull-request-context.test.ts index 5ab1b9745..aeb3247dc 100644 --- a/src/main/text-generation/pull-request-context.test.ts +++ b/src/main/text-generation/pull-request-context.test.ts @@ -1,3 +1,7 @@ +/* eslint-disable max-lines */ +// Why: PR context generation depends on command order across remote-state +// variants; keeping the table of git command mocks together makes regressions +// easier to audit than splitting the suite by helper. import { describe, expect, it, vi } from 'vitest' import { getPullRequestDraftContext } from './pull-request-context' @@ -13,20 +17,17 @@ function createContextInput(base = 'main') { } describe('getPullRequestDraftContext', () => { - it('fetches and rebases onto the resolved remote base before collecting PR context', async () => { + it('fetches the resolved remote base before collecting PR context without mutating HEAD', async () => { const execGit = vi.fn(async (args) => { if (args[0] === 'fetch') { return { stdout: '', stderr: '' } } + if (args[0] === 'remote') { + return { stdout: 'origin\nupstream\n', stderr: '' } + } if (args[0] === 'for-each-ref') { return { stdout: 'origin/HEAD\norigin/main\nupstream/main\n', stderr: '' } } - if (args[0] === 'rebase') { - return { stdout: 'Current branch feature is up to date.\n', stderr: '' } - } - if (args[0] === 'rev-parse') { - return { stdout: 'unchanged-head\n', stderr: '' } - } if (args[0] === 'branch') { return { stdout: 'feature/pr-details\n', stderr: '' } } @@ -54,27 +55,149 @@ describe('getPullRequestDraftContext', () => { commitSummary: '- feat: summarize branch', changeSummary: 'M\tsrc/file.ts' }) - expect(execGit).toHaveBeenCalledWith(['fetch', '--all', '--prune'], expect.any(Object)) - expect(execGit).toHaveBeenCalledWith(['rebase', 'origin/main'], expect.any(Object)) + expect(execGit).toHaveBeenCalledWith( + ['fetch', '--no-tags', 'origin', '+refs/heads/main:refs/remotes/origin/main'], + expect.any(Object) + ) + expect(execGit).not.toHaveBeenCalledWith(expect.arrayContaining(['rebase']), expect.anything()) + expect(execGit).not.toHaveBeenCalledWith( + expect.arrayContaining(['rev-parse']), + expect.anything() + ) expect(execGit).toHaveBeenCalledWith(['merge-base', 'origin/main', 'HEAD'], expect.any(Object)) const commandNames = execGit.mock.calls.map(([args]) => args[0]) - expect(commandNames.indexOf('rebase')).toBeLessThan(commandNames.indexOf('merge-base')) + expect(commandNames.indexOf('fetch')).toBeLessThan(commandNames.indexOf('merge-base')) }) - it('reports when preparation changes HEAD', async () => { - let revParseCount = 0 + it('fetches the preferred remote base even when the tracking ref is absent locally', async () => { const execGit = vi.fn(async (args) => { - if (args[0] === 'fetch' || args[0] === 'rebase') { + if (args[0] === 'fetch') { return { stdout: '', stderr: '' } } + if (args[0] === 'remote') { + return { stdout: 'origin\n', stderr: '' } + } + if (args[0] === 'for-each-ref') { + return { stdout: '', stderr: '' } + } + if (args[0] === 'branch') { + return { stdout: 'feature/pr-details\n', stderr: '' } + } + if (args[0] === 'merge-base') { + return { stdout: 'abc123\n', stderr: '' } + } + if (args[0] === 'log') { + return { stdout: '- feat: summarize branch\n', stderr: '' } + } + if (args[0] === 'diff') { + return { stdout: 'M\tREADME.md\n', stderr: '' } + } + throw new Error(`Unexpected git args: ${args.join(' ')}`) + }) + + await getPullRequestDraftContext(execGit, createContextInput()) + + expect(execGit).toHaveBeenCalledWith( + ['fetch', '--no-tags', 'origin', '+refs/heads/main:refs/remotes/origin/main'], + expect.any(Object) + ) + expect(execGit).not.toHaveBeenCalledWith(expect.arrayContaining(['rebase']), expect.anything()) + }) + + it('does not fetch unrelated fork remotes before generating PR context', async () => { + const execGit = vi.fn(async (args) => { + if (args[0] === 'fetch') { + expect(args).not.toContain('--all') + expect(args[2]).toBe('origin') + return { stdout: '', stderr: '' } + } + if (args[0] === 'remote') { + return { stdout: 'origin\nstale-fork\n', stderr: '' } + } + if (args[0] === 'for-each-ref') { + return { + stdout: 'origin/main\nstale-fork/feature/from-stale-fork\n', + stderr: '' + } + } + if (args[0] === 'branch') { + return { stdout: 'feature/pr-details\n', stderr: '' } + } + if (args[0] === 'merge-base') { + return { stdout: 'abc123\n', stderr: '' } + } + if (args[0] === 'log') { + return { stdout: '- feat: change\n', stderr: '' } + } + if (args[0] === 'diff') { + return { stdout: 'M\tREADME.md\n', stderr: '' } + } + throw new Error(`Unexpected git args: ${args.join(' ')}`) + }) + + await expect(getPullRequestDraftContext(execGit, createContextInput())).resolves.toMatchObject({ + branch: 'feature/pr-details' + }) + + expect(execGit).not.toHaveBeenCalledWith(['fetch', '--all', '--prune'], expect.any(Object)) + expect(execGit).not.toHaveBeenCalledWith( + expect.arrayContaining(['stale-fork']), + expect.any(Object) + ) + }) + + it('does not guess between multiple non-preferred remote bases for a bare base name', async () => { + const execGit = vi.fn(async (args) => { + if (args[0] === 'fetch') { + throw new Error(`Unexpected fetch: ${args.join(' ')}`) + } + if (args[0] === 'remote') { + return { stdout: 'contributor-a\ncontributor-b\n', stderr: '' } + } + if (args[0] === 'for-each-ref') { + return { stdout: 'contributor-a/main\ncontributor-b/main\n', stderr: '' } + } + if (args[0] === 'branch') { + return { stdout: 'feature\n', stderr: '' } + } + if (args[0] === 'merge-base') { + expect(args[1]).toBe('main') + return { stdout: 'abc123\n', stderr: '' } + } + if (args[0] === 'log') { + return { stdout: '- feat: change\n', stderr: '' } + } + if (args[0] === 'diff') { + return { stdout: 'M\tREADME.md\n', stderr: '' } + } + throw new Error(`Unexpected git args: ${args.join(' ')}`) + }) + + await getPullRequestDraftContext(execGit, createContextInput()) + + expect(execGit).not.toHaveBeenCalledWith( + expect.arrayContaining(['contributor-a']), + expect.any(Object) + ) + expect(execGit).not.toHaveBeenCalledWith( + expect.arrayContaining(['contributor-b']), + expect.any(Object) + ) + expect(execGit).not.toHaveBeenCalledWith(expect.arrayContaining(['rebase']), expect.anything()) + }) + + it('reports no branch change because PR context preparation is read-only', async () => { + const execGit = vi.fn(async (args) => { + if (args[0] === 'fetch') { + return { stdout: '', stderr: '' } + } + if (args[0] === 'remote') { + return { stdout: 'origin\n', stderr: '' } + } if (args[0] === 'for-each-ref') { return { stdout: 'origin/main\n', stderr: '' } } - if (args[0] === 'rev-parse') { - revParseCount += 1 - return { stdout: `${revParseCount === 1 ? 'old-head' : 'new-head'}\n`, stderr: '' } - } if (args[0] === 'branch') { return { stdout: 'feature\n', stderr: '' } } @@ -92,23 +215,28 @@ describe('getPullRequestDraftContext', () => { const context = await getPullRequestDraftContext(execGit, createContextInput()) - expect(context?.branchChangedByPreparation).toBe(true) + expect(context?.branchChangedByPreparation).toBe(false) + expect(execGit).not.toHaveBeenCalledWith(expect.arrayContaining(['rebase']), expect.anything()) + expect(execGit).not.toHaveBeenCalledWith( + expect.arrayContaining(['rev-parse']), + expect.anything() + ) }) it('keeps a remote-qualified base when the selected base includes the remote', async () => { const execGit = vi.fn(async (args) => { - if (args[0] === 'fetch' || args[0] === 'rebase') { + if (args[0] === 'fetch') { return { stdout: '', stderr: '' } } + if (args[0] === 'remote') { + return { stdout: 'origin\nupstream\n', stderr: '' } + } if (args[0] === 'for-each-ref') { return { stdout: 'origin/main\nupstream/main\n', stderr: '' } } if (args[0] === 'branch') { return { stdout: 'feature\n', stderr: '' } } - if (args[0] === 'rev-parse') { - return { stdout: 'abc123\n', stderr: '' } - } if (args[0] === 'merge-base') { return { stdout: 'abc123\n', stderr: '' } } @@ -123,36 +251,73 @@ describe('getPullRequestDraftContext', () => { await getPullRequestDraftContext(execGit, createContextInput('upstream/main')) - expect(execGit).toHaveBeenCalledWith(['rebase', 'upstream/main'], expect.any(Object)) + expect(execGit).toHaveBeenCalledWith( + ['fetch', '--no-tags', 'upstream', '+refs/heads/main:refs/remotes/upstream/main'], + expect.any(Object) + ) + expect(execGit).not.toHaveBeenCalledWith(expect.arrayContaining(['rebase']), expect.anything()) expect(execGit).toHaveBeenCalledWith( ['merge-base', 'upstream/main', 'HEAD'], expect.any(Object) ) }) - it('stops generation when the rebase fails', async () => { + it('does not run rebase before collecting PR context', async () => { const execGit = vi.fn(async (args) => { if (args[0] === 'fetch') { return { stdout: '', stderr: '' } } + if (args[0] === 'remote') { + return { stdout: 'origin\n', stderr: '' } + } if (args[0] === 'for-each-ref') { return { stdout: 'origin/main\n', stderr: '' } } - if (args[0] === 'rev-parse') { - return { stdout: 'abc123\n', stderr: '' } + if (args[0] === 'branch') { + return { stdout: 'feature\n', stderr: '' } } if (args[0] === 'rebase') { - throw new Error('Command failed: git rebase origin/main\nCONFLICT (content): README.md') + throw new Error('Generate must not rebase the live worktree') + } + if (args[0] === 'merge-base') { + return { stdout: 'abc123\n', stderr: '' } + } + if (args[0] === 'log') { + return { stdout: '- feat: change\n', stderr: '' } + } + if (args[0] === 'diff') { + return { stdout: 'M\tREADME.md\n', stderr: '' } + } + throw new Error(`Unexpected git args: ${args.join(' ')}`) + }) + + await expect(getPullRequestDraftContext(execGit, createContextInput())).resolves.toMatchObject({ + branch: 'feature' + }) + expect(execGit).not.toHaveBeenCalledWith(expect.arrayContaining(['rebase']), expect.anything()) + }) + + it('stops generation when the relevant base fetch fails', async () => { + const execGit = vi.fn(async (args) => { + if (args[0] === 'remote') { + return { stdout: 'origin\nstale-fork\n', stderr: '' } + } + if (args[0] === 'for-each-ref') { + return { stdout: 'origin/main\nstale-fork/main\n', stderr: '' } + } + if (args[0] === 'fetch') { + if (args[2] !== 'origin') { + throw new Error(`Fetched unrelated remote: ${args.join(' ')}`) + } + throw new Error( + 'Command failed: git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main\nfatal: unable to access origin' + ) } throw new Error(`Unexpected git args: ${args.join(' ')}`) }) await expect(getPullRequestDraftContext(execGit, createContextInput())).rejects.toThrow( - 'Rebase before generating PR details failed: CONFLICT (content): README.md' - ) - expect(execGit).not.toHaveBeenCalledWith( - ['merge-base', 'origin/main', 'HEAD'], - expect.anything() + 'Fetch before generating PR details failed: fatal: unable to access origin' ) }) diff --git a/src/main/text-generation/pull-request-context.ts b/src/main/text-generation/pull-request-context.ts index 77dfdb199..1eaf078f6 100644 --- a/src/main/text-generation/pull-request-context.ts +++ b/src/main/text-generation/pull-request-context.ts @@ -43,26 +43,108 @@ async function requiredExec(execGit: GitExec, args: string[], label: string): Pr } } -async function resolveComparisonBase(execGit: GitExec, base: string): Promise { - const refs = ( - await safeExec(execGit, ['for-each-ref', '--format=%(refname:short)', 'refs/remotes']) - ) +type RemoteState = { + remotes: string[] + refs: string[] +} + +type RemoteBranch = { + remote: string + branch: string + ref: string +} + +function splitGitLines(output: string): string[] { + return output .split('\n') .map((line) => line.trim()) - .filter((line) => line && !line.endsWith('/HEAD')) + .filter(Boolean) +} - if (refs.includes(base)) { - return base +async function getRemoteState(execGit: GitExec): Promise { + const [remoteOutput, refOutput] = await Promise.all([ + safeExec(execGit, ['remote']), + safeExec(execGit, ['for-each-ref', '--format=%(refname:short)', 'refs/remotes']) + ]) + return { + remotes: splitGitLines(remoteOutput), + refs: splitGitLines(refOutput).filter((line) => !line.endsWith('/HEAD')) + } +} + +function parseRemoteBranch(ref: string, remotes: string[]): RemoteBranch | null { + const remote = [...remotes] + .sort((a, b) => b.length - a.length) + .find((candidate) => ref.startsWith(`${candidate}/`)) + if (!remote) { + return null + } + const branch = ref.slice(remote.length + 1) + return branch ? { remote, branch, ref } : null +} + +function parseRemoteRef(ref: string, remotes: string[]): RemoteBranch | null { + const parsed = parseRemoteBranch(ref, remotes) + if (parsed) { + return parsed + } + const slashIndex = ref.indexOf('/') + if (slashIndex <= 0 || slashIndex === ref.length - 1) { + return null + } + return { + remote: ref.slice(0, slashIndex), + branch: ref.slice(slashIndex + 1), + ref + } +} + +function resolveComparisonBase( + base: string, + state: RemoteState +): { + comparisonBase: string + fetchTarget: RemoteBranch | null +} { + const qualifiedBase = parseRemoteBranch(base, state.remotes) + if (qualifiedBase) { + return { comparisonBase: qualifiedBase.ref, fetchTarget: qualifiedBase } + } + if (state.refs.includes(base)) { + return { comparisonBase: base, fetchTarget: parseRemoteRef(base, state.remotes) } } const preferredRemoteRefs = [`origin/${base}`, `upstream/${base}`] for (const ref of preferredRemoteRefs) { - if (refs.includes(ref)) { - return ref + const parsed = parseRemoteRef(ref, state.remotes) + if (parsed && (state.refs.includes(ref) || state.remotes.includes(parsed.remote))) { + return { comparisonBase: ref, fetchTarget: parsed } } } - return refs.find((ref) => ref.endsWith(`/${base}`)) ?? base + const matchingRefs = state.refs.filter((ref) => ref.endsWith(`/${base}`)) + if (matchingRefs.length === 1) { + const ref = matchingRefs[0] + return { comparisonBase: ref, fetchTarget: parseRemoteRef(ref, state.remotes) } + } + + return { comparisonBase: base, fetchTarget: null } +} + +async function fetchComparisonBase(execGit: GitExec, target: RemoteBranch | null): Promise { + if (!target) { + return + } + await requiredExec( + execGit, + [ + 'fetch', + '--no-tags', + target.remote, + `+refs/heads/${target.branch}:refs/remotes/${target.remote}/${target.branch}` + ], + 'Fetch before generating PR details failed' + ) } type PullRequestBranchPreparation = { @@ -74,25 +156,15 @@ async function preparePullRequestBranch( execGit: GitExec, base: string ): Promise { - await requiredExec( - execGit, - ['fetch', '--all', '--prune'], - 'Fetch before generating PR details failed' - ) - const comparisonBase = await resolveComparisonBase(execGit, base) - const headBeforeRebase = await safeExec(execGit, ['rev-parse', 'HEAD']) - // Why: GitHub PR diffs are three-dot based; rebasing first keeps already-landed - // branch changes from bleeding into the generated description. - await requiredExec( - execGit, - ['rebase', comparisonBase], - 'Rebase before generating PR details failed' - ) - const headAfterRebase = await safeExec(execGit, ['rev-parse', 'HEAD']) + const { comparisonBase, fetchTarget } = resolveComparisonBase(base, await getRemoteState(execGit)) + // Why: PR generation only needs the selected base branch. A repo-wide + // `fetch --all` makes stale contributor fork remotes block unrelated PRs. + await fetchComparisonBase(execGit, fetchTarget) return { comparisonBase, - branchChanged: - Boolean(headBeforeRebase) && Boolean(headAfterRebase) && headBeforeRebase !== headAfterRebase + // Why: Generate must be read-only. Rebasing the live worktree can rewrite + // files under the running dev app and trigger a full Electron/Vite reload. + branchChanged: false } } diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 63796155a..40c8e51de 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -1509,6 +1509,7 @@ export type PreloadApi = { push: (args: { worktreePath: string publish?: boolean + forceWithLease?: boolean connectionId?: string pushTarget?: GitPushTarget }) => Promise diff --git a/src/preload/index.ts b/src/preload/index.ts index 16d0d7510..fdfb78e0c 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -2081,6 +2081,7 @@ const api = { push: (args: { worktreePath: string publish?: boolean + forceWithLease?: boolean connectionId?: string pushTarget?: unknown }): Promise => ipcRenderer.invoke('git:push', args), diff --git a/src/relay/git-handler-worktree-ops.test.ts b/src/relay/git-handler-worktree-ops.test.ts new file mode 100644 index 000000000..f36a6c363 --- /dev/null +++ b/src/relay/git-handler-worktree-ops.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it, vi } from 'vitest' +import type { GitExec } from './git-handler-ops' +import { removeWorktreeOp } from './git-handler-worktree-ops' + +function worktreeList(...entries: { path: string; branch?: string }[]): string { + return entries + .map((entry, index) => + [ + `worktree ${entry.path}`, + `HEAD ${index}`, + ...(entry.branch ? [`branch refs/heads/${entry.branch}`] : []) + ].join('\n') + ) + .join('\n\n') +} + +describe('removeWorktreeOp', () => { + it('deletes the now-unused branch after removing an SSH worktree', async () => { + const calls: string[] = [] + let listCount = 0 + const git = vi.fn(async (args, cwd) => { + calls.push(`${cwd}$ ${args.join(' ')}`) + if (args[0] === 'rev-parse') { + return { stdout: '/repo/.git\n', stderr: '' } + } + if (args[0] === 'worktree' && args[1] === 'list') { + listCount += 1 + return { + stdout: + listCount === 1 + ? worktreeList( + { path: '/repo', branch: 'main' }, + { path: '/repo-feature', branch: 'feature/test' } + ) + : worktreeList({ path: '/repo', branch: 'main' }), + stderr: '' + } + } + return { stdout: '', stderr: '' } + }) + + await removeWorktreeOp(git, { worktreePath: '/repo-feature' }) + + expect(calls).toEqual([ + '/repo-feature$ rev-parse --git-common-dir', + '/repo$ worktree list --porcelain', + '/repo$ worktree remove /repo-feature', + '/repo$ worktree prune', + '/repo$ worktree list --porcelain', + '/repo$ branch -D feature/test' + ]) + }) + + it('keeps the branch when another SSH worktree still uses it', async () => { + let listCount = 0 + const git = vi.fn(async (args, _cwd) => { + if (args[0] === 'rev-parse') { + return { stdout: '/repo/.git\n', stderr: '' } + } + if (args[0] === 'worktree' && args[1] === 'list') { + listCount += 1 + return { + stdout: + listCount === 1 + ? worktreeList( + { path: '/repo', branch: 'main' }, + { path: '/repo-feature', branch: 'feature/test' } + ) + : worktreeList( + { path: '/repo', branch: 'main' }, + { path: '/repo-other', branch: 'feature/test' } + ), + stderr: '' + } + } + return { stdout: '', stderr: '' } + }) + + await removeWorktreeOp(git, { worktreePath: '/repo-feature' }) + + expect(git).not.toHaveBeenCalledWith(['branch', '-D', 'feature/test'], expect.any(String)) + }) +}) diff --git a/src/relay/git-handler-worktree-ops.ts b/src/relay/git-handler-worktree-ops.ts index ea7a0684c..c42e7fd01 100644 --- a/src/relay/git-handler-worktree-ops.ts +++ b/src/relay/git-handler-worktree-ops.ts @@ -6,6 +6,7 @@ */ import * as path from 'path' import type { GitExec } from './git-handler-ops' +import { parseWorktreeList } from './git-handler-utils' // ─── Worktree management ───────────────────────────────────────────── @@ -83,6 +84,12 @@ export async function removeWorktreeOp( // fall through with worktreePath as repo } + const worktreesBeforeRemoval = await listRelayWorktrees(git, repoPath) + const removedWorktree = worktreesBeforeRemoval.find((worktree) => + areRelayWorktreePathsEqual(worktree.path, worktreePath) + ) + const branchName = normalizeLocalBranchRef(removedWorktree?.branch ?? '') + const args = ['worktree', 'remove'] if (force) { args.push('--force') @@ -90,6 +97,59 @@ export async function removeWorktreeOp( args.push(worktreePath) await git(args, repoPath) await git(['worktree', 'prune'], repoPath) + + if (!branchName) { + return + } + + // Why: SSH worktree deletion should mirror local deletion. Dropping the + // branch also removes its upstream config, which lets fork-remotes cleanup + // after the last PR review worktree is gone. + const worktreesAfterPrune = await listRelayWorktrees(git, repoPath) + const branchStillInUse = worktreesAfterPrune.some( + (worktree) => normalizeLocalBranchRef(worktree.branch ?? '') === branchName + ) + if (branchStillInUse) { + return + } + + try { + await git(['branch', '-D', branchName], repoPath) + } catch (error) { + console.warn( + `relay removeWorktree: failed to delete local branch "${branchName}" after removing worktree`, + error + ) + } +} + +type RelayWorktreeInfo = { + path: string + branch?: string +} + +async function listRelayWorktrees(git: GitExec, repoPath: string): Promise { + try { + const { stdout } = await git(['worktree', 'list', '--porcelain'], repoPath) + return parseWorktreeList(stdout) + .map((worktree) => ({ + path: typeof worktree.path === 'string' ? worktree.path : '', + branch: typeof worktree.branch === 'string' ? worktree.branch : undefined + })) + .filter((worktree) => worktree.path.length > 0) + } catch { + return [] + } +} + +function normalizeLocalBranchRef(branch: string): string { + return branch.replace(/^refs\/heads\//, '') +} + +function areRelayWorktreePathsEqual(leftPath: string, rightPath: string): boolean { + const left = path.normalize(path.resolve(leftPath)) + const right = path.normalize(path.resolve(rightPath)) + return process.platform === 'win32' ? left.toLowerCase() === right.toLowerCase() : left === right } // ─── Commit ────────────────────────────────────────────────────────── diff --git a/src/relay/git-handler.ts b/src/relay/git-handler.ts index d5bc1f071..839cfd18a 100644 --- a/src/relay/git-handler.ts +++ b/src/relay/git-handler.ts @@ -19,6 +19,7 @@ import { commitChangesRelay, addWorktreeOp, removeWorktreeOp } from './git-handl import { checkIgnoredPathsOp, detectConflictOperation, getStatusOp } from './git-handler-status-ops' import { resolveRelayPushTarget } from './git-handler-push-target' import { normalizeGitErrorMessage, isNoUpstreamError } from '../shared/git-remote-error' +import { upstreamOnlyCommitsArePatchEquivalent } from '../shared/git-upstream-status' import { loadGitHistoryFromExecutor } from '../shared/git-history' import { buildRelayCommandEnv } from './relay-command-env' @@ -312,11 +313,18 @@ export class GitHandler { if (!Number.isFinite(ahead) || !Number.isFinite(behind) || ahead < 0 || behind < 0) { throw new Error(`Unparseable git rev-list counts: ${JSON.stringify(countsStdout)}`) } + const behindCommitsArePatchEquivalent = + ahead > 0 && behind > 0 + ? await this.getBehindCommitsArePatchEquivalent(worktreePath) + : undefined return { hasUpstream: true, upstreamName, ahead, - behind + behind, + ...(behindCommitsArePatchEquivalent !== undefined + ? { behindCommitsArePatchEquivalent } + : {}) } } catch (error) { // Why: we only swallow the 'no upstream configured' error — that's an @@ -331,6 +339,20 @@ export class GitHandler { } } + private async getBehindCommitsArePatchEquivalent(worktreePath: string): Promise { + try { + const { stdout } = await this.git( + ['log', '--oneline', '--cherry-mark', '--right-only', 'HEAD...@{u}', '--'], + worktreePath + ) + return upstreamOnlyCommitsArePatchEquivalent(stdout) + } catch { + // Why: this only identifies stale post-rebase upstreams. If the probe + // fails over SSH, keep the conservative pull-first sync path. + return false + } + } + private async fetch(params: Record) { const worktreePath = params.worktreePath as string try { @@ -354,9 +376,12 @@ export class GitHandler { worktreePath, params.pushTarget ) - const args = target - ? ['push', '--set-upstream', target.remote, target.refspec] - : ['push', '--set-upstream', 'origin', 'HEAD'] + const args = [ + 'push', + ...(params.forceWithLease === true ? ['--force-with-lease'] : []), + '--set-upstream', + ...(target ? [target.remote, target.refspec] : ['origin', 'HEAD']) + ] await this.git(args, worktreePath) } catch (error) { // Why: mirror the local gitPush normalization so SSH users see the same diff --git a/src/renderer/src/components/right-sidebar/CommitArea.chevron-spinner.test.tsx b/src/renderer/src/components/right-sidebar/CommitArea.chevron-spinner.test.tsx index 1874672eb..7141c13f8 100644 --- a/src/renderer/src/components/right-sidebar/CommitArea.chevron-spinner.test.tsx +++ b/src/renderer/src/components/right-sidebar/CommitArea.chevron-spinner.test.tsx @@ -1,5 +1,6 @@ import { describe, expect, it, vi } from 'vitest' import { renderToStaticMarkup } from 'react-dom/server' +import { TooltipProvider } from '@/components/ui/tooltip' import { CommitArea } from './SourceControl' import { resolvePrimaryAction, type PrimaryActionInputs } from './source-control-primary-action' import { resolveDropdownItems, type DropdownActionKind } from './source-control-dropdown-items' @@ -54,7 +55,13 @@ function buttons(markup: string): string[] { } function renderButtons(props: ReturnType): string[] { - return buttons(renderToStaticMarkup()) + return buttons( + renderToStaticMarkup( + + + + ) + ) } describe('CommitArea chevron spinner', () => { diff --git a/src/renderer/src/components/right-sidebar/CommitArea.primary-icons.test.tsx b/src/renderer/src/components/right-sidebar/CommitArea.primary-icons.test.tsx index 045b0904a..1fdcc75fc 100644 --- a/src/renderer/src/components/right-sidebar/CommitArea.primary-icons.test.tsx +++ b/src/renderer/src/components/right-sidebar/CommitArea.primary-icons.test.tsx @@ -1,5 +1,6 @@ import { describe, expect, it, vi } from 'vitest' import { renderToStaticMarkup } from 'react-dom/server' +import { TooltipProvider } from '@/components/ui/tooltip' import { CommitArea } from './SourceControl' import { resolvePrimaryAction, type PrimaryActionInputs } from './source-control-primary-action' import { resolveDropdownItems, type DropdownActionKind } from './source-control-dropdown-items' @@ -50,7 +51,11 @@ function baseProps(overrides: Partial = {}) { } function primaryButton(props: ReturnType): string { - const markup = renderToStaticMarkup() + const markup = renderToStaticMarkup( + + + + ) const match = markup.match(//) if (!match) { throw new Error('primary button not found') diff --git a/src/renderer/src/components/right-sidebar/CommitArea.test.tsx b/src/renderer/src/components/right-sidebar/CommitArea.test.tsx index 7e6568846..65c02d8c9 100644 --- a/src/renderer/src/components/right-sidebar/CommitArea.test.tsx +++ b/src/renderer/src/components/right-sidebar/CommitArea.test.tsx @@ -3,6 +3,7 @@ import { renderToStaticMarkup } from 'react-dom/server' import { CommitArea, ConflictSummaryCard } from './SourceControl' import { resolvePrimaryAction, type PrimaryActionInputs } from './source-control-primary-action' import { resolveDropdownItems, type DropdownActionKind } from './source-control-dropdown-items' +import { TooltipProvider } from '@/components/ui/tooltip' function buildInputs(overrides: Partial = {}): PrimaryActionInputs { return { @@ -45,7 +46,11 @@ function baseProps(overrides: Partial = {}) { } function renderCommitArea(props: ReturnType): string { - return renderToStaticMarkup() + return renderToStaticMarkup( + + + + ) } function firstButton(markup: string): string { diff --git a/src/renderer/src/components/right-sidebar/SourceControl.commit-drafts.test.ts b/src/renderer/src/components/right-sidebar/SourceControl.commit-drafts.test.ts index 3dc2f0a24..368ac3e86 100644 --- a/src/renderer/src/components/right-sidebar/SourceControl.commit-drafts.test.ts +++ b/src/renderer/src/components/right-sidebar/SourceControl.commit-drafts.test.ts @@ -9,6 +9,7 @@ import { normalizeSourceControlViewMode, pickDefaultSourceControlAgent, readCommitDraftForWorktree, + refreshSourceControlAfterRemoteAction, requestSourceControlViewModePreferenceWrite, shouldRenderCommitArea, type SourceControlViewModePreferenceWriteState, @@ -155,6 +156,41 @@ describe('SourceControl conflict resolution state', () => { }) }) +describe('SourceControl remote action refresh', () => { + it('refreshes status, branch compare, and history after remote actions settle', async () => { + const refreshGitStatus = vi.fn().mockResolvedValue(undefined) + const refreshBranchCompare = vi.fn().mockResolvedValue(undefined) + const refreshGitHistory = vi.fn().mockResolvedValue(undefined) + + refreshSourceControlAfterRemoteAction({ + refreshGitStatus, + refreshBranchCompare, + refreshGitHistory + }) + await Promise.resolve() + + expect(refreshGitStatus).toHaveBeenCalledTimes(1) + expect(refreshBranchCompare).toHaveBeenCalledTimes(1) + expect(refreshGitHistory).toHaveBeenCalledTimes(1) + }) + + it('routes post-remote refresh failures to the provided error handler', async () => { + const error = new Error('refresh failed') + const onError = vi.fn() + + refreshSourceControlAfterRemoteAction({ + refreshGitStatus: vi.fn().mockResolvedValue(undefined), + refreshBranchCompare: vi.fn().mockRejectedValue(error), + refreshGitHistory: vi.fn().mockResolvedValue(undefined), + onError + }) + await Promise.resolve() + await Promise.resolve() + + expect(onError).toHaveBeenCalledWith(error) + }) +}) + describe('SourceControl view mode preference', () => { it('normalizes missing and unknown persisted values to list', () => { expect(normalizeSourceControlViewMode(undefined)).toBe('list') diff --git a/src/renderer/src/components/right-sidebar/SourceControl.tsx b/src/renderer/src/components/right-sidebar/SourceControl.tsx index a1175a816..80bd71611 100644 --- a/src/renderer/src/components/right-sidebar/SourceControl.tsx +++ b/src/renderer/src/components/right-sidebar/SourceControl.tsx @@ -127,9 +127,11 @@ import { } from '@/runtime/runtime-git-client' import { getRuntimeRepoBaseRefDefault } from '@/runtime/runtime-repo-client' import { PullRequestIcon } from './checks-panel-content' -import { CreatePullRequestDialog } from './CreatePullRequestDialog' +import { stripBaseRef, useCreatePullRequestDialogFields } from './useCreatePullRequestDialogFields' import { GitHistoryPanel, type GitHistoryPanelState } from './GitHistoryPanel' import type { GitHistoryItem } from '../../../../shared/git-history' +import { normalizeHostedReviewHeadRef } from '../../../../shared/hosted-review-refs' +import { shouldForcePushWithLeaseForUpstream } from '../../../../shared/git-upstream-status' import type { DiffComment, GitBranchChangeEntry, @@ -481,6 +483,23 @@ function resolveRemoteActionError(kind: RemoteOpKind, error: unknown): string { }) } +export function refreshSourceControlAfterRemoteAction({ + refreshGitStatus, + refreshBranchCompare, + refreshGitHistory, + onError = (error) => console.warn('[SourceControl] post-remote refresh failed', error) +}: { + refreshGitStatus: () => Promise + refreshBranchCompare: () => Promise + refreshGitHistory: () => Promise + onError?: (error: unknown) => void +}): void { + // Why: fetch/sync can move the remote base ref without changing local files. + // Refresh all three visible git projections so the branch comparison table + // re-runs against the newly fetched base instead of waiting for polling. + void Promise.all([refreshGitStatus(), refreshBranchCompare(), refreshGitHistory()]).catch(onError) +} + function HostedReviewIcon({ review, className @@ -566,6 +585,7 @@ function SourceControlInner(): React.JSX.Element { const getHostedReviewCreationEligibility = useAppStore( (s) => s.getHostedReviewCreationEligibility ) + const createHostedReview = useAppStore((s) => s.createHostedReview) const fetchPRForBranch = useAppStore((s) => s.fetchPRForBranch) const prCache = useAppStore((s) => s.prCache) const enqueueGitHubPRRefresh = useAppStore((s) => s.enqueueGitHubPRRefresh) @@ -773,8 +793,13 @@ function SourceControlInner(): React.JSX.Element { const generateError = generateErrors[activeWorktreeId ?? ''] ?? null const [hostedReviewCreation, setHostedReviewCreation] = useState(null) - const [createPrDialogOpen, setCreatePrDialogOpen] = useState(false) - const [createPrPushFirst, setCreatePrPushFirst] = useState(false) + const createPrInFlightRef = useRef>({}) + const [createPrInFlightByWorktree, setCreatePrInFlightByWorktree] = useState< + Record + >({}) + const [createPrErrors, setCreatePrErrors] = useState>({}) + const isCreatingPr = createPrInFlightByWorktree[activeWorktreeId ?? ''] ?? false + const createPrError = createPrErrors[activeWorktreeId ?? ''] ?? null const commitMessageAi = useAppStore((s) => s.settings?.commitMessageAi) const effectiveCommitMessageAgentId = useMemo( () => resolveCommitMessageAgentChoice(commitMessageAi?.agentId, settings?.defaultTuiAgent), @@ -963,53 +988,12 @@ function SourceControlInner(): React.JSX.Element { linkedGitLabMR ]) - useEffect(() => { - if (!isBranchVisible || !activeRepo || isFolder || !branchName) { - setHostedReviewCreation(null) - return - } - let stale = false - void getHostedReviewCreationEligibility({ - repoPath: activeRepo.path, - ...(worktreePath ? { worktreePath } : {}), - branch: branchName, - base: effectiveBaseRef ?? null, - hasUncommittedChanges: hasUncommittedEntries, - hasUpstream: remoteStatus?.hasUpstream, - ahead: remoteStatus?.ahead, - behind: remoteStatus?.behind, - linkedGitHubPR, - linkedGitLabMR - }) - .then((result) => { - if (!stale) { - setHostedReviewCreation(result) - } - }) - .catch((error) => { - console.warn('[SourceControl] hosted review creation eligibility failed', error) - if (!stale) { - setHostedReviewCreation(null) - } - }) - return () => { - stale = true - } - }, [ - activeRepo, - branchName, - effectiveBaseRef, - getHostedReviewCreationEligibility, - hasUncommittedEntries, - isBranchVisible, - isFolder, - linkedGitHubPR, - linkedGitLabMR, - remoteStatus?.ahead, - remoteStatus?.behind, - remoteStatus?.hasUpstream, - worktreePath - ]) + // Why: eligibility is recomputed below, after prGenerating / isCreatingPr are + // available, so the effect can pause refetches while a user-initiated PR flow + // is in flight. AI generation runs `git fetch` + `git rebase`, which mutates + // ahead/behind counts; without this guard the next refetch would return + // canCreate:false (typically needs_push), flip primaryAction.kind off + // create_pr, unmount the composer, and cancel the in-flight generation. const grouped = useMemo(() => { const groups = { @@ -1258,8 +1242,6 @@ function SourceControlInner(): React.JSX.Element { // repos and back to re-trigger the resolver. setFilterQuery('') setIsExecutingBulk(false) - setCreatePrDialogOpen(false) - setCreatePrPushFirst(false) // Why: no reset for commit-in-flight state — it now lives in a per-worktree // map, so it cannot leak across worktrees. Resetting here would actually // clear in-flight state for the *incoming* worktree if the user is coming @@ -1480,12 +1462,14 @@ function SourceControlInner(): React.JSX.Element { return } if (kind === 'push') { + const forceWithLease = shouldForcePushWithLeaseForUpstream(remoteStatus) await pushBranch( activeWorktreeId, worktreePath, false, connectionId, - activeWorktree?.pushTarget + activeWorktree?.pushTarget, + forceWithLease ? { forceWithLease: true } : undefined ) return } @@ -1512,7 +1496,11 @@ function SourceControlInner(): React.JSX.Element { } })) } finally { - void refreshGitHistoryRef.current() + refreshSourceControlAfterRemoteAction({ + refreshGitStatus: refreshActiveGitStatusAfterMutation, + refreshBranchCompare: refreshBranchCompareRef.current, + refreshGitHistory: refreshGitHistoryRef.current + }) } }, [ @@ -1521,6 +1509,8 @@ function SourceControlInner(): React.JSX.Element { fetchBranch, pullBranch, pushBranch, + refreshActiveGitStatusAfterMutation, + remoteStatus, syncBranch, worktreePath ] @@ -1544,44 +1534,6 @@ function SourceControlInner(): React.JSX.Element { [handleCommit, runRemoteAction] ) - const openCreatePullRequestDialog = useCallback((pushFirst: boolean): void => { - setCreatePrPushFirst(pushFirst) - setCreatePrDialogOpen(true) - }, []) - - const pushBeforeCreatePullRequest = useCallback(async (): Promise => { - if (!activeWorktreeId || !worktreePath) { - return false - } - const connectionId = getConnectionId(activeWorktreeId) ?? undefined - try { - await pushBranch( - activeWorktreeId, - worktreePath, - false, - connectionId, - activeWorktree?.pushTarget - ) - await refreshActiveGitStatusAfterMutation() - return true - } catch { - return false - } - }, [ - activeWorktree?.pushTarget, - activeWorktreeId, - pushBranch, - refreshActiveGitStatusAfterMutation, - worktreePath - ]) - - const handleBranchChangedByPullRequestGeneration = useCallback(async (): Promise => { - // Why: AI PR detail generation rebases before summarizing; if HEAD moved, - // the dialog must not create a PR from stale push/create eligibility. - setCreatePrPushFirst(true) - await refreshActiveGitStatusAfterMutation() - }, [refreshActiveGitStatusAfterMutation]) - const handlePullRequestCreated = useCallback( async (result: { number: number; url: string }): Promise => { if (!activeRepo || !branchName) { @@ -1628,6 +1580,202 @@ function SourceControlInner(): React.JSX.Element { setRightSidebarTab('checks') }, [setRightSidebarOpen, setRightSidebarTab]) + const handleBranchChangedByPullRequestGeneration = useCallback(async (): Promise => { + // Why: AI PR detail generation may rebase before summarizing; if HEAD moved, + // refresh status before letting the user submit the generated draft. + await refreshActiveGitStatusAfterMutation() + }, [refreshActiveGitStatusAfterMutation]) + + const { + aiGenerationEnabled: prAiGenerationEnabled, + base: prBase, + setBase: setPrBase, + title: prTitle, + setTitle: setPrTitle, + body: prBody, + setBody: setPrBody, + draft: prDraft, + setDraft: setPrDraft, + baseQuery: prBaseQuery, + setBaseQuery: setPrBaseQuery, + baseResults: prBaseResults, + setBaseResults: setPrBaseResults, + baseSearchError: prBaseSearchError, + generating: prGenerating, + generateError: prGenerateError, + generateDisabled: prGenerateDisabled, + generateDisabledReason: prGenerateDisabledReason, + handleGenerate: handleGeneratePullRequestFields, + handleCancelGenerate: handleCancelGeneratePullRequestFields + } = useCreatePullRequestDialogFields({ + open: hostedReviewCreation?.canCreate === true, + repoId: activeRepo?.id ?? '', + worktreeId: activeWorktreeId, + worktreePath: worktreePath ?? '', + branch: branchName, + eligibility: hostedReviewCreation, + settings, + submitting: isCreatingPr, + onBranchChangedByGeneration: handleBranchChangedByPullRequestGeneration + }) + + useEffect(() => { + if (!isBranchVisible || !activeRepo || isFolder || !branchName) { + setHostedReviewCreation(null) + return + } + // Why: skip refetches while the user's PR flow is mid-flight. AI generation + // rebases the branch (changing ahead/behind), and submission runs network + // calls that briefly perturb the same counts. Either flip can switch + // canCreate to false and tear down the composer underneath the user. The + // post-completion eligibility refresh in handlePullRequestCreated / + // onBranchChangedByGeneration restores the truth once the work settles. + if (prGenerating || isCreatingPr) { + return + } + let stale = false + void getHostedReviewCreationEligibility({ + repoPath: activeRepo.path, + ...(worktreePath ? { worktreePath } : {}), + branch: branchName, + base: effectiveBaseRef ?? null, + hasUncommittedChanges: hasUncommittedEntries, + hasUpstream: remoteStatus?.hasUpstream, + ahead: remoteStatus?.ahead, + behind: remoteStatus?.behind, + linkedGitHubPR, + linkedGitLabMR + }) + .then((result) => { + if (!stale) { + setHostedReviewCreation(result) + } + }) + .catch((error) => { + console.warn('[SourceControl] hosted review creation eligibility failed', error) + if (!stale) { + setHostedReviewCreation(null) + } + }) + return () => { + stale = true + } + }, [ + activeRepo, + branchName, + effectiveBaseRef, + getHostedReviewCreationEligibility, + hasUncommittedEntries, + isBranchVisible, + isCreatingPr, + isFolder, + linkedGitHubPR, + linkedGitLabMR, + prGenerating, + remoteStatus?.ahead, + remoteStatus?.behind, + remoteStatus?.hasUpstream, + worktreePath + ]) + + const handleCreatePullRequest = useCallback(async (): Promise => { + if ( + !activeRepo || + !activeWorktreeId || + !worktreePath || + !hostedReviewCreation?.canCreate || + prGenerating || + createPrInFlightRef.current[activeWorktreeId] + ) { + return + } + + const base = stripBaseRef(prBase).trim() + const title = prTitle.trim() + + if (!title) { + setCreatePrErrors((prev) => ({ + ...prev, + [activeWorktreeId]: 'Enter a pull request title.' + })) + return + } + + if (!base || stripBaseRef(base).toLowerCase() === stripBaseRef(branchName).toLowerCase()) { + setCreatePrErrors((prev) => ({ + ...prev, + [activeWorktreeId]: 'Choose a different base branch before creating a pull request.' + })) + return + } + + createPrInFlightRef.current[activeWorktreeId] = true + setCreatePrInFlightByWorktree((prev) => ({ ...prev, [activeWorktreeId]: true })) + setCreatePrErrors((prev) => ({ ...prev, [activeWorktreeId]: null })) + try { + const result = await createHostedReview(activeRepo.path, { + provider: 'github', + base, + head: normalizeHostedReviewHeadRef(branchName), + title, + body: prBody, + draft: prDraft, + worktreePath + }) + + if (result.ok) { + toast.success(`Pull request #${result.number} created`, { + action: { + label: 'Open on GitHub', + onClick: () => window.api.shell.openUrl(result.url) + } + }) + await handlePullRequestCreated(result) + return + } + + if (result.existingReview?.url) { + const number = result.existingReview.number + toast.success( + number ? `Pull request #${number} is already open` : 'Pull request is already open', + { + action: { + label: 'Open on GitHub', + onClick: () => window.api.shell.openUrl(result.existingReview!.url) + } + } + ) + if (number) { + await handlePullRequestCreated({ number, url: result.existingReview.url }) + return + } + } + + setCreatePrErrors((prev) => ({ ...prev, [activeWorktreeId]: result.error })) + } catch (error) { + setCreatePrErrors((prev) => ({ + ...prev, + [activeWorktreeId]: error instanceof Error ? error.message : 'Failed to create pull request' + })) + } finally { + createPrInFlightRef.current[activeWorktreeId] = false + setCreatePrInFlightByWorktree((prev) => ({ ...prev, [activeWorktreeId]: false })) + } + }, [ + activeRepo, + activeWorktreeId, + branchName, + createHostedReview, + handlePullRequestCreated, + hostedReviewCreation, + prBase, + prBody, + prDraft, + prGenerating, + prTitle, + worktreePath + ]) + const hasUnstagedChanges = grouped.unstaged.length > 0 || grouped.untracked.length > 0 const hasPartiallyStagedChanges = useMemo(() => { if (grouped.staged.length === 0 || grouped.unstaged.length === 0) { @@ -1637,41 +1785,43 @@ function SourceControlInner(): React.JSX.Element { return grouped.staged.some((entry) => unstagedPaths.has(entry.path)) }, [grouped.staged, grouped.unstaged]) - const primaryAction: PrimaryAction = useMemo( - () => - resolvePrimaryAction({ - stagedCount: grouped.staged.length, - hasUnstagedChanges, - hasPartiallyStagedChanges, - hasMessage: commitMessage.trim().length > 0, - hasUnresolvedConflicts: unresolvedConflicts.length > 0, - isCommitting, - isRemoteOperationActive, - upstreamStatus: remoteStatus, - prState: hostedReview?.state ?? null, - isPRStateLoading: isHostedReviewStateLoading, - inFlightRemoteOpKind, - hostedReviewCreation, - branchCommitsAhead: - branchSummary?.status === 'ready' ? (branchSummary.commitsAhead ?? 0) : undefined - }), - [ - commitMessage, - grouped.staged.length, + const primaryAction: PrimaryAction = useMemo(() => { + const action = resolvePrimaryAction({ + stagedCount: grouped.staged.length, hasUnstagedChanges, hasPartiallyStagedChanges, + hasMessage: commitMessage.trim().length > 0, + hasUnresolvedConflicts: unresolvedConflicts.length > 0, isCommitting, isRemoteOperationActive, + upstreamStatus: remoteStatus, + prState: hostedReview?.state ?? null, + isPRStateLoading: isHostedReviewStateLoading, inFlightRemoteOpKind, hostedReviewCreation, - isHostedReviewStateLoading, - hostedReview?.state, - branchSummary?.commitsAhead, - branchSummary?.status, - remoteStatus, - unresolvedConflicts.length - ] - ) + branchCommitsAhead: + branchSummary?.status === 'ready' ? (branchSummary.commitsAhead ?? 0) : undefined + }) + return isCreatingPr && action.kind === 'create_pr' + ? { ...action, title: 'Creating pull request...', disabled: true } + : action + }, [ + commitMessage, + grouped.staged.length, + hasUnstagedChanges, + hasPartiallyStagedChanges, + isCommitting, + isRemoteOperationActive, + inFlightRemoteOpKind, + hostedReviewCreation, + isHostedReviewStateLoading, + hostedReview?.state, + isCreatingPr, + branchSummary?.commitsAhead, + branchSummary?.status, + remoteStatus, + unresolvedConflicts.length + ]) const dropdownItems: DropdownEntry[] = useMemo( () => @@ -1688,6 +1838,7 @@ function SourceControlInner(): React.JSX.Element { isPRStateLoading: isHostedReviewStateLoading, inFlightRemoteOpKind, hostedReviewCreation, + isPullRequestOperationActive: prGenerating || isCreatingPr, branchCommitsAhead: branchSummary?.status === 'ready' ? (branchSummary.commitsAhead ?? 0) : undefined }), @@ -1700,8 +1851,10 @@ function SourceControlInner(): React.JSX.Element { isRemoteOperationActive, inFlightRemoteOpKind, hostedReviewCreation, + isCreatingPr, isHostedReviewStateLoading, hostedReview?.state, + prGenerating, branchSummary?.commitsAhead, branchSummary?.status, remoteStatus, @@ -1715,6 +1868,9 @@ function SourceControlInner(): React.JSX.Element { // pure remote actions go through runRemoteAction. const handleActionInvoke = useCallback( (kind: DropdownActionKind): void => { + if (prGenerating || isCreatingPr) { + return + } switch (kind) { case 'commit': void handleCommit() @@ -1726,10 +1882,10 @@ function SourceControlInner(): React.JSX.Element { void runCompoundCommitAction('sync') return case 'create_pr': - openCreatePullRequestDialog(false) + void handleCreatePullRequest() return case 'push_create_pr': - openCreatePullRequestDialog(true) + void runRemoteAction('push') return case 'push': case 'pull': @@ -1747,7 +1903,14 @@ function SourceControlInner(): React.JSX.Element { } } }, - [handleCommit, openCreatePullRequestDialog, runCompoundCommitAction, runRemoteAction] + [ + handleCommit, + handleCreatePullRequest, + isCreatingPr, + prGenerating, + runCompoundCommitAction, + runRemoteAction + ] ) const handleOpenDiff = useCallback( @@ -2052,7 +2215,7 @@ function SourceControlInner(): React.JSX.Element { // Why: PrimaryActionKind is narrowed to the single-action kinds the // primary can emit ('commit' | 'stage' | 'push' | 'pull' | 'sync' | - // 'publish') — compound commit_* kinds are dropdown-only. An exhaustive + // 'publish' | 'create_pr') — compound commit_* kinds are dropdown-only. An exhaustive // switch keeps the mapping honest: if a new PrimaryActionKind is added, // TypeScript lights up the missing case instead of silently falling // through. 'stage' routes to a dedicated primary-only handler because @@ -2744,20 +2907,6 @@ function SourceControlInner(): React.JSX.Element { return ( <> -
{(['all', 'uncommitted'] as const).map((value) => ( @@ -3008,47 +3157,78 @@ function SourceControlInner(): React.JSX.Element { clears. Active merge/rebase/cherry-pick operations are the exception: commits would be misleading before the user continues or aborts the operation. */} - {shouldRenderCommitArea(scope, unresolvedConflicts.length, conflictOperation) && ( - 0) - } - isGenerating={isGenerating} - generateError={generateError} - stagedCount={grouped.staged.length} - hasUnresolvedConflicts={unresolvedConflicts.length > 0} - isRemoteOperationActive={isRemoteOperationActive} - inFlightRemoteOpKind={inFlightRemoteOpKind} - primaryAction={primaryAction} - dropdownItems={dropdownItems} - onCommitMessageChange={(value) => { - if (!activeWorktreeId) { - return + {shouldRenderCommitArea(scope, unresolvedConflicts.length, conflictOperation) && + (primaryAction.kind === 'create_pr' ? ( + void handleGeneratePullRequestFields()} + onCancelGenerate={handleCancelGeneratePullRequestFields} + onPrimaryAction={handlePrimaryClick} + onDropdownAction={handleActionInvoke} + /> + ) : ( + 0) } - setCommitDrafts((prev) => - writeCommitDraftForWorktree(prev, activeWorktreeId, value) - ) - }} - onGenerate={() => { - void handleGenerate() - }} - onCancelGenerate={handleCancelGenerate} - onPrimaryAction={handlePrimaryClick} - onDropdownAction={handleActionInvoke} - /> - )} + isGenerating={isGenerating} + generateError={generateError} + stagedCount={grouped.staged.length} + hasUnresolvedConflicts={unresolvedConflicts.length > 0} + isRemoteOperationActive={isRemoteOperationActive} + inFlightRemoteOpKind={inFlightRemoteOpKind} + primaryAction={primaryAction} + dropdownItems={dropdownItems} + onCommitMessageChange={(value) => { + if (!activeWorktreeId) { + return + } + setCommitDrafts((prev) => + writeCommitDraftForWorktree(prev, activeWorktreeId, value) + ) + }} + onGenerate={() => { + void handleGenerate() + }} + onCancelGenerate={handleCancelGenerate} + onPrimaryAction={handlePrimaryClick} + onDropdownAction={handleActionInvoke} + /> + ))} {(scope === 'all' || scope === 'uncommitted') && hasFilteredUncommittedEntries && ( <> @@ -3463,12 +3643,348 @@ function SourceControlInner(): React.JSX.Element { const SourceControl = React.memo(SourceControlInner) export default SourceControl +type PullRequestComposerProps = { + branch: string + base: string + setBase: (value: string) => void + title: string + setTitle: (value: string) => void + body: string + setBody: (value: string) => void + draft: boolean + setDraft: (value: boolean) => void + baseQuery: string + setBaseQuery: (value: string) => void + baseResults: string[] + setBaseResults: (value: string[]) => void + baseSearchError: string | null + aiGenerationEnabled: boolean + generating: boolean + generateDisabled: boolean + generateDisabledReason?: string + generateError: string | null + createError: string | null + isCreating: boolean + primaryAction: PrimaryAction + dropdownItems: DropdownEntry[] + onGenerate: () => void + onCancelGenerate: () => void + onPrimaryAction: () => void + onDropdownAction: (kind: DropdownActionKind) => void +} + +function PullRequestComposer({ + branch, + base, + setBase, + title, + setTitle, + body, + setBody, + draft, + setDraft, + baseQuery, + setBaseQuery, + baseResults, + setBaseResults, + baseSearchError, + aiGenerationEnabled, + generating, + generateDisabled, + generateDisabledReason, + generateError, + createError, + isCreating, + primaryAction, + dropdownItems, + onGenerate, + onCancelGenerate, + onPrimaryAction, + onDropdownAction +}: PullRequestComposerProps): React.JSX.Element { + const normalizedBase = stripBaseRef(base) + const strippedBranch = stripBaseRef(branch) + const baseSameAsBranch = normalizedBase.toLowerCase() === strippedBranch.toLowerCase() + const createDisabled = + primaryAction.disabled || + generating || + title.trim().length === 0 || + normalizedBase.trim().length === 0 || + baseSameAsBranch + // Why: surface a concrete reason on the disabled Create PR button so the + // user knows what's blocking submission instead of a silent gray state. + let createDisabledReason: string | undefined + if (generating) { + createDisabledReason = 'Wait for AI generation to finish.' + } else if (title.trim().length === 0) { + createDisabledReason = 'Enter a pull request title.' + } else if (normalizedBase.trim().length === 0) { + createDisabledReason = 'Choose a base branch.' + } else if (baseSameAsBranch) { + createDisabledReason = 'Base branch must differ from the head branch.' + } + + // Why: lock the title/body/base inputs while AI generation is running so + // the user can't race the request — the hook otherwise rejects the result + // with "Fields changed while generating" and silently drops the draft. + const fieldsLocked = generating + + return ( +
+
+
+
+
+ {aiGenerationEnabled ? ( + generating ? ( + + ) : ( + + ) + ) : null} +
+ + {/* Why: a single line that shows the head→base flow plain-language so + the user can sanity-check the merge direction at a glance. */} +
+ + {strippedBranch} + +
+ +
+ setTitle(event.target.value)} + placeholder="Title" + className="h-8 w-full min-w-0 rounded-md border border-border bg-background px-2 text-xs font-medium text-foreground outline-none placeholder:text-muted-foreground/70 focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-60" + /> + +