Skip to content

Commit e3bd4aa

Browse files
author
Docker Agent
committed
fix(snapshot): canonicalize agent directory symlinks at Open
Address review feedback on #2904. git rev-parse --show-toplevel resolves symlinks, while filepath.Abs preserves them. When the agent's directory is reached through a symlink (e.g. /tmp -> /private/tmp on macOS), r.directory and r.worktree no longer share a prefix, so filepath.Rel produces a '..'-prefixed path and scope() silently falls back to '.' — expanding snapshot operations to the entire worktree and defeating the subfolder fix. EvalSymlinks the abs path at Open() so r.directory uses the same canonicalization as r.worktree. Also stabilizes the shadow gitdir hash for symlinked entry points. Add TestOpenCanonicalizesSymlinkedDirectory to lock this in.
1 parent 9363f83 commit e3bd4aa

2 files changed

Lines changed: 48 additions & 0 deletions

File tree

pkg/snapshot/snapshot.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,15 @@ func (m *Manager) Open(ctx context.Context, dir string) (*Repo, error) {
7373
if err != nil {
7474
return nil, err
7575
}
76+
// Canonicalize symlinks so r.directory uses the same convention as the
77+
// worktree path returned by `git rev-parse --show-toplevel` (which always
78+
// resolves symlinks). Without this, on hosts where the working directory
79+
// is reached through a symlink (e.g. /tmp -> /private/tmp on macOS),
80+
// filepath.Rel(worktree, directory) would produce a ".."-prefixed path
81+
// and scope() would silently expand to the entire worktree.
82+
if resolved, err := filepath.EvalSymlinks(abs); err == nil {
83+
abs = resolved
84+
}
7685
worktree, err := gitWorktree(ctx, abs)
7786
if err != nil {
7887
return nil, err
@@ -482,6 +491,10 @@ func (r *Repo) add(ctx context.Context) error {
482491
if err := r.syncExcludes(ctx, nil); err != nil {
483492
return err
484493
}
494+
// Both git plumbing commands emit worktree-root-relative paths here:
495+
// diff-files always does so by design, and ls-files is forced to do so
496+
// via --full-name. Keeping the conventions aligned is what lets us merge
497+
// the two lists and feed them back to git as pathspecs unchanged.
485498
diff := r.git(ctx, append(quoteArgs(), r.args(append([]string{"diff-files", "--name-only", "-z", "--"}, r.scope()...)...)...), gitOpts{cwd: r.worktree})
486499
other := r.git(ctx, append(quoteArgs(), r.args(append([]string{"ls-files", "--others", "--exclude-standard", "-z", "--full-name", "--"}, r.scope()...)...)...), gitOpts{cwd: r.worktree})
487500
if diff.err != nil {

pkg/snapshot/snapshot_test.go

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,41 @@ func TestSubfolderScopeIgnoresSiblingChanges(t *testing.T) {
170170
}, patch.Files)
171171
}
172172

173+
// Regression test: when the agent's directory is reached through a symlink
174+
// (e.g. /tmp -> /private/tmp on macOS), git rev-parse --show-toplevel
175+
// returns the canonicalized path while filepath.Abs preserves the symlink.
176+
// Without canonicalizing the directory at Open() time, filepath.Rel would
177+
// produce a "..-prefixed path and scope() would silently expand to the
178+
// entire worktree — defeating the subfolder-scoping fix.
179+
func TestOpenCanonicalizesSymlinkedDirectory(t *testing.T) {
180+
root := bootstrapRepo(t)
181+
sub := filepath.Join(root, "assistant")
182+
require.NoError(t, os.MkdirAll(sub, 0o755))
183+
require.NoError(t, os.WriteFile(filepath.Join(sub, "gogo.yaml"), []byte("hello"), 0o644))
184+
runGit(t, root, "add", ".")
185+
runGit(t, root, "commit", "-m", "add subfolder")
186+
187+
link := filepath.Join(t.TempDir(), "link")
188+
if err := os.Symlink(sub, link); err != nil {
189+
t.Skipf("cannot create symlink: %v", err)
190+
}
191+
192+
repo := openRepo(t, link)
193+
assert.Equal(t, sub, repo.Directory(), "Open must canonicalize the directory so it matches the worktree-relative scope")
194+
195+
before, err := repo.Track(t.Context())
196+
require.NoError(t, err)
197+
198+
require.NoError(t, os.WriteFile(filepath.Join(root, "a.txt"), []byte("sibling change"), 0o644))
199+
require.NoError(t, os.WriteFile(filepath.Join(sub, "new.yaml"), []byte("new"), 0o644))
200+
201+
patch, err := repo.Patch(t.Context(), before)
202+
require.NoError(t, err)
203+
assert.ElementsMatch(t, []string{
204+
filepath.ToSlash(filepath.Join(repo.Worktree(), "assistant", "new.yaml")),
205+
}, patch.Files)
206+
}
207+
173208
func bootstrapRepo(t *testing.T) string {
174209
t.Helper()
175210
if _, err := exec.LookPath("git"); err != nil {

0 commit comments

Comments
 (0)