Skip to content

Exported API

Every exported symbol in gitlab.com/phpboyscout/go/workspace, what it returns, and how it behaves at the edges.

Detect — walk up from a directory you name

func Detect(fs afero.Fs, startDir string, markers []string, opts ...Option) (*Workspace, error)

Resolves startDir with filepath.Abs, then for each directory from there upwards calls fs.Stat(filepath.Join(dir, marker)) for every entry in markers, in order. The first Stat that succeeds ends the walk and returns &Workspace{Root: dir, Marker: marker}.

The climb stops when any of these happens first:

  • a marker matched — returns the Workspace, err == nil;
  • filepath.Dir(dir) == dir, meaning the filesystem root was reached — returns nil, ErrNotFound;
  • the depth budget ran out — returns nil, ErrNotFound.

fs and markers have no defaults; both are required positional arguments. Passing a nil marker slice is legal and always yields ErrNotFound.

Two errors are possible and they are not the same:

Error When How to test
ErrNotFound No marker matched anywhere the walk looked errors.Is(err, workspace.ErrNotFound)
"resolving start directory: …" filepath.Abs failed, which only happens for a relative startDir when os.Getwd fails errors.Is against the wrapped os error

A returned *Workspace is never nil when err == nil, and is always nil when err != nil.

DetectFromCWD — walk up from the process working directory

func DetectFromCWD(fs afero.Fs, markers []string, opts ...Option) (*Workspace, error)

Calls os.Getwd() and passes the result to Detect unchanged. Everything above applies. If os.Getwd fails it returns "getting current directory: …" wrapping the os error.

The working directory comes from the operating system, not from fs. Handing this function an afero.NewMemMapFs() therefore starts the walk at the real process working directory, inside the in-memory filesystem — which is why the in-memory tests in this repository seed cwd + "/go.mod" rather than an arbitrary path.

Workspace — what a successful detection tells you

type Workspace struct {
    Root   string // absolute path to the directory where a marker matched
    Marker string // the entry from your markers slice that matched
}

Root is always absolute, because the walk begins with filepath.Abs(startDir).

Marker is the string as you supplied it, not a resolved path — for DefaultMarkers it is one of ".gtb/manifest.yaml", "go.mod" or ".git". That is what lets a caller tell "this is a generated project" from "this is some Go module" without a second Stat.

The struct has no methods, and neither field is validated after the fact. Joining them back together with filepath.Join(ws.Root, ws.Marker) reproduces the path that matched.

DefaultMarkers — the built-in Go-shaped marker set

var DefaultMarkers = []string{
    ".gtb/manifest.yaml", // GTB-generated project
    "go.mod",             // Go module root
    ".git",               // Git repository root
}

Order is significant and is precedence within a single directory: a directory holding both .gtb/manifest.yaml and go.mod resolves as the manifest. It is not precedence across directory levels — the nearest directory with any match wins, whichever marker that is. The marker walk works through why.

This is an exported var, not a constant, so it is writable by any package that imports it. Assigning to an element (DefaultMarkers[0] = "…") changes it for every caller in the process. Appending is safe today because len == cap == 3, so append allocates a new backing array rather than writing into this one — but do not rely on that; copy the slice if you intend to extend it:

markers := append(slices.Clone(workspace.DefaultMarkers), "Cargo.toml")

DefaultMaxDepth — how far the walk climbs by default

const DefaultMaxDepth = 100

The number of parent levels the walk will ascend when no WithMaxDepth option is given. The start directory is always inspected on top of that, so a default walk inspects up to 101 directories and issues up to 101 × len(markers) Stat calls before giving up.

The bound is a guard, not a tuning knob: 100 parent levels is far deeper than any real project tree, so under normal use the walk ends at the filesystem root or at a marker, never at the bound.

WithMaxDepth — tighten or loosen the climb

func WithMaxDepth(depth int) Option

depth counts parent levels only. The start directory is always checked.

Value Directories inspected
WithMaxDepth(0) the start directory alone
WithMaxDepth(1) the start directory and its parent
WithMaxDepth(n) the start directory and n parents
omitted the start directory and 100 parents
negative none — see what happens when an argument is wrong

Passing the option more than once keeps the last value; options are applied in the order given.

Running out of depth is reported as ErrNotFound, indistinguishable from reaching the filesystem root with no match.

ErrNotFound — the one sentinel

var ErrNotFound = errors.New("workspace not found: no marker file detected")

Returned unwrapped, so both errors.Is(err, workspace.ErrNotFound) and err == workspace.ErrNotFound work; prefer errors.Is. The message text is workspace not found: no marker file detected.

It is the only sentinel the package defines. It carries no detail about which directories were searched, how far the walk got, or which of the two stopping conditions ended it.

Option — the functional-option type

type Option func(*detectConfig)

detectConfig is unexported, so WithMaxDepth is the only option that can exist outside this package. Option appears in the signature of Detect and DetectFromCWD and nowhere else; you cannot write your own.