@feng0531/dsh-vcs 0.6.1 → 0.7.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 +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 +20 -31
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.1",
|
|
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. */
|
|
@@ -63,12 +65,13 @@ export async function detectRepository(cwd) {
|
|
|
63
65
|
|
|
64
66
|
const DISCOVERY_SKIP = new Set(['.git', '.svn', 'node_modules', 'bower_components', 'vendor', '.pnpm', '.yarn', '.npm', '.cache', '__pycache__', '.venv', 'venv', '.tox', '.mypy_cache', '.pytest_cache', '.gradle', '.nuget']);
|
|
65
67
|
|
|
66
|
-
async function repositoryAt(directory, type, options = () => undefined) {
|
|
68
|
+
async function repositoryAt(directory, type, options = () => undefined, includeBranch = true) {
|
|
67
69
|
if (type === 'svn') {
|
|
68
70
|
const info = parseXML(await svn(directory, ['info', '--xml', '--', '.'], undefined, options())).info.entry[0];
|
|
69
71
|
return { root: await fs.realpath(info['wc-info']['wcroot-abspath']), type };
|
|
70
72
|
}
|
|
71
73
|
const root = await fs.realpath((await git(directory, ['rev-parse', '--show-toplevel'], undefined, options())).toString('utf8').trim());
|
|
74
|
+
if (!includeBranch) return {root, type};
|
|
72
75
|
let branch;
|
|
73
76
|
try { branch = (await git(root, ['symbolic-ref', '--quiet', '--short', 'HEAD'], undefined, options())).toString('utf8').trim(); }
|
|
74
77
|
catch (e) {
|
|
@@ -192,10 +195,10 @@ export async function discoverRepositories(cwd, { signal, maxDepth = 6, maxDirec
|
|
|
192
195
|
return result;
|
|
193
196
|
}
|
|
194
197
|
|
|
195
|
-
async function checkedRepo(repo, signal, budget) {
|
|
198
|
+
async function checkedRepo(repo, signal, budget, includeBranch = true) {
|
|
196
199
|
signal?.throwIfAborted();
|
|
197
200
|
if (!repo || !['git', 'svn'].includes(repo.type) || typeof repo.root !== 'string') throw new Error('Invalid repository');
|
|
198
|
-
const found = await repositoryAt(repo.root, repo.type, budget || (() => ({signal})));
|
|
201
|
+
const found = await repositoryAt(repo.root, repo.type, budget || (() => ({signal})), includeBranch);
|
|
199
202
|
signal?.throwIfAborted();
|
|
200
203
|
if (!found || found.type !== repo.type || path.resolve(found.root) !== path.resolve(repo.root)) throw new Error('Repository root changed or is invalid');
|
|
201
204
|
return found;
|
|
@@ -204,8 +207,8 @@ function assertGit(repo, message) {
|
|
|
204
207
|
if (repo.type !== 'git') throw new Error(message);
|
|
205
208
|
return repo;
|
|
206
209
|
}
|
|
207
|
-
async function checkedGitRepo(repo, signal, message, budget) {
|
|
208
|
-
return assertGit(await checkedRepo(repo, signal, budget), message);
|
|
210
|
+
async function checkedGitRepo(repo, signal, message, budget, includeBranch = true) {
|
|
211
|
+
return assertGit(await checkedRepo(repo, signal, budget, includeBranch), message);
|
|
209
212
|
}
|
|
210
213
|
// Probe --since-as-filter support so older Git cannot run a silently unfiltered query.
|
|
211
214
|
async function assertSinceAsFilterSupport(repo, signal, label) {
|
|
@@ -272,35 +275,18 @@ export async function listStashes(repo,{signal}={}){
|
|
|
272
275
|
return {stashes};
|
|
273
276
|
}
|
|
274
277
|
|
|
275
|
-
export async function searchRepositoryContent(repo,{commit,query,signal}={}) {
|
|
278
|
+
export async function searchRepositoryContent(repo,{commit,query,cursor,limit=100,signal}={}) {
|
|
279
|
+
return withOperationBudget({signal},async()=>{
|
|
276
280
|
signal?.throwIfAborted();
|
|
277
281
|
if(typeof query!=='string'||!query||query.length>1024||/[\0\r\n]/.test(query))throw new Error('Invalid content search query');
|
|
282
|
+
if(cursor!==undefined&&!commit)throw Error('Content search continuation requires a fixed commit');
|
|
278
283
|
repo=await checkedGitRepo(repo,signal,'Content search supports Git only');
|
|
279
284
|
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
|
-
}
|
|
285
|
+
if(!snapshot) snapshot=await resolveHeadCommit(args=>git(repo.root,args,MAX_TEXT,{signal}));
|
|
284
286
|
if(!isObjectId(snapshot))throw new Error('Invalid content search commit');
|
|
285
287
|
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};
|
|
288
|
+
return searchContentPage(git,repo.root,{snapshot,query,cursor,limit,signal});
|
|
289
|
+
});
|
|
304
290
|
}
|
|
305
291
|
|
|
306
292
|
export async function getCommitDetails(repo,options={}) {
|
|
@@ -349,11 +335,14 @@ function parseCommitChanges(text,scope){
|
|
|
349
335
|
}
|
|
350
336
|
return changes;
|
|
351
337
|
}
|
|
352
|
-
export async function getHistoricalSegment(repo, {commit,path:file,offset
|
|
338
|
+
export async function getHistoricalSegment(repo, {commit,path:file,offset,line,signal} = {}) {
|
|
339
|
+
return withOperationBudget({signal},async()=>{
|
|
353
340
|
const expectedEpoch = historicalAccess.captureEpoch();
|
|
354
341
|
// Capture before any await: revoked in-flight validation cannot refill object bytes.
|
|
355
|
-
|
|
356
|
-
|
|
342
|
+
// Segment identity depends on the canonical root and pinned commit, not current HEAD metadata.
|
|
343
|
+
repo = await checkedGitRepo(repo, signal, 'Historical segments support Git only', undefined, false);
|
|
344
|
+
return historicalAccess.segment(repo.root, {commit,path:file,offset,line,signal,expectedEpoch});
|
|
345
|
+
});
|
|
357
346
|
}
|
|
358
347
|
|
|
359
348
|
export async function getHistoricalFile(repo,{commit,path:file,signal}={}) {
|