{"record":{"id":"4e47d427a20dc90a","repo":"gastownhall/beads","slug":"workspacegate-s-is-a-symlink-gate-the-physical","errorCode":null,"errorMessage":"workspacegate: %s is a symlink; gate the physical directory it points to","messagePattern":"workspacegate: (.+?) is a symlink; gate the physical directory it points to","errorType":"validation","errorClass":null,"httpStatus":null,"severity":"error","filePath":"internal/workspacegate/gate.go","lineNumber":180,"sourceCode":"func forDir(dir string) (Gate, error) {\n\tabs, err := filepath.Abs(dir)\n\tif err != nil {\n\t\treturn Gate{}, fmt.Errorf(\"workspacegate: resolving %s: %w\", dir, err)\n\t}\n\tabs = filepath.Clean(abs)\n\n\t// Gate identity must be stable across the guarded directory being\n\t// absent, created, replaced, or recreated — that is the point of\n\t// placing the gate beside it. So identity derives from the\n\t// canonicalized PARENT plus the literal base name, never from\n\t// resolving the guarded path itself: full-path resolution would\n\t// silently select a different gate once the directory appears as a\n\t// symlink, letting two exclusive holders coexist. The flip side is\n\t// that a guarded directory that IS a symlink has no stable identity\n\t// under this scheme, so it is refused outright rather than gated\n\t// ambiguously.\n\tif fi, err := os.Lstat(abs); err == nil && fi.Mode()&os.ModeSymlink != 0 {\n\t\treturn Gate{}, fmt.Errorf(\"workspacegate: %s is a symlink; gate the physical directory it points to\", abs)\n\t}\n\tparent, base := filepath.Split(abs)\n\tswitch base {\n\tcase \"\", \".\", \"..\":\n\t\treturn Gate{}, fmt.Errorf(\"workspacegate: cannot gate %q: the guarded path must be a named directory\", dir)\n\t}\n\tcanonParent, err := filepath.EvalSymlinks(filepath.Clean(parent))\n\tif err != nil {\n\t\treturn Gate{}, fmt.Errorf(\"workspacegate: gate parent %s must exist: %w\", parent, err)\n\t}\n\treturn Gate{path: filepath.Join(canonParent, gateFileName(base))}, nil\n}\n\n// ForWorkspace returns the gate guarding a workspace's .beads directory\n// (pass the .beads directory itself). The gate file is a sibling of\n// .beads; bd's project gitignore management covers \"*.gate.lock*\".\nfunc ForWorkspace(beadsDir string) (Gate, error) { return forDir(beadsDir) }\n","sourceCodeStart":162,"sourceCodeEnd":198,"githubUrl":"https://github.com/gastownhall/beads/blob/71377f276968b452ee607177637970a4ff888584/internal/workspacegate/gate.go#L162-L198","documentation":"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.","triggerScenarios":"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.","commonSituations":"Workspaces set up via symlinks for version switching (project -> project-v2); macOS ~/Library style links; dotfile-managed directories that symlink project folders; tmpfs shortcuts.","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"],"exampleFix":"// before\ng, err := workspacegate.ForWorkspace(\"/ws/current-project\")\n// after\nreal, err := filepath.EvalSymlinks(\"/ws/current-project\")\nif err != nil { return err }\ng, err := workspacegate.ForWorkspace(real)","handlingStrategy":"validation","validationCode":"real, err := filepath.EvalSymlinks(dir)\nif err != nil { return err }\nif fi, err := os.Lstat(dir); err == nil && fi.Mode()&os.ModeSymlink != 0 {\n    dir = real // gate the physical directory\n}","typeGuard":"func isSymlink(p string) bool {\n    fi, err := os.Lstat(p)\n    return err == nil && fi.Mode()&os.ModeSymlink != 0\n}","tryCatchPattern":"g, err := workspacegate.ForWorkspace(dir)\nif err != nil && strings.Contains(err.Error(), \"is a symlink\") {\n    real, rerr := filepath.EvalSymlinks(dir)\n    if rerr == nil { g, err = workspacegate.ForWorkspace(real) }\n}","preventionTips":["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"],"tags":["filesystem","symlink","workspacegate"],"backgroundTag":"symlinked-directory-refused","analyzedSha":"71377f276968b452ee607177637970a4ff888584","analyzedAt":"2026-08-30T18:55:39.744Z","schemaVersion":2},"datasetVersion":"2026-08-30T23:17:21.991Z"}