Skip to content

Find the repository root, not the nearest module

Detect returns the nearest enclosing boundary and stops. In a monorepo that is usually not the answer you want: a tool writing a lockfile, resolving a shared config or shelling out to git normally wants the repository, not the package it happens to have been run inside.

There is no option for "keep climbing". These are the two ways to get the outer boundary.

Ask for the boundary you actually want

The simplest fix is usually the marker set. If what you mean is "the git repository", say so and drop the module markers entirely:

ws, err := workspace.Detect(fs, startDir, []string{".git"})

Given a repository at /repo with .git, and a module at /repo/service with go.mod, detecting from /repo/service/internal:

  • with workspace.DefaultMarkersRoot: "/repo/service", Marker: "go.mod"
  • with []string{".git"}Root: "/repo", Marker: ".git"

Do this whenever the two boundaries are different kinds of thing. It is one call, it cannot climb past what you asked for, and the marker in the result says which kind of boundary you got.

Climb again from the parent when the markers are the same

When the outer and inner boundaries share a marker — nested Go modules, nested package.json workspaces — you cannot separate them by marker set. Detect once, then detect again starting one level above the result, and keep going until it stops finding anything:

// Outermost returns the highest enclosing boundary rather than the nearest.
func Outermost(fs afero.Fs, startDir string, markers []string) (*workspace.Workspace, error) {
    ws, err := workspace.Detect(fs, startDir, markers)
    if err != nil {
        return nil, err
    }

    for {
        parent := filepath.Dir(ws.Root)
        if parent == ws.Root {
            return ws, nil // reached the filesystem root
        }

        outer, err := workspace.Detect(fs, parent, markers)
        if errors.Is(err, workspace.ErrNotFound) {
            return ws, nil // nothing above it
        }
        if err != nil {
            return nil, err
        }

        ws = outer
    }
}

The parent == ws.Root check is what stops the loop at /. Without it the last iteration detects from / forever if a marker happens to live there.

Cost is one full walk per boundary found, so on a two-level layout it is two walks. Bound it with WithMaxDepth if the start path might be pathological.

Do not try to infer the outer root from the inner one

Trimming path segments off ws.Root and guessing looks tempting and is wrong: it assumes a fixed depth between the two boundaries, which the next repository layout breaks. Detect again instead — a second walk costs a handful of Stat calls.