diff --git a/packages/pi-fff/README.md b/packages/pi-fff/README.md index 7951206db..9375c5ab9 100644 --- a/packages/pi-fff/README.md +++ b/packages/pi-fff/README.md @@ -144,7 +144,8 @@ For persistent global configuration, create `pi-fff.json` in pi's agent director "historyDbPath": "/path/to/history", "enableFsRootScanning": false, "enableHomeDirScanning": true, - "warnOnHomeDirScan": true + "warnOnHomeDirScan": true, + "followSymlinks": true } ``` @@ -159,6 +160,7 @@ All fields are optional: | `enableFsRootScanning` | boolean | `false` | | `enableHomeDirScanning` | boolean | `true` | | `warnOnHomeDirScan` | boolean | `true` | +| `followSymlinks` | boolean | `true` | CLI flags take precedence over environment variables, which take precedence over this file. A missing file is ignored. Malformed JSON, unknown fields, and invalid values stop the extension from loading and report the file path and error. `/fff-mode` changes the current session; it does not edit this file. @@ -172,6 +174,7 @@ The file is global only. Project-level config cannot safely control tool names b - `--fff-enable-root-scan` — allow indexing when launched from `/` (also: `FFF_ENABLE_ROOT_SCAN=1` env). FFF refuses to init at the filesystem root by default. - `--fff-enable-home-scan` — index the home directory when launched from `$HOME` (also: `FFF_ENABLE_HOME_SCAN` env). Enabled by default. Disable with `--fff-enable-home-scan=false` or `FFF_ENABLE_HOME_SCAN=0` if your `$HOME` contains huge trees (toolchains, kernel sources, build outputs) that make the background index run for a long time. When launched from `$HOME` with this enabled, pi shows a warning that the whole home tree is being indexed. - `--fff-warn-home-scan` — show the warning notification when `$HOME` is indexed (also: `FFF_WARN_HOME_SCAN` env). Enabled by default. Disable with `--fff-warn-home-scan=false`, `FFF_WARN_HOME_SCAN=0`, or `"warnOnHomeDirScan": false` in `pi-fff.json`. Indexing and the footer status are unaffected. +- `--fff-follow-symlinks` — index through directory symlinks (also: `FFF_FOLLOW_SYMLINKS` env, or `"followSymlinks"` in `pi-fff.json`). Enabled by default: trees that reach their real files through links — a git worktree whose `docs/` links back to the main checkout, or a stowed dotfiles layout — would otherwise be missing from `@`-mentions and from find/grep with no visible sign. Disable with `--fff-follow-symlinks=false` or `FFF_FOLLOW_SYMLINKS=0` to keep the walk inside the real tree, which is worth doing when a linked target pulls in a large tree outside the workspace. Symlink cycles are detected and broken by the walker. ## Data diff --git a/packages/pi-fff/pi-fff.schema.json b/packages/pi-fff/pi-fff.schema.json index 56f1d25bf..5f2783a4e 100644 --- a/packages/pi-fff/pi-fff.schema.json +++ b/packages/pi-fff/pi-fff.schema.json @@ -41,6 +41,11 @@ "type": "boolean", "default": true, "description": "Shows a warning notification when the home directory is indexed." + }, + "followSymlinks": { + "type": "boolean", + "default": true, + "description": "Indexes through directory symlinks, e.g. a git worktree or stow layout whose files live behind links. Set to false to keep the walk inside the real tree." } } } diff --git a/packages/pi-fff/src/aux-finders.ts b/packages/pi-fff/src/aux-finders.ts index 45f4170b4..12e1ecbf0 100644 --- a/packages/pi-fff/src/aux-finders.ts +++ b/packages/pi-fff/src/aux-finders.ts @@ -16,6 +16,7 @@ interface AuxPicker { export interface AuxOpts { enableFsRootScanning: boolean; enableHomeDirScanning?: boolean; + followSymlinks?: boolean; pickers: FilePickerFactory; // Called before a newly spawned aux picker starts a scan that covers $HOME. onHomeDirScan?: (root: string) => void; @@ -104,6 +105,7 @@ export class AuxFinderPool { basePath: root, enableHomeDirScanning, enableFsRootScanning: this.opts.enableFsRootScanning, + followSymlinks: this.opts.followSymlinks, }); const entry: AuxPicker = { root, finder, lastUsed: Date.now() }; diff --git a/packages/pi-fff/src/config.ts b/packages/pi-fff/src/config.ts index f95d34f70..8e12f9d0f 100644 --- a/packages/pi-fff/src/config.ts +++ b/packages/pi-fff/src/config.ts @@ -15,6 +15,7 @@ export interface FffConfig { enableFsRootScanning?: boolean; enableHomeDirScanning?: boolean; warnOnHomeDirScan?: boolean; + followSymlinks?: boolean; } const CONFIG_KEYS = new Set([ @@ -25,6 +26,7 @@ const CONFIG_KEYS = new Set([ "enableFsRootScanning", "enableHomeDirScanning", "warnOnHomeDirScan", + "followSymlinks", ]); export function loadConfig(agentDir = piDataDir()): FffConfig { @@ -67,6 +69,7 @@ export function loadConfig(agentDir = piDataDir()): FffConfig { validateBoolean(configPath, parsed, "enableFsRootScanning"); validateBoolean(configPath, parsed, "enableHomeDirScanning"); validateBoolean(configPath, parsed, "warnOnHomeDirScan"); + validateBoolean(configPath, parsed, "followSymlinks"); return parsed as FffConfig; } @@ -97,7 +100,11 @@ function validateString( function validateBoolean( configPath: string, config: Record, - key: "enableFsRootScanning" | "enableHomeDirScanning" | "warnOnHomeDirScan", + key: + | "enableFsRootScanning" + | "enableHomeDirScanning" + | "warnOnHomeDirScan" + | "followSymlinks", ): void { const value = config[key]; if (value !== undefined && typeof value !== "boolean") { diff --git a/packages/pi-fff/src/file-picker.ts b/packages/pi-fff/src/file-picker.ts index 1be6681dd..d77ed027e 100644 --- a/packages/pi-fff/src/file-picker.ts +++ b/packages/pi-fff/src/file-picker.ts @@ -5,6 +5,7 @@ export interface PickerOptions { basePath: string; enableHomeDirScanning?: boolean; enableFsRootScanning?: boolean; + followSymlinks?: boolean; } /** Opens every picker in this pi process — the cwd picker and the aux pickers — diff --git a/packages/pi-fff/src/index.ts b/packages/pi-fff/src/index.ts index 5a7e64cca..542bb277b 100644 --- a/packages/pi-fff/src/index.ts +++ b/packages/pi-fff/src/index.ts @@ -345,6 +345,7 @@ export default function fffExtension(pi: ExtensionAPI) { let enableFsRootScanning = false; let enableHomeDirScanning = true; let warnOnHomeDirScan = true; + let followSymlinks = true; function setMode(mode: FffMode): void { currentMode = mode; @@ -394,6 +395,15 @@ export default function fffExtension(pi: ExtensionAPI) { true, parseBoolean, ); + // On by default: worktree and stow layouts reach their files through links, + // and an agent silently missing them is worse than the extra walk. + followSymlinks = getConfigValue( + "fff-follow-symlinks", + "FFF_FOLLOW_SYMLINKS", + config.followSymlinks, + true, + parseBoolean, + ); } function getMode(): FffMode { @@ -440,6 +450,7 @@ export default function fffExtension(pi: ExtensionAPI) { auxPool = new AuxFinderPool({ enableFsRootScanning, enableHomeDirScanning, + followSymlinks, onHomeDirScan: warnHomeDirScan, pickers, }); @@ -466,6 +477,7 @@ export default function fffExtension(pi: ExtensionAPI) { basePath: cwd, enableHomeDirScanning, enableFsRootScanning, + followSymlinks, }); finderCwd = cwd; return mainFinder; @@ -677,6 +689,12 @@ export default function fffExtension(pi: ExtensionAPI) { type: "boolean", }); + pi.registerFlag("fff-follow-symlinks", { + description: + "Index through directory symlinks, e.g. a git worktree or stow layout (default true; disable with --fff-follow-symlinks=false or FFF_FOLLOW_SYMLINKS=0)", + type: "boolean", + }); + pi.registerFlag("fff-warn-home-scan", { description: "Warn when indexing $HOME (default true; silence with --fff-warn-home-scan=false or FFF_WARN_HOME_SCAN=0)", diff --git a/packages/pi-fff/test/aux-pool.test.ts b/packages/pi-fff/test/aux-pool.test.ts index 86aa56130..5d715367f 100644 --- a/packages/pi-fff/test/aux-pool.test.ts +++ b/packages/pi-fff/test/aux-pool.test.ts @@ -84,6 +84,13 @@ describe("AuxFinderPool covering reuse", () => { expect(created.length).toBe(1); }); + test("passes followSymlinks through to the aux picker", async () => { + const pool = makePool({ followSymlinks: true }); + await pool.acquire("/a/b/c"); + + expect(createOptions[0]?.followSymlinks).toBe(true); + }); + test("does not reuse a picker rooted deeper than the requested path", async () => { const pool = makePool(); await pool.acquire("/a/b/c"); diff --git a/packages/pi-fff/test/config.test.ts b/packages/pi-fff/test/config.test.ts index 85ade4f84..e21f3442a 100644 --- a/packages/pi-fff/test/config.test.ts +++ b/packages/pi-fff/test/config.test.ts @@ -32,6 +32,7 @@ describe("loadConfig", () => { enableFsRootScanning: true, enableHomeDirScanning: false, warnOnHomeDirScan: false, + followSymlinks: true, }; writeConfig(config); @@ -68,6 +69,7 @@ describe("loadConfig", () => { [{ enableFsRootScanning: 1 }, '"enableFsRootScanning" must be a boolean'], [{ enableHomeDirScanning: "false" }, '"enableHomeDirScanning" must be a boolean'], [{ warnOnHomeDirScan: "false" }, '"warnOnHomeDirScan" must be a boolean'], + [{ followSymlinks: "true" }, '"followSymlinks" must be a boolean'], ]; for (const [config, message] of cases) { diff --git a/packages/pi-fff/test/extension.test.ts b/packages/pi-fff/test/extension.test.ts index c6820f207..b41fafa29 100644 --- a/packages/pi-fff/test/extension.test.ts +++ b/packages/pi-fff/test/extension.test.ts @@ -191,6 +191,7 @@ const CONFIG_ENV_KEYS = [ "FFF_ENABLE_ROOT_SCAN", "FFF_ENABLE_HOME_SCAN", "FFF_WARN_HOME_SCAN", + "FFF_FOLLOW_SYMLINKS", ] as const; const savedEnv: Record = {}; @@ -227,6 +228,7 @@ describe("pi-fff global config", () => { historyDbPath: "/config/history", enableFsRootScanning: true, enableHomeDirScanning: false, + followSymlinks: false, }); const setup = await start(); @@ -242,6 +244,7 @@ describe("pi-fff global config", () => { aiMode: true, enableHomeDirScanning: false, enableFsRootScanning: true, + followSymlinks: false, }); await shutdown(setup); }); @@ -259,10 +262,12 @@ describe("pi-fff global config", () => { process.env.FFF_HISTORY_DB = "/env/history"; process.env.FFF_ENABLE_ROOT_SCAN = "1"; process.env.FFF_ENABLE_HOME_SCAN = "1"; + process.env.FFF_FOLLOW_SYMLINKS = "1"; const setup = await start("tools-and-ui", undefined, { "fff-frecency-db": "/flag/frecency", "fff-enable-root-scan": false, + "fff-follow-symlinks": false, }); const toolNames = setup.pi.registerTool.mock.calls.map(([tool]) => tool.name); @@ -275,10 +280,22 @@ describe("pi-fff global config", () => { aiMode: true, enableHomeDirScanning: true, enableFsRootScanning: false, + followSymlinks: false, }); await shutdown(setup); }); + // #627: worktree and stow layouts reach their files through symlinks, so following + // them is the default and the environment is the way out. + test("stops following symlinks when the environment disables them", async () => { + process.env.FFF_FOLLOW_SYMLINKS = "0"; + + const setup = await start(); + + expect((createCalls[0] as { followSymlinks: boolean }).followSymlinks).toBe(false); + await shutdown(setup); + }); + test("falls through invalid flag and environment modes", async () => { writeConfig({ mode: "override" }); process.env.PI_FFF_MODE = "invalid-env-mode"; @@ -476,6 +493,7 @@ describe("pi-fff autocomplete registration", () => { aiMode: true, enableHomeDirScanning: true, enableFsRootScanning: false, + followSymlinks: true, }, ]); });