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.

The algorithm

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 DefaultMaxDepth (100) levels have been climbed.
  4. If the climb ends with no match, return ErrNotFound.

Nothing else happens: no reading of file contents, no globbing, no symlink resolution beyond what the underlying afero.Fs does for Stat.

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.

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.