Skip to content

Commit 8826d49

Browse files
authored
Merge pull request #157 from zaknafeyn/feat/multi-remote-support
2 parents 313d52e + 25a1ee4 commit 8826d49

20 files changed

Lines changed: 759 additions & 45 deletions

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ Click a setting ID to open that setting in VS Code.
5959
| ⚙️ [`git-smart-checkout.logging.enabled`](vscode://settings/git-smart-checkout.logging.enabled) (Logging enabled) | `boolean` | Enables the extension logging output. |
6060
| ⚙️ [`git-smart-checkout.useFastBranchList`](vscode://settings/git-smart-checkout.useFastBranchList) (Use fast branch list) | `boolean` | Seeds branch pickers from VS Code's cached Git model, preloads details for the first visible refs, and keeps a 48-hour details cache. Disable to build branch lists with a full `git for-each-ref` scan. |
6161
| ⚙️ [`git-smart-checkout.recentBranchCount`](vscode://settings/git-smart-checkout.recentBranchCount) (Recent branch count) | `number` | Number of recently checked-out branches shown in a "Recent" section at the top of the `Checkout to...` picker, ranked by frequency and recency. Set to `0` to disable the section. Default `5`. |
62+
| ⚙️ [`git-smart-checkout.defaultRemote`](vscode://settings/git-smart-checkout.defaultRemote) (Default remote) | `string` | Preferred Git remote for fetch and push operations (e.g. `upstream` in a fork setup). Leave empty to resolve automatically: branch upstream → this setting → the repo's only remote → a picker if still ambiguous, remembered per repo for the session. |
6263
| ⚙️ [`git-smart-checkout.defaultTargetBranch`](vscode://settings/git-smart-checkout.defaultTargetBranch) (Default target branch) | `string` | Default target branch for PR cloning. Leave empty to use the first available branch. |
6364
| ⚙️ [`git-smart-checkout.defaultWorktreeDirectory`](vscode://settings/git-smart-checkout.defaultWorktreeDirectory) (Default worktree directory) | `string` | Directory where PR clone temporary worktrees are created. Leave empty to create them one level up from the current repository. |
6465
| ⚙️ [`git-smart-checkout.prClone.checkoutAfterClone`](vscode://settings/git-smart-checkout.prClone.checkoutAfterClone) (Checkout after PR clone) | `string` | Whether to stay on the branch/worktree created by PR Clone once it finishes. Available values: `ask` (default; prompts after each clone), `always` (in-place: skip restoring the original branch, keeping your WIP stashed; temp-worktree: keep the worktree and move it into the configured worktree directory), `never` (restore the original branch / tear down the worktree, as before). |

package.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -475,6 +475,11 @@
475475
"minimum": 0,
476476
"description": "Number of recently checked-out branches shown in the checkout picker. Set to 0 to disable."
477477
},
478+
"git-smart-checkout.defaultRemote": {
479+
"type": "string",
480+
"default": "",
481+
"description": "Preferred Git remote for fetch and push operations. Leave empty to select automatically."
482+
},
478483
"git-smart-checkout.showWhatsNew": {
479484
"type": "string",
480485
"enum": ["minor", "always", "never"],

src/commands/checkoutByPRCommand/index.ts

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
parsePRInput,
1616
} from '../utils/parsePRInput';
1717
import { findWorktreeForBranch, handleWorktreeBranchConflict } from '../utils/worktreeBranchConflict';
18+
import { resolveGitHubRemoteInteractive } from '../../utils/remoteSelection';
1819

1920
export class CheckoutByPRCommand extends BaseCommand {
2021
constructor(
@@ -75,6 +76,12 @@ export class CheckoutByPRCommand extends BaseCommand {
7576
const currentBranch = await git.getCurrentBranch();
7677
const isAlreadyOnPrBranch = isFork && currentBranch === headRef;
7778

79+
// Tracks which remote the branch was actually fetched from (same-repo PR
80+
// path only — fork PRs fetch by URL, not by remote name) so the later
81+
// checkout uses that same remote instead of silently defaulting to
82+
// 'origin', which would break or misresolve on multi-remote repos.
83+
let fetchedFromRemote: string | undefined;
84+
7885
await vscode.window.withProgress(
7986
{
8087
location: vscode.ProgressLocation.Notification,
@@ -89,7 +96,13 @@ export class CheckoutByPRCommand extends BaseCommand {
8996
// out, so fetch to FETCH_HEAD instead and let the user know.
9097
await git.fetchFromUrl(pr.head.repo.clone_url, headRef, isAlreadyOnPrBranch);
9198
} else {
92-
await git.fetchSpecificBranch(headRef, 'origin');
99+
fetchedFromRemote = await resolveGitHubRemoteInteractive(git, {
100+
branch: headRef,
101+
defaultRemote: this.configManager.get().defaultRemote,
102+
purpose: 'fetch',
103+
githubRepo: pr.base.repo?.full_name,
104+
});
105+
await git.fetchSpecificBranch(headRef, fetchedFromRemote);
93106
}
94107
}
95108
);
@@ -109,7 +122,8 @@ export class CheckoutByPRCommand extends BaseCommand {
109122

110123
const prBranch: IGitRef = {
111124
name: headRef,
112-
fullName: headRef,
125+
fullName: fetchedFromRemote ? `${fetchedFromRemote}/${headRef}` : headRef,
126+
remote: fetchedFromRemote,
113127
authorName: '',
114128
comment: pr.title,
115129
};

src/commands/prReviewInWorktreeCommand/index.ts

Lines changed: 20 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import {
2121
} from '../utils/prReviewWorktree';
2222
import { completeWorktreeCreation, showWorktreeCompletionActions } from '../utils/worktreeCompletionActions';
2323
import { selectWorktreePath } from '../utils/worktreePath';
24+
import { resolveGitHubRemoteInteractive } from '../../utils/remoteSelection';
2425
import { WorktreeSetupService } from '../../services/worktreeSetupService';
2526

2627
export class PRReviewInWorktreeCommand extends BaseCommand {
@@ -114,9 +115,17 @@ export class PRReviewInWorktreeCommand extends BaseCommand {
114115
},
115116
async (progress) => {
116117
progress.report({ message: `Fetching PR #${prNumber} branch "${headRef}"...` });
117-
await this.fetchPRBranch(git, headRef, isFork ? pr.head.repo?.clone_url : undefined);
118-
119-
return await this.resolveFetchedBranch(git, headRef);
118+
const remoteName = isFork
119+
? undefined
120+
: await resolveGitHubRemoteInteractive(git, {
121+
branch: headRef,
122+
defaultRemote: this.configManager.get().defaultRemote,
123+
purpose: 'fetch',
124+
githubRepo: pr.base.repo?.full_name,
125+
});
126+
await this.fetchPRBranch(git, headRef, isFork ? pr.head.repo?.clone_url : undefined, remoteName);
127+
128+
return await this.resolveFetchedBranch(git, headRef, remoteName);
120129
}
121130
);
122131

@@ -162,30 +171,31 @@ export class PRReviewInWorktreeCommand extends BaseCommand {
162171
private async fetchPRBranch(
163172
git: GitExecutor,
164173
headRef: string,
165-
forkCloneUrl?: string
174+
forkCloneUrl?: string,
175+
remoteName = 'origin'
166176
): Promise<void> {
167177
if (forkCloneUrl) {
168178
await git.fetchFromUrl(forkCloneUrl, headRef);
169179
return;
170180
}
171181

172-
await git.fetchSpecificBranch(headRef, 'origin');
182+
await git.fetchSpecificBranch(headRef, remoteName);
173183
}
174184

175-
private async resolveFetchedBranch(git: GitExecutor, headRef: string): Promise<IGitRef> {
185+
private async resolveFetchedBranch(git: GitExecutor, headRef: string, remoteName = 'origin'): Promise<IGitRef> {
176186
const refs = await git.getAllRefListExtended();
177187
const localBranch = refs.find((ref) => !ref.isTag && !ref.remote && ref.name === headRef);
178188

179189
if (localBranch) {
180190
return localBranch;
181191
}
182192

183-
const originBranch = refs.find(
184-
(ref) => !ref.isTag && ref.remote === 'origin' && ref.name === headRef
193+
const remoteBranch = refs.find(
194+
(ref) => !ref.isTag && ref.remote === remoteName && ref.name === headRef
185195
);
186196

187-
if (originBranch) {
188-
return originBranch;
197+
if (remoteBranch) {
198+
return remoteBranch;
189199
}
190200

191201
throw new Error(`Could not find fetched PR branch "${headRef}".`);

src/common/git/gitExecutor.ts

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -520,6 +520,26 @@ export class GitExecutor {
520520
return stdout.trim();
521521
}
522522

523+
async listRemotes(): Promise<Array<{ name: string; fetchUrl: string; pushUrl: string }>> {
524+
const { stdout } = await this.#execGitCommand(['remote', '-v']);
525+
const remotes = new Map<string, { name: string; fetchUrl: string; pushUrl: string }>();
526+
for (const line of stdout.split('\n')) {
527+
const match = line.match(/^([^\s]+)\s+(.+)\s+\((fetch|push)\)$/);
528+
if (!match) continue;
529+
const current = remotes.get(match[1]) ?? { name: match[1], fetchUrl: '', pushUrl: '' };
530+
current[match[3] === 'fetch' ? 'fetchUrl' : 'pushUrl'] = match[2];
531+
remotes.set(match[1], current);
532+
}
533+
return [...remotes.values()];
534+
}
535+
536+
async getUpstreamRemote(branch: string): Promise<string | undefined> {
537+
try {
538+
const { stdout } = await this.#execGitCommand(['for-each-ref', '--format=%(upstream:remotename)', `refs/heads/${branch}`]);
539+
return stdout.trim() || undefined;
540+
} catch { return undefined; }
541+
}
542+
523543
async pullFromRemoteBranch(options: { rebase?: boolean } = {}) {
524544
await this.#execGitCommand(['pull', ...(options.rebase ? ['--rebase'] : [])]);
525545
}
@@ -998,8 +1018,8 @@ export class GitExecutor {
9981018
}
9991019
}
10001020

1001-
async pushBranchToGitHub(branchName: string): Promise<void> {
1002-
await this.#execGitCommand(['push', '-u', 'origin', branchName]);
1021+
async pushBranchToGitHub(branchName: string, remoteName = 'origin'): Promise<void> {
1022+
await this.#execGitCommand(['push', '-u', remoteName, branchName]);
10031023
}
10041024

10051025
async tagExists(tagName: string): Promise<boolean> {

src/common/git/remoteResolver.ts

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
import { GitExecutor, parseGitHubRemoteUrl } from './gitExecutor';
2+
3+
export interface IGitRemoteInfo {
4+
name: string;
5+
fetchUrl: string;
6+
pushUrl: string;
7+
}
8+
9+
export type RemoteResolution = { remote: string } | { needsPick: IGitRemoteInfo[] };
10+
11+
/**
12+
* Per-repository session memory: once a user has resolved an ambiguous
13+
* remote pick (via the caller-layer QuickPick), we remember it for the
14+
* rest of the VS Code session so we don't ask again for the same repo.
15+
*/
16+
const remembered = new Map<string, string>();
17+
18+
export interface ResolveRemoteOptions {
19+
/** Branch whose configured upstream remote should be preferred, if any. */
20+
branch?: string;
21+
/** Value of the `git-smart-checkout.defaultRemote` setting. */
22+
defaultRemote?: string;
23+
purpose: 'fetch' | 'push';
24+
}
25+
26+
/**
27+
* Safely lists remotes for a repository. Falls back to an empty list if the
28+
* underlying git call fails (e.g. not a git repo yet) or if `git` is a
29+
* lightweight test double that doesn't implement `listRemotes` — callers
30+
* treat an empty list the same as "nothing to resolve, use 'origin'".
31+
*/
32+
async function safeListRemotes(git: GitExecutor): Promise<IGitRemoteInfo[]> {
33+
try {
34+
return (await git.listRemotes?.()) ?? [];
35+
} catch {
36+
return [];
37+
}
38+
}
39+
40+
async function safeGetUpstreamRemote(git: GitExecutor, branch: string): Promise<string | undefined> {
41+
try {
42+
return await git.getUpstreamRemote?.(branch);
43+
} catch {
44+
return undefined;
45+
}
46+
}
47+
48+
/**
49+
* Resolves which remote should be used for a git operation, following the
50+
* order documented in the multi-remote support spec:
51+
* 1. The branch's configured upstream remote.
52+
* 2. The `defaultRemote` setting, if set and it exists in the repo.
53+
* 3. If the repo has exactly one remote, that remote.
54+
* 4. A remembered pick from earlier in this session (for this repo).
55+
* 5. Otherwise, the caller must prompt the user (`needsPick`).
56+
*
57+
* This function is UI-free by design; callers that receive `needsPick`
58+
* are responsible for prompting the user and calling `rememberRemote`.
59+
* If remote discovery is unavailable (e.g. `listRemotes` fails or isn't
60+
* implemented by the caller's `GitExecutor`), it falls back to `'origin'`
61+
* so existing single-remote-repo behavior is unchanged.
62+
*/
63+
export async function resolveRemote(git: GitExecutor, opts: ResolveRemoteOptions): Promise<RemoteResolution> {
64+
const remotes = await safeListRemotes(git);
65+
if (remotes.length === 0) {
66+
return { remote: 'origin' };
67+
}
68+
69+
const available = new Set(remotes.map((remote) => remote.name));
70+
71+
const upstream = opts.branch ? await safeGetUpstreamRemote(git, opts.branch) : undefined;
72+
if (upstream && available.has(upstream)) {
73+
return { remote: upstream };
74+
}
75+
76+
if (opts.defaultRemote && available.has(opts.defaultRemote)) {
77+
return { remote: opts.defaultRemote };
78+
}
79+
80+
if (remotes.length === 1) {
81+
return { remote: remotes[0].name };
82+
}
83+
84+
const cached = remembered.get(git.repositoryPath);
85+
if (cached && available.has(cached)) {
86+
return { remote: cached };
87+
}
88+
89+
return { needsPick: remotes };
90+
}
91+
92+
/** Records the user's answer to an ambiguous remote pick for this repo/session. */
93+
export function rememberRemote(repositoryPath: string, remote: string): void {
94+
remembered.set(repositoryPath, remote);
95+
}
96+
97+
/** Test-only helper to reset session memory between test cases. */
98+
export function clearRememberedRemotes(): void {
99+
remembered.clear();
100+
}
101+
102+
export interface ResolveGitHubRemoteOptions extends ResolveRemoteOptions {
103+
/** `owner/repo` full name of the GitHub repository the operation targets (e.g. a PR's base repo). */
104+
githubRepo?: string;
105+
}
106+
107+
/**
108+
* Resolves the remote to use for GitHub-specific flows (PR clone,
109+
* checkout-by-PR, PR review). When `githubRepo` is provided, remotes whose
110+
* fetch URL parses (via `parseGitHubRemoteUrl`) to that `owner/repo` are
111+
* preferred over the generic resolution order — this is what lets a fork
112+
* setup (`origin` = fork, `upstream` = canonical repo) pick the correct
113+
* remote for a given PR's base repository.
114+
*/
115+
export async function resolveGitHubRemote(git: GitExecutor, opts: ResolveGitHubRemoteOptions): Promise<RemoteResolution> {
116+
if (!opts.githubRepo) {
117+
return resolveRemote(git, opts);
118+
}
119+
120+
const remotes = await safeListRemotes(git);
121+
if (remotes.length === 0) {
122+
return { remote: 'origin' };
123+
}
124+
125+
const matches = remotes.filter((remote) => {
126+
const parsed = parseGitHubRemoteUrl(remote.fetchUrl);
127+
return parsed ? `${parsed.owner}/${parsed.repo}`.toLowerCase() === opts.githubRepo!.toLowerCase() : false;
128+
});
129+
130+
if (matches.length === 1) {
131+
return { remote: matches[0].name };
132+
}
133+
134+
if (matches.length > 1) {
135+
// Multiple remotes point at the same GitHub repo (rare) — fall back to
136+
// the generic resolution order, restricted to the matching remotes.
137+
const available = new Set(matches.map((remote) => remote.name));
138+
const upstream = opts.branch ? await safeGetUpstreamRemote(git, opts.branch) : undefined;
139+
if (upstream && available.has(upstream)) return { remote: upstream };
140+
if (opts.defaultRemote && available.has(opts.defaultRemote)) return { remote: opts.defaultRemote };
141+
const cached = remembered.get(git.repositoryPath);
142+
if (cached && available.has(cached)) return { remote: cached };
143+
return { needsPick: matches };
144+
}
145+
146+
// No remote matches the PR's GitHub repo (e.g. shallow/renamed remotes) —
147+
// fall back to the generic resolution order across all remotes.
148+
return resolveRemote(git, opts);
149+
}

src/configuration/configurationManager.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,7 @@ export class ConfigurationManager {
9696
mode: vscodeConfig.get('mode', AUTO_STASH_MODE_MANUAL),
9797
useFastBranchList: vscodeConfig.get('useFastBranchList', true),
9898
recentBranchCount: vscodeConfig.get('recentBranchCount', 5),
99+
defaultRemote: vscodeConfig.get('defaultRemote', ''),
99100
showWhatsNew: vscodeConfig.get('showWhatsNew', 'minor'),
100101
showStatusBar: vscodeConfig.get('showStatusBar', true),
101102
defaultTargetBranch: vscodeConfig.get('defaultTargetBranch', 'main'),

src/configuration/extensionConfig.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ export interface ExtensionConfig {
4242
mode: TAutoStashModeConfig;
4343
useFastBranchList: boolean;
4444
recentBranchCount: number;
45+
defaultRemote: string;
4546
showWhatsNew: 'minor' | 'always' | 'never';
4647
showStatusBar: boolean;
4748
defaultTargetBranch: string;

src/extension.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -170,7 +170,7 @@ export function activate(context: vscode.ExtensionContext) {
170170
prCloneService
171171
);
172172
const autoStashService = new AutoStashService(configManager, logService, () =>
173-
void updateNotificationService.recordStashCarryingCheckoutSuccess(context)
173+
updateNotificationService.recordStashCarryingCheckoutSuccess(context)
174174
);
175175
const refDetailsCache = new RefDetailsCache(context.globalState, logService);
176176

0 commit comments

Comments
 (0)