@feng0531/dsh-vcs 0.4.9 → 0.5.1

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,23 @@ 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
+ import {createHistoricalAccess,validateHistoricalPath} from './src/historical-access.mjs';
17
+ import {withComparisonPreview} from './src/preview-capabilities.mjs';
18
+
19
+ const historicalAccess = createHistoricalAccess({run:git});
20
+ /** Host invalidates object bytes on grant replacement, expiry and plugin disposal. */
21
+ export const clearHistoricalCache = root => historicalAccess.clear(root);
22
+ export const historicalCacheStats = () => historicalAccess.stats();
23
+
24
+ // Shared read-only adapter bounds. Values are part of the public contract:
25
+ // history pagination, per-result entry caps and segmented-preview byte budgets.
26
+ const MAX_HISTORY_OFFSET = 10000;
27
+ const MAX_HISTORY_PAGE = 100;
28
+ const MAX_RESULT_ENTRIES = 10000;
29
+ const RENAME_CONFIRMATION_LIMIT = 32;
30
+ const METADATA_BATCH_SIZE = 64;
31
+ const METADATA_BATCH_BYTES = 24000;
32
+
16
33
  const xml = new XMLParser({ ignoreAttributes: false, attributeNamePrefix: '', parseTagValue: false, parseAttributeValue: false, trimValues: false, processEntities: true, isArray: name => ['entry', 'target', 'property'].includes(name) });
17
34
 
18
35
 
@@ -183,6 +200,34 @@ async function checkedRepo(repo, signal, budget) {
183
200
  if (!found || found.type !== repo.type || path.resolve(found.root) !== path.resolve(repo.root)) throw new Error('Repository root changed or is invalid');
184
201
  return found;
185
202
  }
203
+ function assertGit(repo, message) {
204
+ if (repo.type !== 'git') throw new Error(message);
205
+ return repo;
206
+ }
207
+ async function checkedGitRepo(repo, signal, message, budget) {
208
+ return assertGit(await checkedRepo(repo, signal, budget), message);
209
+ }
210
+ // Probe --since-as-filter support so older Git cannot run a silently unfiltered query.
211
+ async function assertSinceAsFilterSupport(repo, signal, label) {
212
+ signal?.throwIfAborted();
213
+ try { await git(repo.root,['log','--since-as-filter=1970-01-01','-1','--format=%H'],MAX_TEXT,{signal}); }
214
+ catch (e) {
215
+ if (e.code === 'VCS_COMMAND') throw new Error('Date-range ' + label + ' requires Git 2.37 or newer (--since-as-filter).');
216
+ throw e;
217
+ }
218
+ }
219
+ // Resolve HEAD^{commit}; only an unborn symbolic HEAD is absent, never corruption.
220
+ async function resolveHeadCommit(run) {
221
+ try { return (await run(['rev-parse','--verify','HEAD^{commit}'])).toString('utf8').trim(); }
222
+ catch (e) {
223
+ if (e.code !== 'VCS_COMMAND') throw e;
224
+ // Distinguish an unborn symbolic HEAD from corruption or other command failures.
225
+ const ref=(await run(['symbolic-ref','-q','HEAD'])).toString('utf8').trim();
226
+ try { await run(['show-ref','--verify','--quiet',ref]); }
227
+ catch (missing) { if (missing.code === 'VCS_COMMAND' && missing.exitCode === 1) return null; throw missing; }
228
+ throw e;
229
+ }
230
+ }
186
231
  export async function getSvnIdentity(repo,{signal}={}){
187
232
  repo=await checkedRepo(repo,signal);if(repo.type!=='svn')throw Error('SVN working copy required');
188
233
  const result=parseSvnIdentity((await svn(repo.root,['info','--xml','--', '.'],MAX_TEXT,{signal})).toString('utf8'));
@@ -190,42 +235,28 @@ export async function getSvnIdentity(repo,{signal}={}){
190
235
  }
191
236
  export async function listHistory(repo, {snapshot,offset=0,limit=50,search,signal} = {}) {
192
237
  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');
238
+ 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');
239
+ if(snapshot!==undefined && (typeof snapshot!=='string'||!isObjectId(snapshot))) throw new Error('Invalid history snapshot');
195
240
  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
- }
241
+ repo=await checkedGitRepo(repo,signal,'History currently supports Git only');
242
+ if(query.args.some(a=>a.startsWith('--since-as-filter='))) await assertSinceAsFilterSupport(repo,signal,'history search');
203
243
  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
- }
244
+ snapshot=await resolveHeadCommit(args=>git(repo.root,args,MAX_TEXT,{signal}));
245
+ if(snapshot===null)return {snapshot:null,commits:[],nextOffset:null};
213
246
  }
214
247
  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
248
  const rows=parseHistory(text.toString('utf8'),limit+1);
216
249
  return historyPage(rows,snapshot,offset,limit);
217
250
  }
218
251
  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');
252
+ signal?.throwIfAborted();repo=await checkedGitRepo(repo,signal,'References support Git only');
221
253
  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
254
  return {references:parseReferences(raw.toString('utf8'))};
223
255
  }
224
256
 
225
257
  const STASH_FORMAT='%H%x00%gs%x00%gd';
226
258
  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');
259
+ signal?.throwIfAborted();repo=await checkedGitRepo(repo,signal,'Stash supports Git only');
229
260
  const text=(await git(repo.root,['stash','list','--format='+STASH_FORMAT],MAX_TEXT,{signal})).toString('utf8');
230
261
  if(!text.trim())return {stashes:[]};
231
262
  const stashes=[];
@@ -234,7 +265,7 @@ export async function listStashes(repo,{signal}={}){
234
265
  const fields=line.split('\0');
235
266
  if(fields.length!==3)throw new Error('Invalid stash entry');
236
267
  const [id,message,ref]=fields;
237
- if(!/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/.test(id))throw new Error('Invalid stash entry');
268
+ if(!isObjectId(id))throw new Error('Invalid stash entry');
238
269
  stashes.push({id,message:message||ref,ref});
239
270
  if(stashes.length>1000)throw new Error('Stash exceeds entry limit');
240
271
  }
