Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/search-starts-at-pattern-dir.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- The workspace's `glob` starts its walk at the directory its pattern names before its first wildcard, and `grep` at the deeper of its `path` and the directory its `glob` filter names that way. On a mount larger than its walk cap, a search for one directory's files then finds them, as long as that directory's own walk fits the cap. A walk of the whole mount could reach the cap before it got there and answer with no matches. The walk starts only as deep as disk lists each segment with the pattern's spelling, so on a case-insensitive disk a pattern spelled in another case finds nothing, as before. A search's `incomplete` now names only what lies beneath the directory its walk starts at, and a pattern into a directory the mount passes over by design (its ignore list, or another mount's directory) names that directory there. The start never goes through a link, as a walk of the whole mount doesn't. A `grep` whose `path` and filter name directories that don't overlap searches nothing. A search that starts below the mount root takes in, and announces, only the disk changes beneath its start, and no longer marks the mount's first whole sync done in `status`.
82 changes: 74 additions & 8 deletions src/modules/workspace/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
import { isUtf8 } from 'node:buffer';
import { constants as fsConstants } from 'node:fs';
import type { Stats } from 'node:fs';
import { open, readFile, stat, lstat, realpath } from 'node:fs/promises';
import { open, readFile, readdir, stat, lstat, realpath } from 'node:fs/promises';
import { isAbsolute, join, resolve, relative, sep } from 'node:path';
import type { JsStore } from '@animalabs/chronicle';
import type { Module, ModuleContext, ProcessState, EventResponse } from '../../types/module.js';
Expand Down Expand Up @@ -40,7 +40,7 @@ import { MountWatcher, type FsChange } from './watcher.js';
import { hashContent, DEFAULT_MAX_FILE_SIZE } from './sync.js';
import { DiskAgreement, sameRootIdentity } from './disk-agreement.js';
import { BranchIntents } from './branch-intent.js';
import { IGNORED, OTHER_MOUNT, filesystemTrustsCtime, inOtherMount, locateRoot, passedOverAt, readPrefix } from './observe.js';
import { IGNORED, OTHER_MOUNT, checkDir, filesystemTrustsCtime, inOtherMount, locateRoot, passedOverAt, readPrefix } from './observe.js';
import { TOOL_BATCH_HOOK_TIMEOUT_MS, ensureStateRegistered } from '../../module-registry.js';
import {
type MountRuntime,
Expand Down Expand Up @@ -853,6 +853,33 @@ export class WorkspaceModule implements Module {
};
}

/**
* Where a search's walk starts: `base`, its path as given, then each
* segment of `beneath` while disk lists it, in the directory reached so
* far, as a directory with exactly that spelling. The walk names what it
* finds from its start's spelling, and a case-insensitive disk would let
* it go on under a spelling disk doesn't list, taking files in under that
* spelling beside their own. So the start stops before a segment spelled
* any other way, not there, or not a directory, and before a link, which
* a walk of the whole mount doesn't go through either. It lists only
* directories it has checked lie inside the mount, and none the mount
* passes over by design, which the walk then answers for.
*/
private async walkStart(mount: MountState, base: string, beneath: string): Promise<string> {
if (beneath === '') return base;
const { view, rootReal } = await this.runtime(mount);
let start = base;
for (const segment of beneath.split('/')) {
if (start !== '' && passedOverAt(start, view.ignore, view.others) !== undefined) break;
const here = await checkDir(view, rootReal, start);
if (here.kind !== 'checked') break;
const entry = (await readdir(here.dir.real, { withFileTypes: true }).catch(() => [])).find((e) => e.name === segment);
if (entry === undefined || !entry.isDirectory()) break;
start = start ? `${start}/${segment}` : segment;
}
return start;
}

