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
- Resolve the symlink yourself (filepath.EvalSymlinks) and gate the physical target directory instead
- Restructure so the working directory is the real path (e.g. cd through the resolved link)
- 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
- Resolve symlinks with filepath.EvalSymlinks before calling gate entry points
- Avoid symlinked project directories in workspaces that use gate-based locking
- Watch for deploy/switch tooling that swaps directories for symlinks and resolve to the target instead
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
- refusing to write migrated hook %s: %w
- refusing to chmod %s: path is a symbolic link
- identity: resolve root path: %w
- workspacegate: resolving %s: %w
- workspacegate: gate parent %s must exist: %w
AI-assisted analysis of gastownhall/beads@71377f2769 (2026-08-30).
Data as JSON: /api/errors/4e47d427a20dc90a.
Report an issue: GitHub.