@feng0531/dsh-vcs 0.4.9 → 0.4.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/vcs.mjs CHANGED
@@ -3,7 +3,7 @@ import {parseCommitSignature} from './src/commit-signature.mjs';
3
3
  import {parseSvnIdentity} from './src/svn-identity.mjs';
4
4
  import {prepareRaster} from './image-preview.mjs';
5
5
  import {git, svn, TIMEOUT} from './local-command.mjs';
6
- import {parseHistory,parseFollowHistory,HISTORY_FORMAT,historyPage,historySearchArgs,parseBlame,parseReferences,REFS_FORMAT,validateDateBound} from './git-history.mjs';
6
+ import {parseHistory,parseFollowHistory,HISTORY_FORMAT,historyPage,historySearchArgs,parseBlame,parseReferences,REFS_FORMAT,validateDateBound,isObjectId,isChangeId} from './git-history.mjs';
7
7
  import * as fs from 'node:fs/promises';
8
8
  import {constants as fileFlags} from 'node:fs';
9
9
  import path from 'node:path';
@@ -13,6 +13,18 @@ import { XMLParser, XMLValidator } from 'fast-xml-parser';
13
13
  import {decode, MAX_TEXT, MAX_LARGE_TEXT, decodeLargeText} from './text-content.mjs';
14
14
  import {inside, confined} from './repository-path.mjs';
15
15
  import {readWorkspaceLargeText} from './workspace-large-text.mjs';
16
+
17
+ // Shared read-only adapter bounds. Values are part of the public contract:
18
+ // history pagination, per-result entry caps and segmented-preview byte budgets.
19
+ const MAX_HISTORY_OFFSET = 10000;
20
+ const MAX_HISTORY_PAGE = 100;
21
+ const MAX_RESULT_ENTRIES = 10000;
22
+ const MAX_SEGMENT_BYTES = 16 * 1024 * 1024;
23
+ const SEGMENT_CHUNK_BYTES = 64 * 1024;
24
+ const RENAME_CONFIRMATION_LIMIT = 32;
25
+ const METADATA_BATCH_SIZE = 64;
26
+ const METADATA_BATCH_BYTES = 24000;
27
+
16
28
  const xml = new XMLParser({ ignoreAttributes: false, attributeNamePrefix: '', parseTagValue: false, parseAttributeValue: false, trimValues: false, processEntities: true, isArray: name => ['entry', 'target', 'property'].includes(name) });
17
29
 
18
30
 
@@ -183,6 +195,34 @@ async function checkedRepo(repo, signal, budget) {
183
195
  if (!found || found.type !== repo.type || path.resolve(found.root) !== path.resolve(repo.root)) throw new Error('Repository root changed or is invalid');
184
196
  return found;
185
197
  }
198
+ function assertGit(repo, message) {
199
+ if (repo.type !== 'git') throw new Error(message);
200
+ return repo;
201
+ }
202
+ async function checkedGitRepo(repo, signal, message, budget) {
203
+ return assertGit(await checkedRepo(repo, signal, budget), message);
204
+ }
205
+ // Probe --since-as-filter support so older Git cannot run a silently unfiltered query.
206
+ async function assertSinceAsFilterSupport(repo, signal, label) {
207
+ signal?.throwIfAborted();
208
+ try { await git(repo.root,['log','--since-as-filter=1970-01-01','-1','--format=%H'],MAX_TEXT,{signal}); }
209
+ catch (e) {
210
+ if (e.code === 'VCS_COMMAND') throw new Error('Date-range ' + label + ' requires Git 2.37 or newer (--since-as-filter).');
211
+ throw e;
212
+ }
213
+ }
214
+ // Resolve HEAD^{commit}; only an unborn symbolic HEAD is absent, never corruption.
215
+ async function resolveHeadCommit(run) {
216
+ try { return (await run(['rev-parse','--verify','HEAD^{commit}'])).toString('utf8').trim(); }
217
+ catch (e) {
218
+ if (e.code !== 'VCS_COMMAND') throw e;
219
+ // Distinguish an unborn symbolic HEAD from corruption or other command failures.
220
+ const ref=(await run(['symbolic-ref','-q','HEAD'])).toString('utf8').trim();
221
+ try { await run(['show-ref','--verify','--quiet',ref]); }
222
+ catch (missing) { if (missing.code === 'VCS_COMMAND' && missing.exitCode === 1) return null; throw missing; }
223
+ throw e;
224
+ }
225
+ }
186
226
  export async function getSvnIdentity(repo,{signal}={}){
187
227
  repo=await checkedRepo(repo,signal);if(repo.type!=='svn')throw Error('SVN working copy required');
188
228
  const result=parseSvnIdentity((await svn(repo.root,['info','--xml','--', '.'],MAX_TEXT,{signal})).toString('utf8'));
@@ -190,42 +230,28 @@ export async function getSvnIdentity(repo,{signal}={}){
190
230
  }
191
231
  export async function listHistory(repo, {snapshot,offset=0,limit=50,search,signal} = {}) {
192
232
  const query=historySearchArgs(search);
193
- if(!Number.isInteger(offset)||offset<0||offset>10000||!Number.isInteger(limit)||limit<1||limit>100) throw new Error('Invalid history pagination');
194
- if(snapshot!==undefined && (typeof snapshot!=='string'||!/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/.test(snapshot))) throw new Error('Invalid history snapshot');
233
+ if(!Number.isInteger(offset)||offset<0||offset>MAX_HISTORY_OFFSET||!Number.isInteger(limit)||limit<1||limit>MAX_HISTORY_PAGE) throw new Error('Invalid history pagination');
234
+ if(snapshot!==undefined && (typeof snapshot!=='string'||!isObjectId(snapshot))) throw new Error('Invalid history snapshot');
195
235
  if(signal?.aborted) throw Object.assign(new Error('History cancelled'),{code:'ABORT_ERR'});
196
- repo=await checkedRepo(repo,signal);
197
- if(repo.type!=='git') throw new Error('History currently supports Git only');
198
- if(query.args.some(a=>a.startsWith('--since-as-filter='))){
199
- signal?.throwIfAborted();
200
- try{await git(repo.root,['log','--since-as-filter=1970-01-01','-1','--format=%H'],MAX_TEXT,{signal});}
201
- catch(e){if(e.code==='VCS_COMMAND')throw new Error('Date-range history search requires Git 2.37 or newer (--since-as-filter).');throw e;}
202
- }
236
+ repo=await checkedGitRepo(repo,signal,'History currently supports Git only');
237
+ if(query.args.some(a=>a.startsWith('--since-as-filter='))) await assertSinceAsFilterSupport(repo,signal,'history search');
203
238
  if(!snapshot){
204
- try{snapshot=(await git(repo.root,['rev-parse','--verify','HEAD^{commit}'],MAX_TEXT,{signal})).toString('utf8').trim();}
205
- catch(e){
206
- if(e.code!=='VCS_COMMAND')throw e;
207
- // Distinguish an unborn symbolic HEAD from corruption or other command failures.
208
- const ref=(await git(repo.root,['symbolic-ref','-q','HEAD'],MAX_TEXT,{signal})).toString('utf8').trim();
209
- try{await git(repo.root,['show-ref','--verify','--quiet',ref],MAX_TEXT,{signal});}
210
- catch(missing){if(missing.code==='VCS_COMMAND'&&missing.exitCode===1)return {snapshot:null,commits:[],nextOffset:null};throw missing;}
211
- throw e;
212
- }
239
+ snapshot=await resolveHeadCommit(args=>git(repo.root,args,MAX_TEXT,{signal}));
240
+ if(snapshot===null)return {snapshot:null,commits:[],nextOffset:null};
213
241
  }
214
242
  const text=await git(repo.root,['--literal-pathspecs','log','-z','--no-show-signature','--encoding=UTF-8','--topo-order','--max-count='+String(limit+1),'--skip='+String(offset),'--format='+HISTORY_FORMAT,...query.args,snapshot,'--',...query.paths],MAX_TEXT,{signal});
215
243
  const rows=parseHistory(text.toString('utf8'),limit+1);
216
244
  return historyPage(rows,snapshot,offset,limit);
217
245
  }
218
246
  export async function listReferences(repo,{signal}={}){
219
- signal?.throwIfAborted();repo=await checkedRepo(repo,signal);
220
- if(repo.type!=='git')throw new Error('References support Git only');
247
+ signal?.throwIfAborted();repo=await checkedGitRepo(repo,signal,'References support Git only');
221
248
  const raw=await git(repo.root,['for-each-ref','--sort=refname','--count=1001','--format='+REFS_FORMAT,'refs/heads/','refs/tags/','refs/remotes/'],MAX_TEXT,{signal});
222
249
  return {references:parseReferences(raw.toString('utf8'))};
223
250
  }
224
251
 
225
252
  const STASH_FORMAT='%H%x00%gs%x00%gd';
226
253
  export async function listStashes(repo,{signal}={}){
227
- signal?.throwIfAborted();repo=await checkedRepo(repo,signal);
228
- if(repo.type!=='git')throw new Error('Stash supports Git only');
254
+ signal?.throwIfAborted();repo=await checkedGitRepo(repo,signal,'Stash supports Git only');
229
255
  const text=(await git(repo.root,['stash','list','--format='+STASH_FORMAT],MAX_TEXT,{signal})).toString('utf8');
230
256
  if(!text.trim())return {stashes:[]};
231
257
  const stashes=[];
@@ -234,7 +260,7 @@ export async function listStashes(repo,{signal}={}){
234
260
  const fields=line.split('\0');
235
261
  if(fields.length!==3)throw new Error('Invalid stash entry');
236
262
  const [id,message,ref]=fields;
237
- if(!/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/.test(id))throw new Error('Invalid stash entry');
263
+ if(!isObjectId(id))throw new Error('Invalid stash entry');
238
264
  stashes.push({id,message:message||ref,ref});
239
265
  if(stashes.length>1000)throw new Error('Stash exceeds entry limit');
240
266
  }
@@ -244,14 +270,13 @@ export async function listStashes(repo,{signal}={}){
244
270
  export async function searchRepositoryContent(repo,{commit,query,signal}={}) {
245
271
  signal?.throwIfAborted();
246
272
  if(typeof query!=='string'||!query||query.length>1024||/[\0\r\n]/.test(query))throw new Error('Invalid content search query');
247
- repo=await checkedRepo(repo,signal);
248
- if(repo.type!=='git')throw new Error('Content search supports Git only');
273
+ repo=await checkedGitRepo(repo,signal,'Content search supports Git only');
249
274
  let snapshot=commit;
250
275
  if(!snapshot){
251
276
  try{snapshot=(await git(repo.root,['rev-parse','--verify','HEAD^{commit}'],MAX_TEXT,{signal})).toString('utf8').trim();}
252
277
  catch{throw new Error('Content search requires a resolved HEAD commit');}
253
278
  }
254
- if(!/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/.test(snapshot))throw new Error('Invalid content search commit');
279
+ if(!isObjectId(snapshot))throw new Error('Invalid content search commit');
255
280
  let text;
256
281
  try{text=(await git(repo.root,['-c','grep.column=false','grep','--no-color','-F','-n','-I','-z','--max-count=100','-e',query,snapshot,'--'],MAX_TEXT,{signal})).toString('utf8');}
257
282
  catch(error){if(error.code==='VCS_COMMAND'&&error.exitCode===1)return {matches:[]};throw error;}
@@ -276,11 +301,10 @@ export async function getCommitDetails(repo,options={}) {
276
301
  return commitDetails(repo,options,true);
277
302
  }
278
303
  async function commitDetails(repo,{commit,parentIndex=0,signal}={},includeMessage=false) {
279
- if(typeof commit!=='string'||!/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/.test(commit))throw new Error('Invalid commit id');
304
+ if(typeof commit!=='string'||!isObjectId(commit))throw new Error('Invalid commit id');
280
305
  if(!Number.isInteger(parentIndex)||parentIndex<0)throw new Error('Invalid parent index');
281
306
  signal?.throwIfAborted();
282
- repo=await checkedRepo(repo,signal);
283
- if(repo.type!=='git')throw new Error('Commit details support Git only');
307
+ repo=await checkedGitRepo(repo,signal,'Commit details support Git only');
284
308
  const raw=await git(repo.root,['log','-z','--no-show-signature','--encoding=UTF-8','--max-count=1','--format='+HISTORY_FORMAT,commit,'--'],MAX_TEXT,{signal});
285
309
  const metadata=parseHistory(raw.toString('utf8'),1)[0];
286
310
  if(!metadata||metadata.id!==commit)throw new Error('Commit object required');
@@ -315,22 +339,30 @@ function parseCommitChanges(text,scope){
315
339
  if(!first||!file)throw new Error('Truncated commit path');
316
340
  const entry={path:file,...(renamed?{oldPath:first}:{}),status:statuses[code[0]]||'modified'};
317
341
  changes.push({...entry,id:identity(entry,scope)});
318
- if(changes.length>10000)throw new Error('Commit exceeds file count limit');
342
+ if(changes.length>MAX_RESULT_ENTRIES)throw new Error('Commit exceeds file count limit');
319
343
  }
320
344
  return changes;
321
345
  }
322
346
  export async function getHistoricalSegment(repo,{commit,path:file,offset=0,signal}={}){
323
- if(!Number.isInteger(offset)||offset<0||offset>16777216)throw Error('Invalid segment offset');
347
+ if(!Number.isInteger(offset)||offset<0||offset>MAX_SEGMENT_BYTES)throw Error('Invalid segment offset');
324
348
  if(typeof file!=='string'||!file||file.includes('\0'))throw Error('Invalid historical path');
325
- repo=await checkedRepo(repo,signal);const tree=await getHistoricalTree(repo,{commit,signal}),entry=tree.entries.find(row=>row.path===file);
349
+ repo=await checkedRepo(repo,signal);
350
+ const tree=await getHistoricalTree(repo,{commit,signal}),entry=tree.entries.find(row=>row.path===file);
326
351
  if(!entry||entry.type!=='blob'||!['100644','100755'].includes(entry.mode))throw Error('Segment requires a selected committed regular file');
327
- const sizeText=(await git(repo.root,['cat-file','-s',entry.oid],MAX_TEXT,{signal})).toString('utf8').trim();if(!/^\d+$/.test(sizeText))throw Error('Invalid object size');const size=Number(sizeText);
328
- if(size>16777216)throw Error('Segmented file exceeds 16 MiB limit');if(offset>size)throw Error('Segment offset exceeds file size');
329
- const buffer=await git(repo.root,['cat-file','blob',entry.oid],16777216,{signal});if(buffer.length!==size)throw Error('Historical blob size mismatch');
352
+ const sizeText=(await git(repo.root,['cat-file','-s',entry.oid],MAX_TEXT,{signal})).toString('utf8').trim();
353
+ if(!/^\d+$/.test(sizeText))throw Error('Invalid object size');
354
+ const size=Number(sizeText);
355
+ if(size>MAX_SEGMENT_BYTES)throw Error('Segmented file exceeds 16 MiB limit');
356
+ if(offset>size)throw Error('Segment offset exceeds file size');
357
+ const buffer=await git(repo.root,['cat-file','blob',entry.oid],MAX_SEGMENT_BYTES,{signal});
358
+ if(buffer.length!==size)throw Error('Historical blob size mismatch');
330
359
  if(buffer.includes(0)||buffer.some(byte=>byte<32&&![9,10,12,13].includes(byte)))throw Error('Segments support UTF-8 text only');
331
- new TextDecoder('utf-8',{fatal:true}).decode(buffer);if(offset<size&&(buffer[offset]&192)===128)throw Error('Segment offset splits UTF-8 character');
332
- let end=Math.min(size,offset+65536);while(end<size&&(buffer[end]&192)===128)end--;
333
- const text=new TextDecoder('utf-8',{fatal:true}).decode(buffer.subarray(offset,end));signal?.throwIfAborted();
360
+ new TextDecoder('utf-8',{fatal:true}).decode(buffer);
361
+ if(offset<size&&(buffer[offset]&192)===128)throw Error('Segment offset splits UTF-8 character');
362
+ let end=Math.min(size,offset+SEGMENT_CHUNK_BYTES);
363
+ while(end<size&&(buffer[end]&192)===128)end--;
364
+ const text=new TextDecoder('utf-8',{fatal:true}).decode(buffer.subarray(offset,end));
365
+ signal?.throwIfAborted();
334
366
  return {commit,path:file,oid:entry.oid,encoding:'UTF-8',offset,endOffset:end,totalBytes:size,nextOffset:end<size?end:null,text};
335
367
  }
336
368
  export async function getHistoricalFile(repo,{commit,path:file,signal}={}){
@@ -344,12 +376,12 @@ export async function getHistoricalFile(repo,{commit,path:file,signal}={}){
344
376
  return {commit,path:file,oid:entry.oid,...result};
345
377
  }
346
378
  export async function getHistoricalTree(repo,{commit,signal}={}){
347
- if(typeof commit!=='string'||!/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/.test(commit))throw new Error('Invalid commit id');
348
- repo=await checkedRepo(repo,signal);if(repo.type!=='git')throw new Error('Historical tree supports Git only');
379
+ if(typeof commit!=='string'||!isObjectId(commit))throw new Error('Invalid commit id');
380
+ repo=await checkedGitRepo(repo,signal,'Historical tree supports Git only');
349
381
  const type=(await git(repo.root,['cat-file','-t',commit],MAX_TEXT,{signal})).toString('utf8').trim();if(type!=='commit')throw new Error('Commit object required');
350
382
  const text=(await git(repo.root,['ls-tree','-r','-t','-z','--full-tree',commit],MAX_TEXT,{signal})).toString('utf8');
351
383
  const fields=text.split(String.fromCharCode(0));if(fields.pop()!=='')throw new Error('Truncated historical tree');
352
- if(fields.length>10000)throw new Error('Historical tree exceeds entry limit');
384
+ if(fields.length>MAX_RESULT_ENTRIES)throw new Error('Historical tree exceeds entry limit');
353
385
  const entries=fields.map(record=>{
354
386
  const tab=record.indexOf('\t'),head=record.slice(0,tab),file=record.slice(tab+1);
355
387
  const match=/^(040000|100644|100755|120000|160000) (tree|blob|commit) ([a-f0-9]{40}|[a-f0-9]{64})$/.exec(head);
@@ -362,7 +394,7 @@ export async function getHistoricalTree(repo,{commit,signal}={}){
362
394
  }
363
395
  export async function getCommitImage(repo,{commit,parentIndex=0,id,side='right',signal}={}){
364
396
  if(!['left','right'].includes(side))throw new Error('Invalid image side');
365
- if(typeof id!=='string'||!/^[a-f0-9]{64}$/.test(id))throw new Error('Invalid historical change id');
397
+ if(typeof id!=='string'||!isChangeId(id))throw new Error('Invalid historical change id');
366
398
  const details=await commitDetails(repo,{commit,parentIndex,signal}),entry=details.changes.find(e=>e.id===id);
367
399
  if(!entry)throw new Error('Change is not part of selected commit');
368
400
  const revision=side==='left'?details.parent:commit,file=side==='left'?(entry.oldPath||entry.path):entry.path;
@@ -382,8 +414,8 @@ async function historicalImage(repo,revision,file,signal){
382
414
  async function workspacePng(buffer,signal){const {data,...metadata}=await prepareRaster(buffer,{signal});return {...metadata,base64:data.toString('base64')};}
383
415
  // Workspace image reads use immutable HEAD/index blobs and a checked regular-file handle.
384
416
  export async function getWorkspaceImage(repo,{mode='all',id,side='right',signal}={}){
385
- if(typeof id!=='string'||!/^[a-f0-9]{64}$/.test(id)||!['left','right'].includes(side))throw Error('Invalid workspace image selection');
386
- repo=await checkedRepo(repo,signal);if(repo.type!=='git')throw Error('Workspace PNG supports Git only');modeFor(repo,mode);
417
+ if(typeof id!=='string'||!isChangeId(id)||!['left','right'].includes(side))throw Error('Invalid workspace image selection');
418
+ repo=await checkedGitRepo(repo,signal,'Workspace PNG supports Git only');modeFor(repo,mode);
387
419
  const entry=(await changes(repo,mode)).find(row=>row.id===id);if(!entry)throw Error('Change no longer exists; refresh repository status.');
388
420
  if(entry.status==='conflicted')throw Error('Conflicted images are not supported');
389
421
  const head=async()=>{try{return (await git(repo.root,['rev-parse','--verify','HEAD'],MAX_TEXT,{signal})).toString('utf8').trim();}catch(e){if(e.code==='VCS_COMMAND')return null;throw e;}};
@@ -419,7 +451,7 @@ export async function getWorkspaceImage(repo,{mode='all',id,side='right',signal}
419
451
  }
420
452
  export async function getRevisionImage(repo,{base,target,id,side='right',signal}={}){
421
453
  if(!['left','right'].includes(side))throw Error('Invalid image side');
422
- if(typeof id!=='string'||!/^[a-f0-9]{64}$/.test(id))throw Error('Invalid revision change id');
454
+ if(typeof id!=='string'||!isChangeId(id))throw Error('Invalid revision change id');
423
455
  const details=await getRevisionChanges(repo,{base,target,signal}),entry=details.changes.find(row=>row.id===id);
424
456
  if(!entry)throw Error('Change is not part of selected revision pair');
425
457
  const revision=side==='left'?base:target,file=side==='left'?(entry.oldPath||entry.path):entry.path;
@@ -428,7 +460,7 @@ export async function getRevisionImage(repo,{base,target,id,side='right',signal}
428
460
  }
429
461
  export async function getFileBlame(repo,{commit,parentIndex=0,id,startLine=1,lineLimit=500,signal}={}){
430
462
  if(!Number.isInteger(startLine)||startLine<1||startLine>100001||!Number.isInteger(lineLimit)||lineLimit<1||lineLimit>500)throw Error('Invalid blame line window');
431
- if(typeof id!=='string'||!/^[a-f0-9]{64}$/.test(id))throw new Error('Invalid historical change id');
463
+ if(typeof id!=='string'||!isChangeId(id))throw new Error('Invalid historical change id');
432
464
  const details=await commitDetails(repo,{commit,parentIndex,signal});
433
465
  const entry=details.changes.find(row=>row.id===id);if(!entry)throw new Error('Change is not part of selected commit');
434
466
  const result={commit,path:entry.path,lines:[],truncated:false,startLine,nextLine:null};
@@ -446,38 +478,16 @@ export async function getFileBlame(repo,{commit,parentIndex=0,id,startLine=1,lin
446
478
  return {...result,lines:parseBlame(output.toString('utf8'),limit),totalLines:count,nextLine:endLine<count&&endLine<100001?endLine+1:null,truncated:endLine<count};
447
479
  }
448
480
  export async function listFileHistory(repo,{commit,parentIndex=0,id,offset=0,limit=50,follow=false,since,until,signal}={}){
449
- if(!Number.isInteger(offset)||offset<0||offset>10000||!Number.isInteger(limit)||limit<1||limit>100)throw new Error('Invalid history pagination');
450
- if(typeof id!=='string'||!/^[a-f0-9]{64}$/.test(id))throw new Error('Invalid historical change id');
481
+ if(!Number.isInteger(offset)||offset<0||offset>MAX_HISTORY_OFFSET||!Number.isInteger(limit)||limit<1||limit>MAX_HISTORY_PAGE)throw new Error('Invalid history pagination');
482
+ if(typeof id!=='string'||!isChangeId(id))throw new Error('Invalid historical change id');
451
483
  if(typeof follow!=='boolean')throw Error('Invalid rename follow mode');
452
484
  const sinceArg=since===undefined?null:validateDateBound(since),untilArg=until===undefined?null:validateDateBound(until);
453
485
  if((sinceArg||untilArg)&&follow)throw Error('Date-range file history is not combined with rename following.');
454
486
  const details=await commitDetails(repo,{commit,parentIndex,signal});
455
487
  const entry=details.changes.find(row=>row.id===id);
456
488
  if(!entry)throw new Error('Change is not part of selected commit');
457
- if(follow){
458
- const count=offset+limit+1,deadline=Date.now()+TIMEOUT;
459
- const followOptions=()=>{signal?.throwIfAborted();const timeoutMs=deadline-Date.now();if(timeoutMs<=0)throw Object.assign(Error('Rename tracing timed out'),{code:'TIMEOUT'});return {signal,timeoutMs};};
460
- const output=await git(repo.root,['--literal-pathspecs','log','-z','--follow','--first-parent','--diff-merges=first-parent','--find-renames=50%','-l100','--name-status','--no-ext-diff','--no-textconv','--no-show-signature','--encoding=UTF-8','--max-count='+count,'--format='+HISTORY_FORMAT,commit,'--',entry.path],MAX_TEXT,followOptions());
461
- const chain=parseFollowHistory(output.toString('utf8'),entry.path,count,{similarity:true});
462
- let checks=0;
463
- for(let index=0;index<chain.length;index++){
464
- const row=chain[index];if(!row.oldPath)continue;
465
- let boundary;
466
- if(++checks>32)boundary='rename-check-limit';
467
- else {
468
- // Confirm the whole transition, not Git's selected-path heuristic alone.
469
- // A single deletion and addition excludes competing deleted sources.
470
- const raw=await git(repo.root,['diff-tree','--no-commit-id','--name-status','-z','-r','--no-renames','--no-ext-diff','--no-textconv',row.parents[0],row.id,'--'],MAX_TEXT,followOptions());
471
- const changes=parseCommitChanges(raw.toString('utf8'),row.id);
472
- const removed=changes.filter(change=>change.status==='deleted'),added=changes.filter(change=>change.status==='added');
473
- if(removed.length!==1||added.length!==1||removed[0].path!==row.oldPath||added[0].path!==row.path)boundary='ambiguous-rename';
474
- }
475
- if(boundary){chain[index]={...row,boundary};chain.length=index+1;break;}
476
- }
477
- const rows=chain.slice(offset);
478
- return {...historyPage(rows,commit,offset,limit),path:entry.path,followsRenames:true,followPolicy:'unique-similarity-first-parent'};
479
- }
480
- if(sinceArg){signal?.throwIfAborted();try{await git(repo.root,['log','--since-as-filter=1970-01-01','-1','--format=%H'],MAX_TEXT,{signal});}catch(e){if(e.code==='VCS_COMMAND')throw new Error('Date-range file history requires Git 2.37 or newer (--since-as-filter).');throw e;}}
489
+ if(follow)return followHistoricalPath(repo,{commit,file:entry.path,offset,limit,signal});
490
+ if(sinceArg)await assertSinceAsFilterSupport(repo,signal,'file history');
481
491
  const dateArgs=[...(sinceArg?['--since-as-filter='+sinceArg]:[]),...(untilArg?['--until='+untilArg]:[])];
482
492
  const text=await git(repo.root,['--literal-pathspecs','log','-z','--no-show-signature','--encoding=UTF-8','--topo-order','--max-count='+String(limit+1),'--skip='+String(offset),'--format='+HISTORY_FORMAT,...dateArgs,commit,'--',entry.path],MAX_TEXT,{signal});
483
493
  return {...historyPage(parseHistory(text.toString('utf8'),limit+1),commit,offset,limit),path:entry.path,followsRenames:false};
@@ -493,8 +503,10 @@ async function followHistoricalPath(repo,{commit,file,offset,limit,signal}){
493
503
  for(let index=0;index<chain.length;index++){
494
504
  const row=chain[index];if(!row.oldPath)continue;
495
505
  let boundary;
496
- if(++checks>32)boundary='rename-check-limit';
506
+ if(++checks>RENAME_CONFIRMATION_LIMIT)boundary='rename-check-limit';
497
507
  else {
508
+ // Confirm the whole transition, not Git's selected-path heuristic alone.
509
+ // A single deletion and addition excludes competing deleted sources.
498
510
  const raw=await git(repo.root,['diff-tree','--no-commit-id','--name-status','-z','-r','--no-renames','--no-ext-diff','--no-textconv',row.parents[0],row.id,'--'],MAX_TEXT,followOptions());
499
511
  const changes=parseCommitChanges(raw.toString('utf8'),row.id);
500
512
  const removed=changes.filter(change=>change.status==='deleted'),added=changes.filter(change=>change.status==='added');
@@ -507,28 +519,26 @@ async function followHistoricalPath(repo,{commit,file,offset,limit,signal}){
507
519
 
508
520
  // Explicit historical path history; the path must exist in the selected commit.
509
521
  export async function listPathHistory(repo,{commit,path:file,offset=0,limit=50,follow=false,since,until,signal}={}){
510
- if(typeof commit!=='string'||!/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/.test(commit))throw Error('Invalid commit id');
522
+ if(typeof commit!=='string'||!isObjectId(commit))throw Error('Invalid commit id');
511
523
  if(typeof file!=='string'||!file||file.length>32768||file.includes('\0')||file.startsWith('/')||file.split('/').some(part=>!part||part==='.'||part==='..'))throw Error('Invalid historical path');
512
524
  if(typeof follow!=='boolean')throw Error('Invalid rename follow mode');
513
525
  const sinceArg=since===undefined?null:validateDateBound(since),untilArg=until===undefined?null:validateDateBound(until);
514
526
  if((sinceArg||untilArg)&&follow)throw Error('Date-range path history is not combined with rename following.');
515
- if(!Number.isInteger(offset)||offset<0||offset>10000||!Number.isInteger(limit)||limit<1||limit>100)throw Error('Invalid history pagination');
516
- repo=await checkedRepo(repo,signal);
517
- if(repo.type!=='git')throw Error('Historical path history supports Git only');
527
+ if(!Number.isInteger(offset)||offset<0||offset>MAX_HISTORY_OFFSET||!Number.isInteger(limit)||limit<1||limit>MAX_HISTORY_PAGE)throw Error('Invalid history pagination');
528
+ repo=await checkedGitRepo(repo,signal,'Historical path history supports Git only');
518
529
  const listing=(await git(repo.root,['ls-tree','-z',commit,'--',file],MAX_TEXT,{signal})).toString('utf8');
519
530
  const record=listing.split('\0').filter(Boolean).find(row=>row.slice(row.indexOf('\t')+1)===file);
520
531
  if(!record)throw Error('Path does not exist in the selected commit');
521
532
  const mode=record.slice(0,record.indexOf('\t')).split(' ')[0];
522
533
  if(follow&&!['100644','100755','120000'].includes(mode))throw Error('Rename following requires a committed regular file or link');
523
- if(sinceArg){signal?.throwIfAborted();try{await git(repo.root,['log','--since-as-filter=1970-01-01','-1','--format=%H'],MAX_TEXT,{signal});}catch(e){if(e.code==='VCS_COMMAND')throw new Error('Date-range path history requires Git 2.37 or newer (--since-as-filter).');throw e;}}
534
+ if(sinceArg)await assertSinceAsFilterSupport(repo,signal,'path history');
524
535
  const dateArgs=[...(sinceArg?['--since-as-filter='+sinceArg]:[]),...(untilArg?['--until='+untilArg]:[])];
525
536
  return follow?followHistoricalPath(repo,{commit,file,offset,limit,signal}):{...historyPage(parseHistory((await git(repo.root,['--literal-pathspecs','log','-z','--no-show-signature','--encoding=UTF-8','--topo-order','--max-count='+String(limit+1),'--skip='+String(offset),'--format='+HISTORY_FORMAT,...dateArgs,commit,'--',file],MAX_TEXT,{signal})).toString('utf8'),limit+1),commit,offset,limit),path:file,followsRenames:false};
526
537
  }
527
538
 
528
539
  export async function getRevisionChanges(repo,{base,target,signal}={}){
529
- for(const oid of [base,target])if(typeof oid!=='string'||!/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/.test(oid))throw new Error('Invalid revision commit id');
530
- signal?.throwIfAborted();repo=await checkedRepo(repo,signal);
531
- if(repo.type!=='git')throw new Error('Revision comparison supports Git only');
540
+ for(const oid of [base,target])if(typeof oid!=='string'||!isObjectId(oid))throw new Error('Invalid revision commit id');
541
+ signal?.throwIfAborted();repo=await checkedGitRepo(repo,signal,'Revision comparison supports Git only');
532
542
  for(const oid of new Set([base,target])){
533
543
  const type=(await git(repo.root,['cat-file','-t',oid],MAX_TEXT,{signal})).toString('utf8').trim();
534
544
  if(type!=='commit')throw new Error('Commit object required');
@@ -553,9 +563,9 @@ async function annotateSubmodules(repo, entries, options) {
553
563
  const files=entries.filter(entry=>entry.status!=='conflicted').map(entry=>entry.path);
554
564
  for(let from=0;from<files.length;){
555
565
  check();const batch=[];let bytes=0;
556
- while(from<files.length&&batch.length<64){const file=files[from],size=Buffer.byteLength(file,'utf8')+1;if(size>24000)throw Error('Selected path exceeds metadata budget');if(bytes+size>24000)break;batch.push(file);bytes+=size;from++;}
566
+ while(from<files.length&&batch.length<METADATA_BATCH_SIZE){const file=files[from],size=Buffer.byteLength(file,'utf8')+1;if(size>METADATA_BATCH_BYTES)throw Error('Selected path exceeds metadata budget');if(bytes+size>METADATA_BATCH_BYTES)break;batch.push(file);bytes+=size;from++;}
557
567
  const listing=(await git(repo.root,['ls-files','--stage','-z','--',...batch],MAX_TEXT,{...options,timeoutMs:deadline-Date.now()})).toString('utf8');
558
- for(const record of listing.split('\0')){const tab=record.indexOf('\t');if(tab<0)continue;const [mode,oid,stage]=record.slice(0,tab).split(' '),file=record.slice(tab+1);if(stage==='0'&&mode==='160000'&&batch.includes(file)&&/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/.test(oid))pointers.set(file,oid);}
568
+ for(const record of listing.split('\0')){const tab=record.indexOf('\t');if(tab<0)continue;const [mode,oid,stage]=record.slice(0,tab).split(' '),file=record.slice(tab+1);if(stage==='0'&&mode==='160000'&&batch.includes(file)&&isObjectId(oid))pointers.set(file,oid);}
559
569
  }
560
570
  if (!pointers.size) return;
561
571
  const initialized = [];
@@ -576,8 +586,8 @@ async function annotateSubmodules(repo, entries, options) {
576
586
  // Bounded, read-only submodule worktree state from the parent's own `git status`
577
587
  // (never runs Git inside the submodule): --porcelain=v2 exposes `S` fields for new
578
588
  // commits, modified content and untracked content in an initialized checkout.
579
- for (let from = 0; from < initialized.length; from += 64) {
580
- check();const batch = initialized.slice(from, from + 64);
589
+ for (let from = 0; from < initialized.length; from += METADATA_BATCH_SIZE) {
590
+ check();const batch = initialized.slice(from, from + METADATA_BATCH_SIZE);
581
591
  const out = (await git(repo.root, ['status', '--porcelain=v2', '-z', '--ignore-submodules=none', '--', ...batch.map(entry => entry.path)], MAX_TEXT, {...options, timeoutMs: deadline - Date.now()})).toString('utf8');
582
592
  const records=out.split(String.fromCharCode(0));
583
593
  for(let i=0;i<records.length;i++){
@@ -661,7 +671,7 @@ async function gitBlob(root, revision, file, options) {
661
671
  for(const row of raw.split('\0')){const tab=row.indexOf('\t');if(tab<0||row.slice(tab+1)!==file)continue;const [mode,oid,stage]=row.slice(0,tab).split(' ');if(stage==='0')record={mode,oid,type:mode==='160000'?'commit':'blob'};}
662
672
  if(!record)return {text:'',notice:'No stage-0 Index entry; conflict stages are not inferred.'};
663
673
  }
664
- if(!/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/.test(record.oid))throw Error('Invalid selected object');
674
+ if(!isObjectId(record.oid))throw Error('Invalid selected object');
665
675
  if(record.mode==='160000')return {text:'Submodule '+record.oid,notice:'Recorded gitlink pointer; nested checkout commit and contents are not loaded by this preview.'};
666
676
  if(record.type!=='blob'||!['100644','100755','120000'].includes(record.mode))return {text:'',notice:'Unsupported selected object; contents not loaded.'};
667
677
  const body=await content(()=>git(root,['cat-file','blob',record.oid],MAX_TEXT,options));
@@ -693,7 +703,7 @@ async function historicalBlob(root,revision,file,signal,large=false,budget){
693
703
  }
694
704
  export async function getCommitComparison(repo,{commit,parentIndex=0,id,signal,large=false}={}){
695
705
  if(typeof large!=='boolean')throw new Error('Invalid large comparison mode');
696
- if(typeof id!=='string'||!/^[a-f0-9]{64}$/.test(id))throw new Error('Invalid historical change id');
706
+ if(typeof id!=='string'||!isChangeId(id))throw new Error('Invalid historical change id');
697
707
  const details=await commitDetails(repo,{commit,parentIndex,signal});
698
708
  const entry=details.changes.find(row=>row.id===id);
699
709
  if(!entry)throw new Error('Historical change is not part of selected commit and parent');
@@ -703,7 +713,7 @@ export async function getCommitComparison(repo,{commit,parentIndex=0,id,signal,l
703
713
  }
704
714
  export async function getRevisionComparison(repo,{base,target,id,signal,large=false}={}){
705
715
  if(typeof large!=='boolean')throw new Error('Invalid large comparison mode');
706
- if(typeof id!=='string'||!/^[a-f0-9]{64}$/.test(id))throw new Error('Invalid revision change id');
716
+ if(typeof id!=='string'||!isChangeId(id))throw new Error('Invalid revision change id');
707
717
  const details=await getRevisionChanges(repo,{base,target,signal});
708
718
  const entry=details.changes.find(row=>row.id===id);
709
719
  if(!entry)throw new Error('Change is not part of selected revision pair');
@@ -722,13 +732,12 @@ async function properties(root, file, base, signal) {
722
732
  async function getWorkspaceLargeComparison(repo,{mode,id,signal}){
723
733
  const deadline=Date.now()+TIMEOUT;
724
734
  const options=()=>{signal?.throwIfAborted();const timeoutMs=deadline-Date.now();if(timeoutMs<=0)throw Object.assign(Error('Workspace comparison timed out'),{code:'TIMEOUT'});return {signal,timeoutMs};};
725
- repo=await checkedRepo(repo,signal,options);modeFor(repo,mode);
726
- if(repo.type!=='git')throw Error('Large workspace comparison supports Git only');
735
+ repo=await checkedRepo(repo,signal,options);modeFor(repo,mode);assertGit(repo,'Large workspace comparison supports Git only');
727
736
  const run=args=>git(repo.root,args,MAX_TEXT,options());
728
737
  const status=()=>changes(repo,mode,options());
729
738
  const entry=(await status()).find(row=>row.id===id);if(!entry)throw Error('Change no longer exists; refresh repository status');
730
739
  if(entry.status==='conflicted')throw Error('Large conflicted comparisons are not supported');
731
- const head=async()=>{try{return (await run(['rev-parse','--verify','HEAD^{commit}'])).toString('utf8').trim();}catch(e){if(e.code!=='VCS_COMMAND')throw e;const ref=(await run(['symbolic-ref','-q','HEAD'])).toString('utf8').trim();try{await run(['show-ref','--verify','--quiet',ref]);}catch(missing){if(missing.code==='VCS_COMMAND'&&missing.exitCode===1)return null;throw missing;}throw e;}};
740
+ const head=()=>resolveHeadCommit(run);
732
741
  const revision=await head(),indexArgs=['ls-files','--stage','-z','--',entry.path],index=await run(indexArgs);
733
742
  const readIndex=async()=>{
734
743
  const rows=index.toString('utf8').split('\0').filter(Boolean);if(rows.length!==1)throw Error('Large comparison requires one stage0 Index entry');
@@ -752,8 +761,8 @@ async function getWorkspaceLargeComparison(repo,{mode,id,signal}){
752
761
  /** Explicit Index stage snapshot; never reads the selected working file or submodule. */
753
762
  export async function getConflictStages(repo,{mode='all',id,signal}={}){
754
763
  const deadline=Date.now()+TIMEOUT,budget=()=>{signal?.throwIfAborted();const timeoutMs=deadline-Date.now();if(timeoutMs<=0)throw Object.assign(Error('Conflict stage read timed out'),{code:'TIMEOUT'});return {signal,timeoutMs};};
755
- if(typeof id!=='string'||!/^[a-f0-9]{64}$/.test(id))throw Error('Invalid conflict change id');
756
- repo=await checkedRepo(repo,signal,budget);modeFor(repo,mode);if(repo.type!=='git')throw Error('Conflict stages support Git only');
764
+ if(typeof id!=='string'||!isChangeId(id))throw Error('Invalid conflict change id');
765
+ repo=await checkedRepo(repo,signal,budget);modeFor(repo,mode);assertGit(repo,'Conflict stages support Git only');
757
766
  const entry=(await changes(repo,mode,budget())).find(row=>row.id===id);if(!entry||entry.status!=='conflicted')throw Error('Selected conflict no longer exists; refresh status.');
758
767
  await confined(repo.root,entry.path);
759
768
  const list=()=>git(repo.root,['ls-files','--unmerged','-z','--',entry.path],MAX_TEXT,budget());