Skip to content

Commit 6e72bf4

Browse files
author
Test
committed
merge: resolve conflicts from main (Feature 11 multi-remote landed)
2 parents 0c2613f + 8826d49 commit 6e72bf4

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.githubEnterpriseBaseUrl`](vscode://settings/git-smart-checkout.githubEnterpriseBaseUrl) (GitHub Enterprise base URL) | `string` | Base URL for a GitHub Enterprise Server instance, e.g. `https://ghe.example.com`. When the current repository's remote host matches this URL's host, PR Clone, Checkout by PR, and PR Review in Worktree call the Enterprise REST API (`<baseUrl>/api/v3`) and build web/compare links against `<baseUrl>` instead of github.com. Leave empty (default) to use github.com only. |
6465
| ⚙️ [`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. |

package.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -480,6 +480,11 @@
480480
"default": "",
481481
"description": "Base URL for GitHub Enterprise, for example https://ghe.example.com."
482482
},
483+
"git-smart-checkout.defaultRemote": {
484+
"type": "string",
485+
"default": "",
486+
"description": "Preferred Git remote for fetch and push operations. Leave empty to select automatically."
487+
},
483488
"git-smart-checkout.showWhatsNew": {
484489
"type": "string",
485490
"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(
@@ -78,6 +79,12 @@ export class CheckoutByPRCommand extends BaseCommand {
7879
const currentBranch = await git.getCurrentBranch();
7980
const isAlreadyOnPrBranch = isFork && currentBranch === headRef;
8081

82+
// Tracks which remote the branch was actually fetched from (same-repo PR
83+
// path only — fork PRs fetch by URL, not by remote name) so the later
84+
// checkout uses that same remote instead of silently defaulting to
85+
// 'origin', which would break or misresolve on multi-remote repos.
86+
let fetchedFromRemote: string | undefined;
87+
8188
await vscode.window.withProgress(
8289
{
8390
location: vscode.ProgressLocation.Notification,
@@ -92,7 +99,13 @@ export class CheckoutByPRCommand extends BaseCommand {
9299
// out, so fetch to FETCH_HEAD instead and let the user know.
93100
await git.fetchFromUrl(pr.head.repo.clone_url, headRef, isAlreadyOnPrBranch);
94101
} else {
95-
await git.fetchSpecificBranch(headRef, 'origin');
102+
fetchedFromRemote = await resolveGitHubRemoteInteractive(git, {
103+
branch: headRef,
104+
defaultRemote: this.configManager.get().defaultRemote,
105+
purpose: 'fetch',
106+
githubRepo: pr.base.repo?.full_name,
107+
});
108+
await git.fetchSpecificBranch(headRef, fetchedFromRemote);
96109
}
97110
}
98111
);
@@ -112,7 +125,8 @@ export class CheckoutByPRCommand extends BaseCommand {
112125

113126
const prBranch: IGitRef = {
114127
name: headRef,
115-
fullName: headRef,
128+
fullName: fetchedFromRemote ? `${fetchedFromRemote}/${headRef}` : headRef,
129+
remote: fetchedFromRemote,
116130
authorName: '',
117131
comment: pr.title,
118132
};

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 {
@@ -117,9 +118,17 @@ export class PRReviewInWorktreeCommand extends BaseCommand {
117118
},
118119
async (progress) => {
119120
progress.report({ message: `Fetching PR #${prNumber} branch "${headRef}"...` });
120-
await this.fetchPRBranch(git, headRef, isFork ? pr.head.repo?.clone_url : undefined);
121-
122-
return await this.resolveFetchedBranch(git, headRef);
121+
const remoteName = isFork
122+
? undefined
123+
: await resolveGitHubRemoteInteractive(git, {
124+
branch: headRef,
125+
defaultRemote: this.configManager.get().defaultRemote,
126+
purpose: 'fetch',
127+
githubRepo: pr.base.repo?.full_name,
128+
});
129+
await this.fetchPRBranch(git, headRef, isFork ? pr.head.repo?.clone_url : undefined, remoteName);
130+
131+
return await this.resolveFetchedBranch(git, headRef, remoteName);
123132
}
124133
);
125134

