Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions cmd/dotagents/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@ func loadContext(opts runOptions) (string, string, config, []agentConfig, error)
return "", "", config{}, nil, err
}
repoRoot := filepath.Dir(configPath)
if err := refuseWorktreeRoot(repoRoot); err != nil {
return "", "", config{}, nil, err
}
Comment thread
sourcery-ai[bot] marked this conversation as resolved.
Comment thread
yourconscience marked this conversation as resolved.

cfg, err := loadConfig(repoRoot, home, configPath)
if err != nil {
Expand Down Expand Up @@ -358,6 +361,34 @@ func absoluteExpandedPath(path string, home string) (string, error) {
return abs, nil
}

// refuseWorktreeRoot rejects repo roots that are (or live under) a linked
// git worktree. sync materializes absolute links into the repo root; when
// the worktree is removed those links dangle and every managed skill
// breaks (ENOENT). The canonical checkout is the only valid root.
//
// Two detections: a linked worktree has a .git FILE pointing at its git
// dir (the canonical checkout has a .git directory or none), and the
// project convention places worktrees under .worktrees/, which catches
// roots whose .git check cannot run (missing dir, odd layouts).
func refuseWorktreeRoot(repoRoot string) error {
resolved := repoRoot
if real, err := filepath.EvalSymlinks(repoRoot); err == nil {
resolved = real
}
refuse := func() error {
return fmt.Errorf("refusing to run with repo root %s: it is (or lives inside) a linked git worktree; materialized links would dangle when the worktree is removed — run from the canonical checkout", repoRoot)
}
if fi, err := os.Stat(filepath.Join(resolved, ".git")); err == nil && fi.Mode().IsRegular() {
return refuse()
Comment on lines +381 to +382

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Check linked-worktree metadata above nested config roots

When an explicit config is in a subdirectory of an arbitrarily named linked worktree (for example, --config /tmp/agents-test/config/dotagents.yaml), the linked worktree's .git file is at /tmp/agents-test/.git, but this check probes only /tmp/agents-test/config/.git; because the path also lacks a .worktrees segment, the guard permits sync and recreates the dangling-link failure it is intended to prevent. Walk ancestors or query Git for the resolved root rather than checking only the config directory.

AGENTS.md reference: AGENTS.md:L5-L9

Useful? React with 👍 / 👎.

}
for _, seg := range strings.Split(filepath.ToSlash(resolved), "/") {
if seg == ".worktrees" {
return refuse()
}
}
return nil
}

func expandPath(path string, home string) string {
path = strings.TrimSpace(path)
switch {
Expand Down
55 changes: 55 additions & 0 deletions cmd/dotagents/config_worktree_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
package main

import (
"os"
"path/filepath"
"strings"
"testing"
)

func TestRefuseWorktreeRoot(t *testing.T) {
repo := t.TempDir()
if err := refuseWorktreeRoot(repo); err != nil {
t.Fatalf("canonical root must be accepted: %v", err)
}

worktree := filepath.Join(repo, ".worktrees", "sync-main")
if err := os.MkdirAll(worktree, 0o755); err != nil {
t.Fatal(err)
}
if err := refuseWorktreeRoot(worktree); err == nil {
t.Fatal("worktree root must be refused")
} else if !strings.Contains(err.Error(), "canonical checkout") {
t.Fatalf("unexpected error: %v", err)
}

// A linked worktree outside .worktrees/ is caught by its .git file.
arbitrary := filepath.Join(t.TempDir(), "agents-test")
if err := os.MkdirAll(arbitrary, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(arbitrary, ".git"), []byte("gitdir: elsewhere\n"), 0o644); err != nil {
t.Fatal(err)
}
if err := refuseWorktreeRoot(arbitrary); err == nil {
t.Fatal("linked worktree via .git file must be refused")
}

// A canonical checkout has a .git directory, which is allowed.
canonical := filepath.Join(t.TempDir(), "repo")
if err := os.MkdirAll(filepath.Join(canonical, ".git"), 0o755); err != nil {
t.Fatal(err)
}
if err := refuseWorktreeRoot(canonical); err != nil {
t.Fatalf("canonical checkout with .git dir must be accepted: %v", err)
}

// A symlink aliasing a worktree root resolves to the same refusal.
alias := filepath.Join(t.TempDir(), "alias")
if err := os.Symlink(worktree, alias); err != nil {
t.Skipf("symlink unavailable: %v", err)
}
if err := refuseWorktreeRoot(alias); err == nil {
t.Fatal("symlinked worktree root must be refused")
}
}
3 changes: 3 additions & 0 deletions cmd/dotagents/setup.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@ func runSetup(opts runOptions) error {
return err
}
repoRoot := filepath.Dir(configPath)
if err := refuseWorktreeRoot(repoRoot); err != nil {
return err
}
streams := setupStreams(opts)

cfg, err := loadSetupConfig(configPath, home)
Expand Down
Loading