Skip to content

Commit 6348a07

Browse files
authored
Agents - create branch when needed during a pull request creation (#333163)
* Agents - create branch when needed during a pull request creation * Pull request feedback
1 parent aeee94e commit 6348a07

13 files changed

Lines changed: 204 additions & 37 deletions

src/vs/platform/agentHost/common/agentHostGitService.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -254,6 +254,8 @@ export interface IAgentHostGitService {
254254
* recreating the worktree.
255255
*/
256256
branchExists(repositoryRoot: URI, branchName: string): Promise<boolean>;
257+
/** Creates a new branch and optionally checks it out while preserving the working tree. */
258+
createBranch(workingDirectory: URI, branchName: string, options?: { readonly checkout?: boolean }): Promise<void>;
257259
/**
258260
* Returns true when the working tree has any tracked, staged, or
259261
* untracked changes. Used by archive cleanup to skip removing a

src/vs/platform/agentHost/node/agentHostGitService.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -356,6 +356,14 @@ export class AgentHostGitService implements IAgentHostGitService {
356356
return output !== undefined;
357357
}
358358

359+
async createBranch(workingDirectory: URI, branchName: string, options?: { readonly checkout?: boolean }): Promise<void> {
360+
const args = options?.checkout
361+
? ['checkout', '-q', '-b', branchName, '--no-track']
362+
: ['branch', '-q', branchName];
363+
364+
await this._runGit(workingDirectory, args, { throwOnError: true });
365+
}
366+
359367
async hasUncommittedChanges(workingDirectory: URI): Promise<boolean> {
360368
const output = await this._runGitStatus(workingDirectory, ['--porcelain']);
361369
return !!output && output.trim().length > 0;

src/vs/platform/agentHost/node/agentHostPullRequestOperationHandler.ts

Lines changed: 52 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ import { type AutoMergeMethod, type CreatedPullRequest, IAgentHostOctoKitService
1818
import type { InvokeChangesetOperationParams, InvokeChangesetOperationResult } from '../common/state/protocol/channels-changeset/commands.js';
1919
import { ICopilotApiService, type ICopilotUtilityChatMessage } from './shared/copilotApiService.js';
2020
import { buildConversationContext } from '../common/agentHostConversationContext.js';
21+
import { IAgentBranchNameGenerator } from './shared/agentBranchNameGenerator.js';
22+
import { SessionConfigKey } from '../common/sessionConfigKeys.js';
2123

2224
/**
2325
* Soft upper bound, in characters, for the conversation context fed to the
@@ -51,13 +53,14 @@ export interface PullRequestCreatedEvent {
5153
*
5254
* 1. Resolve session → working directory + current/base branch from
5355
* {@link ISessionGitState}.
54-
* 2. Commit any uncommitted working-tree changes.
55-
* 3. Push the current branch to its GitHub upstream remote (with `--set-upstream` when missing).
56-
* 4. Resolve `owner` / `repo` from {@link ISessionGitState.githubOwner}
56+
* 2. If the current branch is the base branch, create a generated session branch.
57+
* 3. Commit any uncommitted working-tree changes.
58+
* 4. Push the current branch to its GitHub upstream remote (with `--set-upstream` when missing).
59+
* 5. Resolve `owner` / `repo` from {@link ISessionGitState.githubOwner}
5760
* / {@link ISessionGitState.githubRepo} (populated by the git probe).
58-
* 5. Reuse an existing PR for the branch, or POST `/repos/{owner}/{repo}/pulls`
61+
* 6. Reuse an existing PR for the branch, or POST `/repos/{owner}/{repo}/pulls`
5962
* via {@link IAgentHostOctoKitService}.
60-
* 6. Return the PR URL as an {@link InvokeChangesetOperationResult.followUp}.
63+
* 7. Return the PR URL as an {@link InvokeChangesetOperationResult.followUp}.
6164
*/
6265
export class AgentHostPullRequestOperationHandler implements IChangesetOperationHandler {
6366

@@ -78,6 +81,7 @@ export class AgentHostPullRequestOperationHandler implements IChangesetOperation
7881
@IAgentHostOctoKitService private readonly _octoKitService: IAgentHostOctoKitService,
7982
@IAgentHostGitHubEndpointService private readonly _gitHubEndpointService: IAgentHostGitHubEndpointService,
8083
@ICopilotApiService private readonly _copilotApiService: ICopilotApiService,
84+
@IAgentBranchNameGenerator private readonly _branchNameGenerator: IAgentBranchNameGenerator,
8185
@ILogService private readonly _logService: ILogService,
8286
) { }
8387

@@ -100,8 +104,8 @@ export class AgentHostPullRequestOperationHandler implements IChangesetOperation
100104
throw new ProtocolError(JsonRpcErrorCodes.InvalidParams, `Not a changeset URI: ${params.channel}`);
101105
}
102106
this._throwIfCancelled(token);
103-
const sessionUri = parsed.sessionUri;
104107

108+
const sessionUri = parsed.sessionUri;
105109
const sessionState = this._getSessionState(sessionUri);
106110
if (!sessionState) {
107111
throw new ProtocolError(AHP_SESSION_NOT_FOUND, `Session not found: ${sessionUri}`);
@@ -123,17 +127,18 @@ export class AgentHostPullRequestOperationHandler implements IChangesetOperation
123127
const workingDirectory = URI.parse(workingDirectoryStr);
124128
const storedGitState = readSessionGitState(sessionState._meta);
125129
const effectiveBaseBranch = await this._resolveBaseBranchName(sessionUri);
126-
const gitState = await this._gitService.getSessionGitState(workingDirectory, effectiveBaseBranch) ?? storedGitState;
127-
const branchName = gitState?.branchName ?? await this._gitService.getCurrentBranch(workingDirectory);
130+
131+
let gitState = await this._gitService.getSessionGitState(workingDirectory, effectiveBaseBranch) ?? storedGitState;
132+
let branchName = gitState?.branchName ?? await this._gitService.getCurrentBranch(workingDirectory);
128133
if (!branchName) {
129134
throw new ProtocolError(JsonRpcErrorCodes.InternalError, `Could not determine current branch for ${workingDirectory}`);
130135
}
131136

132-
const baseBranchName = effectiveBaseBranch ?? gitState?.baseBranchName ?? (await this._gitService.getDefaultBranch(workingDirectory))?.name;
137+
const defaultBranch = await this._gitService.getDefaultBranch(workingDirectory);
138+
const baseBranchName = effectiveBaseBranch ?? gitState?.baseBranchName ?? defaultBranch?.name;
133139
if (!baseBranchName) {
134140
throw new ProtocolError(JsonRpcErrorCodes.InternalError, `Could not determine base branch for ${workingDirectory}`);
135141
}
136-
const base = baseBranchName;
137142

138143
const repoResource = this._gitHubEndpointService.getRepoResource();
139144
const authToken = this._authenticationService.getAuthToken({
@@ -149,9 +154,39 @@ export class AgentHostPullRequestOperationHandler implements IChangesetOperation
149154
}
150155

151156
const hasUncommitted = await this._gitService.hasUncommittedChanges(workingDirectory);
157+
158+
// Create a new branch if the current branch is the same
159+
// as the base branch and there are uncommitted changes
160+
if (hasUncommitted && branchName === baseBranchName) {
161+
const branchPrefix = sessionState.config?.values[SessionConfigKey.WorktreeBranchPrefix];
162+
163+
try {
164+
const generatedBranchName = await this._branchNameGenerator.generateBranchName({
165+
sessionId: URI.parse(sessionUri).path.split('/').filter(Boolean).pop() ?? sessionUri,
166+
message: sessionState.turns.find(turn => turn.message.text.trim())?.message.text,
167+
githubToken: authToken,
168+
signal,
169+
branchPrefix: typeof branchPrefix === 'string' ? branchPrefix : undefined,
170+
branchNameCollides: candidate => this._gitService.branchExists(workingDirectory, candidate).catch(() => true),
171+
});
172+
173+
this._throwIfCancelled(token);
174+
this._logService.info(`[AgentHostPullRequestOperationHandler] Creating branch ${generatedBranchName} for session ${sessionUri}`);
175+
176+
await this._gitService.createBranch(workingDirectory, generatedBranchName, { checkout: true });
177+
branchName = generatedBranchName;
178+
179+
gitState = await this._gitService.getSessionGitState(workingDirectory, effectiveBaseBranch);
180+
} catch (err) {
181+
this._throwIfCancelled(token);
182+
throw new ProtocolError(JsonRpcErrorCodes.InternalError, `Failed to create a branch before creating a pull request: ${err instanceof Error ? err.message : String(err)}`);
183+
}
184+
}
185+
152186
if (hasUncommitted) {
153187
this._throwIfCancelled(token);
154188
this._logService.info(`[AgentHostPullRequestOperationHandler] Committing uncommitted changes for session ${sessionUri}`);
189+
155190
try {
156191
await this._gitService.commitAll(workingDirectory, this._formatCommitMessage(branchName));
157192
} catch (err) {
@@ -161,7 +196,7 @@ export class AgentHostPullRequestOperationHandler implements IChangesetOperation
161196
}
162197
this._throwIfCancelled(token);
163198

164-
const branchChanges = await this._gitService.computeSessionFileDiffs(workingDirectory, { sessionUri, baseBranch: base });
199+
const branchChanges = await this._gitService.computeSessionFileDiffs(workingDirectory, { sessionUri, baseBranch: baseBranchName });
165200
if (branchChanges === undefined) {
166201
throw new ProtocolError(JsonRpcErrorCodes.InternalError, localize('agentHost.changeset.pr.computeChangesFailed', "Could not compute branch changes to create a pull request."));
167202
}
@@ -171,7 +206,7 @@ export class AgentHostPullRequestOperationHandler implements IChangesetOperation
171206
this._throwIfCancelled(token);
172207

173208
const githubHeadOwner = gitState?.githubHeadOwner;
174-
const upstreamBranch = githubHeadOwner ? parseUpstreamBranchName(gitState.upstreamBranchName) : undefined;
209+
const upstreamBranch = githubHeadOwner ? parseUpstreamBranchName(gitState?.upstreamBranchName) : undefined;
175210
const headOwner = upstreamBranch && githubHeadOwner ? githubHeadOwner : gitHubState.owner;
176211
const headBranch = upstreamBranch?.branch ?? branchName;
177212
const pushRef = headBranch === branchName ? branchName : `${branchName}:${headBranch}`;
@@ -195,12 +230,12 @@ export class AgentHostPullRequestOperationHandler implements IChangesetOperation
195230
}
196231
this._throwIfCancelled(token);
197232

198-
const generated = await this._generateTitleAndDescription(sessionState, branchName, base, branchChanges, signal, token);
199-
this._throwIfCancelled(token);
233+
const generated = await this._generateTitleAndDescription(sessionState, branchName, baseBranchName, branchChanges, signal, token);
200234
const title = generated?.title ?? this._formatTitle(branchName);
201-
const body = generated?.description ?? this._formatBody(branchName, base);
235+
const body = generated?.description ?? this._formatBody(branchName, baseBranchName);
236+
this._throwIfCancelled(token);
202237

203-
this._logService.info(`[AgentHostPullRequestOperationHandler] Creating ${this._draft ? 'draft ' : ''}PR ${gitHubState.owner}/${gitHubState.repo} ${createHead} -> ${base}`);
238+
this._logService.info(`[AgentHostPullRequestOperationHandler] Creating ${this._draft ? 'draft ' : ''}PR ${gitHubState.owner}/${gitHubState.repo} ${createHead} -> ${baseBranchName}`);
204239
let created: CreatedPullRequest;
205240
try {
206241
created = await this._octoKitService.createPullRequest(
@@ -209,7 +244,7 @@ export class AgentHostPullRequestOperationHandler implements IChangesetOperation
209244
title,
210245
body,
211246
createHead,
212-
base,
247+
baseBranchName,
213248
this._draft,
214249
authToken,
215250
signal,

src/vs/platform/agentHost/node/shared/agentBranchNameGenerator.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,10 +40,12 @@ export interface IAgentBranchNameGeneratorRequest {
4040
export const IAgentBranchNameGenerator = createDecorator<IAgentBranchNameGenerator>('agentBranchNameGenerator');
4141

4242
export interface IAgentBranchNameGenerator {
43+
readonly _serviceBrand: undefined;
4344
generateBranchName(request: IAgentBranchNameGeneratorRequest): Promise<string>;
4445
}
4546

4647
export class AgentBranchNameGenerator implements IAgentBranchNameGenerator {
48+
declare readonly _serviceBrand: undefined;
4749

4850
constructor(
4951
@ICopilotApiService private readonly _copilotApiService: ICopilotApiService,

src/vs/platform/agentHost/test/common/sessionTestHelpers.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -350,6 +350,7 @@ export function createNoopGitService(): import('../../common/agentHostGitService
350350
addExistingWorktree: async () => { },
351351
removeWorktree: async () => { },
352352
branchExists: async () => false,
353+
createBranch: async () => { },
353354
hasUncommittedChanges: async () => false,
354355
commitAll: async () => { },
355356
mergeBranch: async () => '',

src/vs/platform/agentHost/test/node/agentHostCommitOperationHandler.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ class TestGitService implements IAgentHostGitService {
4747
async addExistingWorktree(): Promise<void> { }
4848
async removeWorktree(): Promise<void> { }
4949
async branchExists(): Promise<boolean> { return false; }
50+
async createBranch(): Promise<void> { }
5051
async hasUncommittedChanges(): Promise<boolean> {
5152
this.calls.push('hasUncommittedChanges');
5253
return this.uncommitted;

src/vs/platform/agentHost/test/node/agentHostDiscardChangesOperationHandler.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ class TestGitService implements IAgentHostGitService {
3535
async addExistingWorktree(): Promise<void> { }
3636
async removeWorktree(): Promise<void> { }
3737
async branchExists(): Promise<boolean> { return false; }
38+
async createBranch(): Promise<void> { }
3839
async hasUncommittedChanges(): Promise<boolean> { return true; }
3940
async commitAll(): Promise<void> { }
4041
async mergeBranch(): Promise<string> { return ''; }

src/vs/platform/agentHost/test/node/agentHostGitService.integrationTest.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -581,6 +581,25 @@ suite('AgentHostGitService - worktree helpers (real git)', () => {
581581
assert.strictEqual(await svc!.branchExists(URI.file(dir), 'does-not-exist'), false);
582582
});
583583

584+
(hasGit ? test : test.skip)('createBranch preserves dirty changes and leaves the base branch unchanged', async () => {
585+
const dir = initRepo();
586+
const fs = await import('fs/promises');
587+
await fs.writeFile(join(dir, 'dirty.txt'), 'session changes');
588+
const baseHead = cp.execFileSync('git', ['rev-parse', 'main'], { cwd: dir, env, encoding: 'utf8' }).trim();
589+
590+
await svc!.createBranch(URI.file(dir), 'agents/session', { checkout: true });
591+
592+
const branchName = cp.execFileSync('git', ['branch', '--show-current'], { cwd: dir, env, encoding: 'utf8' }).trim();
593+
const currentHead = cp.execFileSync('git', ['rev-parse', 'HEAD'], { cwd: dir, env, encoding: 'utf8' }).trim();
594+
const status = cp.execFileSync('git', ['status', '--porcelain'], { cwd: dir, env, encoding: 'utf8' }).trim();
595+
assert.deepStrictEqual({ branchName, currentHead, baseHead, status }, {
596+
branchName: 'agents/session',
597+
currentHead: baseHead,
598+
baseHead,
599+
status: '?? dirty.txt',
600+
});
601+
});
602+
584603
(hasGit ? test : test.skip)('hasUncommittedChanges flips with untracked and committed work', async () => {
585604
const dir = initRepo();
586605
assert.strictEqual(await svc!.hasUncommittedChanges(URI.file(dir)), false);

0 commit comments

Comments
 (0)