@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/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: payload.offset ?? 0, signal});
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.6.1",
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 = 0,signal,expectedEpoch = epoch} = {}) => {
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 buffer = generation === epoch ? cache.get(key)?.buffer : undefined;
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
- return {commit,path:file,oid:entry.oid,encoding:'UTF-8',offset,endOffset:end,totalBytes:size,nextOffset:end < size ? end : null,text};
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
- let text;
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');}
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 = 0,signal} = {}) {
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}={}) {