Skip to content
Open
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
8 changes: 8 additions & 0 deletions cmd/codeaf/engine.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
67 changes: 67 additions & 0 deletions cmd/codeaf/engine_symlink_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
19 changes: 19 additions & 0 deletions docs/changes/unreleased/1798-symlink-workspace-keys.md
Original file line number Diff line number Diff line change
@@ -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 <link>` missed the engine holding `<dir>`, and home's projects panel listed the folder once per spelling. engineWorkspace stopped at filepath.Clean, so each spelling hashed to its own enginehost host directory. engineWorkspace now resolves symlinks (filepath.EvalSymlinks after the absolute path is settled), so both spellings of one folder reach one project, one conversation history and one engine."
---

Found in the v0.7.1 happy-path pass on macOS, where `/tmp` is a link to
`/private/tmp`, and reproducible on any system with `mkdir d && ln -s d l`.
The workspace key-minting door in `cmd/codeaf` — the one every engine command
(`--daemon`, `--status`, `--stop`) and the host's workspace name go through —
expanded and cleaned the path but never resolved its links. Two spellings of
one folder then keyed two host directories in `internal/enginehost.where`
(sha256 of the cleaned string), so the person saw one folder while codeaf kept
two of everything. The fix names a workspace by its resolved path at that door;
a regression test drives a symlink through `engineWorkspace` and asserts both
spellings land on the same directory.
11 changes: 11 additions & 0 deletions internal/tui3/projectcmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ package tui3

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

tea "charm.land/bubbletea/v2"
Expand Down Expand Up @@ -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, "")
Expand Down