4.0 KiB
Project detection
pmacs identifies project roots by walking upward from a file's
parent directory looking for a marker (Cargo.toml for Rust,
package.json for Node, .git as a generic VCS fallback, etc.).
The walk stops at the first match, with language-specific markers
preferred over generic VCS roots when both exist at the same level.
The default behavior matches git rev-parse --show-toplevel: walk
all the way to the filesystem root.
When the default surprises you
A file under /tmp/scratch.rs will be classified as part of a
project rooted at /tmp if /tmp/.git exists — because the walk
finds the marker before hitting the filesystem root. This is the
same surprise git, cargo, and other tools produce; it's
predictable but occasionally inconvenient.
The escape hatch is pmacs.project.set_search_boundary(path). Set
this in your init.lua to clamp the upward walk so a stray marker
high in the tree cannot capture unrelated files.
-- Restrict project detection to walk only within ~/code.
-- Files outside ~/code will not have a project root detected.
pmacs.project.set_search_boundary(os.getenv("HOME") .. "/code")
The boundary is inclusive: a marker located at the boundary path
itself is still found. Set the boundary to the directory that
contains your projects, not to one level above. To restore the
default behavior (walk all the way to the filesystem root), pass
nil:
pmacs.project.set_search_boundary(nil)
Symlinks
The boundary applies after symlink resolution. When the
boundary is /home/user/code and a search starts from a symlinked
path that resolves into /home/user/code/..., the walk respects
the boundary correctly.
This matters for two common setups:
- Corporate
/homemounts, where/home/usermay be a symlink to/var/empire/users/useror similar — the boundary you set against your visible home path still works. - User-organized symlink farms (e.g.,
~/work/foolinked to~/code/foo) — search from the symlinked path still terminates at the boundary you set against the canonical location.
If the boundary path or the search start does not exist on disk, canonicalization falls through to the literal path; the comparison becomes lexical. This affects pre-creation tests but not normal operation.
API summary
pmacs.project.set_search_boundary(path) -- set, or nil to clear
pmacs.project.search_boundary() -- current value, or nil
pmacs.project.detect(file_path) -- honors the boundary
pmacs.project.detect(file_path) returns
{ root, kind, language_id } for the detected project, or nil if
no marker matches before the boundary (or the filesystem root, when
no boundary is set).
Design notes
We considered several alternatives to the unbounded walk, and chose the opt-in boundary as the most predictable:
- Hard-coded stops at
$HOME//tmp/ mount points break legitimate cases (someone's project lives under/srv/work, someone's$HOMEis/var/jeansover SSH, etc.). - Ownership-based stops ("walk while same uid as the start
file") break shared-dev setups and read-only repo mounts, and add
a
statper ancestor. - Confidence-weighted detection (heuristically score "real project-ness") sacrifices the property that makes detection useful: predictability.
Matching git's behavior keeps detection's failure mode consistent
with the rest of the user's toolchain. The boundary gives users
who care a precise, configurable opt-in without imposing a
specific policy on everyone.
Forward planning
The boundary is workspace-scoped (one boundary per Workspace
instance). When project-local init.lua lands (post-v0.1) we may
extend this to a per-project boundary, or to a stack of boundaries
that nested project loads can push and pop. The v0.1 surface is
deliberately minimal so those future extensions don't break
existing user config: setting a single workspace-wide boundary in
your global init.lua continues to do exactly what it does today.