diff --git a/src/git/__tests__/git-service.test.ts b/src/git/__tests__/git-service.test.ts index 923a6109..5c8fcdbc 100644 --- a/src/git/__tests__/git-service.test.ts +++ b/src/git/__tests__/git-service.test.ts @@ -735,6 +735,23 @@ describe('GitService', () => { expect(calls[0].some(a => a.startsWith('refs/heads/'))).toBe(false); }); + it('publishes the renamed branch and retargets upstream when the upstream ref name differs', async () => { + // `git branch -m old new` keeps branch.new.merge = refs/heads/old, so a + // bare `git push` fails with "The upstream branch of your current branch + // does not match". The renamed branch must be published under its new + // name, with -u retargeting the upstream so later pushes work. + (service as any).branches = async () => [ + { name: 'feature-renamed', current: true, upstream: 'origin/feature', ahead: 1, behind: 0, hash: 'abc' }, + ]; + await service.pushCurrentBranch({ force: 'with-lease' }); + expect(calls).toHaveLength(1); + expect(calls[0]).toContain('-u'); + expect(calls[0]).toContain('origin'); + expect(calls[0]).toContain('refs/heads/feature-renamed'); + expect(calls[0]).toContain('--force-with-lease'); + expect(calls[0]).not.toContain('refs/heads/feature'); + }); + it('sets upstream and pushes to default remote when no upstream', async () => { (service as any).branches = async () => [ { name: 'feature', current: true, ahead: 0, behind: 0, hash: 'abc' }, diff --git a/src/git/git-service.ts b/src/git/git-service.ts index 71e65760..5273b16e 100644 --- a/src/git/git-service.ts +++ b/src/git/git-service.ts @@ -1431,11 +1431,19 @@ export class GitService { } /** - * Pushes the current branch, used by "push after rebase/merge/…" follow-up - * actions. Mirrors the PushModal convention: when the branch has an upstream - * we push with no remote/refspec (git resolves it from the upstream); when it - * doesn't, we set upstream (-u) on the default remote (origin if present, else - * the first remote). With no remotes configured the push is skipped. + * Pushes the current branch, used by the PushModal (which sends no + * remote/branch when it detects an upstream) and by "push after + * rebase/merge/…" follow-up actions. Mirrors the PushModal convention: when + * the branch has an upstream we push with no remote/refspec (git resolves it + * from the upstream); when it doesn't, we set upstream (-u) on the default + * remote (origin if present, else the first remote). With no remotes + * configured the push is skipped. + * + * `git branch -m old new` keeps the upstream config pointing at the old + * branch (`branch.new.merge = refs/heads/old`), so a bare `git push` fails + * with "The upstream branch of your current branch does not match". In that + * case publish the renamed branch under its real name and retarget the + * upstream (-u), which also makes later bare pushes work. */ async pushCurrentBranch(options?: { force?: 'with-lease' | 'force' }): Promise<{ pushed: boolean; reason?: 'no-remote' }> { const current = (await this.branches()).find(b => b.current); @@ -1443,6 +1451,15 @@ export class GitService { throw new GitError('No current branch to push (detached HEAD)', null, []); } if (current.upstream) { + const slash = current.upstream.indexOf('/'); + if (slash > 0) { + const remote = current.upstream.substring(0, slash); + const upstreamBranch = current.upstream.substring(slash + 1); + if (upstreamBranch !== current.name) { + await this.push(remote, current.name, { force: options?.force, setUpstream: true }); + return { pushed: true }; + } + } await this.push(undefined, undefined, { force: options?.force }); return { pushed: true }; } diff --git a/src/panels/MainPanel.ts b/src/panels/MainPanel.ts index 5869905d..fa53bfe4 100644 --- a/src/panels/MainPanel.ts +++ b/src/panels/MainPanel.ts @@ -834,7 +834,15 @@ export class MainPanel { break; } case 'push': { - await this.gitService.push(message.payload.remote, message.payload.branch, { force: message.payload.force, setUpstream: message.payload.setUpstream }); + if (!message.payload.remote && !message.payload.branch) { + // The webview omits remote/branch when it detects an upstream. + // Route through the branch-aware helper so a branch renamed since + // the upstream was configured is published under its real name + // (a bare `git push` is rejected by git in that case, #97). + await this.gitService.pushCurrentBranch({ force: message.payload.force }); + } else { + await this.gitService.push(message.payload.remote, message.payload.branch, { force: message.payload.force, setUpstream: message.payload.setUpstream }); + } this.post({ type: 'operationComplete', payload: { operation: 'push', success: true }, diff --git a/src/panels/__tests__/MainPanel.test.ts b/src/panels/__tests__/MainPanel.test.ts index 76051ee3..f39d75da 100644 --- a/src/panels/__tests__/MainPanel.test.ts +++ b/src/panels/__tests__/MainPanel.test.ts @@ -26,6 +26,8 @@ const H = vi.hoisted(() => { stashSave: vi.fn(async () => {}), checkout: vi.fn(async () => {}), pull: vi.fn(async () => {}), + push: vi.fn(async () => ''), + pushCurrentBranch: vi.fn(async () => ({ pushed: true })), clean: vi.fn(async () => {}), setWarningHandler: vi.fn(), setAuthRetryHandler: vi.fn(), @@ -276,6 +278,19 @@ describe('MainPanel message routing', () => { expect(postedOfType('fullRefresh').length).toBeGreaterThan(0); }); + it('push without remote/branch routes through pushCurrentBranch (upstream case, #97)', async () => { + await dispatch({ type: 'push', payload: {} }); + expect(H.git.push).not.toHaveBeenCalled(); + expect(H.git.pushCurrentBranch).toHaveBeenCalledWith({ force: undefined }); + expect(postedOfType('operationComplete').some(m => m.payload?.operation === 'push')).toBe(true); + }); + + it('push with an explicit remote/branch calls push directly', async () => { + await dispatch({ type: 'push', payload: { remote: 'origin', branch: 'feature', setUpstream: true } }); + expect(H.git.pushCurrentBranch).not.toHaveBeenCalled(); + expect(H.git.push).toHaveBeenCalledWith('origin', 'feature', { force: undefined, setUpstream: true }); + }); + it('checkout with stash stashes before checking out', async () => { await dispatch({ type: 'checkout', payload: { ref: 'main', stash: true } }); expect(H.git.stashSave).toHaveBeenCalled();