From 3f23261dfe2d9360742c1a3e46ed5743aef3d8fd Mon Sep 17 00:00:00 2001 From: Lucas Farias Date: Tue, 15 Sep 2026 15:06:58 -0300 Subject: [PATCH 1/5] agentHost: only offer Sync Changes for a remote-tracking upstream The sync operation resolves the upstream through refs/remotes// and rejects anything else, but the provider advertised "Sync Changes" for every branch that reported an upstream with ahead/behind counts. A branch whose upstream is a local branch (branch..remote = .) therefore showed the button and failed on every click with "Could not resolve the remote". Use the existing parseUpstreamBranchName check so the provider and the handler agree on what can be synced. --- .../agentHost/node/agentHostSyncOperationProvider.ts | 5 +++-- .../test/node/agentHostSyncOperationProvider.test.ts | 12 ++++++++++++ 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/src/vs/platform/agentHost/node/agentHostSyncOperationProvider.ts b/src/vs/platform/agentHost/node/agentHostSyncOperationProvider.ts index 194149cbf84aab..9a5f303f2b5963 100644 --- a/src/vs/platform/agentHost/node/agentHostSyncOperationProvider.ts +++ b/src/vs/platform/agentHost/node/agentHostSyncOperationProvider.ts @@ -7,6 +7,7 @@ import { Disposable, DisposableStore, IDisposable } from '../../../base/common/l import { localize } from '../../../nls.js'; import { IInstantiationService } from '../../instantiation/common/instantiation.js'; import type { IChangesetOperationContribution, IChangesetOperationContext, IChangesetOperationRegistry } from '../common/agentHostChangesetOperationService.js'; +import { parseUpstreamBranchName } from '../common/agentHostGitService.js'; import { ChangesetOperationScope, ChangesetOperationStatus, SessionLifecycle, type ChangesetOperation } from '../common/state/sessionState.js'; import { AgentHostStateManager, IAgentHostStateManager } from './agentHostStateManager.js'; import { AgentHostSyncOperationHandler } from './agentHostSyncOperationHandler.js'; @@ -39,8 +40,8 @@ export class AgentHostSyncOperationContribution extends Disposable implements IC return undefined; } - // No upstream branch - if (!gitState?.upstreamBranchName) { + // No remote-tracking upstream branch (a local upstream cannot be synced) + if (!parseUpstreamBranchName(gitState?.upstreamBranchName)) { return undefined; } diff --git a/src/vs/platform/agentHost/test/node/agentHostSyncOperationProvider.test.ts b/src/vs/platform/agentHost/test/node/agentHostSyncOperationProvider.test.ts index e9ebd3455ea6d3..35658650cdc946 100644 --- a/src/vs/platform/agentHost/test/node/agentHostSyncOperationProvider.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostSyncOperationProvider.test.ts @@ -131,6 +131,18 @@ suite('AgentHostSyncOperationContribution', () => { assert.strictEqual(operations, undefined); }); + test('does not advertise sync when the upstream is a local branch', () => { + const provider = createContribution(); + const operations = provider.getOperations({ + sessionKey, + changesetUri: uncommittedChangesetUri, + changesetKind: ChangesetKind.Uncommitted, + gitState: { ...gitStateWithIncomingChanges, upstreamBranchName: 'main' }, + }); + + assert.strictEqual(operations, undefined); + }); + test('does not advertise incoming sync on a draft with uncommitted changes', () => { const provider = createContribution(true); const operations = provider.getOperations({ From 86dc5e4a7904da94fb83e926bc8fc57c32993fc9 Mon Sep 17 00:00:00 2001 From: Lucas Farias Date: Tue, 15 Sep 2026 15:20:07 -0300 Subject: [PATCH 2/5] agentHost: persist the upstream remote and use it to gate Sync Changes Inferring "remote-tracking" from the shape of the upstream name misses a local upstream whose name contains a slash (feature/base). Ask git instead: for-each-ref %(upstream:remotename) reports the remote of the upstream and "." for a local branch. Persist it as upstreamRemote in the session git state and let the sync provider require it. The same value replaces the split('/')[0] guess that fed the GitHub head-owner lookup. --- .../agentHost/common/state/sessionState.ts | 4 ++++ .../agentHost/node/agentHostGitService.ts | 11 +++++++++- .../node/agentHostSyncOperationProvider.ts | 3 +-- .../agentHostGitService.integrationTest.ts | 21 +++++++++++++++++++ .../agentHostSyncOperationProvider.test.ts | 9 ++++---- 5 files changed, 41 insertions(+), 7 deletions(-) diff --git a/src/vs/platform/agentHost/common/state/sessionState.ts b/src/vs/platform/agentHost/common/state/sessionState.ts index 33768d01d4b446..72b160f5dd0876 100644 --- a/src/vs/platform/agentHost/common/state/sessionState.ts +++ b/src/vs/platform/agentHost/common/state/sessionState.ts @@ -1596,6 +1596,8 @@ export interface ISessionGitState { readonly baseBranchName?: string; /** Upstream tracking branch (e.g. `origin/feature`). */ readonly upstreamBranchName?: string; + /** Remote of the upstream tracking branch (e.g. `origin`). Absent when there is no upstream or the upstream is a local branch. */ + readonly upstreamRemote?: string; /** Number of commits the upstream branch has ahead of the local branch. */ readonly incomingChanges?: number; /** Number of commits the local branch has ahead of the upstream branch. */ @@ -1808,6 +1810,7 @@ export function readSessionGitState(meta: SessionMeta | undefined): ISessionGitS isDetachedHead?: boolean; baseBranchName?: string; upstreamBranchName?: string; + upstreamRemote?: string; incomingChanges?: number; outgoingChanges?: number; uncommittedChanges?: number; @@ -1821,6 +1824,7 @@ export function readSessionGitState(meta: SessionMeta | undefined): ISessionGitS if (typeof raw['isDetachedHead'] === 'boolean') { result.isDetachedHead = raw['isDetachedHead']; } if (typeof raw['baseBranchName'] === 'string') { result.baseBranchName = raw['baseBranchName']; } if (typeof raw['upstreamBranchName'] === 'string') { result.upstreamBranchName = raw['upstreamBranchName']; } + if (typeof raw['upstreamRemote'] === 'string') { result.upstreamRemote = raw['upstreamRemote']; } if (typeof raw['incomingChanges'] === 'number') { result.incomingChanges = raw['incomingChanges']; } if (typeof raw['outgoingChanges'] === 'number') { result.outgoingChanges = raw['outgoingChanges']; } if (typeof raw['uncommittedChanges'] === 'number') { result.uncommittedChanges = raw['uncommittedChanges']; } diff --git a/src/vs/platform/agentHost/node/agentHostGitService.ts b/src/vs/platform/agentHost/node/agentHostGitService.ts index 66032b3901bcc3..521e0916a20062 100644 --- a/src/vs/platform/agentHost/node/agentHostGitService.ts +++ b/src/vs/platform/agentHost/node/agentHostGitService.ts @@ -1017,7 +1017,10 @@ export class AgentHostGitService implements IAgentHostGitService { const hasGitHubRemote = parseHasGitHubRemote(remotesOutput); const baseBranchName = configuredBaseBranch ?? parseDefaultBranchRef(defaultBranchRef); const githubRepo = parseGitHubRepoFromRemote(remotesOutput); - const upstreamRemote = status.upstreamBranchName?.split('/')[0]; + // Ask git for the upstream remote: a local upstream (branch..remote = .) has none. + const upstreamRemote = status.upstreamBranchName && status.branchName + ? await this._getUpstreamRemote(repositoryRoot, status.branchName) + : undefined; // `gh pr checkout` can create a local branch whose head lives on a fork but // has no upstream tracking ref; Git still reports the branch's push remote, // which can be a remote name or the literal fork URL. @@ -1051,6 +1054,7 @@ export class AgentHostGitService implements IAgentHostGitService { isDetachedHead: status.isDetachedHead, baseBranchName, upstreamBranchName: status.upstreamBranchName, + upstreamRemote, incomingChanges: status.incomingChanges, outgoingChanges, uncommittedChanges: status.uncommittedChanges, @@ -1064,6 +1068,11 @@ export class AgentHostGitService implements IAgentHostGitService { return stripUndefined(result); } + private async _getUpstreamRemote(repositoryRoot: URI, branchName: string): Promise { + const remote = (await this._runGit(repositoryRoot, ['for-each-ref', '--format=%(upstream:remotename)', `refs/heads/${branchName}`]))?.trim(); + return remote && remote !== '.' ? remote : undefined; + } + private async _getPushRemote(repositoryRoot: URI, branchName: string): Promise { return (await this._runGit(repositoryRoot, ['for-each-ref', '--format=%(push:remotename)', `refs/heads/${branchName}`]))?.trim() || undefined; } diff --git a/src/vs/platform/agentHost/node/agentHostSyncOperationProvider.ts b/src/vs/platform/agentHost/node/agentHostSyncOperationProvider.ts index 9a5f303f2b5963..d590c36f115bb9 100644 --- a/src/vs/platform/agentHost/node/agentHostSyncOperationProvider.ts +++ b/src/vs/platform/agentHost/node/agentHostSyncOperationProvider.ts @@ -7,7 +7,6 @@ import { Disposable, DisposableStore, IDisposable } from '../../../base/common/l import { localize } from '../../../nls.js'; import { IInstantiationService } from '../../instantiation/common/instantiation.js'; import type { IChangesetOperationContribution, IChangesetOperationContext, IChangesetOperationRegistry } from '../common/agentHostChangesetOperationService.js'; -import { parseUpstreamBranchName } from '../common/agentHostGitService.js'; import { ChangesetOperationScope, ChangesetOperationStatus, SessionLifecycle, type ChangesetOperation } from '../common/state/sessionState.js'; import { AgentHostStateManager, IAgentHostStateManager } from './agentHostStateManager.js'; import { AgentHostSyncOperationHandler } from './agentHostSyncOperationHandler.js'; @@ -41,7 +40,7 @@ export class AgentHostSyncOperationContribution extends Disposable implements IC } // No remote-tracking upstream branch (a local upstream cannot be synced) - if (!parseUpstreamBranchName(gitState?.upstreamBranchName)) { + if (!gitState?.upstreamBranchName || !gitState.upstreamRemote) { return undefined; } diff --git a/src/vs/platform/agentHost/test/node/agentHostGitService.integrationTest.ts b/src/vs/platform/agentHost/test/node/agentHostGitService.integrationTest.ts index 2c8996f7d5f37a..c6f7325bc41c89 100644 --- a/src/vs/platform/agentHost/test/node/agentHostGitService.integrationTest.ts +++ b/src/vs/platform/agentHost/test/node/agentHostGitService.integrationTest.ts @@ -120,11 +120,32 @@ suite('AgentHostGitService - getSessionGitState (real git)', () => { githubHeadOwner: result?.githubHeadOwner, githubRepo: result?.githubRepo, upstreamBranchName: result?.upstreamBranchName, + upstreamRemote: result?.upstreamRemote, }, { githubOwner: 'base-owner', githubHeadOwner: 'fork-owner', githubRepo: 'repo', upstreamBranchName: 'fork/feature', + upstreamRemote: 'fork', + }); + }); + + (hasGit ? test : test.skip)('reports no upstream remote when the upstream is a local branch', async () => { + const dir = initRepo({ remote: 'https://github.com/owner/repo.git' }); + cp.execFileSync('git', ['branch', 'feature/base'], { cwd: dir, stdio: 'pipe' }); + cp.execFileSync('git', ['checkout', '-q', '-b', 'topic'], { cwd: dir, stdio: 'pipe' }); + cp.execFileSync('git', ['branch', '--set-upstream-to', 'feature/base'], { cwd: dir, stdio: 'pipe' }); + + const result = await svc!.getSessionGitState(URI.file(dir)); + + assert.deepStrictEqual({ + upstreamBranchName: result?.upstreamBranchName, + upstreamRemote: result?.upstreamRemote, + incomingChanges: result?.incomingChanges, + }, { + upstreamBranchName: 'feature/base', + upstreamRemote: undefined, + incomingChanges: 0, }); }); diff --git a/src/vs/platform/agentHost/test/node/agentHostSyncOperationProvider.test.ts b/src/vs/platform/agentHost/test/node/agentHostSyncOperationProvider.test.ts index 35658650cdc946..c72ade48ed395e 100644 --- a/src/vs/platform/agentHost/test/node/agentHostSyncOperationProvider.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostSyncOperationProvider.test.ts @@ -19,6 +19,7 @@ const uncommittedChangesetUri = buildUncommittedChangesetUri(sessionKey); const gitStateWithOutgoingChanges: ISessionGitState = { branchName: 'feature/test', upstreamBranchName: 'origin/feature/test', + upstreamRemote: 'origin', outgoingChanges: 2, }; @@ -133,14 +134,14 @@ suite('AgentHostSyncOperationContribution', () => { test('does not advertise sync when the upstream is a local branch', () => { const provider = createContribution(); - const operations = provider.getOperations({ + const operations = ['main', 'feature/base'].map(upstreamBranchName => provider.getOperations({ sessionKey, changesetUri: uncommittedChangesetUri, changesetKind: ChangesetKind.Uncommitted, - gitState: { ...gitStateWithIncomingChanges, upstreamBranchName: 'main' }, - }); + gitState: { ...gitStateWithIncomingChanges, upstreamBranchName, upstreamRemote: undefined }, + })); - assert.strictEqual(operations, undefined); + assert.deepStrictEqual(operations, [undefined, undefined]); }); test('does not advertise incoming sync on a draft with uncommitted changes', () => { From 47a3b7b6b82d3026ab8cf876959bbf1324442bb1 Mon Sep 17 00:00:00 2001 From: Lucas Farias Date: Tue, 15 Sep 2026 15:29:57 -0300 Subject: [PATCH 3/5] agentHost: keep the GitHub head-owner lookup unchanged, probe the upstream remote in parallel The persisted upstreamRemote is only consumed by the sync provider. Feeding it into the GitHub head-owner lookup changed which sessions get a head owner and opened a new path into the pull request handlers, which still parse the short upstream name. Leave that lookup as it was and run the new probe alongside the other per-branch probes. Tests: cover a remote whose name contains a slash with real git, pin that a local upstream keeps no head owner, and add the "remote-looking name without a remote" case to the provider test. --- .../agentHost/node/agentHostGitService.ts | 13 ++++++----- .../agentHostGitService.integrationTest.ts | 22 +++++++++++++++++-- .../agentHostSyncOperationProvider.test.ts | 7 +++--- 3 files changed, 31 insertions(+), 11 deletions(-) diff --git a/src/vs/platform/agentHost/node/agentHostGitService.ts b/src/vs/platform/agentHost/node/agentHostGitService.ts index 521e0916a20062..2cc9836bf84f93 100644 --- a/src/vs/platform/agentHost/node/agentHostGitService.ts +++ b/src/vs/platform/agentHost/node/agentHostGitService.ts @@ -1017,20 +1017,21 @@ export class AgentHostGitService implements IAgentHostGitService { const hasGitHubRemote = parseHasGitHubRemote(remotesOutput); const baseBranchName = configuredBaseBranch ?? parseDefaultBranchRef(defaultBranchRef); const githubRepo = parseGitHubRepoFromRemote(remotesOutput); - // Ask git for the upstream remote: a local upstream (branch..remote = .) has none. - const upstreamRemote = status.upstreamBranchName && status.branchName - ? await this._getUpstreamRemote(repositoryRoot, status.branchName) - : undefined; + const upstreamRemote = status.upstreamBranchName?.split('/')[0]; // `gh pr checkout` can create a local branch whose head lives on a fork but // has no upstream tracking ref; Git still reports the branch's push remote, // which can be a remote name or the literal fork URL. - const [pushRemote, baseBranchDivergence] = await Promise.all([ + // The persisted remote is asked from git: a local upstream (branch..remote = .) has none. + const [pushRemote, baseBranchDivergence, upstreamTrackingRemote] = await Promise.all([ !upstreamRemote && status.branchName ? this._getPushRemote(repositoryRoot, status.branchName) : undefined, baseBranchName && status.branchName && status.branchName !== baseBranchName ? this._computeBaseBranchDivergence(repositoryRoot, baseBranchName, status.outgoingChanges === undefined) : undefined, + status.upstreamBranchName && status.branchName + ? this._getUpstreamRemote(repositoryRoot, status.branchName) + : undefined, ]); const githubHeadRepo = upstreamRemote ? parseGitHubRepoFromRemote(remotesOutput, upstreamRemote) @@ -1054,7 +1055,7 @@ export class AgentHostGitService implements IAgentHostGitService { isDetachedHead: status.isDetachedHead, baseBranchName, upstreamBranchName: status.upstreamBranchName, - upstreamRemote, + upstreamRemote: upstreamTrackingRemote, incomingChanges: status.incomingChanges, outgoingChanges, uncommittedChanges: status.uncommittedChanges, diff --git a/src/vs/platform/agentHost/test/node/agentHostGitService.integrationTest.ts b/src/vs/platform/agentHost/test/node/agentHostGitService.integrationTest.ts index c6f7325bc41c89..3e605323dec59d 100644 --- a/src/vs/platform/agentHost/test/node/agentHostGitService.integrationTest.ts +++ b/src/vs/platform/agentHost/test/node/agentHostGitService.integrationTest.ts @@ -141,11 +141,29 @@ suite('AgentHostGitService - getSessionGitState (real git)', () => { assert.deepStrictEqual({ upstreamBranchName: result?.upstreamBranchName, upstreamRemote: result?.upstreamRemote, - incomingChanges: result?.incomingChanges, + githubHeadOwner: result?.githubHeadOwner, }, { upstreamBranchName: 'feature/base', upstreamRemote: undefined, - incomingChanges: 0, + githubHeadOwner: undefined, + }); + }); + + (hasGit ? test : test.skip)('reports the full upstream remote name when it contains a slash', async () => { + const dir = initRepo({ remote: 'https://github.com/base-owner/repo.git' }); + cp.execFileSync('git', ['remote', 'add', 'my/fork', 'https://github.com/fork-owner/repo.git'], { cwd: dir, stdio: 'pipe' }); + cp.execFileSync('git', ['checkout', '-q', '-b', 'feature'], { cwd: dir, stdio: 'pipe' }); + cp.execFileSync('git', ['update-ref', 'refs/remotes/my/fork/feature', 'HEAD'], { cwd: dir, stdio: 'pipe' }); + cp.execFileSync('git', ['branch', '--set-upstream-to', 'my/fork/feature'], { cwd: dir, stdio: 'pipe' }); + + const result = await svc!.getSessionGitState(URI.file(dir)); + + assert.deepStrictEqual({ + upstreamBranchName: result?.upstreamBranchName, + upstreamRemote: result?.upstreamRemote, + }, { + upstreamBranchName: 'my/fork/feature', + upstreamRemote: 'my/fork', }); }); diff --git a/src/vs/platform/agentHost/test/node/agentHostSyncOperationProvider.test.ts b/src/vs/platform/agentHost/test/node/agentHostSyncOperationProvider.test.ts index c72ade48ed395e..33eb218f275f92 100644 --- a/src/vs/platform/agentHost/test/node/agentHostSyncOperationProvider.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostSyncOperationProvider.test.ts @@ -132,16 +132,17 @@ suite('AgentHostSyncOperationContribution', () => { assert.strictEqual(operations, undefined); }); - test('does not advertise sync when the upstream is a local branch', () => { + test('does not advertise sync without an upstream remote', () => { const provider = createContribution(); - const operations = ['main', 'feature/base'].map(upstreamBranchName => provider.getOperations({ + // Local upstreams (`main`, `feature/base`) and a remote-looking name whose remote is unknown. + const operations = ['main', 'feature/base', 'origin/feature/test'].map(upstreamBranchName => provider.getOperations({ sessionKey, changesetUri: uncommittedChangesetUri, changesetKind: ChangesetKind.Uncommitted, gitState: { ...gitStateWithIncomingChanges, upstreamBranchName, upstreamRemote: undefined }, })); - assert.deepStrictEqual(operations, [undefined, undefined]); + assert.deepStrictEqual(operations, [undefined, undefined, undefined]); }); test('does not advertise incoming sync on a draft with uncommitted changes', () => { From 8d3ca287cc68af154efc38bebe30694e6b5af456 Mon Sep 17 00:00:00 2001 From: Lucas Farias Date: Tue, 15 Sep 2026 15:51:51 -0300 Subject: [PATCH 4/5] agentHost: carry the upstream remote from git into branch refs and refresh legacy state Two gaps in the previous commits: - A remote whose name contains a slash (my/fork) was offered for Sync but the branch parser derived the remote from the first path segment, so the handler pulled from "my". getRefs now asks for %(upstream:remotename) and the parser carries that value into branch.upstream.remote. - Git state persisted before upstreamRemote existed was indistinguishable from a known local upstream, so Sync stayed hidden until an unrelated refresh. upstreamRemote now stores git's value as is ("." for a local upstream), the provider treats "." as not syncable, and needsSessionGitStateRefresh asks for one recompute when a state names an upstream without its remote. The name-derived guess only feeds the GitHub head-owner lookup and is named as such. A failed remote probe is logged. --- .../agentHost/common/state/sessionState.ts | 14 +++++- .../agentHost/node/agentHostGitService.ts | 39 +++++++++++---- .../node/agentHostSyncOperationProvider.ts | 4 +- .../agentHostGitService.integrationTest.ts | 11 ++++- .../test/node/agentHostGitService.test.ts | 48 ++++++++++++++++++- .../agentHostSyncOperationHandler.test.ts | 40 ++++++++++++++++ .../agentHostSyncOperationProvider.test.ts | 24 ++++++++-- 7 files changed, 158 insertions(+), 22 deletions(-) diff --git a/src/vs/platform/agentHost/common/state/sessionState.ts b/src/vs/platform/agentHost/common/state/sessionState.ts index 72b160f5dd0876..81f56a7b5af06e 100644 --- a/src/vs/platform/agentHost/common/state/sessionState.ts +++ b/src/vs/platform/agentHost/common/state/sessionState.ts @@ -1596,7 +1596,11 @@ export interface ISessionGitState { readonly baseBranchName?: string; /** Upstream tracking branch (e.g. `origin/feature`). */ readonly upstreamBranchName?: string; - /** Remote of the upstream tracking branch (e.g. `origin`). Absent when there is no upstream or the upstream is a local branch. */ + /** + * Remote of the upstream tracking branch as git reports it (`origin`, `my/fork`), or `.` + * when the upstream is a local branch. Absent when there is no upstream, and in git + * state persisted before this field existed. + */ readonly upstreamRemote?: string; /** Number of commits the upstream branch has ahead of the local branch. */ readonly incomingChanges?: number; @@ -1847,9 +1851,15 @@ export function readSessionGitState(meta: SessionMeta | undefined): ISessionGitS * `HEAD` is a legitimate branch-less checkout and must not be mistaken for it, * or every caller would refresh in a loop against a repository that will never * report a branch. + * + * A state that names an upstream but not its remote was persisted before + * {@link ISessionGitState.upstreamRemote} existed; recompute it once so the + * consumers that need the remote (Sync Changes) do not stay stranded. */ export function needsSessionGitStateRefresh(gitState: ISessionGitState | undefined): boolean { - return gitState === undefined || (gitState.branchName === undefined && !gitState.isDetachedHead); + return gitState === undefined + || (gitState.branchName === undefined && !gitState.isDetachedHead) + || (gitState.upstreamBranchName !== undefined && gitState.upstreamRemote === undefined); } /** diff --git a/src/vs/platform/agentHost/node/agentHostGitService.ts b/src/vs/platform/agentHost/node/agentHostGitService.ts index 2cc9836bf84f93..63203d2c02b2a1 100644 --- a/src/vs/platform/agentHost/node/agentHostGitService.ts +++ b/src/vs/platform/agentHost/node/agentHostGitService.ts @@ -87,7 +87,7 @@ export class AgentHostGitService implements IAgentHostGitService { } async getRefs(workingDirectory: URI, query?: IRefQuery): Promise { - const args = ['for-each-ref', '--format=%(refname)%00%(upstream)']; + const args = ['for-each-ref', '--format=%(refname)%00%(upstream)%00%(upstream:remotename)']; if (query?.sort && query.sort !== 'alphabetically') { args.push('--sort', `-${query.sort}`); @@ -1017,13 +1017,14 @@ export class AgentHostGitService implements IAgentHostGitService { const hasGitHubRemote = parseHasGitHubRemote(remotesOutput); const baseBranchName = configuredBaseBranch ?? parseDefaultBranchRef(defaultBranchRef); const githubRepo = parseGitHubRepoFromRemote(remotesOutput); - const upstreamRemote = status.upstreamBranchName?.split('/')[0]; + // Guessed from the name; only feeds the GitHub head-owner lookup below. + const upstreamRemoteGuess = status.upstreamBranchName?.split('/')[0]; // `gh pr checkout` can create a local branch whose head lives on a fork but // has no upstream tracking ref; Git still reports the branch's push remote, // which can be a remote name or the literal fork URL. - // The persisted remote is asked from git: a local upstream (branch..remote = .) has none. + // The persisted remote is asked from git, which reports `.` for a local upstream. const [pushRemote, baseBranchDivergence, upstreamTrackingRemote] = await Promise.all([ - !upstreamRemote && status.branchName + !upstreamRemoteGuess && status.branchName ? this._getPushRemote(repositoryRoot, status.branchName) : undefined, baseBranchName && status.branchName && status.branchName !== baseBranchName @@ -1033,8 +1034,11 @@ export class AgentHostGitService implements IAgentHostGitService { ? this._getUpstreamRemote(repositoryRoot, status.branchName) : undefined, ]); - const githubHeadRepo = upstreamRemote - ? parseGitHubRepoFromRemote(remotesOutput, upstreamRemote) + if (status.upstreamBranchName && !upstreamTrackingRemote) { + this._logService.warn(`[agentHostGitService] Could not resolve the upstream remote of ${status.branchName}; the state will be recomputed: ${repositoryRoot.fsPath}`); + } + const githubHeadRepo = upstreamRemoteGuess + ? parseGitHubRepoFromRemote(remotesOutput, upstreamRemoteGuess) : parseGitHubHeadRepoFromRemoteSelection(remotesOutput, pushRemote); // `git status -b --porcelain=v2` only emits ahead/behind counts when the @@ -1070,8 +1074,7 @@ export class AgentHostGitService implements IAgentHostGitService { } private async _getUpstreamRemote(repositoryRoot: URI, branchName: string): Promise { - const remote = (await this._runGit(repositoryRoot, ['for-each-ref', '--format=%(upstream:remotename)', `refs/heads/${branchName}`]))?.trim(); - return remote && remote !== '.' ? remote : undefined; + return (await this._runGit(repositoryRoot, ['for-each-ref', '--format=%(upstream:remotename)', `refs/heads/${branchName}`]))?.trim() || undefined; } private async _getPushRemote(repositoryRoot: URI, branchName: string): Promise { @@ -1752,6 +1755,22 @@ export function parseRemoteBranchRef(ref: string): { ref: string; name: string; return { ref, name, remote }; } +/** + * Resolves a branch's upstream from `%(upstream)` and `%(upstream:remotename)`. The remote + * name comes from git, so a remote containing `/` (`my/fork`) is not split at the first + * segment; a local upstream (remote `.`) has no remote-tracking ref and yields `undefined`. + * Without the remote name (older callers), falls back to splitting the ref. + */ +export function parseUpstreamRef(upstream: string, upstreamRemote: string | undefined): { ref: string; name: string; remote: string } | undefined { + if (!upstreamRemote) { + return parseRemoteBranchRef(upstream); + } + if (upstreamRemote === '.' || !upstream.startsWith(`refs/remotes/${upstreamRemote}/`)) { + return undefined; + } + return { ref: upstream, name: upstream.substring('refs/remotes/'.length), remote: upstreamRemote }; +} + export function parseGitRefs(output: string | undefined): GitRef[] { if (!output) { return []; @@ -1759,14 +1778,14 @@ export function parseGitRefs(output: string | undefined): GitRef[] { const refs: GitRef[] = []; for (const line of output.split(/\r?\n/g)) { - const [ref, upstream] = line.trim().split('\0'); + const [ref, upstream, upstreamRemote] = line.trim().split('\0'); if (ref.startsWith('refs/heads/')) { refs.push({ ref, name: ref.substring(11), upstream: upstream - ? parseRemoteBranchRef(upstream) + ? parseUpstreamRef(upstream, upstreamRemote) : undefined, kind: GitRefType.Head } satisfies IBranch); diff --git a/src/vs/platform/agentHost/node/agentHostSyncOperationProvider.ts b/src/vs/platform/agentHost/node/agentHostSyncOperationProvider.ts index d590c36f115bb9..6bc400fc44935f 100644 --- a/src/vs/platform/agentHost/node/agentHostSyncOperationProvider.ts +++ b/src/vs/platform/agentHost/node/agentHostSyncOperationProvider.ts @@ -39,8 +39,8 @@ export class AgentHostSyncOperationContribution extends Disposable implements IC return undefined; } - // No remote-tracking upstream branch (a local upstream cannot be synced) - if (!gitState?.upstreamBranchName || !gitState.upstreamRemote) { + // No remote-tracking upstream branch: none, unknown, or a local one (`.`) cannot be synced + if (!gitState?.upstreamBranchName || !gitState.upstreamRemote || gitState.upstreamRemote === '.') { return undefined; } diff --git a/src/vs/platform/agentHost/test/node/agentHostGitService.integrationTest.ts b/src/vs/platform/agentHost/test/node/agentHostGitService.integrationTest.ts index 3e605323dec59d..76f17bbfb6d5a9 100644 --- a/src/vs/platform/agentHost/test/node/agentHostGitService.integrationTest.ts +++ b/src/vs/platform/agentHost/test/node/agentHostGitService.integrationTest.ts @@ -27,7 +27,7 @@ import { FileService } from '../../../files/common/fileService.js'; import { Schemas } from '../../../../base/common/network.js'; import { DiskFileSystemProvider } from '../../../files/node/diskFileSystemProvider.js'; import { DisposableStore } from '../../../../base/common/lifecycle.js'; -import { CheckoutBlockedByLocalChangesError } from '../../common/agentHostGitService.js'; +import { CheckoutBlockedByLocalChangesError, GitRefType } from '../../common/agentHostGitService.js'; import { AgentHostGitService } from '../../node/agentHostGitService.js'; class TestLogService extends NullLogService { @@ -137,15 +137,18 @@ suite('AgentHostGitService - getSessionGitState (real git)', () => { cp.execFileSync('git', ['branch', '--set-upstream-to', 'feature/base'], { cwd: dir, stdio: 'pipe' }); const result = await svc!.getSessionGitState(URI.file(dir)); + const branch = await svc!.getBranch(URI.file(dir), 'topic'); assert.deepStrictEqual({ upstreamBranchName: result?.upstreamBranchName, upstreamRemote: result?.upstreamRemote, githubHeadOwner: result?.githubHeadOwner, + branchUpstream: branch?.kind === GitRefType.Head ? branch.upstream : 'no branch', }, { upstreamBranchName: 'feature/base', - upstreamRemote: undefined, + upstreamRemote: '.', githubHeadOwner: undefined, + branchUpstream: undefined, }); }); @@ -158,12 +161,16 @@ suite('AgentHostGitService - getSessionGitState (real git)', () => { const result = await svc!.getSessionGitState(URI.file(dir)); + const branch = await svc!.getBranch(URI.file(dir), 'feature'); + assert.deepStrictEqual({ upstreamBranchName: result?.upstreamBranchName, upstreamRemote: result?.upstreamRemote, + branchUpstream: branch?.kind === GitRefType.Head ? branch.upstream : undefined, }, { upstreamBranchName: 'my/fork/feature', upstreamRemote: 'my/fork', + branchUpstream: { ref: 'refs/remotes/my/fork/feature', name: 'my/fork/feature', remote: 'my/fork' }, }); }); diff --git a/src/vs/platform/agentHost/test/node/agentHostGitService.test.ts b/src/vs/platform/agentHost/test/node/agentHostGitService.test.ts index ec4e1f44acf80e..a5aca6cd517064 100644 --- a/src/vs/platform/agentHost/test/node/agentHostGitService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostGitService.test.ts @@ -5,10 +5,10 @@ import assert from 'assert'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; -import { formatGitError, getRemoteTrackingRef, GitCheckoutProgressParser, isRetryableWorktreeRemovalError, parseChangedPaths, parseDefaultBranchRef, parseFetchRemoteUrls, parseGitDiffRawNumstat, parseGitHubRepoFromRemote, parseGitStatusV2, parseHasGitHubRemote, parseSingleLsTreeEntry, parseUntrackedPaths, summarizeStderrForError } from '../../node/agentHostGitService.js'; +import { formatGitError, getRemoteTrackingRef, GitCheckoutProgressParser, isRetryableWorktreeRemovalError, parseChangedPaths, parseDefaultBranchRef, parseFetchRemoteUrls, parseGitDiffRawNumstat, parseGitHubRepoFromRemote, parseGitRefs, parseGitStatusV2, parseHasGitHubRemote, parseSingleLsTreeEntry, parseUntrackedPaths, parseUpstreamRef, summarizeStderrForError } from '../../node/agentHostGitService.js'; import { buildGitBlobUri } from '../../node/gitDiffContent.js'; import { URI } from '../../../../base/common/uri.js'; -import { EMPTY_TREE_OBJECT, getBranchCompletions, resolveDiffBaseBranchName } from '../../common/agentHostGitService.js'; +import { EMPTY_TREE_OBJECT, getBranchCompletions, GitRefType, resolveDiffBaseBranchName } from '../../common/agentHostGitService.js'; import { needsSessionGitStateRefresh } from '../../common/state/sessionState.js'; suite('AgentHostGitService', () => { @@ -162,15 +162,59 @@ suite('AgentHostGitService', () => { probeFailureRemnant: needsSessionGitStateRefresh({ baseBranchName: 'main' }), detachedHead: needsSessionGitStateRefresh({ isDetachedHead: true, baseBranchName: 'main' }), onABranch: needsSessionGitStateRefresh({ branchName: 'feature', baseBranchName: 'main' }), + // Persisted before upstreamRemote existed: the remote is needed to offer Sync Changes. + upstreamWithoutRemote: needsSessionGitStateRefresh({ branchName: 'feature', upstreamBranchName: 'origin/feature' }), + remoteUpstream: needsSessionGitStateRefresh({ branchName: 'feature', upstreamBranchName: 'origin/feature', upstreamRemote: 'origin' }), + localUpstream: needsSessionGitStateRefresh({ branchName: 'feature', upstreamBranchName: 'main', upstreamRemote: '.' }), }, { neverComputed: true, probeFailureRemnant: true, detachedHead: false, onABranch: false, + upstreamWithoutRemote: true, + remoteUpstream: false, + localUpstream: false, }); }); }); + suite('parseUpstreamRef', () => { + test('takes the remote name from git instead of splitting the ref', () => { + assert.deepStrictEqual({ + origin: parseUpstreamRef('refs/remotes/origin/feature', 'origin'), + slashRemote: parseUpstreamRef('refs/remotes/my/fork/feature', 'my/fork'), + local: parseUpstreamRef('refs/heads/main', '.'), + mismatch: parseUpstreamRef('refs/remotes/origin/feature', 'other'), + legacy: parseUpstreamRef('refs/remotes/origin/feature', undefined), + empty: parseUpstreamRef('refs/remotes/origin/feature', ''), + }, { + origin: { ref: 'refs/remotes/origin/feature', name: 'origin/feature', remote: 'origin' }, + slashRemote: { ref: 'refs/remotes/my/fork/feature', name: 'my/fork/feature', remote: 'my/fork' }, + local: undefined, + mismatch: undefined, + legacy: { ref: 'refs/remotes/origin/feature', name: 'origin/feature', remote: 'origin' }, + empty: { ref: 'refs/remotes/origin/feature', name: 'origin/feature', remote: 'origin' }, + }); + }); + }); + + suite('parseGitRefs', () => { + test('reads the upstream remote column', () => { + const out = [ + 'refs/heads/main\0\0', + 'refs/heads/topic\0refs/heads/main\0.', + 'refs/heads/feature\0refs/remotes/my/fork/feature\0my/fork', + 'refs/remotes/my/fork/feature\0\0', + ].join('\n'); + assert.deepStrictEqual(parseGitRefs(out).map(ref => ref.kind === GitRefType.Head ? { name: ref.name, upstream: ref.upstream } : { name: ref.name, kind: ref.kind }), [ + { name: 'main', upstream: undefined }, + { name: 'topic', upstream: undefined }, + { name: 'feature', upstream: { ref: 'refs/remotes/my/fork/feature', name: 'my/fork/feature', remote: 'my/fork' } }, + { name: 'my/fork/feature', kind: GitRefType.RemoteHead }, + ]); + }); + }); + suite('parseHasGitHubRemote', () => { test('detects ssh github remote', () => { assert.strictEqual(parseHasGitHubRemote('origin\tgit@github.com:owner/repo.git (fetch)\n'), true); diff --git a/src/vs/platform/agentHost/test/node/agentHostSyncOperationHandler.test.ts b/src/vs/platform/agentHost/test/node/agentHostSyncOperationHandler.test.ts index 5384296c706dc6..c7d356e9067d7e 100644 --- a/src/vs/platform/agentHost/test/node/agentHostSyncOperationHandler.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostSyncOperationHandler.test.ts @@ -100,6 +100,46 @@ suite('AgentHostSyncOperationHandler', () => { }); }); + test('syncs through a remote whose name contains a slash', async () => { + const gitCalls: Array<{ readonly operation: string; readonly options?: IPullOptions | IPushOptions }> = []; + const gitService = new class extends mock() { + declare readonly _serviceBrand: undefined; + + override async getCurrentBranchName(): Promise { + return 'local-name'; + } + + override async getBranch() { + return { + ref: 'refs/heads/local-name', + name: 'local-name', + upstream: { + ref: 'refs/remotes/my/fork/remote-name', + name: 'my/fork/remote-name', + remote: 'my/fork', + }, + kind: GitRefType.Head, + } as const; + } + + override async pull(_workingDirectory: URI, options?: IPullOptions): Promise { + gitCalls.push({ operation: 'pull', options }); + } + + override async push(_workingDirectory: URI, options?: IPushOptions): Promise { + gitCalls.push({ operation: 'push', options }); + } + }(); + const { handler } = createHandler(gitService); + + await invoke(handler); + + assert.deepStrictEqual(gitCalls, [ + { operation: 'pull', options: { remote: 'my/fork', ref: 'remote-name' } }, + { operation: 'push', options: { remote: 'my/fork', ref: 'refs/heads/local-name:refs/heads/remote-name' } }, + ]); + }); + test('rejects a cached branch that no longer matches the current checkout', async () => { let branchLookup = false; const gitService = new class extends mock() { diff --git a/src/vs/platform/agentHost/test/node/agentHostSyncOperationProvider.test.ts b/src/vs/platform/agentHost/test/node/agentHostSyncOperationProvider.test.ts index 33eb218f275f92..fcfadbdcde73ce 100644 --- a/src/vs/platform/agentHost/test/node/agentHostSyncOperationProvider.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostSyncOperationProvider.test.ts @@ -132,19 +132,35 @@ suite('AgentHostSyncOperationContribution', () => { assert.strictEqual(operations, undefined); }); - test('does not advertise sync without an upstream remote', () => { + test('does not advertise sync without a remote-tracking upstream', () => { const provider = createContribution(); - // Local upstreams (`main`, `feature/base`) and a remote-looking name whose remote is unknown. - const operations = ['main', 'feature/base', 'origin/feature/test'].map(upstreamBranchName => provider.getOperations({ + // Local upstreams (`main`, `feature/base`, remote `.`) and a state whose remote is unknown. + const operations = [ + { upstreamBranchName: 'main', upstreamRemote: '.' }, + { upstreamBranchName: 'feature/base', upstreamRemote: '.' }, + { upstreamBranchName: 'origin/feature/test', upstreamRemote: undefined }, + ].map(upstream => provider.getOperations({ sessionKey, changesetUri: uncommittedChangesetUri, changesetKind: ChangesetKind.Uncommitted, - gitState: { ...gitStateWithIncomingChanges, upstreamBranchName, upstreamRemote: undefined }, + gitState: { ...gitStateWithIncomingChanges, ...upstream }, })); assert.deepStrictEqual(operations, [undefined, undefined, undefined]); }); + test('advertises sync for a remote whose name contains a slash', () => { + const provider = createContribution(); + const operations = provider.getOperations({ + sessionKey, + changesetUri: uncommittedChangesetUri, + changesetKind: ChangesetKind.Uncommitted, + gitState: { ...gitStateWithIncomingChanges, upstreamBranchName: 'my/fork/feature/test', upstreamRemote: 'my/fork' }, + }); + + assert.deepStrictEqual(operations?.map(operation => operation.id), ['sync']); + }); + test('does not advertise incoming sync on a draft with uncommitted changes', () => { const provider = createContribution(true); const operations = provider.getOperations({ From a7692c373674bf876c35f45b457de26cfd20ccc8 Mon Sep 17 00:00:00 2001 From: Lucas Farias Date: Tue, 15 Sep 2026 16:03:35 -0300 Subject: [PATCH 5/5] agentHost: only persist an upstream remote the sync handler can use The provider and the handler now share one predicate. The persisted upstreamRemote keeps the remote only when parseUpstreamRef accepts the upstream ref, which is what the handler resolves; a local upstream or a fetch refspec that keeps the tracking ref outside refs/remotes// is stored as ".", so Sync Changes is not offered for an upstream the handler would reject. Covered by a resolveUpstreamRemote unit test and a real-git integration test with a custom fetch refspec. --- .../agentHost/common/state/sessionState.ts | 7 +++--- .../agentHost/node/agentHostGitService.ts | 19 +++++++++++--- .../node/agentHostSyncOperationProvider.ts | 2 +- .../agentHostGitService.integrationTest.ts | 25 +++++++++++++++++-- .../test/node/agentHostGitService.test.ts | 24 +++++++++++++++++- 5 files changed, 67 insertions(+), 10 deletions(-) diff --git a/src/vs/platform/agentHost/common/state/sessionState.ts b/src/vs/platform/agentHost/common/state/sessionState.ts index 81f56a7b5af06e..1c0e2f7d3364e1 100644 --- a/src/vs/platform/agentHost/common/state/sessionState.ts +++ b/src/vs/platform/agentHost/common/state/sessionState.ts @@ -1597,9 +1597,10 @@ export interface ISessionGitState { /** Upstream tracking branch (e.g. `origin/feature`). */ readonly upstreamBranchName?: string; /** - * Remote of the upstream tracking branch as git reports it (`origin`, `my/fork`), or `.` - * when the upstream is a local branch. Absent when there is no upstream, and in git - * state persisted before this field existed. + * Remote of the upstream branch when it can be synced through its remote-tracking ref + * (`origin`, `my/fork`), or `.` when it cannot: a local upstream, or a fetch refspec + * that keeps the tracking ref outside `refs/remotes//`. Absent when there is + * no upstream, and in git state persisted before this field existed. */ readonly upstreamRemote?: string; /** Number of commits the upstream branch has ahead of the local branch. */ diff --git a/src/vs/platform/agentHost/node/agentHostGitService.ts b/src/vs/platform/agentHost/node/agentHostGitService.ts index 63203d2c02b2a1..4e86bcefe69cf9 100644 --- a/src/vs/platform/agentHost/node/agentHostGitService.ts +++ b/src/vs/platform/agentHost/node/agentHostGitService.ts @@ -1022,7 +1022,7 @@ export class AgentHostGitService implements IAgentHostGitService { // `gh pr checkout` can create a local branch whose head lives on a fork but // has no upstream tracking ref; Git still reports the branch's push remote, // which can be a remote name or the literal fork URL. - // The persisted remote is asked from git, which reports `.` for a local upstream. + // The persisted remote is asked from git and only kept when the sync handler can use it. const [pushRemote, baseBranchDivergence, upstreamTrackingRemote] = await Promise.all([ !upstreamRemoteGuess && status.branchName ? this._getPushRemote(repositoryRoot, status.branchName) @@ -1035,7 +1035,7 @@ export class AgentHostGitService implements IAgentHostGitService { : undefined, ]); if (status.upstreamBranchName && !upstreamTrackingRemote) { - this._logService.warn(`[agentHostGitService] Could not resolve the upstream remote of ${status.branchName}; the state will be recomputed: ${repositoryRoot.fsPath}`); + this._logService.warn(`[agentHostGitService] Could not resolve the upstream remote of ${status.branchName}; the state will be recomputed on the next refresh: ${repositoryRoot.fsPath}`); } const githubHeadRepo = upstreamRemoteGuess ? parseGitHubRepoFromRemote(remotesOutput, upstreamRemoteGuess) @@ -1074,7 +1074,7 @@ export class AgentHostGitService implements IAgentHostGitService { } private async _getUpstreamRemote(repositoryRoot: URI, branchName: string): Promise { - return (await this._runGit(repositoryRoot, ['for-each-ref', '--format=%(upstream:remotename)', `refs/heads/${branchName}`]))?.trim() || undefined; + return resolveUpstreamRemote(await this._runGit(repositoryRoot, ['for-each-ref', '--format=%(upstream)%00%(upstream:remotename)', `refs/heads/${branchName}`])); } private async _getPushRemote(repositoryRoot: URI, branchName: string): Promise { @@ -1771,6 +1771,19 @@ export function parseUpstreamRef(upstream: string, upstreamRemote: string | unde return { ref: upstream, name: upstream.substring('refs/remotes/'.length), remote: upstreamRemote }; } +/** + * The {@link ISessionGitState.upstreamRemote} value for a `%(upstream)%00%(upstream:remotename)` + * line: the remote when {@link parseUpstreamRef} accepts the upstream, which is exactly what + * the sync handler needs; `.` for an upstream it cannot sync; `undefined` without an upstream. + */ +export function resolveUpstreamRemote(output: string | undefined): string | undefined { + const [upstream, remote] = (output ?? '').trim().split(/\r?\n/)[0].split('\0'); + if (!remote) { + return undefined; + } + return parseUpstreamRef(upstream, remote) ? remote : '.'; +} + export function parseGitRefs(output: string | undefined): GitRef[] { if (!output) { return []; diff --git a/src/vs/platform/agentHost/node/agentHostSyncOperationProvider.ts b/src/vs/platform/agentHost/node/agentHostSyncOperationProvider.ts index 6bc400fc44935f..be5a4f1397eeee 100644 --- a/src/vs/platform/agentHost/node/agentHostSyncOperationProvider.ts +++ b/src/vs/platform/agentHost/node/agentHostSyncOperationProvider.ts @@ -39,7 +39,7 @@ export class AgentHostSyncOperationContribution extends Disposable implements IC return undefined; } - // No remote-tracking upstream branch: none, unknown, or a local one (`.`) cannot be synced + // No syncable upstream: none, unknown (legacy state), or one the handler cannot sync (`.`) if (!gitState?.upstreamBranchName || !gitState.upstreamRemote || gitState.upstreamRemote === '.') { return undefined; } diff --git a/src/vs/platform/agentHost/test/node/agentHostGitService.integrationTest.ts b/src/vs/platform/agentHost/test/node/agentHostGitService.integrationTest.ts index 76f17bbfb6d5a9..898789ce62de44 100644 --- a/src/vs/platform/agentHost/test/node/agentHostGitService.integrationTest.ts +++ b/src/vs/platform/agentHost/test/node/agentHostGitService.integrationTest.ts @@ -130,7 +130,7 @@ suite('AgentHostGitService - getSessionGitState (real git)', () => { }); }); - (hasGit ? test : test.skip)('reports no upstream remote when the upstream is a local branch', async () => { + (hasGit ? test : test.skip)('reports an unsyncable upstream remote when the upstream is a local branch', async () => { const dir = initRepo({ remote: 'https://github.com/owner/repo.git' }); cp.execFileSync('git', ['branch', 'feature/base'], { cwd: dir, stdio: 'pipe' }); cp.execFileSync('git', ['checkout', '-q', '-b', 'topic'], { cwd: dir, stdio: 'pipe' }); @@ -152,6 +152,28 @@ suite('AgentHostGitService - getSessionGitState (real git)', () => { }); }); + (hasGit ? test : test.skip)('reports no syncable remote when a fetch refspec keeps the tracking ref outside refs/remotes', async () => { + const dir = initRepo({ remote: 'https://github.com/owner/repo.git' }); + cp.execFileSync('git', ['config', 'remote.origin.fetch', '+refs/heads/*:refs/custom/origin/*'], { cwd: dir, stdio: 'pipe' }); + cp.execFileSync('git', ['checkout', '-q', '-b', 'feature'], { cwd: dir, stdio: 'pipe' }); + cp.execFileSync('git', ['update-ref', 'refs/custom/origin/feature', 'HEAD'], { cwd: dir, stdio: 'pipe' }); + cp.execFileSync('git', ['config', 'branch.feature.remote', 'origin'], { cwd: dir, stdio: 'pipe' }); + cp.execFileSync('git', ['config', 'branch.feature.merge', 'refs/heads/feature'], { cwd: dir, stdio: 'pipe' }); + + const result = await svc!.getSessionGitState(URI.file(dir)); + const branch = await svc!.getBranch(URI.file(dir), 'feature'); + + assert.deepStrictEqual({ + hasUpstream: result?.upstreamBranchName !== undefined, + upstreamRemote: result?.upstreamRemote, + branchUpstream: branch?.kind === GitRefType.Head ? branch.upstream : 'no branch', + }, { + hasUpstream: true, + upstreamRemote: '.', + branchUpstream: undefined, + }); + }); + (hasGit ? test : test.skip)('reports the full upstream remote name when it contains a slash', async () => { const dir = initRepo({ remote: 'https://github.com/base-owner/repo.git' }); cp.execFileSync('git', ['remote', 'add', 'my/fork', 'https://github.com/fork-owner/repo.git'], { cwd: dir, stdio: 'pipe' }); @@ -160,7 +182,6 @@ suite('AgentHostGitService - getSessionGitState (real git)', () => { cp.execFileSync('git', ['branch', '--set-upstream-to', 'my/fork/feature'], { cwd: dir, stdio: 'pipe' }); const result = await svc!.getSessionGitState(URI.file(dir)); - const branch = await svc!.getBranch(URI.file(dir), 'feature'); assert.deepStrictEqual({ diff --git a/src/vs/platform/agentHost/test/node/agentHostGitService.test.ts b/src/vs/platform/agentHost/test/node/agentHostGitService.test.ts index a5aca6cd517064..eb20c0b587ada5 100644 --- a/src/vs/platform/agentHost/test/node/agentHostGitService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostGitService.test.ts @@ -5,7 +5,7 @@ import assert from 'assert'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; -import { formatGitError, getRemoteTrackingRef, GitCheckoutProgressParser, isRetryableWorktreeRemovalError, parseChangedPaths, parseDefaultBranchRef, parseFetchRemoteUrls, parseGitDiffRawNumstat, parseGitHubRepoFromRemote, parseGitRefs, parseGitStatusV2, parseHasGitHubRemote, parseSingleLsTreeEntry, parseUntrackedPaths, parseUpstreamRef, summarizeStderrForError } from '../../node/agentHostGitService.js'; +import { formatGitError, getRemoteTrackingRef, GitCheckoutProgressParser, isRetryableWorktreeRemovalError, parseChangedPaths, parseDefaultBranchRef, parseFetchRemoteUrls, parseGitDiffRawNumstat, parseGitHubRepoFromRemote, parseGitRefs, parseGitStatusV2, parseHasGitHubRemote, parseSingleLsTreeEntry, parseUntrackedPaths, parseUpstreamRef, resolveUpstreamRemote, summarizeStderrForError } from '../../node/agentHostGitService.js'; import { buildGitBlobUri } from '../../node/gitDiffContent.js'; import { URI } from '../../../../base/common/uri.js'; import { EMPTY_TREE_OBJECT, getBranchCompletions, GitRefType, resolveDiffBaseBranchName } from '../../common/agentHostGitService.js'; @@ -198,6 +198,26 @@ suite('AgentHostGitService', () => { }); }); + suite('resolveUpstreamRemote', () => { + test('keeps the remote only when the sync handler can use the upstream', () => { + assert.deepStrictEqual({ + noUpstream: resolveUpstreamRemote('\0'), + probeFailed: resolveUpstreamRemote(undefined), + origin: resolveUpstreamRemote('refs/remotes/origin/feature\0origin'), + slashRemote: resolveUpstreamRemote('refs/remotes/my/fork/feature\0my/fork'), + local: resolveUpstreamRemote('refs/heads/main\0.'), + customRefspec: resolveUpstreamRemote('refs/custom/origin/feature\0origin'), + }, { + noUpstream: undefined, + probeFailed: undefined, + origin: 'origin', + slashRemote: 'my/fork', + local: '.', + customRefspec: '.', + }); + }); + }); + suite('parseGitRefs', () => { test('reads the upstream remote column', () => { const out = [ @@ -205,12 +225,14 @@ suite('AgentHostGitService', () => { 'refs/heads/topic\0refs/heads/main\0.', 'refs/heads/feature\0refs/remotes/my/fork/feature\0my/fork', 'refs/remotes/my/fork/feature\0\0', + 'refs/heads/legacy\0refs/remotes/origin/legacy', ].join('\n'); assert.deepStrictEqual(parseGitRefs(out).map(ref => ref.kind === GitRefType.Head ? { name: ref.name, upstream: ref.upstream } : { name: ref.name, kind: ref.kind }), [ { name: 'main', upstream: undefined }, { name: 'topic', upstream: undefined }, { name: 'feature', upstream: { ref: 'refs/remotes/my/fork/feature', name: 'my/fork/feature', remote: 'my/fork' } }, { name: 'my/fork/feature', kind: GitRefType.RemoteHead }, + { name: 'legacy', upstream: { ref: 'refs/remotes/origin/legacy', name: 'origin/legacy', remote: 'origin' } }, ]); }); });