/** One disk→store pass; the caller holds the mount's turn. Emits its events. */
private async passUnlocked(mount: MountState, scope: Scope, opts: PassOptions = {}): Promise<PassResult> {
const result = await reconcilePass(this.getStore(), this.agreementOrThrow(), await this.runtime(mount), scope, opts);
Expand Down Expand Up @@ -2680,9 +2707,14 @@ export class WorkspaceModule implements Module {
? [this.parsePath(input.path)]
: [...this.mounts.values()].map(m => ({ mount: m, relativePath: '' }));

// Every match lies beneath the pattern's literal directory, so the walk
// starts there: past the walk's cap, a walk of the whole scope can stop
// before it gets there.
const patternDir = literalDirOf(input.pattern);
for (const { mount, relativePath } of mountsToSearch) {
const start = await this.walkStart(mount, relativePath, patternDir);
const pass = await this.withMount(mount, () =>
this.passUnlocked(mount, { kind: 'dir', dir: relativePath, recursive: true }));
this.passUnlocked(mount, { kind: 'dir', dir: start, recursive: true }));
for (const report of [...pass.reports.values()].sort((a, b) => a.path.localeCompare(b.path))) {
const testPath = relativePath ? report.path.slice(relativePath.length + 1) : report.path;
if (regex.test(testPath)) {
Expand Down Expand Up @@ -2716,6 +2748,10 @@ export class WorkspaceModule implements Module {
}

const fileGlob = input.glob ? globToRegex(input.glob) : null;
// The filter matches mount-relative paths, so every file it lets through
// lies beneath its literal directory, and the walk starts at the deeper of
// that and `path`.
const filterDir = input.glob ? literalDirOf(input.glob) : '';
const contextBefore = input.contextBefore ?? 0;
const contextAfter = input.contextAfter ?? 0;

Expand Down Expand Up @@ -2753,6 +2789,11 @@ export class WorkspaceModule implements Module {
incomplete.push({ path: `${mount.config.name}/${relativePath}`, reason: this.otherMountNote(mount.config.name, relativePath, 'list') });
continue;
}
// A `path` and a filter whose directories don't overlap leave no file
// the filter can let through, so nothing is searched.
const deeper = deeperOf(relativePath, filterDir);
if (deeper === null) continue;
const start = await this.walkStart(mount, relativePath, deeper === relativePath ? '' : deeper.slice(relativePath ? relativePath.length + 1 : 0));
await this.withMount(mount, async () => {
// A `path` naming a single file greps just that file; otherwise it is a
// directory prefix. (A file path once produced the prefix "notes.md/"
Expand All @@ -2770,7 +2811,7 @@ export class WorkspaceModule implements Module {
for (let attempt = 0; attempt < 3 && pass === null; attempt++) {
const next = await this.passUnlocked(mount, fileScope
? { kind: 'paths', paths: [relativePath] }
: { kind: 'dir', dir: relativePath, recursive: true });
: { kind: 'dir', dir: start, recursive: true });
if (next.branchId === undefined || next.branchId === store.currentBranch().id) pass = next;
}
if (pass === null) {
Expand Down Expand Up @@ -3401,10 +3442,10 @@ export class WorkspaceModule implements Module {
const scan: AgentActionScan = { late: false, settled: Promise.resolve() };
const pass = this.withMount(mount, () => {
if (mount.agentActionScanWaiting === scan) mount.agentActionScanWaiting = undefined;
// From the mount's start, as a listing walks, keeping no place: on a
// mount over its walkCap, every batch's scan takes the first walkCap
// entries in the walk's order, and the place that syncs and catch-up
// scans resume from stays theirs.
// From the mount's root, keeping no place, as a listing keeps none:
// on a mount over its walkCap, every batch's scan takes the first
// walkCap entries in the walk's order, and the place that syncs and
// catch-up scans resume from stays theirs.
return this.passUnlocked(mount, { kind: 'dir', dir: '', recursive: true });
});
scan.settled = pass.then(
Expand Down Expand Up @@ -3649,6 +3690,31 @@ function globToRegex(pattern: string): RegExp {
return new RegExp(`^${regex}$`);
}

/**
* The directory a glob pattern names before its first wildcard, beneath
* which every path it matches lies: its leading segments that hold none of
* globToRegex's wildcards (`*`, `?`, `{`), short of the last segment, which
* names the files. '' when the pattern starts with a wildcard or has a
* single segment. A segment that's empty, `.` or `..`, which no path
* matched holds, is one no directory listing holds either, so walkStart
* stops before it.
*/
function literalDirOf(pattern: string): string {
const dir: string[] = [];
for (const segment of pattern.split('/').slice(0, -1)) {
if (/[*?{]/.test(segment)) break;
dir.push(segment);
}
return dir.join('/');
}

/** The deeper of two mount-relative directories, '' being the root, or null when neither holds the other. */
function deeperOf(a: string, b: string): string | null {
if (a === '' || b === a || b.startsWith(a + '/')) return b;
if (b === '' || a.startsWith(b + '/')) return a;
return null;
}

/** A glob's refusal of a brace form it can't read, with the way to match a literal "{". */
function braceRefusal(pattern: string, problem: string): string {
return `glob pattern ${JSON.stringify(pattern)} ${problem}. To match a "{" in a name, put "?" in its place.`;
Expand Down
15 changes: 11 additions & 4 deletions src/modules/workspace/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,9 +63,13 @@ export interface MountConfig {
* kept with the mount's disk evidence, so a restart resumes there, unless
* the machine went down before the store made the latest place durable.
* An agent-action scan (`watch: 'on-agent-action'`) and a listing (`ls`,
* `glob`, `grep`) take up to it from their scope's start every time. Each
* directory's listing is read whole, so a deletion is seen wherever the
* walk lists, whatever it took.
* `glob`, `grep`) take up to it from their scope's start every time:
* `glob`'s is the directory its pattern's segments before the first
* wildcard name beneath its `path`, and `grep`'s the deeper of its `path`
* and its `glob` filter's directory, as far as disk lists each segment
* with that spelling as a directory, not a link. Each directory's listing
* is read whole, so a deletion is seen wherever the walk lists, whatever
* it took.
*
* It bounds what a walk observes, not the walk's own work: directories
* don't count against it, and each one the walk visits is read whole, so
Expand Down Expand Up @@ -143,7 +147,10 @@ export interface MountState {
lastMaterializedSeq: number;
/** Paths currently suppressed from watcher (recently materialized) */
suppressedPaths: Set<string>;
/** Whether a full scan of the mount has completed this session */
/**
* Whether a pass over the whole mount has run this session. On a mount
* over its walkCap, that pass took the first walkCap entries.
*/
initialSyncDone: boolean;
/** Branch ID that was active when this mount last materialized */
lastMaterializedBranchId: string | null;
Expand Down
Loading
Loading