Skip to content

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):

  1. For each marker in order, join it onto the current directory and Stat it. The first marker that exists returns a Workspace{Root: dir, Marker: marker}.
  2. If none matched, move to the parent directory.
  3. 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 whatever WithMaxDepth set instead.
  4. 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:

/repo/.git
/repo/service/go.mod   <- start below here

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.