Skip to content

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 "".

ws, _ := workspace.Detect(fs, "/a/b/c", []string{""})
// ws.Root == "/a/b/c", ws.Marker == ""

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.