diff --git a/cmd/dotagents/config.go b/cmd/dotagents/config.go index 41525df..8368c5d 100644 --- a/cmd/dotagents/config.go +++ b/cmd/dotagents/config.go @@ -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 + } cfg, err := loadConfig(repoRoot, home, configPath) if err != nil { @@ -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() + } + 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 { diff --git a/cmd/dotagents/config_worktree_test.go b/cmd/dotagents/config_worktree_test.go new file mode 100644 index 0000000..2b1376a --- /dev/null +++ b/cmd/dotagents/config_worktree_test.go @@ -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") + } +} diff --git a/cmd/dotagents/setup.go b/cmd/dotagents/setup.go index 511029d..e43322b 100644 --- a/cmd/dotagents/setup.go +++ b/cmd/dotagents/setup.go @@ -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)