@@ -244,17 +275,17 @@ export async function listStashes(repo,{signal}={}){
244
275
  export async function searchRepositoryContent(repo,{commit,query,signal}={}) {
245
276
  signal?.throwIfAborted();
246
277
  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');
278
+ repo=await checkedGitRepo(repo,signal,'Content search supports Git only');
249
279
  let snapshot=commit;
250
280
  if(!snapshot){
251
281
  try{snapshot=(await git(repo.root,['rev-parse','--verify','HEAD^{commit}'],MAX_TEXT,{signal})).toString('utf8').trim();}
252
282
  catch{throw new Error('Content search requires a resolved HEAD commit');}
253
283
  }
254
- if(!/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/.test(snapshot))throw new Error('Invalid content search commit');
284
+ if(!isObjectId(snapshot))throw new Error('Invalid content search commit');
285
+ if((await git(repo.root,['cat-file','-t',snapshot],MAX_TEXT,{signal})).toString('utf8').trim()!=='commit')throw Error('Content search requires a commit object');
255
286
  let text;
256
287
  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
- catch(error){if(error.code==='VCS_COMMAND'&&error.exitCode===1)return {matches:[]};throw error;}
288
+ catch(error){if(error.code==='VCS_COMMAND'&&error.exitCode===1)return {snapshot,matches:[]};throw error;}
258
289
  signal?.throwIfAborted();
259
290
  const matches=[];
260
291
  // -z frames path and line with NUL; legal paths can contain LF.
@@ -269,18 +300,17 @@ export async function searchRepositoryContent(repo,{commit,query,signal}={}) {
269
300
  matches.push({path,line:Number(lineNo),text:lineText});
270
301
  if(matches.length>500)throw new Error('Content search exceeds match limit');
271
302
  }
272
- return {matches};
303
+ return {snapshot,matches};
273
304
  }
274
305
 
275
306
  export async function getCommitDetails(repo,options={}) {
276
307
  return commitDetails(repo,options,true);
277
308
  }
278
309
  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');
310
+ if(typeof commit!=='string'||!isObjectId(commit))throw new Error('Invalid commit id');
280
311
  if(!Number.isInteger(parentIndex)||parentIndex<0)throw new Error('Invalid parent index');
281
312
  signal?.throwIfAborted();
282
- repo=await checkedRepo(repo,signal);
283
- if(repo.type!=='git')throw new Error('Commit details support Git only');
313
+ repo=await checkedGitRepo(repo,signal,'Commit details support Git only');
284
314
  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
315
  const metadata=parseHistory(raw.toString('utf8'),1)[0];
286
316
  if(!metadata||metadata.id!==commit)throw new Error('Commit object required');
@@ -315,41 +345,40 @@ function parseCommitChanges(text,scope){
315
345
  if(!first||!file)throw new Error('Truncated commit path');
316
346
  const entry={path:file,...(renamed?{oldPath:first}:{}),status:statuses[code[0]]||'modified'};
317
347
  changes.push({...entry,id:identity(entry,scope)});
318
- if(changes.length>10000)throw new Error('Commit exceeds file count limit');
348
+ if(changes.length>MAX_RESULT_ENTRIES)throw new Error('Commit exceeds file count limit');
319
349
  }
320
350
  return changes;
321
351
  }
