Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions src/git/__tests__/git-service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
Expand Down
27 changes: 22 additions & 5 deletions src/git/git-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1431,18 +1431,35 @@ 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);
if (!current) {
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 };
}
Expand Down
10 changes: 9 additions & 1 deletion src/panels/MainPanel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand Down
15 changes: 15 additions & 0 deletions src/panels/__tests__/MainPanel.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down Expand Up @@ -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();
Expand Down