@feng0531/dsh-vcs 0.6.1 → 0.7.0
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 +3 -2
- package/dist/client.js +2 -3
- package/dist/editor.js +1 -1
- package/dist/frontend.json +1 -1
- package/dist/interface.js +2 -3
- package/index.mjs +7 -4
- package/package.json +2 -1
- package/src/content-search.mjs +74 -0
- package/src/historical-access.mjs +38 -4
- package/vcs.mjs +12 -25
package/index.mjs
CHANGED
|
@@ -40,7 +40,7 @@ const REQUEST_FIELDS = {
|
|
|
40
40
|
'vcs/branch-switch-prepare': ['sessionId', 'repositoryId', 'branch'],
|
|
41
41
|
'vcs/branch-switch-confirm': ['sessionId', 'repositoryId', 'token'],
|
|
42
42
|
'vcs/stash-list': ['sessionId', 'repositoryId'],
|
|
43
|
-
'vcs/grep': ['sessionId', 'repositoryId', 'commit', 'query'],
|
|
43
|
+
'vcs/grep': ['sessionId', 'repositoryId', 'commit', 'query', 'cursor', 'limit'],
|
|
44
44
|
'vcs/status': ['sessionId', 'repositoryId', 'mode'],
|
|
45
45
|
'vcs/compare': ['sessionId', 'repositoryId', 'mode', 'id', 'large'],
|
|
46
46
|
'vcs/conflict-stages': ['sessionId', 'repositoryId', 'mode', 'id'],
|
|
@@ -55,7 +55,7 @@ const REQUEST_FIELDS = {
|
|
|
55
55
|
'vcs/tree': ['sessionId', 'repositoryId', 'commit'],
|
|
56
56
|
'vcs/tree-page': ['sessionId', 'repositoryId', 'commit', 'path', 'offset', 'limit', 'query'],
|
|
57
57
|
'vcs/tree-file': ['sessionId', 'repositoryId', 'commit', 'path'],
|
|
58
|
-
'vcs/tree-segment': ['sessionId', 'repositoryId', 'commit', 'path', 'offset'],
|
|
58
|
+
'vcs/tree-segment': ['sessionId', 'repositoryId', 'commit', 'path', 'offset', 'line'],
|
|
59
59
|
'vcs/tree-history': ['sessionId', 'repositoryId', 'commit', 'path', 'offset', 'limit', 'follow', 'since', 'until'],
|
|
60
60
|
'vcs/commit-image': ['sessionId', 'repositoryId', 'commit', 'parentIndex', 'id', 'side'],
|
|
61
61
|
'vcs/workspace-image': ['sessionId', 'repositoryId', 'mode', 'id', 'side'],
|
|
@@ -135,6 +135,8 @@ function validateContentSearch(endpoint, payload) {
|
|
|
135
135
|
if (endpoint !== 'vcs/grep') return;
|
|
136
136
|
if (typeof payload.query !== 'string' || !payload.query || payload.query.length > 1024 || /[\0\r\n]/.test(payload.query)) throw invalid('Invalid content search query.');
|
|
137
137
|
if (payload.commit !== undefined && (typeof payload.commit !== 'string' || !isObjectId(payload.commit))) throw invalid('Invalid content search commit.');
|
|
138
|
+
if (payload.cursor !== undefined && (typeof payload.cursor !== 'string' || !payload.cursor || payload.cursor.length > 4096 || !payload.commit)) throw invalid('Invalid content search cursor.');
|
|
139
|
+
if (payload.limit !== undefined && (!Number.isInteger(payload.limit) || payload.limit < 1 || payload.limit > 100)) throw invalid('Invalid content search page size.');
|
|
138
140
|
}
|
|
139
141
|
|
|
140
142
|
function validateRenameFollow(endpoint, payload) {
|
|
@@ -178,6 +180,7 @@ function validateRepositoryPayload(endpoint, payload) {
|
|
|
178
180
|
validateCommitSelection(endpoint, payload);
|
|
179
181
|
validateLargeComparison(endpoint, payload);
|
|
180
182
|
if (endpoint === 'vcs/tree-segment' && payload.offset !== undefined && (!Number.isInteger(payload.offset) || payload.offset < 0 || payload.offset > 16777216)) throw invalid('Invalid segment offset.');
|
|
183
|
+
if (endpoint === 'vcs/tree-segment' && payload.line !== undefined && (!Number.isSafeInteger(payload.line) || payload.line < 1 || payload.line > 16777217 || payload.offset !== undefined)) throw invalid('Invalid segment target line.');
|
|
181
184
|
validateBlameWindow(endpoint, payload);
|
|
182
185
|
validateContentSearch(endpoint, payload);
|
|
183
186
|
validateRenameFollow(endpoint, payload);
|
|
@@ -215,7 +218,7 @@ async function readRepository(api, endpoint, repository, payload, {cwd, mode, si
|
|
|
215
218
|
...optionalField('since', payload.since), ...optionalField('until', payload.until), signal,
|
|
216
219
|
});
|
|
217
220
|
case 'vcs/tree-segment':
|
|
218
|
-
return api.getHistoricalSegment({...repository}, {commit: payload.commit, path: payload.path, offset
|
|
221
|
+
return api.getHistoricalSegment({...repository}, {commit: payload.commit, path: payload.path, ...optionalField('offset', payload.offset), ...optionalField('line', payload.line), signal});
|
|
219
222
|
case 'vcs/workspace-image':
|
|
220
223
|
return api.getWorkspaceImage({...repository}, {mode, id: payload.id, side: payload.side ?? 'right', signal});
|
|
221
224
|
case 'vcs/revision-image':
|
|
@@ -225,7 +228,7 @@ async function readRepository(api, endpoint, repository, payload, {cwd, mode, si
|
|
|
225
228
|
case 'vcs/stash-list':
|
|
226
229
|
return api.listStashes({...repository}, {signal});
|
|
227
230
|
case 'vcs/grep':
|
|
228
|
-
return api.searchRepositoryContent({...repository}, {commit: payload.commit, query: payload.query, signal});
|
|
231
|
+
return api.searchRepositoryContent({...repository}, {commit: payload.commit, query: payload.query, ...optionalField('cursor', payload.cursor), ...optionalField('limit', payload.limit), signal});
|
|
229
232
|
case 'vcs/commit-image':
|
|
230
233
|
return api.getCommitImage({...repository}, {commit: payload.commit, parentIndex: payload.parentIndex ?? 0, id: payload.id, side: payload.side ?? 'right', signal});
|
|
231
234
|
case 'vcs/tree-file':
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@feng0531/dsh-vcs",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"publishConfig": { "access": "public", "registry": "https://registry.npmjs.org/" },
|
|
6
6
|
"type": "module",
|
|
@@ -31,6 +31,7 @@
|
|
|
31
31
|
"src/commit-signature.mjs",
|
|
32
32
|
"src/operation-budget.mjs",
|
|
33
33
|
"src/historical-access.mjs",
|
|
34
|
+
"src/content-search.mjs",
|
|
34
35
|
"src/preview-capabilities.mjs",
|
|
35
36
|
"dist",
|
|
36
37
|
"locale",
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import {createHash,createHmac,randomBytes,timingSafeEqual} from 'node:crypto';
|
|
2
|
+
import {parseTreeEntries} from './historical-access.mjs';
|
|
3
|
+
import {currentOperationBudget} from './operation-budget.mjs';
|
|
4
|
+
|
|
5
|
+
const MAX_BYTES = 16 * 1024 * 1024;
|
|
6
|
+
const secret = randomBytes(32);
|
|
7
|
+
const invalid = () => Error('Invalid content search cursor');
|
|
8
|
+
const binding = (root,snapshot,query) => createHash('sha256').update(JSON.stringify([root,snapshot,query])).digest('hex');
|
|
9
|
+
const sign = body => createHmac('sha256',secret).update(body).digest('base64url');
|
|
10
|
+
function encode(state) { const body=Buffer.from(JSON.stringify(state)).toString('base64url');return body+'.'+sign(body); }
|
|
11
|
+
function decode(cursor,root,snapshot,query) {
|
|
12
|
+
if(typeof cursor!=='string'||cursor.length>4096)throw invalid();
|
|
13
|
+
const [body,mac,...extra]=cursor.split('.');
|
|
14
|
+
if(!body||!mac||extra.length||!/^[A-Za-z0-9_-]+$/.test(body)||!/^[A-Za-z0-9_-]{43}$/.test(mac)||!timingSafeEqual(Buffer.from(mac),Buffer.from(sign(body))))throw invalid();
|
|
15
|
+
let state;try{state=JSON.parse(Buffer.from(body,'base64url').toString());}catch{throw invalid();}
|
|
16
|
+
if(state.binding!==binding(root,snapshot,query)||!Number.isSafeInteger(state.index)||state.index<0||!Number.isSafeInteger(state.offset)||state.offset<0||state.offset>MAX_BYTES||!Number.isSafeInteger(state.line)||state.line<1||typeof state.skipped!=='boolean')throw invalid();
|
|
17
|
+
return state;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Stable object/tree positions, not an offset into a capped grep result set. */
|
|
21
|
+
export async function searchContentPage(run,root,{snapshot,query,cursor,limit=100,signal}) {
|
|
22
|
+
if(!Number.isInteger(limit)||limit<1||limit>100)throw Error('Invalid content search page size');
|
|
23
|
+
const check=()=>{signal?.throwIfAborted();currentOperationBudget()?.throwIfExpired();};
|
|
24
|
+
check();
|
|
25
|
+
const state=cursor===undefined?{binding:binding(root,snapshot,query),index:0,offset:0,line:1,skipped:false}:decode(cursor,root,snapshot,query);
|
|
26
|
+
const listing=await run(root,['ls-tree','-r','-z','--full-tree',snapshot],MAX_BYTES,{signal});
|
|
27
|
+
const entries=parseTreeEntries(listing).filter(entry=>entry.type==='blob'&&['100644','100755'].includes(entry.mode));
|
|
28
|
+
if(state.index>entries.length)throw invalid();
|
|
29
|
+
const matches=[],needle=Buffer.from(query),warnings=[];
|
|
30
|
+
let scannedBytes=0,responseBytes=0,files=0,reason=null;
|
|
31
|
+
while(state.index<entries.length) {
|
|
32
|
+
check();
|
|
33
|
+
if(matches.length>=limit){reason='page-limit';break;}
|
|
34
|
+
if(files>=64||scannedBytes>=MAX_BYTES){reason='scan-budget';break;}
|
|
35
|
+
const entry=entries[state.index];
|
|
36
|
+
const rawSize=(await run(root,['cat-file','-s',entry.oid],2097152,{signal})).toString().trim();
|
|
37
|
+
if(!/^\d+$/.test(rawSize)||!Number.isSafeInteger(Number(rawSize)))throw Error('Invalid search object size');
|
|
38
|
+
const size=Number(rawSize);
|
|
39
|
+
if(size>MAX_BYTES){state.skipped=true;state.index++;state.offset=0;state.line=1;files++;continue;}
|
|
40
|
+
if(files&&scannedBytes+size>MAX_BYTES){reason='scan-budget';break;}
|
|
41
|
+
const body=await run(root,['cat-file','blob',entry.oid],Math.max(1,size),{signal});
|
|
42
|
+
scannedBytes+=body.length;files++;
|
|
43
|
+
if(body.length!==size||state.offset>size)throw Error('Search object size mismatch');
|
|
44
|
+
// Match git grep -I: binary files are outside this text-search domain.
|
|
45
|
+
if(body.includes(0)){state.index++;state.offset=0;state.line=1;continue;}
|
|
46
|
+
let pos=state.offset,line=state.line;
|
|
47
|
+
while(pos<body.length) {
|
|
48
|
+
check();
|
|
49
|
+
// Single pass: CR-only input must not rescan the remaining blob for LF per line.
|
|
50
|
+
let end=pos;
|
|
51
|
+
while(end<body.length&&body[end]!==10&&body[end]!==13){
|
|
52
|
+
if((end & 65535)===0)check();
|
|
53
|
+
end++;
|
|
54
|
+
}
|
|
55
|
+
const hit=body.subarray(pos,end).indexOf(needle);
|
|
56
|
+
if(hit>=0) {
|
|
57
|
+
let textEnd=Math.min(end,pos+4096);
|
|
58
|
+
while(textEnd<end&&(body[textEnd]&192)===128)textEnd--;
|
|
59
|
+
const match={path:entry.path,line,text:body.subarray(pos,textEnd).toString('utf8'),...(textEnd<end?{textTruncated:true}:{})};
|
|
60
|
+
const matchBytes=Buffer.byteLength(JSON.stringify(match));
|
|
61
|
+
if(responseBytes+matchBytes>1024*1024){reason='page-limit';break;}
|
|
62
|
+
matches.push(match);responseBytes+=matchBytes;
|
|
63
|
+
}
|
|
64
|
+
pos=end<body.length?end+(body[end]===13&&body[end+1]===10?2:1):end;line++;
|
|
65
|
+
if(matches.length>=limit)break;
|
|
66
|
+
}
|
|
67
|
+
if(pos>=body.length){state.index++;state.offset=0;state.line=1;}
|
|
68
|
+
else {state.offset=pos;state.line=line;reason='page-limit';break;}
|
|
69
|
+
}
|
|
70
|
+
check();
|
|
71
|
+
const more=state.index<entries.length;
|
|
72
|
+
if(state.skipped)warnings.push('Files exceeding the 16 MiB text-search limit were skipped.');
|
|
73
|
+
return {snapshot,query,matches,nextCursor:more?encode(state):null,complete:!more&&!state.skipped,truncated:more||state.skipped,reason:reason??(state.skipped?'unsupported-content':null),warnings};
|
|
74
|
+
}
|
|
@@ -1,10 +1,33 @@
|
|
|
1
1
|
import {isObjectId} from '../git-history.mjs';
|
|
2
|
+
import {currentOperationBudget} from './operation-budget.mjs';
|
|
2
3
|
|
|
3
4
|
export const HISTORICAL_CACHE_LIMITS = Object.freeze({maxBytes: 32 * 1024 * 1024, maxEntries: 8, ttlMs: 60000});
|
|
4
5
|
const MAX_BLOB_BYTES = 16 * 1024 * 1024;
|
|
5
6
|
const MAX_LIST_BYTES = 16 * 1024 * 1024;
|
|
6
7
|
const MAX_DIRECTORY_ENTRIES = 100000;
|
|
7
8
|
const CHUNK_BYTES = 64 * 1024;
|
|
9
|
+
const isLineBreak = (buffer,pos) => buffer[pos] === 10 || (buffer[pos] === 13 && buffer[pos + 1] !== 10);
|
|
10
|
+
// At most 257 checkpoints for a 16 MiB blob, independent of its line count.
|
|
11
|
+
function buildLineIndex(buffer,signal) {
|
|
12
|
+
const index=[];let line=1,lineStart=0;
|
|
13
|
+
for(let offset=0;offset<=buffer.length;offset++) {
|
|
14
|
+
if(offset%CHUNK_BYTES===0){signal?.throwIfAborted();currentOperationBudget()?.throwIfExpired();index.push({offset,line,lineStart});}
|
|
15
|
+
if(isLineBreak(buffer,offset)){line++;lineStart=offset+1;}
|
|
16
|
+
}
|
|
17
|
+
return {index,totalLines:line};
|
|
18
|
+
}
|
|
19
|
+
function locateOffset(buffer,index,offset) {
|
|
20
|
+
const point=index[Math.floor(offset/CHUNK_BYTES)];let line=point.line,lineStart=point.lineStart;
|
|
21
|
+
for(let pos=point.offset;pos<offset;pos++)if(isLineBreak(buffer,pos)){line++;lineStart=pos+1;}
|
|
22
|
+
return {line,lineStart};
|
|
23
|
+
}
|
|
24
|
+
function locateLine(buffer,index,line) {
|
|
25
|
+
let point=index[0];for(const candidate of index){if(candidate.line>line)break;point=candidate;}
|
|
26
|
+
if(point.line===line)return point.lineStart;
|
|
27
|
+
let current=point.line;
|
|
28
|
+
for(let pos=point.offset;pos<buffer.length;pos++)if(isLineBreak(buffer,pos)&&++current===line)return pos+1;
|
|
29
|
+
return buffer.length;
|
|
30
|
+
}
|
|
8
31
|
|
|
9
32
|
export function validateHistoricalPath(file, allowRoot = false) {
|
|
10
33
|
if (typeof file !== 'string' || file.length > 32768 || (!file && !allowRoot) || file.includes('\0') || file.startsWith('/') || file.split('/').some(part => (!part || part === '.' || part === '..') && !(allowRoot && file === ''))) {
|
|
@@ -87,14 +110,17 @@ export function createHistoricalAccess({run, now = Date.now, maxBytes = HISTORIC
|
|
|
87
110
|
filtered.sort((a,b) => Number(b.type === 'tree') - Number(a.type === 'tree') || (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
|
|
88
111
|
return {commit,path:directory,query,entries:filtered.slice(offset,offset + limit),nextOffset:offset + limit < filtered.length ? offset + limit : null};
|
|
89
112
|
};
|
|
90
|
-
const segment = async (root, {commit,path:file,offset
|
|
113
|
+
const segment = async (root, {commit,path:file,offset:requestedOffset,line:targetLine,signal,expectedEpoch = epoch} = {}) => {
|
|
91
114
|
const generation = expectedEpoch; // Includes caller repository validation and all member lookups.
|
|
115
|
+
if(targetLine !== undefined && (!Number.isSafeInteger(targetLine) || targetLine < 1 || targetLine > MAX_BLOB_BYTES + 1 || requestedOffset !== undefined)) throw Error('Invalid segment target line');
|
|
116
|
+
let offset = requestedOffset ?? 0;
|
|
92
117
|
if (!Number.isInteger(offset) || offset < 0 || offset > MAX_BLOB_BYTES) throw Error('Invalid segment offset');
|
|
93
118
|
const entry = await lookup(root, {commit,path:file,signal,missingMessage:'Segment requires a selected committed regular file'});
|
|
94
119
|
if (entry.type !== 'blob' || !['100644','100755'].includes(entry.mode)) throw Error('Segment requires a selected committed regular file');
|
|
95
120
|
purge();
|
|
96
121
|
const key = JSON.stringify([root,commit,entry.oid]);
|
|
97
|
-
let
|
|
122
|
+
let cached = generation === epoch ? cache.get(key) : undefined;
|
|
123
|
+
let buffer = cached?.buffer, lineIndex = cached?.lineIndex;
|
|
98
124
|
if (buffer) cacheHits++;
|
|
99
125
|
if (!buffer) {
|
|
100
126
|
const rawSize = (await run(root, ['cat-file','-s',entry.oid], 2097152, {signal})).toString('utf8').trim();
|
|
@@ -116,24 +142,32 @@ export function createHistoricalAccess({run, now = Date.now, maxBytes = HISTORIC
|
|
|
116
142
|
if (buffer.includes(0) || buffer.some(byte => byte < 32 && ![9,10,12,13].includes(byte))) throw Error('Segments support UTF-8 text only');
|
|
117
143
|
new TextDecoder('utf-8',{fatal:true}).decode(buffer);
|
|
118
144
|
decodeCount++;
|
|
145
|
+
lineIndex = buildLineIndex(buffer,signal);
|
|
119
146
|
if (generation === epoch) {
|
|
120
147
|
remove(key);
|
|
121
148
|
const timer = setTimeout(() => remove(key), ttlMs);
|
|
122
149
|
timer.unref?.();
|
|
123
|
-
cache.set(key, {root,buffer,expiresAt:now() + ttlMs,timer});
|
|
150
|
+
cache.set(key, {root,buffer,lineIndex,expiresAt:now() + ttlMs,timer});
|
|
124
151
|
bytes += buffer.length;
|
|
125
152
|
}
|
|
126
153
|
} finally {reservedBytes -= size; reservedEntries--;}
|
|
127
154
|
}
|
|
128
155
|
signal?.throwIfAborted();
|
|
129
156
|
const size = buffer.length;
|
|
157
|
+
const targetFound = targetLine === undefined || targetLine <= lineIndex.totalLines;
|
|
158
|
+
if(targetLine !== undefined) offset = targetFound ? locateLine(buffer,lineIndex.index,targetLine) : size;
|
|
130
159
|
if (offset > size) throw Error('Segment offset exceeds file size');
|
|
160
|
+
const start = locateOffset(buffer,lineIndex.index,offset);
|
|
131
161
|
if (offset < size && (buffer[offset] & 192) === 128) throw Error('Segment offset splits UTF-8 character');
|
|
132
162
|
let end = Math.min(size, offset + CHUNK_BYTES);
|
|
133
163
|
while (end < size && (buffer[end] & 192) === 128) end--;
|
|
164
|
+
if(end < size && buffer[end - 1] === 13 && buffer[end] === 10) end--;
|
|
134
165
|
const text = new TextDecoder('utf-8',{fatal:true,ignoreBOM:true}).decode(buffer.subarray(offset,end));
|
|
135
166
|
signal?.throwIfAborted();
|
|
136
|
-
|
|
167
|
+
const endLine = locateOffset(buffer,lineIndex.index,end).line;
|
|
168
|
+
const endsMidLine = end < size && end > offset && !isLineBreak(buffer,end - 1);
|
|
169
|
+
const lineTruncated = endsMidLine;
|
|
170
|
+
return {commit,path:file,oid:entry.oid,encoding:'UTF-8',offset,endOffset:end,totalBytes:size,nextOffset:end < size ? end : null,text,startLine:start.line,endLine,startsMidLine:offset > start.lineStart,targetFound,lineTruncated,endsMidLine,...(!targetFound?{notice:'Requested line exceeds file line count.'}:{})};
|
|
137
171
|
};
|
|
138
172
|
return {lookup,treePage,segment,clear,captureEpoch:() => epoch,stats:() => {purge();return {bytes,entries:cache.size,reservedBytes,reservedEntries,blobReads,blobReadBytes,decodeCount,cacheHits};}};
|
|
139
173
|
}
|
package/vcs.mjs
CHANGED
|
@@ -15,6 +15,8 @@ import {inside, confined} from './repository-path.mjs';
|
|
|
15
15
|
import {readWorkspaceLargeText} from './workspace-large-text.mjs';
|
|
16
16
|
import {createHistoricalAccess,validateHistoricalPath} from './src/historical-access.mjs';
|
|
17
17
|
import {withComparisonPreview} from './src/preview-capabilities.mjs';
|
|
18
|
+
import {searchContentPage} from './src/content-search.mjs';
|
|
19
|
+
import {withOperationBudget} from './src/operation-budget.mjs';
|
|
18
20
|
|
|
19
21
|
const historicalAccess = createHistoricalAccess({run:git});
|
|
20
22
|
/** Host invalidates object bytes on grant replacement, expiry and plugin disposal. */
|
|
@@ -272,35 +274,18 @@ export async function listStashes(repo,{signal}={}){
|
|
|
272
274
|
return {stashes};
|
|
273
275
|
}
|
|
274
276
|
|
|
275
|
-
export async function searchRepositoryContent(repo,{commit,query,signal}={}) {
|
|
277
|
+
export async function searchRepositoryContent(repo,{commit,query,cursor,limit=100,signal}={}) {
|
|
278
|
+
return withOperationBudget({signal},async()=>{
|
|
276
279
|
signal?.throwIfAborted();
|
|
277
280
|
if(typeof query!=='string'||!query||query.length>1024||/[\0\r\n]/.test(query))throw new Error('Invalid content search query');
|
|
281
|
+
if(cursor!==undefined&&!commit)throw Error('Content search continuation requires a fixed commit');
|
|
278
282
|
repo=await checkedGitRepo(repo,signal,'Content search supports Git only');
|
|
279
283
|
let snapshot=commit;
|
|
280
|
-
if(!snapshot){
|
|
281
|
-
try{snapshot=(await git(repo.root,['rev-parse','--verify','HEAD^{commit}'],MAX_TEXT,{signal})).toString('utf8').trim();}
|
|
282
|
-
catch{throw new Error('Content search requires a resolved HEAD commit');}
|
|
283
|
-
}
|
|
284
|
+
if(!snapshot) snapshot=await resolveHeadCommit(args=>git(repo.root,args,MAX_TEXT,{signal}));
|
|
284
285
|
if(!isObjectId(snapshot))throw new Error('Invalid content search commit');
|
|
285
286
|
if((await git(repo.root,['cat-file','-t',snapshot],MAX_TEXT,{signal})).toString('utf8').trim()!=='commit')throw Error('Content search requires a commit object');
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
catch(error){if(error.code==='VCS_COMMAND'&&error.exitCode===1)return {snapshot,matches:[]};throw error;}
|
|
289
|
-
signal?.throwIfAborted();
|
|
290
|
-
const matches=[];
|
|
291
|
-
// -z frames path and line with NUL; legal paths can contain LF.
|
|
292
|
-
for(let position=0;position<text.length;){
|
|
293
|
-
const pathEnd=text.indexOf(String.fromCharCode(0),position),lineEnd=text.indexOf(String.fromCharCode(0),pathEnd+1),recordEnd=text.indexOf(String.fromCharCode(10),lineEnd+1);
|
|
294
|
-
if(pathEnd<position||lineEnd<0||recordEnd<0)throw new Error('Invalid content search result');
|
|
295
|
-
const treePath=text.slice(position,pathEnd),lineNo=text.slice(pathEnd+1,lineEnd),lineText=text.slice(lineEnd+1,recordEnd);position=recordEnd+1;
|
|
296
|
-
const cut=treePath.indexOf(':');
|
|
297
|
-
if(cut<0)throw new Error('Invalid content search result');
|
|
298
|
-
const path=treePath.slice(cut+1);
|
|
299
|
-
if(!path||!/^\d+$/.test(lineNo))throw new Error('Invalid content search result');
|
|
300
|
-
matches.push({path,line:Number(lineNo),text:lineText});
|
|
301
|
-
if(matches.length>500)throw new Error('Content search exceeds match limit');
|
|
302
|
-
}
|
|
303
|
-
return {snapshot,matches};
|
|
287
|
+
return searchContentPage(git,repo.root,{snapshot,query,cursor,limit,signal});
|
|
288
|
+
});
|
|
304
289
|
}
|
|
305
290
|
|
|
306
291
|
export async function getCommitDetails(repo,options={}) {
|
|
@@ -349,11 +334,13 @@ function parseCommitChanges(text,scope){
|
|
|
349
334
|
}
|
|
350
335
|
return changes;
|
|
351
336
|
}
|
|
352
|
-
export async function getHistoricalSegment(repo, {commit,path:file,offset
|
|
337
|
+
export async function getHistoricalSegment(repo, {commit,path:file,offset,line,signal} = {}) {
|
|
338
|
+
return withOperationBudget({signal},async()=>{
|
|
353
339
|
const expectedEpoch = historicalAccess.captureEpoch();
|
|
354
340
|
// Capture before any await: revoked in-flight validation cannot refill object bytes.
|
|
355
341
|
repo = await checkedGitRepo(repo, signal, 'Historical segments support Git only');
|
|
356
|
-
return historicalAccess.segment(repo.root, {commit,path:file,offset,signal,expectedEpoch});
|
|
342
|
+
return historicalAccess.segment(repo.root, {commit,path:file,offset,line,signal,expectedEpoch});
|
|
343
|
+
});
|
|
357
344
|
}
|
|
358
345
|
|
|
359
346
|
export async function getHistoricalFile(repo,{commit,path:file,signal}={}) {
|