322
- 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');
324
- 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);
326
- 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');
330
- 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();
334
- return {commit,path:file,oid:entry.oid,encoding:'UTF-8',offset,endOffset:end,totalBytes:size,nextOffset:end<size?end:null,text};
335
- }
336
- export async function getHistoricalFile(repo,{commit,path:file,signal}={}){
337
- if(typeof file!=='string'||!file||file.includes('\0'))throw new Error('Invalid historical path');
338
- repo=await checkedRepo(repo,signal);
339
- const tree=await getHistoricalTree(repo,{commit,signal}),entry=tree.entries.find(row=>row.path===file);
340
- if(!entry)throw new Error('Path is not part of selected commit');
341
- if(entry.type!=='blob'||!['100644','100755'].includes(entry.mode))throw new Error('Historical file preview requires a regular file');
342
- const result=await content(()=>git(repo.root,['cat-file','blob',entry.oid],MAX_TEXT,{signal}));
352
+ export async function getHistoricalSegment(repo, {commit,path:file,offset = 0,signal} = {}) {
353
+ const expectedEpoch = historicalAccess.captureEpoch();
354
+ // Capture before any await: revoked in-flight validation cannot refill object bytes.
355
+ repo = await checkedGitRepo(repo, signal, 'Historical segments support Git only');
356
+ return historicalAccess.segment(repo.root, {commit,path:file,offset,signal,expectedEpoch});
357
+ }
358
+
359
+ export async function getHistoricalFile(repo,{commit,path:file,signal}={}) {
360
+ if(typeof file !== 'string' || !file || file.includes('\0')) throw Error('Invalid historical path');
361
+ try {validateHistoricalPath(file);} catch {throw Error('Path is not part of selected commit');}
362
+ repo = await checkedGitRepo(repo, signal, 'Historical file supports Git only');
363
+ const entry = await historicalAccess.lookup(repo.root, {commit,path:file,signal});
364
+ if (entry.type !== 'blob' || !['100644','100755'].includes(entry.mode)) throw Error('Historical file preview requires a regular file');
365
+ const result = await content(() => git(repo.root, ['cat-file','blob',entry.oid], MAX_TEXT, {signal}));
343
366
  signal?.throwIfAborted();
344
367
  return {commit,path:file,oid:entry.oid,...result};
345
368
  }
346
- 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');
369
+
370
+ export async function getHistoricalTreePage(repo, options = {}) {
371
+ repo = await checkedGitRepo(repo, options.signal, 'Historical tree supports Git only');
372
+ return historicalAccess.treePage(repo.root, options);
373
+ }
374
+
375
+ export async function getHistoricalTree(repo,{commit,signal}={}) {
376
+ if(typeof commit!=='string'||!isObjectId(commit))throw new Error('Invalid commit id');
377
+ repo=await checkedGitRepo(repo,signal,'Historical tree supports Git only');
349
378
  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
379
  const text=(await git(repo.root,['ls-tree','-r','-t','-z','--full-tree',commit],MAX_TEXT,{signal})).toString('utf8');
351
380
  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');
381
+ if(fields.length>MAX_RESULT_ENTRIES)throw new Error('Historical tree exceeds entry limit');
353
382
  const entries=fields.map(record=>{
354
383
  const tab=record.indexOf('\t'),head=record.slice(0,tab),file=record.slice(tab+1);
355
384
  const match=/^(040000|100644|100755|120000|160000) (tree|blob|commit) ([a-f0-9]{40}|[a-f0-9]{64})$/.exec(head);
@@ -362,7 +391,7 @@ export async function getHistoricalTree(repo,{commit,signal}={}){
362
391
  }
363
392
  export async function getCommitImage(repo,{commit,parentIndex=0,id,side='right',signal}={}){
364
393
  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');
394
+ if(typeof id!=='string'||!isChangeId(id))throw new Error('Invalid historical change id');
366
395
  const details=await commitDetails(repo,{commit,parentIndex,signal}),entry=details.changes.find(e=>e.id===id);
367
396
  if(!entry)throw new Error('Change is not part of selected commit');
368
397
  const revision=side==='left'?details.parent:commit,file=side==='left'?(entry.oldPath||entry.path):entry.path;
@@ -382,8 +411,8 @@ async function historicalImage(repo,revision,file,signal){
382
411
  async function workspacePng(buffer,signal){const {data,...metadata}=await prepareRaster(buffer,{signal});return {...metadata,base64:data.toString('base64')};}
383
412
  // Workspace image reads use immutable HEAD/index blobs and a checked regular-file handle.
384
413
  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);
414
+ if(typeof id!=='string'||!isChangeId(id)||!['left','right'].includes(side))throw Error('Invalid workspace image selection');
415
+ repo=await checkedGitRepo(repo,signal,'Workspace PNG supports Git only');modeFor(repo,mode);
387
416
  const entry=(await changes(repo,mode)).find(row=>row.id===id);if(!entry)throw Error('Change no longer exists; refresh repository status.');
388
417
  if(entry.status==='conflicted')throw Error('Conflicted images are not supported');
389
418
  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;}};
@@ -406,7 +435,9 @@ export async function getWorkspaceImage(repo,{mode='all',id,side='right',signal}
406
435
  if(!before.isFile()||before.isSymbolicLink())throw Error('Image preview requires a regular file');if(before.size>MAX_TEXT)throw Error('Image exceeds 2 MiB limit');
407
436
  const handle=await fs.open(target,fileFlags.O_RDONLY|(fileFlags.O_NOFOLLOW||0));try{
408
437
  const fingerprint=s=>JSON.stringify([s.dev,s.ino,s.size,s.mtimeMs,s.ctimeMs]);const opened=await handle.stat();if(!opened.isFile()||fingerprint(opened)!==fingerprint(before))throw Error('Working image changed during read');
409
- const buffer=Buffer.alloc(MAX_TEXT+1);const {bytesRead}=await handle.read(buffer,0,buffer.length,0);signal?.throwIfAborted();const after=await handle.stat(),named=await fs.lstat(target);await confined(repo.root,file);
438
+ const buffer=Buffer.alloc(before.size+1);let bytesRead=0;
439
+ while(bytesRead<buffer.length){signal?.throwIfAborted();const result=await handle.read(buffer,bytesRead,buffer.length-bytesRead,bytesRead);if(!result.bytesRead)break;bytesRead+=result.bytesRead;}
440
+ signal?.throwIfAborted();const after=await handle.stat(),named=await fs.lstat(target);await confined(repo.root,file);
410
441
  if(named.isSymbolicLink()||fingerprint(after)!==fingerprint(before)||fingerprint(named)!==fingerprint(before)||bytesRead!==before.size)throw Error('Working image changed during read');
411
442
  result={path:file,commit:'Working tree',...await workspacePng(buffer.subarray(0,bytesRead),signal)};
412
443
  verifyWorking=async()=>{await confined(repo.root,file);const validated=await fs.lstat(target);if(validated.isSymbolicLink()||fingerprint(validated)!==fingerprint(before))throw Error('Working image changed during validation');};await verifyWorking();
@@ -419,7 +450,7 @@ export async function getWorkspaceImage(repo,{mode='all',id,side='right',signal}
419
450
  }
420
451
  export async function getRevisionImage(repo,{base,target,id,side='right',signal}={}){
421
452
  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');
453
+ if(typeof id!=='string'||!isChangeId(id))throw Error('Invalid revision change id');
423
454
  const details=await getRevisionChanges(repo,{base,target,signal}),entry=details.changes.find(row=>row.id===id);
424
455
  if(!entry)throw Error('Change is not part of selected revision pair');
425
456
  const revision=side==='left'?base:target,file=side==='left'?(entry.oldPath||entry.path):entry.path;
@@ -428,7 +459,7 @@ export async function getRevisionImage(repo,{base,target,id,side='right',signal}
428
459
  }
429
460
  export async function getFileBlame(repo,{commit,parentIndex=0,id,startLine=1,lineLimit=500,signal}={}){
430
461
  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');
462
+ if(typeof id!=='string'||!isChangeId(id))throw new Error('Invalid historical change id');
432
463
  const details=await commitDetails(repo,{commit,parentIndex,signal});
433
464
  const entry=details.changes.find(row=>row.id===id);if(!entry)throw new Error('Change is not part of selected commit');
434
465
  const result={commit,path:entry.path,lines:[],truncated:false,startLine,nextLine:null};
@@ -446,38 +477,16 @@ export async function getFileBlame(repo,{commit,parentIndex=0,id,startLine=1,lin
446
477
  return {...result,lines:parseBlame(output.toString('utf8'),limit),totalLines:count,nextLine:endLine<count&&endLine<100001?endLine+1:null,truncated:endLine<count};
447
478
  }
448
479
  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');
480
+ 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');
481
+ if(typeof id!=='string'||!isChangeId(id))throw new Error('Invalid historical change id');
451
482
  if(typeof follow!=='boolean')throw Error('Invalid rename follow mode');
452
483
  const sinceArg=since===undefined?null:validateDateBound(since),untilArg=until===undefined?null:validateDateBound(until);
453
484
  if((sinceArg||untilArg)&&follow)throw Error('Date-range file history is not combined with rename following.');
454
485
  const details=await commitDetails(repo,{commit,parentIndex,signal});
455
486
  const entry=details.changes.find(row=>row.id===id);
456
487
  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;}}
488
+ if(follow)return followHistoricalPath(repo,{commit,file:entry.path,offset,limit,signal});
489
+ if(sinceArg)await assertSinceAsFilterSupport(repo,signal,'file history');
481
490
  const dateArgs=[...(sinceArg?['--since-as-filter='+sinceArg]:[]),...(untilArg?['--until='+untilArg]:[])];
482
491
  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
492
  return {...historyPage(parseHistory(text.toString('utf8'),limit+1),commit,offset,limit),path:entry.path,followsRenames:false};
@@ -493,8 +502,10 @@ async function followHistoricalPath(repo,{commit,file,offset,limit,signal}){
493
502
  for(let index=0;index<chain.length;index++){
494
503
  const row=chain[index];if(!row.oldPath)continue;
495
504
  let boundary;
496
- if(++checks>32)boundary='rename-check-limit';
505
+ if(++checks>RENAME_CONFIRMATION_LIMIT)boundary='rename-check-limit';
497
506
  else {
507
+ // Confirm the whole transition, not Git's selected-path heuristic alone.
508
+ // A single deletion and addition excludes competing deleted sources.
498
509
  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
510
  const changes=parseCommitChanges(raw.toString('utf8'),row.id);
500
511
  const removed=changes.filter(change=>change.status==='deleted'),added=changes.filter(change=>change.status==='added');
@@ -507,28 +518,26 @@ async function followHistoricalPath(repo,{commit,file,offset,limit,signal}){
507
518
 
508
519
  // Explicit historical path history; the path must exist in the selected commit.
509
520
  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');
521
+ if(typeof commit!=='string'||!isObjectId(commit))throw Error('Invalid commit id');
511
522
  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
523
  if(typeof follow!=='boolean')throw Error('Invalid rename follow mode');
513
524
  const sinceArg=since===undefined?null:validateDateBound(since),untilArg=until===undefined?null:validateDateBound(until);
514
525
  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');
526
+ if(!Number.isInteger(offset)||offset<0||offset>MAX_HISTORY_OFFSET||!Number.isInteger(limit)||limit<1||limit>MAX_HISTORY_PAGE)throw Error('Invalid history pagination');
527
+ repo=await checkedGitRepo(repo,signal,'Historical path history supports Git only');
518
528
  const listing=(await git(repo.root,['ls-tree','-z',commit,'--',file],MAX_TEXT,{signal})).toString('utf8');
519
529
  const record=listing.split('\0').filter(Boolean).find(row=>row.slice(row.indexOf('\t')+1)===file);
520
530
  if(!record)throw Error('Path does not exist in the selected commit');
521
531
  const mode=record.slice(0,record.indexOf('\t')).split(' ')[0];
522
532
  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;}}
533
+ if(sinceArg)await assertSinceAsFilterSupport(repo,signal,'path history');
524
534
  const dateArgs=[...(sinceArg?['--since-as-filter='+sinceArg]:[]),...(untilArg?['--until='+untilArg]:[])];
525
535
  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
536
  }
527
537
 
528
538
  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');
539
+ for(const oid of [base,target])if(typeof oid!=='string'||!isObjectId(oid))throw new Error('Invalid revision commit id');
540
+ signal?.throwIfAborted();repo=await checkedGitRepo(repo,signal,'Revision comparison supports Git only');
532
541
  for(const oid of new Set([base,target])){
533
542
  const type=(await git(repo.root,['cat-file','-t',oid],MAX_TEXT,{signal})).toString('utf8').trim();
534
543
  if(type!=='commit')throw new Error('Commit object required');
@@ -553,9 +562,9 @@ async function annotateSubmodules(repo, entries, options) {
553
562
  const files=entries.filter(entry=>entry.status!=='conflicted').map(entry=>entry.path);
554
563
  for(let from=0;from<files.length;){
555
564
  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++;}
565
+ 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
566
  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);}
567
+ 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
568
  }
560
569
  if (!pointers.size) return;
561
570
  const initialized = [];
@@ -576,8 +585,8 @@ async function annotateSubmodules(repo, entries, options) {
576
585
  // Bounded, read-only submodule worktree state from the parent's own `git status`
577
586
  // (never runs Git inside the submodule): --porcelain=v2 exposes `S` fields for new
578
587
  // 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);
588
+ for (let from = 0; from < initialized.length; from += METADATA_BATCH_SIZE) {
589
+ check();const batch = initialized.slice(from, from + METADATA_BATCH_SIZE);
581
590
  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
591
  const records=out.split(String.fromCharCode(0));
583
592
  for(let i=0;i<records.length;i++){
@@ -628,43 +637,47 @@ async function changes(repo, mode, options) {
628
637
  export async function listChanges(repo, mode = 'all', {signal} = {}) { repo = await checkedRepo(repo,signal); modeFor(repo, mode); const result=await changes(repo, mode,{signal});signal?.throwIfAborted();return result; }
629
638
  // Keep the public adapter decoding export compatible with existing consumers.
630
639
  export {decode} from './text-content.mjs';
631
- async function content(fn) { try { return decode(await fn()); } catch (e) { if (e.code === 'TOO_LARGE') return { text: '', notice: 'File exceeds the 2 MiB preview limit.' }; throw e; } }
640
+ async function content(fn) { try { return decode(await fn()); } catch (e) { if (e.code === 'TOO_LARGE') return { text: '', previewReason:'size-limit', notice: 'File exceeds the 2 MiB preview limit.' }; throw e; } }
632
641
  async function working(root, relative, svnLink = false, signal) {
633
642
  signal?.throwIfAborted();
634
643
  const target = await confined(root, relative);
635
644
  let stat; try { stat = await fs.lstat(target); } catch (e) { if (e.code === 'ENOENT') return { text: '' }; throw e; }
636
- if (stat.isSymbolicLink()) return { text: (svnLink ? 'link ' : '') + await fs.readlink(target) };
637
- if (!stat.isFile()) return { text: '', notice: 'Directory or special file; no file-content preview.' };
638
- if (stat.size > MAX_TEXT) return { text: '', notice: 'File exceeds the 2 MiB preview limit.' };
645
+ if (stat.isSymbolicLink()) return { text:(svnLink ? 'link ' : '') + await fs.readlink(target), largeEligible:false };
646
+ if (!stat.isFile()) return { text:'', previewReason:'unsupported-type', largeEligible:false, notice:'Directory or special file; no file-content preview.' };
647
+ if (stat.size > MAX_TEXT) return { text:'', previewReason:'size-limit', largeEligible:stat.size<=MAX_LARGE_TEXT, notice:'File exceeds the 2 MiB preview limit.' };
639
648
  const fingerprint=s=>[s.dev,s.ino,s.size,s.mtimeNs,s.ctimeNs].map(String).join(':');
640
649
  const before=await fs.lstat(target,{bigint:true});
641
- if(!before.isFile()||before.size>BigInt(MAX_TEXT))throw Error('Working file changed before read; refresh status');
650
+ if(!before.isFile()||before.size>BigInt(MAX_TEXT))throw selectionChanged('Working file changed before read; refresh status');
642
651
  const handle = await fs.open(target,fileFlags.O_RDONLY|(fileFlags.O_NOFOLLOW||0));
643
652
  try {
644
- const opened=await handle.stat({bigint:true});if(!opened.isFile()||fingerprint(opened)!==fingerprint(before))throw Error('Working file changed during open');
653
+ const opened=await handle.stat({bigint:true});if(!opened.isFile()||fingerprint(opened)!==fingerprint(before))throw selectionChanged('Working file changed during open');
645
654
  const buffer=Buffer.alloc(Number(before.size)+1);let offset=0;
646
655
  while(offset<buffer.length){signal?.throwIfAborted();const {bytesRead}=await handle.read(buffer,offset,buffer.length-offset,offset);if(!bytesRead)break;offset+=bytesRead;}
647
656
  signal?.throwIfAborted();await confined(root,relative);
648
657
  const after=await handle.stat({bigint:true}),named=await fs.lstat(target,{bigint:true});
649
- if(offset!==Number(before.size)||!named.isFile()||named.isSymbolicLink()||fingerprint(after)!==fingerprint(before)||fingerprint(named)!==fingerprint(before))throw Error('Working file changed during read; refresh status');
658
+ if(offset!==Number(before.size)||!named.isFile()||named.isSymbolicLink()||fingerprint(after)!==fingerprint(before)||fingerprint(named)!==fingerprint(before))throw selectionChanged('Working file changed during read; refresh status');
650
659
  return decode(buffer.subarray(0,offset));
651
660
  } finally { await handle.close(); }
652
661
  }
653
- async function gitBlob(root, revision, file, options) {
662
+ const selectionChanged = message => Object.assign(Error(message || 'Workspace selection changed; refresh repository status'), {code:'vcs/selection-changed'});
663
+
664
+ async function gitBlob(root, revision, file, options, indexSnapshot) {
654
665
  let record;
655
666
  if(revision){
656
667
  const raw=(await git(root,['ls-tree','-z',revision,'--',file],MAX_TEXT,options)).toString('utf8');
657
668
  const row=raw.split('\0').find(row=>row.includes('\t')&&row.slice(row.indexOf('\t')+1)===file);
658
669
  if(!row)return {text:''};const [mode,type,oid]=row.slice(0,row.indexOf('\t')).split(' ');record={mode,type,oid};
659
670
  }else{
660
- const raw=(await git(root,['ls-files','--stage','-z','--',file],MAX_TEXT,options)).toString('utf8');
671
+ const raw=(indexSnapshot ?? await git(root,['ls-files','--stage','-z','--',file],MAX_TEXT,options)).toString('utf8');
661
672
  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
673
  if(!record)return {text:'',notice:'No stage-0 Index entry; conflict stages are not inferred.'};
663
674
  }
664
- if(!/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/.test(record.oid))throw Error('Invalid selected object');
665
- 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
- if(record.type!=='blob'||!['100644','100755','120000'].includes(record.mode))return {text:'',notice:'Unsupported selected object; contents not loaded.'};
675
+ if(!isObjectId(record.oid))throw Error('Invalid selected object');
676
+ if(record.mode==='160000')return {text:'Submodule '+record.oid,largeEligible:false,notice:'Recorded gitlink pointer; nested checkout commit and contents are not loaded by this preview.'};
677
+ if(record.type!=='blob'||!['100644','100755','120000'].includes(record.mode))return {text:'',previewReason:'unsupported-type',largeEligible:false,notice:'Unsupported selected object; contents not loaded.'};
667
678
  const body=await content(()=>git(root,['cat-file','blob',record.oid],MAX_TEXT,options));
679
+ if(body.previewReason==='size-limit'){const size=(await git(root,['cat-file','-s',record.oid],MAX_TEXT,options)).toString('utf8').trim();body.largeEligible=/^\d+$/.test(size)&&Number(size)<=MAX_LARGE_TEXT;}
680
+ if(record.mode==='120000')body.largeEligible=false;
668
681
  if(record.mode==='120000')body.notice=[body.notice,'Symbolic link target text; not followed.'].filter(Boolean).join(' ');
669
682
  return body;
670
683
  }
@@ -675,8 +688,8 @@ async function historicalBlob(root,revision,file,signal,large=false,budget){
675
688
  if(!record)throw new Error('Historical path missing');
676
689
  const [mode,type,oid]=record.slice(0,record.indexOf(' ')).split(' ');
677
690
  if(large&&(!['100644','100755'].includes(mode)||type!=='blob'))throw new Error('Large comparison requires a committed regular file.');
678
- if(mode==='160000')return {text:oid,notice:'Submodule commit reference; repository contents are not loaded.'};
679
- if(type!=='blob'||!['100644','100755','120000'].includes(mode))return {text:'',notice:'Unsupported historical object type.'};
691
+ if(mode==='160000')return {text:oid,largeEligible:false,notice:'Submodule commit reference; repository contents are not loaded.'};
692
+ if(type!=='blob'||!['100644','100755','120000'].includes(mode))return {text:'',previewReason:'unsupported-type',largeEligible:false,notice:'Unsupported historical object type.'};
680
693
  if (large) {
681
694
  const rawSize = (await git(root, ['cat-file', '-s', oid], MAX_TEXT, budget?budget():{signal})).toString('utf8').trim();
682
695
  if (!/^\d+$/.test(rawSize)) throw new Error('Invalid historical object size.');
@@ -687,29 +700,31 @@ async function historicalBlob(root,revision,file,signal,large=false,budget){
687
700
  return decodeLargeText(buffer, signal);
688
701
  }
689
702
 
690
- const result=await content(()=>git(root,['cat-file','blob',oid],MAX_TEXT,{signal}));
703
+ const result=await content(()=>git(root,['cat-file','blob',oid],MAX_TEXT,budget?budget():{signal}));
704
+ if(result.previewReason==='size-limit'){const size=(await git(root,['cat-file','-s',oid],MAX_TEXT,budget?budget():{signal})).toString('utf8').trim();result.largeEligible=/^\d+$/.test(size)&&Number(size)<=MAX_LARGE_TEXT;}
705
+ if(mode==='120000')result.largeEligible=false;
691
706
  if(mode==='120000')result.notice=[result.notice,'Symbolic link target text; not followed.'].filter(Boolean).join(' ');
692
707
  return result;
693
708
  }
694
709
  export async function getCommitComparison(repo,{commit,parentIndex=0,id,signal,large=false}={}){
695
710
  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');
711
+ if(typeof id!=='string'||!isChangeId(id))throw new Error('Invalid historical change id');
697
712
  const details=await commitDetails(repo,{commit,parentIndex,signal});
698
713
  const entry=details.changes.find(row=>row.id===id);
699
714
  if(!entry)throw new Error('Historical change is not part of selected commit and parent');
700
715
  const left=details.parent&&entry.status!=='added'?await historicalBlob(repo.root,details.parent,entry.oldPath||entry.path,signal,large):{text:''};
701
716
  const right=entry.status!=='deleted'?await historicalBlob(repo.root,commit,entry.path,signal,large):{text:''};
702
- return {...entry,large,left:{...left,label:details.parent||'Empty tree'},right:{...right,label:commit},binary:!!(left.binary||right.binary),notice:[left.notice,right.notice].filter(Boolean).join(' ')};
717
+ return withComparisonPreview({...entry,large,left:{...left,label:details.parent||'Empty tree'},right:{...right,label:commit},binary:!!(left.binary||right.binary),notice:[left.notice,right.notice].filter(Boolean).join(' ')},{allowLarge:true});
703
718
  }
704
719
  export async function getRevisionComparison(repo,{base,target,id,signal,large=false}={}){
705
720
  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');
721
+ if(typeof id!=='string'||!isChangeId(id))throw new Error('Invalid revision change id');
707
722
  const details=await getRevisionChanges(repo,{base,target,signal});
708
723
  const entry=details.changes.find(row=>row.id===id);
709
724
  if(!entry)throw new Error('Change is not part of selected revision pair');
710
725
  const left=entry.status!=='added'?await historicalBlob(repo.root,base,entry.oldPath||entry.path,signal,large):{text:''};
711
726
  const right=entry.status!=='deleted'?await historicalBlob(repo.root,target,entry.path,signal,large):{text:''};
712
- return {...entry,base,target,large,left:{...left,label:base},right:{...right,label:target},binary:!!(left.binary||right.binary),notice:[left.notice,right.notice].filter(Boolean).join(' ')};
727
+ return withComparisonPreview({...entry,base,target,large,left:{...left,label:base},right:{...right,label:target},binary:!!(left.binary||right.binary),notice:[left.notice,right.notice].filter(Boolean).join(' ')},{allowLarge:true});
713
728
  }
714
729
  async function properties(root, file, base, signal) {
715
730
  signal?.throwIfAborted();
@@ -722,13 +737,12 @@ async function properties(root, file, base, signal) {
722
737
  async function getWorkspaceLargeComparison(repo,{mode,id,signal}){
723
738
  const deadline=Date.now()+TIMEOUT;
724
739
  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');
740
+ repo=await checkedRepo(repo,signal,options);modeFor(repo,mode);assertGit(repo,'Large workspace comparison supports Git only');
727
741
  const run=args=>git(repo.root,args,MAX_TEXT,options());
728
742
  const status=()=>changes(repo,mode,options());
729
- const entry=(await status()).find(row=>row.id===id);if(!entry)throw Error('Change no longer exists; refresh repository status');
743
+ const entry=(await status()).find(row=>row.id===id);if(!entry)throw selectionChanged('Change no longer exists; refresh repository status');
730
744
  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;}};
745
+ const head=()=>resolveHeadCommit(run);
732
746
  const revision=await head(),indexArgs=['ls-files','--stage','-z','--',entry.path],index=await run(indexArgs);
733
747
  const readIndex=async()=>{
734
748
  const rows=index.toString('utf8').split('\0').filter(Boolean);if(rows.length!==1)throw Error('Large comparison requires one stage0 Index entry');
@@ -742,22 +756,22 @@ async function getWorkspaceLargeComparison(repo,{mode,id,signal}){
742
756
  if(mode==='staged'){if(entry.indexStatus!=='D')right=await readIndex();}
743
757
  else if(entry.worktreeStatus!=='D'){
744
758
  try{const result=await readWorkspaceLargeText(repo.root,entry.path,signal,options);right=result.value;verify=result.verify;}
745
- catch(error){if(error.code!=='ENOENT'||entry.indexStatus!=='D')throw error;verify=async()=>{const target=await confined(repo.root,entry.path);try{await fs.lstat(target);throw Error('Working file appeared; refresh repository status');}catch(missing){if(missing.code!=='ENOENT')throw missing;}};await verify();}
759
+ catch(error){if(error.code!=='ENOENT'||entry.indexStatus!=='D')throw error;verify=async()=>{const target=await confined(repo.root,entry.path);try{await fs.lstat(target);throw selectionChanged('Working file appeared; refresh repository status');}catch(missing){if(missing.code!=='ENOENT')throw missing;}};await verify();}
746
760
  }
747
- options();if(revision!==await head()||!index.equals(await run(indexArgs))||!(await status()).some(row=>row.id===id))throw Error('Workspace selection changed; refresh repository status');
761
+ options();if(revision!==await head()||!index.equals(await run(indexArgs))||!(await status()).some(row=>row.id===id))throw selectionChanged('Workspace selection changed; refresh repository status');
748
762
  await verify?.();options();
749
- return {...entry,large:true,left:{...left,label:mode==='unstaged'?'Index':'HEAD'},right:{...right,label:mode==='staged'?'Index':'Working tree'}};
763
+ return withComparisonPreview({...entry,large:true,left:{...left,label:mode==='unstaged'?'Index':'HEAD'},right:{...right,label:mode==='staged'?'Index':'Working tree'}});
750
764
  }
751
765
 
752
766
  /** Explicit Index stage snapshot; never reads the selected working file or submodule. */
753
767
  export async function getConflictStages(repo,{mode='all',id,signal}={}){
754
768
  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');
757
- 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.');
769
+ if(typeof id!=='string'||!isChangeId(id))throw Error('Invalid conflict change id');
770
+ repo=await checkedRepo(repo,signal,budget);modeFor(repo,mode);assertGit(repo,'Conflict stages support Git only');
771
+ const entry=(await changes(repo,mode,budget())).find(row=>row.id===id);if(!entry||entry.status!=='conflicted')throw selectionChanged('Selected conflict no longer exists; refresh status.');
758
772
  await confined(repo.root,entry.path);
759
773
  const list=()=>git(repo.root,['ls-files','--unmerged','-z','--',entry.path],MAX_TEXT,budget());
760
- const before=await list(),metadata=parseConflictStages(before,entry.path);if(!metadata.length)throw Error('Conflict stages no longer exist; refresh status.');
774
+ const before=await list(),metadata=parseConflictStages(before,entry.path);if(!metadata.length)throw selectionChanged('Conflict stages no longer exist; refresh status.');
761
775
  const stages=[];
762
776
  for(const stage of [1,2,3]){
763
777
  const record=metadata.find(row=>row.stage===stage);if(!record){stages.push({stage,present:false,text:''});continue;}
@@ -765,52 +779,96 @@ export async function getConflictStages(repo,{mode='all',id,signal}={}){
765
779
  else{body=await content(()=>git(repo.root,['cat-file','blob',record.oid],MAX_TEXT,budget()));if(record.kind==='symlink')body.notice=[body.notice,'Symbolic link target text; not followed.'].filter(Boolean).join(' ');}
766
780
  stages.push({...record,present:true,...body});
767
781
  }
768
- if(!(await list()).equals(before))throw Error('Conflict Index changed while reading; refresh status.');
769
- if(!(await changes(repo,mode,budget())).some(row=>row.id===id&&row.status==='conflicted'))throw Error('Selected conflict changed while reading; refresh status.');
782
+ if(!(await list()).equals(before))throw selectionChanged('Conflict Index changed while reading; refresh status.');
783
+ if(!(await changes(repo,mode,budget())).some(row=>row.id===id&&row.status==='conflicted'))throw selectionChanged('Selected conflict changed while reading; refresh status.');
770
784
  await confined(repo.root,entry.path);budget();
771
785
  return {id,path:entry.path,stages,snapshot:createHash('sha256').update(before).digest('hex')};
772
786
  }
773
787
 
774
- /** Revalidates the opaque ID against fresh status before reading any selected path. */
775
- export async function getComparison(repo, { mode = 'all', id, large=false, signal } = {}) {
776
- if(typeof large!=='boolean')throw Error('Invalid large comparison mode');
777
- if(large)return getWorkspaceLargeComparison(repo,{mode,id,signal});
788
+ // SVN emits equivalent XML attributes in different orders; compare semantic fields, not wire bytes.
789
+ function stableSnapshot(value) {
790
+ const canonical = item => Array.isArray(item) ? item.map(canonical) : item && typeof item === 'object' ? Object.fromEntries(Object.keys(item).sort().map(key => [key,canonical(item[key])])) : item;
791
+ return JSON.stringify(canonical(value));
792
+ }
793
+ const svnInfoSnapshot = buffer => stableSnapshot(parseXML(buffer));
794
+
795
+ /** Snapshot selected file metadata without following a symlink or entering a submodule. */
796
+ async function workingIdentity(root, file) {
797
+ const target = await confined(root, file);
798
+ try {
799
+ const stat = await fs.lstat(target,{bigint:true});
800
+ return [stat.dev,stat.ino,stat.mode,stat.size,stat.mtimeNs,stat.ctimeNs,stat.isSymbolicLink() ? await fs.readlink(target) : ''].map(String).join(':');
801
+ } catch (error) {if(error.code === 'ENOENT') return null;throw error;}
802
+ }
803
+
804
+ /** Bind mutable HEAD/Index to immutable objects, then reject any changed selection. */
805
+ export async function getComparison(repo, {mode = 'all',id,large = false,signal} = {}) {
806
+ if(typeof large !== 'boolean') throw Error('Invalid large comparison mode');
807
+ if(large) return getWorkspaceLargeComparison(repo,{mode,id,signal});
778
808
  signal?.throwIfAborted();
779
- repo = await checkedRepo(repo,signal); modeFor(repo, mode);
780
- const entry = (await changes(repo, mode,{signal})).find(item => item.id === id);
781
- if (!entry) throw new Error('Change no longer exists; refresh repository status.');
782
- await confined(repo.root, entry.path); if (entry.oldPath) await confined(repo.root, entry.oldPath);
783
- let left = { text: '' }, right = { text: '' }, leftLabel, rightLabel, props, notice;
784
- if (repo.type === 'git') {
785
- leftLabel = mode === 'unstaged' ? 'Index' : 'HEAD'; rightLabel = mode === 'staged' ? 'Index' : 'Working tree';
786
- if (entry.status === 'conflicted') {
787
- notice = 'Unmerged conflict: showing HEAD versus working tree (index has conflict stages).'; leftLabel = 'HEAD'; rightLabel = 'Working tree';
788
- try { left = await gitBlob(repo.root, 'HEAD', entry.oldPath || entry.path,{signal}); } catch (e) { if (e.code !== 'VCS_COMMAND') throw e; }
789
- right = await working(repo.root, entry.path, false, signal);
809
+ repo = await checkedRepo(repo,signal);modeFor(repo,mode);
810
+ const status = () => changes(repo,mode,{signal});
811
+ const entry = (await status()).find(item => item.id === id);
812
+ if(!entry) throw selectionChanged('Change no longer exists; refresh repository status.');
813
+ await confined(repo.root,entry.path);
814
+ if(entry.oldPath) await confined(repo.root,entry.oldPath);
815
+ const readsWorking = repo.type === 'svn' || mode !== 'staged' || entry.status === 'conflicted';
816
+ const workBefore = readsWorking ? await workingIdentity(repo.root,entry.path) : undefined;
817
+ let left = {text:''},right = {text:''},leftLabel,rightLabel,props,notice,verify;
818
+ if(repo.type === 'git') {
819
+ const run = args => git(repo.root,args,MAX_TEXT,{signal});
820
+ const head = () => resolveHeadCommit(run);
821
+ const revision = await head();
822
+ const indexArgs = ['ls-files','--stage','-z','--',entry.path];
823
+ const index = await run(indexArgs);
824
+ verify = async () => {
825
+ if(revision !== await head() || !index.equals(await run(indexArgs))) throw selectionChanged();
826
+ };
827
+ leftLabel = mode === 'unstaged' ? 'Index' : 'HEAD';
828
+ rightLabel = mode === 'staged' ? 'Index' : 'Working tree';
829
+ if(entry.status === 'conflicted') {
830
+ notice = 'Unmerged conflict: showing HEAD versus working tree (index has conflict stages).';
831
+ leftLabel = 'HEAD';rightLabel = 'Working tree';
832
+ if(revision) left = await gitBlob(repo.root,revision,entry.oldPath || entry.path,{signal});
833
+ right = await working(repo.root,entry.path,false,signal);
790
834
  } else {
791
- let hasHead = true; try { await git(repo.root, ['rev-parse', '--verify', 'HEAD'], undefined, {signal}); } catch (e) { if (e.code !== 'VCS_COMMAND') throw e; hasHead = false; }
792
835
  const untracked = entry.indexStatus === '?';
793
836
  const addedToHead = entry.indexStatus === 'A' || entry.indexStatus === 'C';
794
- if (!untracked && (mode === 'unstaged' ? entry.indexStatus !== 'D' : (hasHead && !addedToHead))) left = await gitBlob(repo.root, mode === 'unstaged' ? '' : 'HEAD', mode === 'unstaged' ? entry.path : entry.oldPath || entry.path,{signal});
795
- if (mode === 'staged') { if (entry.indexStatus !== 'D') right = await gitBlob(repo.root, '', entry.path,{signal}); }
796
- else right = await working(repo.root, entry.path, false, signal);
837
+ if(!untracked && (mode === 'unstaged' ? entry.indexStatus !== 'D' : revision && !addedToHead)) {
838
+ left = await gitBlob(repo.root,mode === 'unstaged' ? '' : revision,mode === 'unstaged' ? entry.path : entry.oldPath || entry.path,{signal},index);
839
+ }
840
+ if(mode === 'staged') {if(entry.indexStatus !== 'D') right = await gitBlob(repo.root,'',entry.path,{signal},index);}
841
+ else right = await working(repo.root,entry.path,false,signal);
797
842
  }
798
843
  } else {
799
- leftLabel = 'BASE'; rightLabel = 'Working copy';
800
- const added = ['added', 'unversioned', 'replaced'].includes(entry.status);
801
- if (entry.status === 'replaced') notice = 'Replaced node: local SVN BASE is unavailable; showing empty versus working content without contacting the server.';
844
+ leftLabel = 'BASE';rightLabel = 'Working copy';
845
+ const added = ['added','unversioned','replaced'].includes(entry.status);
846
+ const info = () => svn(repo.root,['info','--xml','--',entry.path + '@'],undefined,{signal});
847
+ const before = entry.status === 'unversioned' ? null : await info();
848
+ verify = async () => {
849
+ if(before && svnInfoSnapshot(before) !== svnInfoSnapshot(await info())) throw selectionChanged('SVN BASE selection changed; refresh repository status');
850
+ if(props) {
851
+ const after = {left:added ? {} : await properties(repo.root,entry.path,true,signal),right:['deleted','missing'].includes(entry.status) ? {} : await properties(repo.root,entry.path,false,signal)};
852
+ if(stableSnapshot(after) !== stableSnapshot(props)) throw selectionChanged('SVN properties changed; refresh repository status');
853
+ }
854
+ };
855
+ if(entry.status === 'replaced') notice = 'Replaced node: local SVN BASE is unavailable; showing empty versus working content without contacting the server.';
802
856
  let directory = false;
803
- try { directory = (await fs.lstat(await confined(repo.root, entry.path))).isDirectory(); } catch (e) { if (e.code !== 'ENOENT') throw e; }
804
- if (!added && !directory) {
805
- const info = parseXML(await svn(repo.root, ['info', '--xml', '--', entry.path + '@'], undefined, {signal})).info.entry[0]; directory = info.kind === 'dir';
806
- if (!directory) left = await content(() => svn(repo.root, ['cat', '--revision', 'BASE', '--', entry.path + '@'], MAX_TEXT, {signal}));
857
+ try {directory = (await fs.lstat(await confined(repo.root,entry.path))).isDirectory();} catch(error) {if(error.code !== 'ENOENT') throw error;}
858
+ if(!added && !directory) {
859
+ directory = parseXML(before).info.entry[0].kind === 'dir';
860
+ if(!directory) left = await content(() => svn(repo.root,['cat','--revision','BASE','--',entry.path + '@'],MAX_TEXT,{signal}));
807
861
  }
808
- if (directory) notice = 'Directory change; property differences are shown separately.';
809
- else right = await working(repo.root, entry.path, true, signal);
810
- if (entry.status !== 'unversioned') props = { left: added ? {} : await properties(repo.root, entry.path, true, signal), right: ['deleted', 'missing'].includes(entry.status) ? {} : await properties(repo.root, entry.path, false, signal) };
811
- if (entry.status === 'conflicted') notice = 'Conflicted working copy: showing BASE versus working content.';
862
+ if(directory) {notice = 'Directory change; property differences are shown separately.';right = {text:'',previewReason:'unsupported-type',largeEligible:false};}
863
+ else right = await working(repo.root,entry.path,true,signal);
864
+ if(entry.status !== 'unversioned') props = {left:added ? {} : await properties(repo.root,entry.path,true,signal),right:['deleted','missing'].includes(entry.status) ? {} : await properties(repo.root,entry.path,false,signal)};
865
+ if(entry.status === 'conflicted') notice = 'Conflicted working copy: showing BASE versus working content.';
812
866
  }
867
+ await verify();
868
+ if(!(await status()).some(row => row.id === id)) throw selectionChanged();
869
+ if(readsWorking && workBefore !== await workingIdentity(repo.root,entry.path)) throw selectionChanged('Working selection changed; refresh repository status');
813
870
  signal?.throwIfAborted();
814
- if (entry.submodule) notice = [notice, `Submodule pointer ${entry.submodule.recorded} (checkout: ${entry.submodule.checkout}); nested commit and contents are not loaded by this preview; Git status may inspect nested state.`].filter(Boolean).join(' ');
815
- return { ...entry, left: { label: leftLabel, text: left.text, encoding: left.encoding }, right: { label: rightLabel, text: right.text, encoding: right.encoding }, ...(left.binary || right.binary ? { binary: true } : {}), ...([notice, left.notice, right.notice].filter(Boolean).length ? { notice: [...new Set([notice, left.notice, right.notice].filter(Boolean))].join(' ') } : {}), ...(props ? { properties: props } : {}) };
871
+ if(entry.submodule) notice = [notice,`Submodule pointer ${entry.submodule.recorded} (checkout: ${entry.submodule.checkout}); nested commit and contents are not loaded by this preview; Git status may inspect nested state.`].filter(Boolean).join(' ');
872
+ const result = {...entry,left:{label:leftLabel,text:left.text,encoding:left.encoding},right:{label:rightLabel,text:right.text,encoding:right.encoding},...(left.binary || right.binary ? {binary:true} : {}),...([notice,left.notice,right.notice].filter(Boolean).length ? {notice:[...new Set([notice,left.notice,right.notice].filter(Boolean))].join(' ')} : {}),...(props ? {properties:props} : {})};
873
+ return withComparisonPreview(result,{left,right,allowLarge:repo.type === 'git',conflicted:entry.status === 'conflicted'});
816
874
  }