11import { GitServiceInterface , GitTreeEntry , BatchPushItem , BatchPushResult } from './git-service-interface' ;
2- import { BaseGitService , ConnectionTestResult , GitFile , GitHubContentResponse , GitHubTreeResponse , GIT_SYMLINK_MODE } from './git-service-base' ;
2+ import { BaseGitService , ConnectionTestResult , GitFile , GitHubContentResponse , GitHubTreeResponse , GIT_SYMLINK_MODE , BLOB_CREATE_CONCURRENCY } from './git-service-base' ;
33import { logger } from '../utils/logger' ;
4+ import { PushTimingCollector , PushTimingHandler , PushTimingRecord } from './push-timing' ;
45
56/**
67 * Commits any mix of file additions/deletions in one request. Used instead of
@@ -20,6 +21,12 @@ const CREATE_COMMIT_MUTATION = `
2021export class GitHubService extends BaseGitService implements GitServiceInterface {
2122 private owner : string = '' ;
2223 private repo : string = '' ;
24+ private pushTimingHandler ?: PushTimingHandler ;
25+
26+ /** Enables local diagnostic records; the plugin itself never enables this. */
27+ setPushTimingHandler ( handler ?: PushTimingHandler ) : void {
28+ this . pushTimingHandler = handler ;
29+ }
2330
2431 updateConfig ( token : string , owner : string , repo : string , rootPath : string = '' ) {
2532 this . token = token ;
@@ -42,6 +49,10 @@ export class GitHubService extends BaseGitService implements GitServiceInterface
4249 return `https://api.github.com/repos/${ this . owner } /${ this . repo } ` ;
4350 }
4451
52+ async getBranchHead ( branch : string ) : Promise < string > {
53+ return this . getLatestCommitSha ( branch ) ;
54+ }
55+
4556 async getFile ( path : string , branch : string ) : Promise < GitFile > {
4657 try {
4758 const url = `${ this . getApiUrl ( path ) } ?ref=${ branch } ` ;
@@ -64,21 +75,9 @@ export class GitHubService extends BaseGitService implements GitServiceInterface
6475 }
6576 }
6677
67- async pushFile ( path : string , content : string | ArrayBuffer , branch : string , message : string , sha ?: string ) : Promise < { path : string , sha ?: string } > {
68- const url = this . getApiUrl ( path ) ;
69- const body : { message : string ; content : string ; branch : string ; sha ?: string } = {
70- message,
71- content : this . encodeContent ( content ) ,
72- branch,
73- } ;
74- // GitHub's Contents API rejects a blank sha with HTTP 422. Only include
75- // it when updating an existing file; a 404 lookup yields sha === '' for
76- // new files, which must be created without a sha.
77- if ( sha ) body . sha = sha ;
78-
79- const response = await this . safeRequest ( url , 'PUT' , body ) ;
80- const data = this . parseJson < { content : { path : string , sha : string } } > ( response ) ;
81- return { path : data . content . path , sha : data . content . sha } ;
78+ async pushFile ( path : string , content : string | ArrayBuffer , branch : string , message : string , _existingSha ?: string ) : Promise < { path : string , sha ?: string } > {
79+ const [ result ] = await this . pushBatch ( [ { path, content } ] , branch , message ) ;
80+ return result ?? { path } ;
8281 }
8382
8483 async pushSymlink ( path : string , target : string , branch : string , message : string ) : Promise < { path : string , sha ?: string } > {
@@ -108,9 +107,11 @@ export class GitHubService extends BaseGitService implements GitServiceInterface
108107 * rather than an HTTP error status, so this checks for that on top of
109108 * safeRequest's status-code check.
110109 */
111- private async githubGraphQL < T > ( query : string , variables : Record < string , unknown > ) : Promise < T > {
112- const response = await this . safeRequest ( 'https://api.github.com/graphql' , 'POST' , { query, variables } ) ;
113- const body = this . parseJson < { data ?: T ; errors ?: Array < { message : string } > } > ( response ) ;
110+ private async githubGraphQL < T > ( query : string , variables : Record < string , unknown > , timing ?: PushTimingCollector ) : Promise < T > {
111+ const request = ( ) => this . safeRequest ( 'https://api.github.com/graphql' , 'POST' , { query, variables } ) ;
112+ const response = timing ? await timing . measureRequest ( request ) : await request ( ) ;
113+ const parse = ( ) => this . parseJson < { data ?: T ; errors ?: Array < { message : string } > } > ( response ) ;
114+ const body = timing ? timing . measureParsing ( parse ) : parse ( ) ;
114115 if ( body . errors && body . errors . length > 0 ) {
115116 throw new Error ( `GitHub GraphQL error: ${ body . errors . map ( e => e . message ) . join ( '; ' ) } ` ) ;
116117 }
@@ -130,10 +131,11 @@ export class GitHubService extends BaseGitService implements GitServiceInterface
130131 * an obvious staleness error. A short retry with a freshly re-read HEAD
131132 * self-heals once GitHub's read catches up.
132133 */
133- private async commitOnBranch ( branch : string , message : string , fileChanges : Record < string , unknown > ) : Promise < string > {
134+ private async commitOnBranch ( branch : string , message : string , fileChanges : Record < string , unknown > , timing ?: PushTimingCollector ) : Promise < string > {
134135 const maxAttempts = 3 ;
135136 for ( let attempt = 1 ; attempt <= maxAttempts ; attempt ++ ) {
136- const expectedHeadOid = await this . getLatestCommitSha ( branch ) ;
137+ const getHead = ( ) => this . getLatestCommitSha ( branch ) ;
138+ const expectedHeadOid = timing ? await timing . measureRequest ( getHead ) : await getHead ( ) ;
137139 try {
138140 const data = await this . githubGraphQL < { createCommitOnBranch : { commit : { oid : string } } } > ( CREATE_COMMIT_MUTATION , {
139141 input : {
@@ -142,7 +144,7 @@ export class GitHubService extends BaseGitService implements GitServiceInterface
142144 expectedHeadOid,
143145 fileChanges,
144146 } ,
145- } ) ;
147+ } , timing ) ;
146148 return data . createCommitOnBranch . commit . oid ;
147149 } catch ( e ) {
148150 const errorMessage = e instanceof Error ? e . message : String ( e ) ;
@@ -157,31 +159,71 @@ export class GitHubService extends BaseGitService implements GitServiceInterface
157159
158160 async pushBatch ( items : BatchPushItem [ ] , branch : string , message : string ) : Promise < BatchPushResult [ ] > {
159161 if ( items . length === 0 ) return [ ] ;
162+ const timing = this . pushTimingHandler ? new PushTimingCollector ( ) : undefined ;
163+ const preparationStartedAt = performance . now ( ) ;
164+ const preparedItems = items . map ( item => ( { item, path : this . getFullPath ( item . path ) } ) ) ;
165+ const rawBytes = items . reduce ( ( total , item ) => total + this . getByteLength ( item . content ) , 0 ) ;
166+ const changePreparationMs = performance . now ( ) - preparationStartedAt ;
167+ const encodingStartedAt = performance . now ( ) ;
168+ const additions = preparedItems . map ( ( { item, path } ) => ( { path, contents : this . encodeContent ( item . content ) } ) ) ;
169+ const encodedBytes = additions . reduce ( ( total , addition ) => total + this . getByteLength ( addition . contents ) , 0 ) ;
170+ const encodingMs = performance . now ( ) - encodingStartedAt ;
171+ let failure : unknown ;
160172
161- await this . commitOnBranch ( branch , message , {
162- additions : items . map ( item => ( {
163- path : this . getFullPath ( item . path ) ,
164- contents : this . encodeContent ( item . content ) ,
165- } ) ) ,
166- } ) ;
173+ try {
174+ await this . commitOnBranch ( branch , message , { additions } , timing ) ;
175+ // The caller already marks committed paths as synced. Avoiding a
176+ // full recursive tree read saves a request and sidesteps GitHub's
177+ // briefly stale tree reads after a successful mutation.
178+ return items . map ( item => ( { path : item . path } ) ) ;
179+ } catch ( error ) {
180+ failure = error ;
181+ throw error ;
182+ } finally {
183+ this . emitPushTiming ( timing , 'github-graphql' , items . length , rawBytes , encodedBytes , changePreparationMs , encodingMs , failure ) ;
184+ }
185+ }
186+
187+ /**
188+ * Developer-only Git Data API control path for benchmark #61. Production
189+ * pushes continue to use GraphQL because this path requires one blob POST
190+ * per file. It is intentionally not part of GitServiceInterface.
191+ */
192+ async pushBatchViaGitDataApiForBenchmark ( items : BatchPushItem [ ] , branch : string , message : string ) : Promise < BatchPushResult [ ] > {
193+ if ( items . length === 0 ) return [ ] ;
167194
168- // createCommitOnBranch only returns the new commit's oid, not each
169- // file's blob sha, so read them back with a follow-up tree fetch
170- // (mirrors GitLab's pushBatch, which has the same limitation). That
171- // fetch is exposed to the same eventual-consistency lag the retry
172- // above works around, so a fresh tree can still be briefly missing an
173- // entry that was just committed; retry it too rather than silently
174- // returning an undefined sha for that file.
195+ const base = this . getGitDataApiBase ( ) ;
196+ const { latestCommitSha, baseTreeSha } = await this . resolveGitHubStyleBaseTree ( branch ) ;
175197 const fullPaths = items . map ( item => this . getFullPath ( item . path ) ) ;
176- for ( let attempt = 1 ; attempt <= 3 ; attempt ++ ) {
177- const freshTree = await this . listFilesDetailed ( branch , false ) ;
178- const shaByPath = new Map ( freshTree . map ( e => [ e . path , e . sha ] ) ) ;
179- const results = items . map ( ( item , i ) => ( { path : item . path , sha : shaByPath . get ( fullPaths [ i ] as string ) } ) ) ;
180- if ( results . every ( r => r . sha ) || attempt === 3 ) return results ;
181- await new Promise ( resolve => window . setTimeout ( resolve , 500 * attempt ) ) ;
182- }
183- // Unreachable: the loop always returns on its last iteration.
184- throw new Error ( 'pushBatch: exhausted retries reading back blob shas' ) ;
198+ const blobShas = await this . mapWithConcurrency ( items , BLOB_CREATE_CONCURRENCY , async item => {
199+ const response = await this . safeRequest ( `${ base } /git/blobs` , 'POST' , {
200+ content : this . encodeContent ( item . content ) ,
201+ encoding : 'base64' ,
202+ } ) ;
203+ return this . parseJson < { sha : string } > ( response ) . sha ;
204+ } ) ;
205+
206+ await this . commitGitHubStyleTree (
207+ base , branch , baseTreeSha , latestCommitSha ,
208+ fullPaths . map ( ( path , index ) => ( { path, mode : '100644' , type : 'blob' as const , sha : blobShas [ index ] as string } ) ) ,
209+ message
210+ ) ;
211+ return items . map ( ( item , index ) => ( { path : item . path , sha : blobShas [ index ] } ) ) ;
212+ }
213+
214+ private getByteLength ( content : string | ArrayBuffer ) : number {
215+ return typeof content === 'string' ? new TextEncoder ( ) . encode ( content ) . byteLength : content . byteLength ;
216+ }
217+
218+ private getErrorMessage ( error : unknown ) : string | undefined {
219+ if ( error === undefined ) return undefined ;
220+ return error instanceof Error ? error . message : 'Non-Error push failure' ;
221+ }
222+
223+ private emitPushTiming ( timing : PushTimingCollector | undefined , strategy : PushTimingRecord [ 'strategy' ] , fileCount : number , rawBytes : number , encodedBytes : number , changePreparationMs : number , encodingMs : number , error ?: unknown ) : void {
224+ if ( ! timing || ! this . pushTimingHandler ) return ;
225+ const failure = this . getErrorMessage ( error ) ;
226+ this . pushTimingHandler ( timing . createRecord ( strategy , fileCount , rawBytes , encodedBytes , changePreparationMs , encodingMs , failure ) ) ;
185227 }
186228
187229 async listFilesDetailed ( branch : string , useFilter = true ) : Promise < GitTreeEntry [ ] > {
0 commit comments