@@ -165,30 +174,31 @@ export class PRReviewInWorktreeCommand extends BaseCommand {
165174
private async fetchPRBranch(
166175
git: GitExecutor,
167176
headRef: string,
168-
forkCloneUrl?: string
177+
forkCloneUrl?: string,
178+
remoteName = 'origin'
169179
): Promise<void> {
170180
if (forkCloneUrl) {
171181
await git.fetchFromUrl(forkCloneUrl, headRef);
172182
return;
173183
}
174184

175-
await git.fetchSpecificBranch(headRef, 'origin');
185+
await git.fetchSpecificBranch(headRef, remoteName);
176186
}
177187

178-
private async resolveFetchedBranch(git: GitExecutor, headRef: string): Promise<IGitRef> {
188+
private async resolveFetchedBranch(git: GitExecutor, headRef: string, remoteName = 'origin'): Promise<IGitRef> {
179189
const refs = await git.getAllRefListExtended();
180190
const localBranch = refs.find((ref) => !ref.isTag && !ref.remote && ref.name === headRef);
181191

182192
if (localBranch) {
183193
return localBranch;
184194
}
185195

186-
const originBranch = refs.find(
187-
(ref) => !ref.isTag && ref.remote === 'origin' && ref.name === headRef
196+
const remoteBranch = refs.find(
197+
(ref) => !ref.isTag && ref.remote === remoteName && ref.name === headRef
188198
);
189199

190-
if (originBranch) {
191-
return originBranch;
200+
if (remoteBranch) {
201+
return remoteBranch;
192202
}
193203

194204
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
@@ -561,6 +561,26 @@ export class GitExecutor {
561561
return stdout.trim();
562562
}
563563

564+
async listRemotes(): Promise<Array<{ name: string; fetchUrl: string; pushUrl: string }>> {
565+
const { stdout } = await this.#execGitCommand(['remote', '-v']);
566+
const remotes = new Map<string, { name: string; fetchUrl: string; pushUrl: string }>();
567+
for (const line of stdout.split('\n')) {
568+
const match = line.match(/^([^\s]+)\s+(.+)\s+\((fetch|push)\)$/);
569+
if (!match) continue;
570+
const current = remotes.get(match[1]) ?? { name: match[1], fetchUrl: '', pushUrl: '' };
571+
current[match[3] === 'fetch' ? 'fetchUrl' : 'pushUrl'] = match[2];
572+
remotes.set(match[1], current);
573+
}
574+
return [...remotes.values()];
575+
}
576+
577+
async getUpstreamRemote(branch: string): Promise<string | undefined> {
578+
try {
579+
const { stdout } = await this.#execGitCommand(['for-each-ref', '--format=%(upstream:remotename)', `refs/heads/${branch}`]);
580+
return stdout.trim() || undefined;
581+
} catch { return undefined; }
582+
}
583+
564584
async pullFromRemoteBranch(options: { rebase?: boolean } = {}) {
565585
await this.#execGitCommand(['pull', ...(options.rebase ? ['--rebase'] : [])]);
566586
}
@@ -1039,8 +1059,8 @@ export class GitExecutor {
10391059
}
10401060
}
10411061

1042-
async pushBranchToGitHub(branchName: string): Promise<void> {
1043-
await this.#execGitCommand(['push', '-u', 'origin', branchName]);
1062+
async pushBranchToGitHub(branchName: string, remoteName = 'origin'): Promise<void> {
1063+
await this.#execGitCommand(['push', '-u', remoteName, branchName]);
10441064
}
10451065

10461066
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
@@ -97,6 +97,7 @@ export class ConfigurationManager {
9797
useFastBranchList: vscodeConfig.get('useFastBranchList', true),
9898
recentBranchCount: vscodeConfig.get('recentBranchCount', 5),
9999
githubEnterpriseBaseUrl: vscodeConfig.get('githubEnterpriseBaseUrl', ''),
100+
defaultRemote: vscodeConfig.get('defaultRemote', ''),
100101
showWhatsNew: vscodeConfig.get('showWhatsNew', 'minor'),
101102
showStatusBar: vscodeConfig.get('showStatusBar', true),
102103
defaultTargetBranch: vscodeConfig.get('defaultTargetBranch', 'main'),

src/configuration/extensionConfig.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ export interface ExtensionConfig {
4343
useFastBranchList: boolean;
4444
recentBranchCount: number;
4545
githubEnterpriseBaseUrl: string;
46+
defaultRemote: string;
4647
showWhatsNew: 'minor' | 'always' | 'never';
4748
showStatusBar: boolean;
4849
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)