Skip to content

Commit 277e0c0

Browse files
committed
Merge branch 'upstream-development'
2 parents c117357 + abb0f74 commit 277e0c0

20 files changed

Lines changed: 828 additions & 74 deletions

.github/workflows/ci.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ on:
4444
APPLE_APPLICATION_CERT_PASSWORD:
4545

4646
env:
47-
NODE_VERSION: 24.11.1
47+
NODE_VERSION: 24.15.0
4848

4949
jobs:
5050
lint:

.node-version

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
24.11.1
1+
24.15.0

.nvmrc

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
v24.11.1
1+
v24.15.0

.tool-versions

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,2 @@
11
python 3.9.5
2-
nodejs 24.11.1
2+
nodejs 24.15.0

app/.npmrc

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
runtime = electron
22
disturl = https://electronjs.org/headers
3-
target = 40.1.0
3+
target = 42.0.1

app/src/lib/copilot-error.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,51 @@ function isPaymentRequiredErrorCode(
7575
)
7676
}
7777

78+
/**
79+
* Builds a {@link CopilotError} from a Copilot SDK `session.error` event
80+
* payload when its upstream HTTP status code is 402 (Payment Required).
81+
* Returns null for any other status (or no status), so callers can
82+
* distinguish payment-required failures from generic session errors.
83+
*/
84+
export function getCopilotPaymentRequiredErrorFromSessionError(data: {
85+
readonly message?: string
86+
readonly statusCode?: number
87+
readonly errorCode?: string
88+
}): CopilotError | null {
89+
if (data.statusCode !== HttpStatusCode.PaymentRequired) {
90+
return null
91+
}
92+
93+
const code = isPaymentRequiredErrorCode(data.errorCode)
94+
? data.errorCode
95+
: undefined
96+
const cleaned = cleanSessionErrorMessage(data.message ?? '', data.statusCode)
97+
const message =
98+
cleaned.length > 0 ? cleaned : getFallbackPaymentRequiredMessage(code)
99+
100+
return new CopilotError(message, HttpStatusCode.PaymentRequired, {
101+
paymentRequiredErrorCode: code,
102+
})
103+
}
104+
105+
/**
106+
* SDK `session.error` messages are sometimes formatted as
107+
* `"<statusCode> <message> (Request ID: <id>)"`. Strip the leading status
108+
* code and trailing request-id annotation so the user sees just the
109+
* human-readable reason.
110+
*
111+
* Exported for testing.
112+
*/
113+
export function cleanSessionErrorMessage(
114+
message: string,
115+
statusCode: number
116+
): string {
117+
return message
118+
.replace(new RegExp(`^\\s*${statusCode}\\s+`), '')
119+
.replace(/\s*\(Request ID:[^)]*\)\s*$/i, '')
120+
.trim()
121+
}
122+
78123
function getFallbackPaymentRequiredMessage(
79124
code: CopilotPaymentRequiredErrorCode | undefined
80125
) {

app/src/lib/git/worktree.ts

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,40 @@
1+
import { git } from './core'
2+
import { Repository } from '../../models/repository'
13
import * as Path from 'path'
24
import * as Fs from 'fs'
3-
import type { Repository } from '../../models/repository'
45
import type { WorktreeEntry, WorktreeType } from '../../models/worktree'
5-
import { git } from './core'
66
import { normalizePath } from '../helpers/path'
77

8+
/**
9+
* Get the set of canonical branch refs (e.g. `refs/heads/feature`)
10+
* checked out in any worktree (main or linked).
11+
*/
12+
export async function getWorktreeCheckedOutBranches(
13+
repository: Repository
14+
): Promise<ReadonlySet<string>> {
15+
const result = await git(
16+
['worktree', 'list', '--porcelain', '-z'],
17+
repository.path,
18+
'getWorktreeCheckedOutBranches'
19+
)
20+
21+
const branches = new Set<string>()
22+
23+
// With -z, lines are NUL-terminated and blocks are separated by
24+
// double NUL (i.e. an empty string between two NUL terminators).
25+
const blocks = result.stdout.split('\0\0')
26+
27+
for (const block of blocks) {
28+
for (const line of block.split('\0')) {
29+
if (line.startsWith('branch ')) {
30+
branches.add(line.substring('branch '.length))
31+
}
32+
}
33+
}
34+
35+
return branches
36+
}
37+
838
function getDotGitPath(repositoryPath: string): string {
939
return Path.join(repositoryPath, '.git')
1040
}

app/src/lib/stores/app-store.ts

Lines changed: 42 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -3318,7 +3318,10 @@ export class AppStore extends TypedBaseStore<IAppState> {
33183318
}
33193319

33203320
const { step, operationDetail } = multiCommitOperationState
3321-
if (step.kind !== MultiCommitOperationStepKind.ShowConflicts) {
3321+
if (
3322+
step.kind !== MultiCommitOperationStepKind.ShowConflicts &&
3323+
step.kind !== MultiCommitOperationStepKind.ShowCopilotConflicts
3324+
) {
33223325
return
33233326
}
33243327

@@ -3327,7 +3330,10 @@ export class AppStore extends TypedBaseStore<IAppState> {
33273330
this.repositoryStateCache.updateMultiCommitOperationState(
33283331
repository,
33293332
() => ({
3330-
step: { ...step, manualResolutions },
3333+
step: {
3334+
...step,
3335+
conflictState: { ...step.conflictState, manualResolutions },
3336+
},
33313337
})
33323338
)
33333339

@@ -3403,20 +3409,32 @@ export class AppStore extends TypedBaseStore<IAppState> {
34033409

34043410
this.statsStore.increment('mergeConflictFromExplicitMergeCount')
34053411

3412+
const mcoConflictState = {
3413+
kind: 'multiCommitOperation' as const,
3414+
manualResolutions,
3415+
ourBranch,
3416+
theirBranch,
3417+
}
3418+
3419+
const useCopilot = multiCommitOperationState.useCopilotConflictResolution
3420+
34063421
this._setMultiCommitOperationStep(repository, {
3407-
kind: MultiCommitOperationStepKind.ShowConflicts,
3408-
conflictState: {
3409-
kind: 'multiCommitOperation',
3410-
manualResolutions,
3411-
ourBranch,
3412-
theirBranch,
3413-
},
3422+
kind: useCopilot
3423+
? MultiCommitOperationStepKind.ShowCopilotConflictsLoading
3424+
: MultiCommitOperationStepKind.ShowConflicts,
3425+
conflictState: mcoConflictState,
34143426
})
34153427

34163428
this._showPopup({
34173429
type: PopupType.MultiCommitOperation,
34183430
repository,
34193431
})
3432+
3433+
if (useCopilot) {
3434+
// Auto-route to Copilot: the user previously opted into Copilot
3435+
// resolution during this operation, so skip the manual dialog.
3436+
await this._startCopilotConflictResolution(repository)
3437+
}
34203438
}
34213439

34223440
private async getMergeConflictsTheirBranch(
@@ -6889,14 +6907,24 @@ export class AppStore extends TypedBaseStore<IAppState> {
68896907
return
68906908
}
68916909

6892-
const { copilotResolutions } = multiCommitOperationState
6910+
const { copilotResolutions, step } = multiCommitOperationState
68936911
if (copilotResolutions === null || copilotResolutions.length === 0) {
68946912
return
68956913
}
68966914

6915+
// Respect any manual overrides the user chose in the result dialog
6916+
const manualResolutions =
6917+
step.kind === MultiCommitOperationStepKind.ShowCopilotConflicts
6918+
? step.conflictState.manualResolutions
6919+
: new Map<string, ManualConflictResolution>()
6920+
68976921
const pathsToStage: string[] = []
68986922

68996923
for (const resolution of copilotResolutions) {
6924+
if (manualResolutions.has(resolution.path)) {
6925+
continue
6926+
}
6927+
69006928
const absolutePath = await resolveWithin(repository.path, resolution.path)
69016929
if (absolutePath === null) {
69026930
log.warn(
@@ -8648,8 +8676,10 @@ export class AppStore extends TypedBaseStore<IAppState> {
86488676
if (
86498677
changesState.conflictState === null ||
86508678
multiCommitOperationState === null ||
8651-
multiCommitOperationState.step.kind !==
8652-
MultiCommitOperationStepKind.ShowConflicts
8679+
(multiCommitOperationState.step.kind !==
8680+
MultiCommitOperationStepKind.ShowConflicts &&
8681+
multiCommitOperationState.step.kind !==
8682+
MultiCommitOperationStepKind.ShowCopilotConflicts)
86538683
) {
86548684
return
86558685
}

app/src/lib/stores/copilot-store.ts

Lines changed: 54 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,17 @@
1-
import { CopilotClient } from '@github/copilot-sdk'
2-
import type { ModelInfo, SessionConfig } from '@github/copilot-sdk'
1+
import { CopilotClient, CopilotSession } from '@github/copilot-sdk'
2+
import type {
3+
AssistantMessageEvent,
4+
MessageOptions,
5+
ModelInfo,
6+
SessionConfig,
7+
} from '@github/copilot-sdk'
38
import { AccountsStore } from './accounts-store'
49
import { Account, isDotComAccount } from '../../models/account'
510
import {
611
ICopilotCommitMessage,
712
parseCopilotCommitMessage,
813
} from '../copilot-commit-message'
14+
import { getCopilotPaymentRequiredErrorFromSessionError } from '../copilot-error'
915
import {
1016
CopilotValidationError,
1117
ConflictResolutionSystemPrompt,
@@ -448,6 +454,48 @@ export class CopilotStore extends BaseStore {
448454
}
449455
}
450456

457+
/**
458+
* Sends a prompt on the given session and waits for the assistant
459+
* response, while capturing any `session.error` events emitted during
460+
* the round-trip.
461+
*
462+
* If the SDK emits a `session.error` whose upstream HTTP status code is
463+
* 402 (Payment Required), the corresponding `CopilotError` is thrown
464+
* instead of whatever {@link CopilotSession.sendAndWait} would have
465+
* rejected with — the underlying rejection is intentionally swallowed
466+
* because the SDK surfaces the same failure twice (once on the event
467+
* channel, once on the awaited promise) and only the parsed 402 error
468+
* carries actionable billing metadata for the UI.
469+
*
470+
* Any other `session.error` event is logged and otherwise ignored so
471+
* the original `sendAndWait` rejection (or success) is propagated
472+
* unchanged.
473+
*/
474+
private async sendAndWait(
475+
session: CopilotSession,
476+
options: MessageOptions,
477+
timeoutMs: number
478+
): Promise<AssistantMessageEvent | undefined> {
479+
let paymentRequiredError: Error | undefined
480+
481+
const unsubscribe = session.on('session.error', e => {
482+
const captured = getCopilotPaymentRequiredErrorFromSessionError(e.data)
483+
if (captured !== null) {
484+
paymentRequiredError = captured
485+
} else {
486+
log.error(`CopilotStore: Session error: ${e.toString()}`)
487+
}
488+
})
489+
490+
try {
491+
return await session.sendAndWait(options, timeoutMs)
492+
} catch (e) {
493+
throw paymentRequiredError ?? e
494+
} finally {
495+
unsubscribe()
496+
}
497+
}
498+
451499
/**
452500
* Generates a commit message for the given diff using Copilot.
453501
*
@@ -538,7 +586,9 @@ export class CopilotStore extends BaseStore {
538586
tags,
539587
cleanedRuleDescriptions
540588
)
541-
const response = await session.sendAndWait(
589+
590+
const response = await this.sendAndWait(
591+
session,
542592
{ prompt: userPrompt },
543593
timeoutMs
544594
)
@@ -700,7 +750,7 @@ export class CopilotStore extends BaseStore {
700750
}),
701751
})
702752

703-
const response = await session.sendAndWait({ prompt }, 600_000)
753+
const response = await this.sendAndWait(session, { prompt }, 600_000)
704754

705755
if (!response || !response.data.content) {
706756
throw new Error('No response from Copilot')

app/src/lib/stores/helpers/branch-pruner.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
formatAsLocalRef,
1313
getBranches,
1414
deleteLocalBranch,
15+
getWorktreeCheckedOutBranches,
1516
} from '../../git'
1617
import { fatalError } from '../../fatal-error'
1718
import { RepositoryStateCache } from '../repository-state-cache'
@@ -198,6 +199,11 @@ export class BranchPruner {
198199
await getBranches(this.repository, `refs/remotes/`)
199200
).map(b => formatAsLocalRef(b.name))
200201

202+
// get branches checked out in linked worktrees so we don't delete them
203+
const worktreeBranches = await getWorktreeCheckedOutBranches(
204+
this.repository
205+
)
206+
201207
// create list of branches to be pruned
202208
const branchesReadyForPruning = Array.from(mergedBranches.keys()).filter(
203209
ref => {
@@ -207,6 +213,9 @@ export class BranchPruner {
207213
if (recentlyCheckedOutCanonicalRefs.has(ref)) {
208214
return false
209215
}
216+
if (worktreeBranches.has(ref)) {
217+
return false
218+
}
210219
const upstreamRef = getUpstreamRefForLocalBranchRef(ref, allBranches)
211220
if (upstreamRef === undefined) {
212221
return false

0 commit comments

Comments
 (0)