gastownhall/beads · error

workspacegate: %s is a symlink; gate the physical directory

Error message

workspacegate: %s is a symlink; gate the physical directory it points to

What it means

workspacegate refuses to guard a path that is itself a symlink, reporting that the caller should gate the physical directory it points to. Gate identity is derived from the canonical parent plus the final path component; a symlinked guarded directory has no stable identity under this scheme and could silently select a different gate once the link flips, letting two exclusive holders coexist. Refusal is deliberate: gate the real directory, not ambiguously.

Source

Thrown at internal/workspacegate/gate.go:180

func forDir(dir string) (Gate, error) {
	abs, err := filepath.Abs(dir)
	if err != nil {
		return Gate{}, fmt.Errorf("workspacegate: resolving %s: %w", dir, err)
	}
	abs = filepath.Clean(abs)

	// Gate identity must be stable across the guarded directory being
	// absent, created, replaced, or recreated — that is the point of
	// placing the gate beside it. So identity derives from the
	// canonicalized PARENT plus the literal base name, never from
	// resolving the guarded path itself: full-path resolution would
	// silently select a different gate once the directory appears as a
	// symlink, letting two exclusive holders coexist. The flip side is
	// that a guarded directory that IS a symlink has no stable identity
	// under this scheme, so it is refused outright rather than gated
	// ambiguously.
	if fi, err := os.Lstat(abs); err == nil && fi.Mode()&os.ModeSymlink != 0 {
		return Gate{}, fmt.Errorf("workspacegate: %s is a symlink; gate the physical directory it points to", abs)
	}
	parent, base := filepath.Split(abs)
	switch base {
	case "", ".", "..":
		return Gate{}, fmt.Errorf("workspacegate: cannot gate %q: the guarded path must be a named directory", dir)
	}
	canonParent, err := filepath.EvalSymlinks(filepath.Clean(parent))
	if err != nil {
		return Gate{}, fmt.Errorf("workspacegate: gate parent %s must exist: %w", parent, err)
	}
	return Gate{path: filepath.Join(canonParent, gateFileName(base))}, nil
}

// ForWorkspace returns the gate guarding a workspace's .beads directory
// (pass the .beads directory itself). The gate file is a sibling of
// .beads; bd's project gitignore management covers "*.gate.lock*".
func ForWorkspace(beadsDir string) (Gate, error) { return forDir(beadsDir) }

View on GitHub (pinned to 71377f2769)

Solutions

  1. Resolve the symlink yourself (filepath.EvalSymlinks) and gate the physical target directory instead
  2. Restructure so the working directory is the real path (e.g. cd through the resolved link)
  3. If a build/deploy tooling created the link, point the tool at the physical directory rather than the link

Example fix

// before
g, err := workspacegate.ForWorkspace("/ws/current-project")
// after
real, err := filepath.EvalSymlinks("/ws/current-project")
if err != nil { return err }
g, err := workspacegate.ForWorkspace(real)
Defensive patterns

Strategy: validation

Validate before calling

real, err := filepath.EvalSymlinks(dir)
if err != nil { return err }
if fi, err := os.Lstat(dir); err == nil && fi.Mode()&os.ModeSymlink != 0 {
    dir = real // gate the physical directory
}

Type guard

func isSymlink(p string) bool {
    fi, err := os.Lstat(p)
    return err == nil && fi.Mode()&os.ModeSymlink != 0
}

Try / catch

g, err := workspacegate.ForWorkspace(dir)
if err != nil && strings.Contains(err.Error(), "is a symlink") {
    real, rerr := filepath.EvalSymlinks(dir)
    if rerr == nil { g, err = workspacegate.ForWorkspace(real) }
}

Prevention

When it happens

Trigger: Calling ForWorkspace or ForPhysicalRoot with a path whose final component is a symlink (detected via os.Lstat), e.g. /ws/link where link -> /real/project, or a directory that was recently replaced by a symlink.

Common situations: Workspaces set up via symlinks for version switching (project -> project-v2); macOS ~/Library style links; dotfile-managed directories that symlink project folders; tmpfs shortcuts.

Related errors


AI-assisted analysis of gastownhall/beads@71377f2769 (2026-08-30). Data as JSON: /api/errors/4e47d427a20dc90a. Report an issue: GitHub.