Skip to content

Getting started

By the end of this you'll have a small Go program that prints the root of whatever project it is run inside, tells you which marker file gave that answer, and fails with a useful message when it is run somewhere that isn't a project at all.

Allow about ten minutes.

What you need first

  • Go 1.26.5 or newer — that is the go directive in this module's go.mod. On an older toolchain with the default GOTOOLCHAIN=auto, Go downloads a matching one and switches to it; with GOTOOLCHAIN=local it stops with an error instead.
  • A directory to work in. Any Go module will do — you'll run the finished program from inside one.

Install the module

go get gitlab.com/phpboyscout/go/workspace

This also pulls in afero and cockroachdb/errors. That is the whole dependency graph — there is no framework underneath.

The common case is a tool run from somewhere inside a project that wants the project root. DetectFromCWD starts the walk at the process working directory and climbs.

package main

import (
    "fmt"

    "github.com/spf13/afero"
    "gitlab.com/phpboyscout/go/workspace"
)

func main() {
    ws, err := workspace.DetectFromCWD(afero.NewOsFs(), workspace.DefaultMarkers)
    if err != nil {
        fmt.Println("not inside a project:", err)
        return
    }

    fmt.Println("Project root:", ws.Root)
    fmt.Println("Detected via:", ws.Marker)
}

afero.NewOsFs() is the real filesystem. The package never builds one for you — you pass the filesystem in, which is what makes the walk testable later.

Build it and run it from a subdirectory of a Go module:

$ cd ~/code/myproject/internal/store
$ myprogram
Project root: /home/you/code/myproject
Detected via: go.mod

Two things to notice in that output. Root is absolute, always — the start path is resolved with filepath.Abs before the walk begins. And Marker is the entry from your marker list that matched, not a full path, so you can branch on which kind of boundary you found.

Start the walk somewhere other than the working directory

When you already have a path — a flag value, a positional argument — use Detect and pass the start directory yourself. Same markers, same options.

ws, err := workspace.Detect(afero.NewOsFs(), startDir, workspace.DefaultMarkers)

A relative startDir works, but be clear about what it is relative to: it is resolved against the process working directory, not against the filesystem you passed in. That distinction does not matter here, where both are the real machine. It matters a lot in tests — see test filesystem walks in memory.

Handle "this isn't a project"

Run the program from somewhere with no marker anywhere above it — /tmp on most machines — and it prints:

$ cd /tmp && myprogram
not inside a project: workspace not found: no marker file detected

That is the package's single sentinel error, workspace.ErrNotFound. The only other error either function can return comes from os.Getwd failing, which in practice means the working directory was deleted out from under the process. Everything else arrives as this one value, so errors.Is is all you need:

ws, err := workspace.DetectFromCWD(afero.NewOsFs(), workspace.DefaultMarkers)
switch {
case errors.Is(err, workspace.ErrNotFound):
    return errors.New("run this inside a project directory")
case err != nil:
    return err
default:
    // use ws.Root
}

Worth knowing before you rely on that branch: ErrNotFound does not tell you why nothing was found. Reaching the filesystem root without a match and exhausting the depth budget produce the same error, and so does a start directory that does not exist. If you need to tell those apart, check before you call — the package will not do it for you.

Detect a project that isn't a Go module

DefaultMarkers is {".gtb/manifest.yaml", "go.mod", ".git"}, which only knows about Go. For anything else, hand Detect your own list:

ws, err := workspace.Detect(afero.NewOsFs(), startDir, []string{"package.json"})

The list is checked in order at each directory level and the first entry that exists wins, so put the most specific marker first.