diff --git a/cmd/codeaf/engine.go b/cmd/codeaf/engine.go index 2577e8011c..673331a302 100644 --- a/cmd/codeaf/engine.go +++ b/cmd/codeaf/engine.go @@ -1377,6 +1377,14 @@ func engineWorkspace(path string) (string, error) { expanded = filepath.Join(home, expanded) } expanded = filepath.Clean(expanded) + // A WORKSPACE IS NAMED BY ITS RESOLVED PATH, so a folder reached through a + // symlink keys the same project, conversation history and engine host as + // the folder it points at. Clean alone leaves both spellings distinct and + // sha256(Clean(...)) in enginehost.where then builds one host directory per + // spelling — two engines, two histories, one folder. + if resolved, err := filepath.EvalSymlinks(expanded); err == nil { + expanded = resolved + } info, err := os.Stat(expanded) if err != nil { return "", fmt.Errorf("open %s: %w", expanded, err) diff --git a/cmd/codeaf/engine_symlink_test.go b/cmd/codeaf/engine_symlink_test.go new file mode 100644 index 0000000000..48e488c445 --- /dev/null +++ b/cmd/codeaf/engine_symlink_test.go @@ -0,0 +1,67 @@ +package main + +import ( + "os" + "path/filepath" + "testing" +) + +// TestEngineWorkspaceResolvesSymlink is the regression for issue #1761: a +// folder reached through a symlink must key to the same workspace as the +// folder it points at, so one folder cannot become two projects and two +// engines. Before the fix engineWorkspace stopped at filepath.Clean, so the +// alias and the target returned different paths and enginehost.where hashed +// each spelling to its own host directory. +func TestEngineWorkspaceResolvesSymlink(t *testing.T) { + tmp := t.TempDir() + real := filepath.Join(tmp, "realrepo") + if err := os.Mkdir(real, 0o755); err != nil { + t.Fatal(err) + } + link := filepath.Join(tmp, "linkedrepo") + if err := os.Symlink(real, link); err != nil { + t.Fatal(err) + } + + fromTarget, err := engineWorkspace(real) + if err != nil { + t.Fatalf("engineWorkspace(target): %v", err) + } + fromLink, err := engineWorkspace(link) + if err != nil { + t.Fatalf("engineWorkspace(link): %v", err) + } + + resolvedLink, err := filepath.EvalSymlinks(link) + if err != nil { + t.Fatalf("EvalSymlinks(link): %v", err) + } + + if fromLink != resolvedLink { + t.Fatalf("link spelling not resolved: got %q, want %q", fromLink, resolvedLink) + } + if fromLink != fromTarget { + t.Fatalf("two spellings keyed differently: link=%q target=%q", fromLink, fromTarget) + } +} + +// TestEngineWorkspaceKeepsNonSymlinkPathUnchanged guards that an ordinary +// folder still resolves to itself, so the fix only moves symlinked spellings. +func TestEngineWorkspaceKeepsNonSymlinkPathUnchanged(t *testing.T) { + tmp := t.TempDir() + real := filepath.Join(tmp, "plainrepo") + if err := os.Mkdir(real, 0o755); err != nil { + t.Fatal(err) + } + resolvedReal, err := filepath.EvalSymlinks(real) + if err != nil { + t.Fatalf("EvalSymlinks(plain): %v", err) + } + got, err := engineWorkspace(real) + if err != nil { + t.Fatalf("engineWorkspace(plain): %v", err) + } + if got != resolvedReal { + t.Fatalf("plain path changed: got %q, want %q", got, resolvedReal) + } +} diff --git a/docs/changes/unreleased/1798-symlink-workspace-keys.md b/docs/changes/unreleased/1798-symlink-workspace-keys.md new file mode 100644 index 0000000000..015c7f2de0 --- /dev/null +++ b/docs/changes/unreleased/1798-symlink-workspace-keys.md @@ -0,0 +1,19 @@ +--- +kind: fixed +title: a folder reached through a symlink is one project and one engine, not two +pr: 1798 +surface: [engine, chat] +invalidates: + - "A folder reached through a symlink became two projects, two conversation histories and two engines. `codeaf engine --status --workspace ` missed the engine holding `