From cbb1be90c96de02c4ad05548ab068f598d70af1c Mon Sep 17 00:00:00 2001 From: Kirill Korikov <11762090+yourconscience@users.noreply.github.com> Date: Sun, 6 Sep 2026 13:00:19 +0400 Subject: [PATCH 1/3] refuse to run when repo root is inside .worktrees --- cmd/dotagents/config.go | 20 ++++++++++++++++ cmd/dotagents/config_worktree_test.go | 34 +++++++++++++++++++++++++++ 2 files changed, 54 insertions(+) create mode 100644 cmd/dotagents/config_worktree_test.go diff --git a/cmd/dotagents/config.go b/cmd/dotagents/config.go index 41525df..3923cb4 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,23 @@ func absoluteExpandedPath(path string, home string) (string, error) { return abs, nil } +// refuseWorktreeRoot rejects repo roots that live inside a git worktree +// directory (.worktrees/). 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. +func refuseWorktreeRoot(repoRoot string) error { + resolved := repoRoot + if real, err := filepath.EvalSymlinks(repoRoot); err == nil { + resolved = real + } + for _, seg := range strings.Split(filepath.ToSlash(resolved), "/") { + if seg == ".worktrees" { + return fmt.Errorf("refusing to run with repo root %s: it lives inside a git worktree (.worktrees/); materialized links would dangle when the worktree is removed — run from the canonical checkout", repoRoot) + } + } + 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..4d07b31 --- /dev/null +++ b/cmd/dotagents/config_worktree_test.go @@ -0,0 +1,34 @@ +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 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") + } +} From a47db6f72534bb85c905ec4a64fcbfa37acca6c3 Mon Sep 17 00:00:00 2001 From: Kirill Korikov <11762090+yourconscience@users.noreply.github.com> Date: Sun, 6 Sep 2026 13:03:43 +0400 Subject: [PATCH 2/3] apply worktree-root guard at setup entry before mutations --- cmd/dotagents/setup.go | 3 +++ 1 file changed, 3 insertions(+) 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) From 0694396191cfbb381e7de75f0f2549ad14d1dd16 Mon Sep 17 00:00:00 2001 From: Kirill Korikov <11762090+yourconscience@users.noreply.github.com> Date: Sun, 6 Sep 2026 13:07:43 +0400 Subject: [PATCH 3/3] detect linked worktrees by .git file, not only .worktrees paths --- cmd/dotagents/config.go | 21 ++++++++++++++++----- cmd/dotagents/config_worktree_test.go | 21 +++++++++++++++++++++ 2 files changed, 37 insertions(+), 5 deletions(-) diff --git a/cmd/dotagents/config.go b/cmd/dotagents/config.go index 3923cb4..8368c5d 100644 --- a/cmd/dotagents/config.go +++ b/cmd/dotagents/config.go @@ -361,18 +361,29 @@ func absoluteExpandedPath(path string, home string) (string, error) { return abs, nil } -// refuseWorktreeRoot rejects repo roots that live inside a git worktree -// directory (.worktrees/). 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. +// 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 fmt.Errorf("refusing to run with repo root %s: it lives inside a git worktree (.worktrees/); materialized links would dangle when the worktree is removed — run from the canonical checkout", repoRoot) + return refuse() } } return nil diff --git a/cmd/dotagents/config_worktree_test.go b/cmd/dotagents/config_worktree_test.go index 4d07b31..2b1376a 100644 --- a/cmd/dotagents/config_worktree_test.go +++ b/cmd/dotagents/config_worktree_test.go @@ -23,6 +23,27 @@ func TestRefuseWorktreeRoot(t *testing.T) { 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 {