Skip to content
Merged
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
52 changes: 40 additions & 12 deletions pkg/snapshot/snapshot.go
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,15 @@ func (m *Manager) Open(ctx context.Context, dir string) (*Repo, error) {
if err != nil {
return nil, err
}
// Canonicalize symlinks so r.directory uses the same convention as the
// worktree path returned by `git rev-parse --show-toplevel` (which always
// resolves symlinks). Without this, on hosts where the working directory
// is reached through a symlink (e.g. /tmp -> /private/tmp on macOS),
// filepath.Rel(worktree, directory) would produce a ".."-prefixed path
// and scope() would silently expand to the entire worktree.
if resolved, err := filepath.EvalSymlinks(abs); err == nil {
abs = resolved
}
worktree, err := gitWorktree(ctx, abs)
if err != nil {
return nil, err
Expand Down Expand Up @@ -127,7 +136,7 @@ func (r *Repo) Track(ctx context.Context) (string, error) {
if err := r.add(ctx); err != nil {
return "", err
}
result := r.git(ctx, r.args("write-tree"), gitOpts{cwd: r.directory})
result := r.git(ctx, r.args("write-tree"), gitOpts{cwd: r.worktree})
if result.err != nil {
return "", result.err
}
Expand All @@ -149,7 +158,7 @@ func (r *Repo) Patch(ctx context.Context, hash string) (Patch, error) {
if err := r.add(ctx); err != nil {
return Patch{Hash: hash}, err
}
result := r.git(ctx, append(quoteArgs(), r.args("diff", "--cached", "--no-ext-diff", "--name-only", hash, "--", ".")...), gitOpts{cwd: r.directory})
result := r.git(ctx, append(quoteArgs(), r.args(append([]string{"diff", "--cached", "--no-ext-diff", "--name-only", hash, "--"}, r.scope()...)...)...), gitOpts{cwd: r.worktree})
if result.err != nil {
return Patch{Hash: hash}, result.err
}
Expand Down Expand Up @@ -179,7 +188,7 @@ func (r *Repo) Diff(ctx context.Context, hash string) (string, error) {
if err := r.add(ctx); err != nil {
return "", err
}
result := r.git(ctx, append(quoteArgs(), r.args("diff", "--cached", "--no-ext-diff", hash, "--", ".")...), gitOpts{cwd: r.directory})
result := r.git(ctx, append(quoteArgs(), r.args(append([]string{"diff", "--cached", "--no-ext-diff", hash, "--"}, r.scope()...)...)...), gitOpts{cwd: r.worktree})
if result.err != nil {
return "", result.err
}
Expand Down Expand Up @@ -344,7 +353,7 @@ func (r *Repo) Cleanup(ctx context.Context) error {
if _, err := os.Stat(r.gitdir); errors.Is(err, os.ErrNotExist) {
return nil
}
result := r.git(ctx, r.args("gc", "--prune="+pruneAfter), gitOpts{cwd: r.directory})
result := r.git(ctx, r.args("gc", "--prune="+pruneAfter), gitOpts{cwd: r.worktree})
if result.err != nil {
return result.err
}
Expand All @@ -361,7 +370,7 @@ func (r *Repo) DiffFull(ctx context.Context, from, to string) ([]FileDiff, error
if err := r.ensure(ctx); err != nil {
return nil, err
}
statuses := r.git(ctx, append(quoteArgs(), r.args("diff", "--no-ext-diff", "--name-status", "--no-renames", from, to, "--", ".")...), gitOpts{cwd: r.directory})
statuses := r.git(ctx, append(quoteArgs(), r.args(append([]string{"diff", "--no-ext-diff", "--name-status", "--no-renames", from, to, "--"}, r.scope()...)...)...), gitOpts{cwd: r.worktree})
if statuses.err != nil {
return nil, statuses.err
}
Expand All @@ -384,7 +393,7 @@ func (r *Repo) DiffFull(ctx context.Context, from, to string) ([]FileDiff, error
}
}

numstat := r.git(ctx, append(quoteArgs(), r.args("diff", "--no-ext-diff", "--no-renames", "--numstat", from, to, "--", ".")...), gitOpts{cwd: r.directory})
numstat := r.git(ctx, append(quoteArgs(), r.args(append([]string{"diff", "--no-ext-diff", "--no-renames", "--numstat", from, to, "--"}, r.scope()...)...)...), gitOpts{cwd: r.worktree})
if numstat.err != nil {
return nil, numstat.err
}
Expand Down Expand Up @@ -424,7 +433,7 @@ func (r *Repo) DiffFull(ctx context.Context, from, to string) ([]FileDiff, error
}
patch := ""
if !row.binary {
p := r.git(ctx, append(quoteArgs(), r.args("diff", "--no-ext-diff", "--no-renames", from, to, "--", row.file)...), gitOpts{cwd: r.directory})
p := r.git(ctx, append(quoteArgs(), r.args("diff", "--no-ext-diff", "--no-renames", from, to, "--", row.file)...), gitOpts{cwd: r.worktree})
if p.err != nil {
return nil, p.err
}
Expand Down Expand Up @@ -482,8 +491,12 @@ func (r *Repo) add(ctx context.Context) error {
if err := r.syncExcludes(ctx, nil); err != nil {
return err
}
diff := r.git(ctx, append(quoteArgs(), r.args("diff-files", "--name-only", "-z", "--", ".")...), gitOpts{cwd: r.directory})
other := r.git(ctx, append(quoteArgs(), r.args("ls-files", "--others", "--exclude-standard", "-z", "--", ".")...), gitOpts{cwd: r.directory})
// Both git plumbing commands emit worktree-root-relative paths here:
// diff-files always does so by design, and ls-files is forced to do so
// via --full-name. Keeping the conventions aligned is what lets us merge
// the two lists and feed them back to git as pathspecs unchanged.
diff := r.git(ctx, append(quoteArgs(), r.args(append([]string{"diff-files", "--name-only", "-z", "--"}, r.scope()...)...)...), gitOpts{cwd: r.worktree})
other := r.git(ctx, append(quoteArgs(), r.args(append([]string{"ls-files", "--others", "--exclude-standard", "-z", "--full-name", "--"}, r.scope()...)...)...), gitOpts{cwd: r.worktree})
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[LOW] diff-files lacks --full-name — inconsistent path guarantee vs ls-files

ls-files --others uses --full-name to guarantee worktree-root-relative output, but the adjacent diff-files --name-only call on the line above does not:

diff := r.git(ctx, append(quoteArgs(), r.args(append([]string{"diff-files", "--name-only", "-z", "--"}, r.scope()...)...)...), gitOpts{cwd: r.worktree})
other := r.git(ctx, append(quoteArgs(), r.args(append([]string{"ls-files", "--others", "--exclude-standard", "-z", "--full-name", "--"}, r.scope()...)...)...), gitOpts{cwd: r.worktree})

Both outputs are merged into all and later used with filepath.Join(r.worktree, filepath.FromSlash(item)), which requires paths to be worktree-relative.

Today this is safe because cwd is explicitly set to r.worktree on both calls — when cwd equals the worktree root, git path output is already relative to the worktree root, making --full-name redundant. However, the asymmetry means a future refactor that changes cwd for diff-files (or any change that makes them diverge) would silently produce incorrect Lstat lookups for tracked modified files without any obvious warning. Consider adding --full-name to the diff-files call for symmetry and defensive correctness.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch on the asymmetry, but git diff-files doesn't accept --full-name (it errors out — that flag is ls-files-only). diff-files is plumbing and always emits worktree-root-relative paths by design, so the two outputs are already in the same convention. Added a comment above the two calls in e3bd4aa to make that invariant explicit for future readers.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're absolutely right — I apologize for the incorrect suggestion. I verified that --full-name is indeed ls-files-only and errors out on diff-files. The comment you added in e3bd4aa is perfect: it makes the invariant explicit (both commands emit worktree-root-relative paths, one by design and one by flag) and explains why the merged output works correctly. Thanks for the correction!

if diff.err != nil {
return diff.err
}
Expand Down Expand Up @@ -549,7 +562,7 @@ func (r *Repo) ignore(ctx context.Context, files []string) map[string]bool {
if len(files) == 0 {
return out
}
result := command(ctx, []string{"-C", r.worktree, "-c", "core.quotepath=false", "check-ignore", "--no-index", "--stdin", "-z"}, gitOpts{cwd: r.directory, stdin: []byte(strings.Join(files, "\x00") + "\x00")})
result := command(ctx, []string{"-C", r.worktree, "-c", "core.quotepath=false", "check-ignore", "--no-index", "--stdin", "-z"}, gitOpts{cwd: r.worktree, stdin: []byte(strings.Join(files, "\x00") + "\x00")})
if result.err != nil || (result.code != 0 && result.code != 1) {
return out
}
Expand All @@ -563,7 +576,7 @@ func (r *Repo) drop(ctx context.Context, files []string) error {
if len(files) == 0 {
return nil
}
result := r.git(ctx, append(cfgArgs(), r.args("rm", "--cached", "-f", "--ignore-unmatch", "--pathspec-from-file=-", "--pathspec-file-nul")...), gitOpts{cwd: r.directory, stdin: nulList(files)})
result := r.git(ctx, append(cfgArgs(), r.args("rm", "--cached", "-f", "--ignore-unmatch", "--pathspec-from-file=-", "--pathspec-file-nul")...), gitOpts{cwd: r.worktree, stdin: nulList(files)})
if result.err != nil {
return result.err
}
Expand All @@ -577,7 +590,7 @@ func (r *Repo) stage(ctx context.Context, files []string) error {
if len(files) == 0 {
return nil
}
result := r.git(ctx, append(cfgArgs(), r.args("add", "--all", "--sparse", "--pathspec-from-file=-", "--pathspec-file-nul")...), gitOpts{cwd: r.directory, stdin: nulList(files)})
result := r.git(ctx, append(cfgArgs(), r.args("add", "--all", "--sparse", "--pathspec-from-file=-", "--pathspec-file-nul")...), gitOpts{cwd: r.worktree, stdin: nulList(files)})
if result.err != nil {
return result.err
}
Expand Down Expand Up @@ -623,6 +636,21 @@ func (r *Repo) args(cmd ...string) []string {
return out
}

// scope returns the pathspec(s) that restrict operations to the agent's
// working directory, expressed relative to the worktree root. Returning
// worktree-relative paths keeps git's output and input conventions aligned
// regardless of whether r.directory equals r.worktree or is a subdirectory.
func (r *Repo) scope() []string {
rel, err := filepath.Rel(r.worktree, r.directory)
if err != nil || rel == "" || rel == "." {
return []string{"."}
}
if strings.HasPrefix(rel, "..") {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[MEDIUM] scope() silently expands to entire worktree when directory is accessed via a symlink

filepath.Rel(r.worktree, r.directory) can produce a ..-prefixed path even when r.directory is genuinely inside r.worktree, if the two paths were resolved through different mechanisms: git rev-parse --show-toplevel resolves symlinks (returning the real path) while filepath.Abs does not. This is a real scenario on macOS (e.g., /tmp → /private/tmp, /var → /private/var) and any host where the working directory is reached via a symlink.

When the .. guard triggers, scope() silently returns ["."], causing all snapshot git operations (diff-files, ls-files --others, git add) to run against the entire worktree rather than the agent's subdirectory. This defeats the whole purpose of the fix: files from sibling directories can be included or modified in the snapshot.

Suggested fix: Resolve symlinks on both sides before computing Rel, or resolve only r.directory with filepath.EvalSymlinks to match what --show-toplevel returns:

func (r *Repo) scope() []string {
    dir := r.directory
    if resolved, err := filepath.EvalSymlinks(dir); err == nil {
        dir = resolved
    }
    rel, err := filepath.Rel(r.worktree, dir)
    if err != nil || rel == "" || rel == "." {
        return []string{"."}
    }
    if strings.HasPrefix(rel, "..") {
        return []string{"."}
    }
    return []string{filepath.ToSlash(rel)}
}

Alternatively, log a warning when the fallback triggers so the silent expansion is at least visible.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in e3bd4aa: canonicalize the directory with filepath.EvalSymlinks at Open() time so r.directory and r.worktree (which comes from git rev-parse --show-toplevel, always symlink-resolved) use the same convention. Locked in by TestOpenCanonicalizesSymlinkedDirectory (fails without the fix, both on the Directory() equality check and on the patch wrongly including sibling-directory changes).

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Perfect! The fix handles it exactly as suggested: canonicalize with EvalSymlinks at Open() time so both paths use the same convention before Rel runs. The test is comprehensive — it verifies both that Directory() returns the canonical path and that sibling changes don't leak into the patch, which directly confirms that scope() no longer falls back to ["."].

Bonus: this also stabilizes the shadow gitdir hash for symlinked entry points, so the same repo accessed via different symlink paths now shares one shadow git directory.

return []string{"."}
}
return []string{filepath.ToSlash(rel)}
}

func (r *Repo) git(ctx context.Context, args []string, opts gitOpts) gitResult {
return command(ctx, args, opts)
}
Expand Down
100 changes: 100 additions & 0 deletions pkg/snapshot/snapshot_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,106 @@ func TestOpenOutsideGitRepo(t *testing.T) {
assert.ErrorIs(t, err, ErrNotGitRepository)
}

// Regression test for https://github.com/docker/docker-agent/issues/2903:
// when the agent runs from a subfolder of the git worktree, snapshotting
// must succeed without git emitting "could not open directory" warnings or
// "pathspec did not match any files" errors caused by mixing cwd-relative
// and worktree-relative paths.
func TestTrackPatchFromSubfolder(t *testing.T) {
root := bootstrapRepo(t)
sub := filepath.Join(root, "assistant")
require.NoError(t, os.MkdirAll(sub, 0o755))
require.NoError(t, os.WriteFile(filepath.Join(sub, "gogo.yaml"), []byte("hello"), 0o644))
runGit(t, root, "add", ".")
runGit(t, root, "commit", "-m", "add subfolder")

repo := openRepo(t, sub)
assert.Equal(t, root, repo.Worktree())

before, err := repo.Track(t.Context())
require.NoError(t, err)
require.NotEmpty(t, before)

require.NoError(t, os.WriteFile(filepath.Join(sub, "gogo.yaml"), []byte("hello\nworld"), 0o644))
require.NoError(t, os.WriteFile(filepath.Join(sub, "new.yaml"), []byte("new"), 0o644))

patch, err := repo.Patch(t.Context(), before)
require.NoError(t, err)
assert.ElementsMatch(t, []string{
filepath.ToSlash(filepath.Join(repo.Worktree(), "assistant", "gogo.yaml")),
filepath.ToSlash(filepath.Join(repo.Worktree(), "assistant", "new.yaml")),
}, patch.Files)

after, err := repo.Track(t.Context())
require.NoError(t, err)
diffs, err := repo.DiffFull(t.Context(), before, after)
require.NoError(t, err)
assert.ElementsMatch(t, []string{"assistant/gogo.yaml", "assistant/new.yaml"}, diffFiles(diffs))

require.NoError(t, repo.Revert(t.Context(), []Patch{patch}))
assertFile(t, filepath.Join(sub, "gogo.yaml"), "hello")
assert.NoFileExists(t, filepath.Join(sub, "new.yaml"))
}

// When the agent runs from a subfolder, changes outside that subfolder
// must not appear in patches scoped to the agent's directory.
func TestSubfolderScopeIgnoresSiblingChanges(t *testing.T) {
root := bootstrapRepo(t)
sub := filepath.Join(root, "assistant")
require.NoError(t, os.MkdirAll(sub, 0o755))
require.NoError(t, os.WriteFile(filepath.Join(sub, "gogo.yaml"), []byte("hello"), 0o644))
runGit(t, root, "add", ".")
runGit(t, root, "commit", "-m", "add subfolder")

repo := openRepo(t, sub)
before, err := repo.Track(t.Context())
require.NoError(t, err)

require.NoError(t, os.WriteFile(filepath.Join(sub, "gogo.yaml"), []byte("changed"), 0o644))
require.NoError(t, os.WriteFile(filepath.Join(root, "a.txt"), []byte("sibling change"), 0o644))

patch, err := repo.Patch(t.Context(), before)
require.NoError(t, err)
assert.ElementsMatch(t, []string{
filepath.ToSlash(filepath.Join(repo.Worktree(), "assistant", "gogo.yaml")),
}, patch.Files)
}

// Regression test: when the agent's directory is reached through a symlink
// (e.g. /tmp -> /private/tmp on macOS), git rev-parse --show-toplevel
// returns the canonicalized path while filepath.Abs preserves the symlink.
// Without canonicalizing the directory at Open() time, filepath.Rel would
// produce a "..-prefixed path and scope() would silently expand to the
// entire worktree — defeating the subfolder-scoping fix.
func TestOpenCanonicalizesSymlinkedDirectory(t *testing.T) {
root := bootstrapRepo(t)
sub := filepath.Join(root, "assistant")
require.NoError(t, os.MkdirAll(sub, 0o755))
require.NoError(t, os.WriteFile(filepath.Join(sub, "gogo.yaml"), []byte("hello"), 0o644))
runGit(t, root, "add", ".")
runGit(t, root, "commit", "-m", "add subfolder")

link := filepath.Join(t.TempDir(), "link")
if err := os.Symlink(sub, link); err != nil {
t.Skipf("cannot create symlink: %v", err)
}

repo := openRepo(t, link)
assert.Equal(t, sub, repo.Directory(), "Open must canonicalize the directory so it matches the worktree-relative scope")

before, err := repo.Track(t.Context())
require.NoError(t, err)

require.NoError(t, os.WriteFile(filepath.Join(root, "a.txt"), []byte("sibling change"), 0o644))
require.NoError(t, os.WriteFile(filepath.Join(sub, "new.yaml"), []byte("new"), 0o644))

patch, err := repo.Patch(t.Context(), before)
require.NoError(t, err)
assert.ElementsMatch(t, []string{
filepath.ToSlash(filepath.Join(repo.Worktree(), "assistant", "new.yaml")),
}, patch.Files)
}

func bootstrapRepo(t *testing.T) string {
t.Helper()
if _, err := exec.LookPath("git"); err != nil {
Expand Down
Loading