From 7bce0e25a8542f73bb092856e6a36be09b6ac98d Mon Sep 17 00:00:00 2001 From: ningmao-hlyz <2997825003@qq.com> Date: Fri, 9 Oct 2026 13:32:50 +0800 Subject: [PATCH 1/2] fix(workspace): name a workspace by its resolved path A folder reached through a symlink became two projects, two conversation histories and two engines: engineWorkspace and /project stopped at filepath.Clean, so each spelling hashed to its own enginehost host directory and its own project row. Resolve symlinks (filepath.EvalSymlinks) at both doors where a person's path becomes a key: engineWorkspace (--daemon/--status/--stop and the host's workspace name) and runProjectCommand (the project pin). Fixes Agent-Field/CodeAF#1761 --- cmd/codeaf/engine.go | 8 ++++ cmd/codeaf/engine_symlink_test.go | 67 +++++++++++++++++++++++++++++++ internal/tui3/projectcmd.go | 11 +++++ 3 files changed, 86 insertions(+) create mode 100644 cmd/codeaf/engine_symlink_test.go 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/internal/tui3/projectcmd.go b/internal/tui3/projectcmd.go index bf6c882cc3..1bc3f8264a 100644 --- a/internal/tui3/projectcmd.go +++ b/internal/tui3/projectcmd.go @@ -22,6 +22,7 @@ package tui3 import ( "os" + "path/filepath" "strings" tea "charm.land/bubbletea/v2" @@ -75,6 +76,16 @@ func (a *app) runProjectCommand(rest string) tea.Cmd { return nil } path := a.resolvePath(rest) + // A PIN NAMES A FOLDER, NOT A SPELLING. A path taken through a symlink must + // pin the folder the link reaches, or home stores one project under the + // alias while a launch from the resolved path stores another — two + // projects, two histories, two engines for one folder (#1761). The stat + // below already pays the walk EvalSymlinks costs on a missing target, so a + // path that does not resolve simply falls through to the refusal word as + // it always did. + if resolved, err := filepath.EvalSymlinks(path); err == nil { + path = resolved + } info, err := os.Stat(path) if path == "" || err != nil || !info.IsDir() { a.home.say(projectNoFolderWord+rest, "") From fa7d4383a77025c7b659254bb1b8b4bcb4a9978d Mon Sep 17 00:00:00 2001 From: ningmao-hlyz <2997825003@qq.com> Date: Fri, 9 Oct 2026 13:46:44 +0800 Subject: [PATCH 2/2] changelog: a folder reached through a symlink is one project and one engine (#1798) --- .../unreleased/1798-symlink-workspace-keys.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 docs/changes/unreleased/1798-symlink-workspace-keys.md 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 `