@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/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.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 = 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. */
@@ -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
- 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};
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 = 0,signal} = {}) {
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
- repo = await checkedGitRepo(repo, signal, 'Historical segments support Git only');
356
- return historicalAccess.segment(repo.root, {commit,path:file,offset,signal,expectedEpoch});
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}={}) {