What happens when an argument is wrong¶
Detect validates nothing. It resolves a path, then issues Stat calls until one
succeeds or it runs out of places to look. That makes several wrong-looking inputs
legal, and each one has a defined outcome. They are listed here so you do not have to
find out by experiment.
Every behaviour below was checked against the current implementation of Detect in
workspace.go.
What happens if the markers slice is empty or nil?¶
The walk climbs until it reaches the filesystem root or spends the depth budget,
matches nothing because there is nothing to match, and returns ErrNotFound. It is not
an error and it is not rejected: you pay one loop iteration per directory and zero
Stat calls for a result that could never have been anything else.
If your marker list is built at runtime, check it is non-empty yourself.
What happens if a marker is the empty string?¶
It matches the directory being inspected, immediately. filepath.Join(dir, "") is
dir, and Stat on a directory that exists succeeds, so detection returns at once
with Root set to the start directory and Marker set to "".
An empty string that slips into a marker list — from a trailing comma in config, a
blank line in a file, a strings.Split on an empty value — silently turns "find the
project root" into "return wherever I started". Filter blanks out of any marker list
you did not write by hand.
What happens if WithMaxDepth gets a negative number?¶
Nothing is inspected at all and you get ErrNotFound, even when the start directory
itself holds a marker.
The loop is for depth := 0; depth <= cfg.maxDepth; depth++, so any negative value
fails the condition on the first evaluation and the body never runs. There is no
clamping and no error: WithMaxDepth(-1) is a silent no-op search. If the depth comes
from a flag or a config key, clamp it to zero before passing it in.
What happens if the start directory does not exist?¶
The walk proceeds anyway and can succeed. Nothing stats startDir itself — only
startDir joined with each marker — so a path that is not there simply matches
nothing, and the climb moves to the parent.
// only /p/go.mod exists; /p/does/not/exist does not
ws, _ := workspace.Detect(fs, "/p/does/not/exist", workspace.DefaultMarkers)
// ws.Root == "/p", ws.Marker == "go.mod"
So a typo in a --dir flag does not produce "no such directory". It produces the root
of whichever real ancestor happens to carry a marker, which may be a different project
from the one the user meant. Stat the directory yourself if you need to reject the
typo.
What happens if the start directory is relative, or empty?¶
It is resolved with filepath.Abs, which joins it onto the process working
directory from os.Getwd. The empty string resolves to the working directory itself.
The filesystem you passed in has no say in this. Under afero.NewMemMapFs() a
relative start path still resolves against the real process working directory, and the
walk then looks for that path inside the in-memory tree. Pass absolute paths in tests
and the ambiguity disappears.
What happens if a marker path contains ..?¶
It is joined and cleaned like any other path, so it escapes upwards and matches
outside the directory being inspected. Root is then not the directory containing
the marker — it is the directory the walk was inspecting when the match happened.
// /a/marker exists; the walk starts at /a/b
ws, _ := workspace.Detect(fs, "/a/b", []string{"../marker"})
// ws.Root == "/a/b", ws.Marker == "../marker"
If Root is going to be handed to os.Chdir, a template, or a shell command, do not
accept marker patterns from untrusted input.
What happens if a marker path is absolute?¶
filepath.Join treats it as relative regardless, so "/etc/passwd" joined onto
/a/b is /a/b/etc/passwd. The only level at which an absolute-looking marker can
match is the filesystem root, where the join is a no-op — at which point Root is
"/".
What happens if the marker is a directory rather than a file?¶
It matches. The check is fs.Stat, which succeeds for anything that exists, and the
result is not inspected for mode. This is deliberate — .git in DefaultMarkers is a
directory in every normal clone — but it also means a directory named go.mod is a
Go module root as far as this package is concerned.
What happens if Stat fails for a reason other than "missing"?¶
It counts as "no marker here" and the walk carries on. The code tests if _, statErr
:= fs.Stat(path); statErr == nil, so a permission error on an unreadable parent
directory is indistinguishable from an absent marker, and the walk climbs past it
rather than reporting it. A run as an unprivileged user in a tree it cannot traverse
returns ErrNotFound, not EACCES.
What happens if two markers exist at the same level?¶
The earlier entry in your slice wins. That is the only thing marker order decides — across directory levels the nearest match wins regardless of order. See the marker walk.
What happens if the same option is passed twice?¶
The last one wins. Options are applied in argument order onto a single config value,
and WithMaxDepth assigns rather than accumulates, so
Detect(fs, dir, m, WithMaxDepth(5), WithMaxDepth(2)) walks two parent levels.
What happens if two goroutines detect at once?¶
Nothing shared is written. Detect builds its config on the stack and holds no
package state, so concurrent calls are safe as long as the afero.Fs you hand them is
safe for concurrent use — afero.NewOsFs() and afero.NewMemMapFs() both are.
The exception is DefaultMarkers, which is an exported mutable slice. One goroutine
assigning to an element of it while another reads is a data race. Treat it as
read-only.