workspace

package module
v0.2.1 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 5, 2026 License: MIT Imports: 4 Imported by: 0

README

workspace

Find a project's root by walking up from a starting directory to a marker file — over an injected afero filesystem, so it stays fully testable

Go Reference Pipeline Coverage phpboyscout Go toolkit

Part of the phpboyscout Go toolkit — small, framework-free Go modules extracted from go-tool-base. Docs: workspace.go.phpboyscout.uk


Most CLI tools need to answer one question before they do anything else: where is the project root? workspace answers it the way git and go do — walk up from a starting directory until a marker file appears.

ws, err := workspace.DetectFromCWD(afero.NewOsFs(), workspace.DefaultMarkers)
if err != nil {
    return errors.Wrap(err, "not inside a project")
}
fmt.Println("Project root:", ws.Root)   // e.g. /home/user/project
fmt.Println("Detected via:", ws.Marker) // e.g. go.mod

Design

  • Filesystem is injected. Both Detect and DetectFromCWD take an afero.Fs, so the walk is exercised entirely in memory in tests — no temp directories, no touching the real disk. Pass afero.NewOsFs() in production.
  • Markers are ordered; first match wins. DefaultMarkers is {".gtb/manifest.yaml", "go.mod", ".git"} — a generated-project manifest outranks the Go module root, which outranks the git root. Supply your own for any ecosystem (package.json, Cargo.toml, pyproject.toml, …).
  • Bounded walk. The search climbs at most DefaultMaxDepth (100) parent directories before giving up with ErrNotFound, so a stray start path can never scan unboundedly. Override with WithMaxDepth.
  • No framework weight. The whole dependency graph is afero, cockroachdb/errors, and the standard library. A depfootprint_test.go guard keeps it that way.

What it does not do

  • It returns the nearest boundary, not the outermost. Inside a monorepo package with its own go.mod, DefaultMarkers gives you the package, not the repository.
  • It does not resolve symlinks. The climb is lexical, so a symlinked start directory ascends the parents of the link, not of its target.
  • It never opens the marker. Stat succeeding is the whole test — a directory named go.mod counts.
  • Every failed search is the same ErrNotFound. Depth exhausted, filesystem root reached, empty marker list, start directory that does not exist — one error, no detail.

Full list, with the reasoning: what workspace does not do.

Install

go get gitlab.com/phpboyscout/go/workspace

Custom markers

// Detect from an explicit directory with an ecosystem-specific marker set.
ws, err := workspace.Detect(fs, startDir, []string{"pyproject.toml", "setup.py"})

Documentation

Full guides: workspace.go.phpboyscout.uk.

  • Getting started — a working program in ten minutes.
  • Reference — every exported symbol, every default, and what happens when an argument is wrong.
  • The marker walk — how precedence and the depth bound actually behave.

Godoc signatures: pkg.go.dev.

License

See LICENSE.

Documentation

Overview

Package workspace provides project root detection by walking up from a starting directory to find marker files (.gtb/manifest.yaml, go.mod, .git).

This is a utility package — it has no integration with Props or the command lifecycle. Tool authors use it to scope commands to the current project context.

Usage

ws, err := workspace.DetectFromCWD(afero.NewOsFs(), workspace.DefaultMarkers)
if err != nil {
    return errors.Wrap(err, "not inside a project")
}
fmt.Println("Project root:", ws.Root)

Index

Examples

Constants

View Source
const DefaultMaxDepth = 100

DefaultMaxDepth is the maximum number of parent directories to search before giving up. Prevents runaway scanning on deeply nested paths.

Variables

View Source
var DefaultMarkers = []string{
	".gtb/manifest.yaml",
	"go.mod",
	".git",
}

DefaultMarkers is the standard set of marker files used to detect project boundaries. Checked in order — the first match wins.

View Source
var ErrNotFound = errors.NewSentinel("workspace.not_found", "workspace not found: no marker file detected")

ErrNotFound is returned when no marker file is found before reaching the filesystem root or the max depth.

Functions

This section is empty.

Types

type Option

type Option func(*detectConfig)

Option configures the Detect function.

func WithMaxDepth

func WithMaxDepth(depth int) Option

WithMaxDepth sets the maximum number of parent directories to ascend while searching. The start directory is always checked, so depth counts parent levels only: WithMaxDepth(0) checks the start directory alone without ascending, WithMaxDepth(1) also checks its immediate parent, and so on. Default: DefaultMaxDepth (100).

type Workspace

type Workspace struct {
	// Root is the absolute path to the project root directory.
	Root string
	// Marker is the marker file or directory that was found
	// (e.g. ".gtb/manifest.yaml", "go.mod", ".git").
	Marker string
}

Workspace represents a detected project boundary.

func Detect

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

Detect walks up from startDir looking for any of the given marker files. Returns the first match. Returns ErrNotFound if no marker is found before reaching the filesystem root or the max depth.

Markers are checked in order at each directory level — the first match wins. This means ".gtb/manifest.yaml" takes precedence over "go.mod" when using DefaultMarkers.

Example
package main

import (
	"fmt"

	"github.com/spf13/afero"

	"gitlab.com/phpboyscout/go/workspace"
)

func main() {
	fs := afero.NewMemMapFs()

	// Create a project with a go.mod
	_ = afero.WriteFile(fs, "/home/user/project/go.mod", []byte("module example"), 0o644)
	_ = fs.MkdirAll("/home/user/project/pkg/cmd", 0o755)

	// Detect from a nested directory
	ws, err := workspace.Detect(fs, "/home/user/project/pkg/cmd", workspace.DefaultMarkers)
	if err != nil {
		fmt.Println("Error:", err)
		return
	}

	fmt.Println("Root:", ws.Root)
	fmt.Println("Marker:", ws.Marker)
}
Output:
Root: /home/user/project
Marker: go.mod
Example (CustomMarkers)
package main

import (
	"fmt"

	"github.com/spf13/afero"

	"gitlab.com/phpboyscout/go/workspace"
)

func main() {
	fs := afero.NewMemMapFs()

	_ = afero.WriteFile(fs, "/project/package.json", []byte("{}"), 0o644)
	_ = fs.MkdirAll("/project/src/components", 0o755)

	ws, err := workspace.Detect(fs, "/project/src/components", []string{"package.json"})
	if err != nil {
		return
	}

	fmt.Println("Root:", ws.Root)
}
Output:
Root: /project

func DetectFromCWD

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

DetectFromCWD is a convenience that calls Detect starting from the current working directory.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL