@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/README.md +8 -5
- package/dist/client.js +3 -3
- package/dist/editor.js +59 -59
- package/dist/frontend.json +1 -1
- package/dist/interface.js +3 -3
- package/dist/svn-host.mjs +1 -1
- package/docs/RELEASING.md +41 -9
- package/git-history.mjs +6 -1
- package/index.mjs +313 -120
- package/local-command.mjs +58 -25
- package/package.json +11 -3
- package/src/commit-signature.mjs +26 -13
- package/src/historical-access.mjs +139 -0
- package/src/operation-budget.mjs +44 -0
- package/src/preview-capabilities.mjs +19 -0
- package/vcs.mjs +225 -167
- package/workspace-large-text.mjs +4 -3
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>
|
|
194
|
-
if(snapshot!==undefined && (typeof snapshot!=='string'
|
|
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
|
|
197
|
-
if(
|
|
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
|
-
|
|
205
|
-
|
|
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
|
|
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
|
|
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(
|
|
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
|
|
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(
|
|
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'
|
|
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
|
|
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>
|
|
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
|
-
|
|
324
|
-
|
|
325
|
-
repo=await
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
if(
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
const
|
|
334
|
-
|
|
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
|
-
|
|
347
|
-
|
|
348
|
-
repo=await
|
|
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>
|
|
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'
|
|
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'
|
|
386
|
-
repo=await
|
|
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(
|
|
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'
|
|
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'
|
|
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>
|
|
450
|
-
if(typeof id!=='string'
|
|
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
|
-
|
|
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>
|
|
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'
|
|
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>
|
|
516
|
-
repo=await
|
|
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)
|
|
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'
|
|
530
|
-
signal?.throwIfAborted();repo=await
|
|
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<
|
|
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)
|
|
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 +=
|
|
580
|
-
check();const batch = initialized.slice(from, from +
|
|
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:
|
|
637
|
-
if (!stat.isFile()) return { text: '', notice:
|
|
638
|
-
if (stat.size > MAX_TEXT) return { text: '', notice:
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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(
|
|
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'
|
|
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'
|
|
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
|
|
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=
|
|
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
|
|
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
|
|
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'
|
|
756
|
-
repo=await checkedRepo(repo,signal,budget);modeFor(repo,mode);
|
|
757
|
-
const entry=(await changes(repo,mode,budget())).find(row=>row.id===id);if(!entry||entry.status!=='conflicted')throw
|
|
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
|
|
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
|
|
769
|
-
if(!(await changes(repo,mode,budget())).some(row=>row.id===id&&row.status==='conflicted'))throw
|
|
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
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
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);
|
|
780
|
-
const
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
if (repo.
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
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
|
|
795
|
-
|
|
796
|
-
|
|
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';
|
|
800
|
-
const added = ['added',
|
|
801
|
-
|
|
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 {
|
|
804
|
-
if
|
|
805
|
-
|
|
806
|
-
if
|
|
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
|
|
809
|
-
else right = await working(repo.root,
|
|
810
|
-
if
|
|
811
|
-
if
|
|
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
|
|
815
|
-
|
|
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
|
}
|