Skip to content

What workspace does not do

This package is one loop over Stat. Most of what it does not do, it does not do on purpose — the value of a leaf module is that it stays small enough to reason about and light enough to depend on. This page states the boundaries, so you can decide what to build on top rather than discovering a gap halfway through.

If you want the mechanism rather than the boundaries, read the marker walk.

The climb is lexical. Each step is filepath.Dir on the cleaned absolute path, and nothing calls filepath.EvalSymlinks. Start the walk inside a symlinked directory and it ascends the parents of the link path, never the parents of the target.

That is not a subtle difference. Given:

/srv/checkouts/proj/go.mod
/srv/checkouts/proj/deep/
/home/you/work -> /srv/checkouts/proj/deep

detecting from /home/you/work climbs /home/you, /home, / — and returns whatever marker it meets there, or ErrNotFound. It does not find /srv/checkouts/proj. The same applies to a .. segment in the start path: filepath.Abs cleans it away lexically before the walk begins, so a/../b is b even when a is a link somewhere else entirely.

Resolve the path first if that matters to you:

start, err := filepath.EvalSymlinks(startDir)
if err != nil {
    return err
}

ws, err := workspace.Detect(fs, start, workspace.DefaultMarkers)

This is left to the caller because resolution is a policy decision, not a detail: some tools want the path the user typed to stay the path they see reported back.

It does not find the outermost boundary

Detection returns the nearest enclosing marker and stops. In a monorepo where an inner package has its own go.mod and the repository root has .git, detecting from inside that package with DefaultMarkers returns the package, not the repository.

There is no "keep going" option and no "give me all of them" call. If you need the repository root, ask for the repository root — use a marker set that only contains .git — or detect twice, once from the parent of the first result. The recipe is in find the repository root, not the nearest module.

It does not read, parse, or validate the marker

A marker matches if Stat succeeds. Nothing opens it. A zero-byte go.mod, a directory named go.mod, a symlink pointing at one — all are boundaries as far as this package is concerned. It cannot tell you the module path, the project name, or whether the manifest is well formed.

Whatever you need out of the marker file, read it yourself from filepath.Join(ws.Root, ws.Marker).

It does not distinguish "not a project" from "gave up looking"

ErrNotFound is the whole error vocabulary for a failed search. Reaching the filesystem root with no match, exhausting the depth budget, being handed an empty marker slice and being handed a start directory that does not exist all produce the same value with the same message. Nothing records how far the walk got.

For a CLI that is usually fine — "run this inside a project" is the right message for all of them. For anything that needs to distinguish them, check the preconditions before you call rather than trying to infer them from the error.

It does not cache anything

Every call walks again. There is no memo of previous results and no invalidation logic, because there is nowhere sensible to put one: the package holds no state and the correct lifetime of a cached root is a property of the calling tool, not of the detector. A default walk is at most 101 Stat calls and usually two or three, so caching rarely pays. Cache the *Workspace in your own tool if you call it in a hot loop.

It does not watch for changes

A Workspace is the answer at the moment you asked. If the marker file is deleted, or a new one appears closer to the start directory, nothing tells you. There is no watcher, no revalidation, and no IsStale. Long-running processes that care should re-detect.

It does not touch the filesystem you did not give it

There is no default afero.Fs and no fallback to os. Detect cannot run without a filesystem argument, and it will only ever Stat through the one it was handed — which is what makes the walk fully exercisable in memory.

One thing does escape that rule, and it is worth knowing: DetectFromCWD reads the process working directory through os.Getwd, and filepath.Abs does the same for a relative startDir. Those come from the operating system regardless of which afero.Fs you passed. Pass an absolute startDir to Detect for a call that depends on nothing ambient at all.

It does not respect ignore files or project configuration

There is no .gitignore handling, no .workspaceignore, no reading of a config key to decide the marker set, and no environment variable. Everything about a detection is an argument to the call. A tool that wants "the user configured a different marker list" resolves that itself and passes the result in.

It is not a CLI

There is no binary and no command. The module publishes a library and the CI pipeline builds no artefact — a consequence of the same decision, and why releases are module versions rather than downloadable binaries.

It carries no framework, and that is enforced

The whole dependency graph is afero, cockroachdb/errors and the standard library. That is not an aspiration in a README; depfootprint_test.go fails the build if go list -deps ever contains go-tool-base, Cobra, Viper, pflag, go-git, the Charm stack, OpenTelemetry or a cloud SDK.

The practical consequence is a boundary on what can ever be added here. A feature needing a config loader, a logger, a git library or a progress spinner does not belong in this module, however useful — it belongs in the tool that consumes it.