gastownhall/beads · error

%s is a symlink to %s; writing would rewrite the link target

Error message

%s is a symlink to %s; writing would rewrite the link target, not the hook
Remove the symlink (or leave that hook to its owner) and re-run

What it means

Returned by guardHookWritePath when the hook path is a symlink: writing to it would overwrite the link's target file (owned by whoever set up the link) rather than the hook itself, so bd refuses. This protects foreign tools (e.g. other hook managers) that own the real file.

Source

Thrown at cmd/bd/hooks.go:827

//   - a hook file tracked by git: writing it dirties the working tree.
//     allowTracked exempts shared installs (.beads-hooks/ is deliberately
//     committed).
func guardHookWritePath(hookPath string, allowTracked bool) error {
	fi, err := os.Lstat(hookPath)
	if err != nil {
		if os.IsNotExist(err) {
			return nil // creating a new file — nothing to clobber
		}
		return fmt.Errorf("failed to stat %s: %w", hookPath, err)
	}
	if fi.Mode()&os.ModeSymlink != 0 {
		target := "unresolvable target"
		if resolved, rerr := filepath.EvalSymlinks(hookPath); rerr == nil {
			target = resolved
		} else if link, lerr := os.Readlink(hookPath); lerr == nil {
			target = link
		}
		return fmt.Errorf("%s is a symlink to %s; writing would rewrite the link target, not the hook\nRemove the symlink (or leave that hook to its owner) and re-run", hookPath, target)
	}
	if allowTracked {
		return nil
	}
	// A tracked file is refused only when bd does NOT own it: writing into a
	// foreign tracked file dirties every clone that shares it (the wy-81fnur
	// incident). A bd-owned hook the user chose to commit (e.g. a team-shared
	// .beads/hooks/) is bd's to maintain — same policy as shared installs.
	if isGitTrackedFile(hookPath) && !isBdOwnedHookFile(hookPath) {
		return fmt.Errorf("%s is tracked by git and not a bd-managed hook; bd will not modify committed files it does not own\nUntrack it (git rm --cached) or move hooks to an untracked directory and re-run", hookPath)
	}
	return nil
}

// isBdOwnedHookFile reports whether the hook file at path is bd-managed:
// either section-marker format or a legacy bd hook (shim or inline).
func isBdOwnedHookFile(path string) bool {
	content, err := os.ReadFile(path) // #nosec G304 -- path is a hook location bd resolved

View on GitHub (pinned to 71377f2769)

Solutions

  1. Remove the symlink (rm .git/hooks/<hook>) and re-run bd init / bd hooks install so bd owns a real file.
  2. Leave that particular hook to its owner (skip installing bd's hook there) if another tool manages it.
  3. Configure the other hook manager to chain to bd's hook instead of linking the file.

Example fix

// before
$ ls -l .git/hooks/pre-commit -> /dotfiles/pre-commit
$ bd hooks install
// error: .git/hooks/pre-commit is a symlink to /dotfiles/pre-commit ...
// after
$ rm -f .git/hooks/pre-commit
$ bd hooks install
Defensive patterns

Strategy: validation

Validate before calling

if fi, err := os.Lstat(hookPath); err == nil && fi.Mode()&os.ModeSymlink != 0 {
	return errors.New(hookPath + " is a symlink; resolve before installing bd hooks")
}

Try / catch

if err := guardHookWritePath(hookPath, allowTracked); err != nil {
	if strings.Contains(err.Error(), "is a symlink to") {
		// skip this hook or prompt the user to remove the link
	}
}

Prevention

When it happens

Trigger: installHooksWithOptions or applyHookMigrationExecution finds the hook path is a symlink (Lstat ModeSymlink set); the message includes the resolved target via filepath.EvalSymlinks, or the raw link via os.Readlink if unresolvable.

Common situations: A hook manager (husky, pre-commit, lefthook) or dotfiles setup symlinked .git/hooks/* into a shared config directory; bd then tries to install its hooks over those links.

Related errors


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