The marker walk¶
workspace implements one small algorithm: climb from a starting directory towards the
filesystem root, and at each level look for a marker file. This page explains what that
means in practice and why it behaves the way it does.
How the walk works, step by step¶
Starting at startDir (resolved to an absolute path first):
- For each marker in order, join it onto the current directory and
Statit. The first marker that exists returns aWorkspace{Root: dir, Marker: marker}. - If none matched, move to the parent directory.
- Stop when the parent equals the current directory — that is the filesystem root — or when
the depth budget is spent:
DefaultMaxDepth(100) parent levels, or whateverWithMaxDepthset instead. - If the climb ends with no match, return
ErrNotFound.
Nothing else happens: no reading of file contents, no globbing, no directory listing. The
ascent is purely lexical — step 2 is filepath.Dir on a cleaned path, so a symlinked start
directory climbs the parents of the link rather than of its target. Stat still follows a
symlinked marker, because that is what Stat does. See
what workspace does not do.
Why precedence is per-directory, not per-marker¶
Markers are checked in order within each directory before moving up. This matters when two markers live at different levels. Consider a Go module nested inside a git repository:
Detecting from /repo/service/internal with DefaultMarkers returns /repo/service
(via go.mod), not /repo (via .git), because the walk reaches service first and
go.mod matches there. The nearest boundary wins. Marker order only decides ties when
several markers exist in the same directory — which is why .gtb/manifest.yaml is listed
before go.mod: a generated project sitting on top of a module resolves to the manifest.
Why the filesystem is a parameter¶
The walk Stats paths on an injected afero.Fs rather than
calling os.Stat directly. That keeps the algorithm pure and testable: a whole project tree
can be built in an afero.NewMemMapFs() and the walk exercised with no disk I/O. The package
never constructs an OS filesystem of its own — production callers pass afero.NewOsFs().
DetectFromCWD is the one concession to ambient state: it reads the process working directory
via os.Getwd() to seed the start path, then delegates to Detect on the filesystem you gave
it. When you want a walk that depends on nothing ambient, use Detect with an explicit
startDir.
Why the depth bound exists¶
Absent a marker, the climb would run all the way to the filesystem root — usually cheap, but a
malformed or surprising start path (a very deep tree, an unexpected mount) could turn it into a
lot of wasted Stat calls. DefaultMaxDepth caps the climb at 100 parents; WithMaxDepth
tightens or loosens it. Reaching the bound is reported the same way as reaching the root with
no match: ErrNotFound.
Why a failed search has only one error¶
Every unsuccessful search returns the same ErrNotFound, with no indication of how far the
walk got or which stopping condition ended it. That is a deliberate trade, and it is worth
knowing which side of it you are on.
The argument for collapsing them: a caller cannot act differently on the distinction. "You ran out of depth" and "there is no project above you" both mean this is not a project directory, and both produce the same advice for a user. Carrying a richer error would put a structured type in the public API of a module whose entire point is to be small, and every consumer would have to handle a case none of them branch on.
The cost is real, though, and falls on diagnosis rather than control flow. A start directory
that does not exist, a marker slice that arrived empty from config, and a WithMaxDepth
clamped too tight all present as the same message. When a tool built on this package reports
"not inside a project" and the user swears they are, the error will not tell you which of
those it was. Check the preconditions at the call site, where you still have them, rather
than trying to recover them from the error. Each case is listed in
what happens when an argument is wrong.
What a result means¶
A returned Workspace tells you two things: Root, the absolute directory where a marker was
found, and Marker, which marker matched. The marker is useful when your DefaultMarkers
mix boundary kinds — it lets a caller distinguish "this is a generated project"
(.gtb/manifest.yaml) from "this is just some Go module" (go.mod) without a second Stat.