drive workspace ignore from ignore_files:, add files.walk

Replace the hardcoded .gitignore + .clideignore read with the ordered
ignore_files: chain from .pql/config.yaml (D-4) — the single ignore
knob clide owns (D-3). readIgnoreFiles defaults to .gitignore (plus
.clideignore when present) when the config is absent or malformed, and
honours an explicit [] as "no file-based exclusions".

Add walkFiles + the files.walk command: a recursive, ignore-pruned,
capped flat file listing reused by quick-open (T-51) and the search
engine (T-52). Closes the never-filed ignore-layering placeholder in
files_commands.dart.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-31 20:08:34 +02:00
co-authored by Claude Opus 4.8
parent 7e76455775
commit d7be5535d5
7 changed files with 267 additions and 7 deletions
+18 -7
View File
@@ -7,6 +7,7 @@ import 'dart:io';
import '../files/ignore.dart';
import '../files/listing.dart';
import '../files/path_safety.dart';
import '../files/pql_config.dart';
import '../files/watcher.dart';
import '../ipc/envelope.dart';
import '../ipc/schema_v1.dart';
@@ -130,6 +131,17 @@ void registerFilesCommands(DaemonDispatcher d, FilesService files) {
);
});
d.register('files.walk', (req) async {
final result = await walkFiles(root: files.root, ignore: files.ignore);
return IpcResponse.ok(
id: req.id,
data: {
'files': [for (final e in result.files) e.path],
'truncated': result.truncated,
},
);
});
d.register('files.watch', (req) async {
await files.startWatching();
return IpcResponse.ok(
@@ -153,15 +165,14 @@ Directory _resolveWorkspaceRoot(Directory start) {
return start.absolute;
}
/// Build the default IgnoreSet: clide's always-hide list + any
/// `.gitignore` + `.clideignore` at the workspace root.
///
/// D-004 compliance note: this covers the single-file-at-root case.
/// Full layering across arbitrary paths from `.pql/config.yaml`'s
/// `ignore_files:` is future work — see Q-024 (to be recorded).
/// Build the default IgnoreSet: clide's always-hide list layered under
/// the `ignore_files:` chain from `.pql/config.yaml` (per D-4), in
/// order, later files winning. clide owns that config key (D-3) and
/// [readIgnoreFiles] resolves it (defaulting to `.gitignore` —
/// plus `.clideignore` when present — when the config is absent).
IgnoreSet _defaultIgnore(Directory root) {
final contents = <String>[];
for (final name in ['.gitignore', '.clideignore']) {
for (final name in readIgnoreFiles(root)) {
final f = File('${root.path}/$name');
if (f.existsSync()) contents.add(f.readAsStringSync());
}
+51
View File
@@ -73,3 +73,54 @@ Future<List<FileEntry>> listDir({
});
return entries;
}
/// Result of [walkFiles]: the flat file list plus whether the walk
/// stopped early at [WalkResult.cap].
class WalkResult {
const WalkResult({required this.files, required this.truncated});
/// Every non-ignored file under the root, repo-relative, sorted by path.
final List<FileEntry> files;
/// True when the [maxFiles] cap was reached and the walk stopped early.
final bool truncated;
}
/// Recursively walk [root], returning every non-ignored *file*
/// (directories are descended into but not emitted), pruned by
/// [ignore]. Reuses [listDir] per directory, so ignore filtering,
/// symlink-escape safety (`followLinks: false`), and per-directory
/// sorting are inherited.
///
/// Capped at [maxFiles] to bound work on pathological trees; when the
/// cap is hit the walk stops early and [WalkResult.truncated] is set so
/// callers can surface "results truncated". The returned list is sorted
/// by repo-relative path for a deterministic contract.
Future<WalkResult> walkFiles({
required Directory root,
required IgnoreSet ignore,
int maxFiles = 50000,
}) async {
final out = <FileEntry>[];
// DFS over repo-relative directory paths; '' is the root itself.
final stack = <String>[''];
var truncated = false;
while (stack.isNotEmpty) {
final dir = stack.removeLast();
final entries = await listDir(root: root, dir: dir, ignore: ignore);
for (final e in entries) {
if (e.isDirectory) {
stack.add(e.path);
} else {
out.add(e);
if (out.length >= maxFiles) {
truncated = true;
break;
}
}
}
if (truncated) break;
}
out.sort((a, b) => a.path.compareTo(b.path));
return WalkResult(files: out, truncated: truncated);
}
+54
View File
@@ -0,0 +1,54 @@
/// Reads the clide-owned keys from `.pql/config.yaml`.
///
/// Per [D-3] clide owns pql's `ignore_files:` key (and never touches
/// pql's `.pql/` index/cache data). Per [D-4] that key is the single
/// knob for ignore layering: an ordered list of gitignore-shaped files
/// at the workspace root, with later entries winning on per-pattern
/// conflicts. This module reads the list; the matcher itself lives in
/// [IgnoreSet] (see `ignore.dart`).
///
/// Flutter-free by construction — used by daemon-side file walking and
/// the search engine, both of which run under `dart test`.
library;
import 'dart:io';
import 'package:yaml/yaml.dart';
/// The ordered list of ignore-file names to layer when walking the
/// workspace, read from `ignore_files:` in `.pql/config.yaml`.
///
/// Resolution (per D-4):
/// * config present with an explicit `ignore_files:` list → that list
/// verbatim. An empty list disables file-based exclusions entirely
/// (the built-in `.git/` etc. still apply via [IgnoreSet.builtin]).
/// * config absent / malformed / missing the key → the default
/// `[.gitignore]`, plus `.clideignore` when that file exists
/// (clide-specific deviations, per D-4).
///
/// Never throws: a missing or unparseable config falls back to the
/// default, so a broken YAML file can't silently blank the ignore set.
List<String> readIgnoreFiles(Directory root) {
final cfg = File('${root.path}/.pql/config.yaml');
if (cfg.existsSync()) {
try {
final doc = loadYaml(cfg.readAsStringSync());
if (doc is YamlMap && doc.containsKey('ignore_files')) {
final raw = doc['ignore_files'];
if (raw is YamlList) {
return [
for (final e in raw)
if (e is String) e,
];
}
}
} catch (_) {
// Malformed YAML — fall through to the default below.
}
}
final names = <String>['.gitignore'];
if (File('${root.path}/.clideignore').existsSync()) {
names.add('.clideignore');
}
return names;
}