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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@

### Fixed

- **A worktree moved into the project, or a directory that cannot be read, is no longer indexed by mistake** — `git worktree move` renames the directory and then writes its `.git` file again in place, so a watcher or a walk could find the file empty and index the whole worktree. Such a directory is now treated as a worktree until git is done, by the watchers and by both index walks, and each check reads the `.git` file once: two reads could see the empty file and then the complete one, and answer that a worktree was neither a worktree nor still being written. This happened with git 2.48 and later, which write the file again after the move; older git does not. A directory that the watcher cannot read (for example when the process has no file descriptor left) used to be skipped silently: it was not watched, not retried, and its files were reported as if it were a plain directory. It is now marked as not covered, so the coverage report says so and the retry reads it again, and its files are indexed only once Dexter knows what it is

- **A def inside a macro's `quote` is no longer indexed as a function of the macro's module** — `defmacro route(...) do quote do def handle(...) end end` made the index say that the DSL module defines `handle/2`, which it does not. A consumer that imports the DSL then resolved `Consumer.handle` into the macro's body. Such a def is now skipped, so the call goes to the line in the consumer that declared it, from the compiled BEAM. What `__using__` injects, and a quote in a helper function, are still indexed as before. The index is rebuilt once after the upgrade

- **Compressed BEAM files are read** — a module compiled with the `compressed` option, as some Erlang dependencies are, is a gzip stream around the BEAM container. Dexter rejected it as an invalid BEAM, so its exports were missing from completion and generated-function navigation. It is now decompressed, with the same size limit as an uncompressed file
Expand Down
114 changes: 81 additions & 33 deletions internal/parser/parser.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package parser

import (
"io"
"io/fs"
"os"
"path/filepath"
Expand Down Expand Up @@ -192,7 +193,9 @@ func WalkElixirFiles(root string, fn func(path string, d fs.DirEntry) error) err
if err != nil {
return nil
}
if !isRoot && HasLinkedWorktreeGitFile(dir, entries) {
// A worktree that git is still moving has an empty .git file; it is
// skipped like a settled one, as the watchers treat it.
if !isRoot && GitFileFromEntries(dir, entries).Nested() {
return nil
}
for _, e := range entries {
Expand Down Expand Up @@ -223,53 +226,98 @@ func skipDir(name string) bool {
return name == "_build" || name == ".git" || name == "node_modules"
}

// HasLinkedWorktreeGitFile reports whether dir, whose entries are given, is the
// top of a linked git worktree. Such a checkout nested inside the project (e.g.
// Claude Code's .claude/worktrees/) is a full copy of the repository, and
// indexing it would duplicate every definition. Scanning the entries already
// read costs no syscall; only a directory that has a .git file pays one read.
func HasLinkedWorktreeGitFile(dir string, entries []fs.DirEntry) bool {
for _, e := range entries {
if e.Name() == ".git" {
return !e.IsDir() && isLinkedWorktreeGitFile(filepath.Join(dir, ".git"))
}
}
return false
// openGitFile opens a .git file for reading. Tests replace it to count the
// reads and to serve the states git leaves while it writes the file.
var openGitFile = func(path string) (io.ReadCloser, error) { return os.Open(path) }

// GitFileState is what a directory's .git entry says about the directory.
type GitFileState int

const (
// NoGitFile: the directory has no .git file. A .git directory, as in a
// repository's own root or an old-style submodule, also counts as none.
NoGitFile GitFileState = iota
// PlainGitFile: a .git file that names a git directory that is not a
// linked worktree's, such as a submodule's. The directory is indexed.
PlainGitFile
// WorktreeGitFile: the top of a linked git worktree. Such a checkout nested
// inside the project (e.g. Claude Code's .claude/worktrees/) is a full copy
// of the repository, and indexing it would duplicate every definition.
WorktreeGitFile
// UnsettledGitFile: a .git file that names no git directory yet. Newer
// git (2.48 and later) renames a worktree on `git worktree move` and then
// writes its .git file again in place, so for a moment the file is empty.
// Such a directory is treated as a worktree and checked again later.
UnsettledGitFile
)

// Nested reports whether a walk must leave the directory out: a worktree,
// or what may be one that git has not finished writing.
func (s GitFileState) Nested() bool {
return s == WorktreeGitFile || s == UnsettledGitFile
}

// UnsettledGitFile reports whether dir holds a .git file that names no git
// directory yet. git rewrites a worktree's .git file in place when it moves the
// worktree, so for a moment after the rename the file is empty, and a directory
// that is a worktree looks like a plain one. Such a directory is checked again
// once git is done, rather than indexed.
func UnsettledGitFile(dir string) bool {
info, err := os.Lstat(filepath.Join(dir, ".git"))
// GitFile classifies dir by its .git entry. The file is read once and every
// check uses that read: two reads can see two states while git writes the
// file (empty, then complete), and then answer neither "worktree" nor
// "unsettled" for a worktree.
func GitFile(dir string) GitFileState {
path := filepath.Join(dir, ".git")
info, err := os.Lstat(path)
if err != nil || !info.Mode().IsRegular() {
return false
return NoGitFile
}
_, ok := gitdirFromFile(filepath.Join(dir, ".git"))
return !ok
return gitFileState(path)
}

// HasUnsettledGitFile is UnsettledGitFile for a directory whose entries are
// already read, so a directory without a .git file costs no syscall.
func HasUnsettledGitFile(dir string, entries []fs.DirEntry) bool {
// GitFileFromEntries is GitFile for a directory whose entries are already
// read: a directory without a .git file costs no syscall, and one with a .git
// file pays one read.
func GitFileFromEntries(dir string, entries []fs.DirEntry) GitFileState {
for _, e := range entries {
if e.Name() == ".git" {
return !e.IsDir() && UnsettledGitFile(dir)
// The same rule as GitFile: only a regular file is read. A .git
// directory, a symlink or another kind of entry counts as none.
if !e.Type().IsRegular() {
return NoGitFile
}
return gitFileState(filepath.Join(dir, ".git"))
}
}
return false
return NoGitFile
}
Comment thread
cursor[bot] marked this conversation as resolved.

func gitFileState(path string) GitFileState {
gitdir, ok := gitdirFromFile(path)
if !ok {
// A file that cannot be read, or that names no git directory, is
// what git leaves while it writes the file.
return UnsettledGitFile
}
if linkedWorktreeGitdir(path, gitdir) {
return WorktreeGitFile
}
return PlainGitFile
}

// HasLinkedWorktreeGitFile reports whether dir, whose entries are given, is the
// top of a linked git worktree. Scanning the entries already read costs no
// syscall; only a directory that has a .git file pays one read.
func HasLinkedWorktreeGitFile(dir string, entries []fs.DirEntry) bool {
return GitFileFromEntries(dir, entries) == WorktreeGitFile
}

// isLinkedWorktreeGitFile reports whether the .git file at path belongs to a
// linked worktree. Submodules also have a .git file, and they stay indexed like
// any other directory. Only a directory that has a .git file pays for this check.
func isLinkedWorktreeGitFile(path string) bool {
gitdir, ok := gitdirFromFile(path)
if !ok {
return false
}
return ok && linkedWorktreeGitdir(path, gitdir)
}

// linkedWorktreeGitdir reports whether gitdir, read from the .git file at path,
// is a linked worktree's admin directory.
func linkedWorktreeGitdir(path, gitdir string) bool {
// Git gives each linked worktree an admin directory with a commondir file,
// and a submodule's has none. This also covers worktrees of bare
// repositories. After git prunes the admin directory, only its place under
Expand Down Expand Up @@ -301,7 +349,7 @@ func isLinkedWorktreeGitFile(path string) bool {
// resolved against the file's directory. It reports false when path is not such
// a file, which includes a .git directory.
func gitdirFromFile(path string) (string, bool) {
f, err := os.Open(path)
f, err := openGitFile(path)
if err != nil {
return "", false
}
Expand Down Expand Up @@ -477,7 +525,7 @@ func CollectElixirFilesParallel(root string) []string {
if err != nil {
return
}
if dir != root && HasLinkedWorktreeGitFile(dir, entries) {
if dir != root && GitFileFromEntries(dir, entries).Nested() {
return
}

Expand Down
118 changes: 118 additions & 0 deletions internal/parser/parser_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package parser

import (
"fmt"
"io"
"io/fs"
"os"
"os/exec"
Expand Down Expand Up @@ -3886,3 +3887,120 @@ func TestHeredocLineContinuationClosesHeredoc(t *testing.T) {
})
}
}

// git worktree move renames a worktree and then writes its .git file again in
// place, so for a moment the file is empty. Both walkers skip such a
// directory, as the watchers do, rather than index a worktree on the move.
func TestWalkAndCollectSkipUnsettledGitFile(t *testing.T) {
app := t.TempDir()
for path, content := range map[string]string{
filepath.Join(app, "lib", "app.ex"): "defmodule App do\nend\n",
filepath.Join(app, "moving", "lib", "moving.ex"): "defmodule Moving do\nend\n",
filepath.Join(app, "moving", ".git"): "",
} {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
}
want := []string{filepath.Join(app, "lib", "app.ex")}
walked, collected := walkedAndCollected(t, app)
if !reflect.DeepEqual(walked, want) || !reflect.DeepEqual(collected, want) {
t.Errorf("Walk = %v, Collect = %v; want %v", walked, collected, want)
}
}

// GitFile tells each kind of .git entry apart, from one read of the file.
func TestGitFileStates(t *testing.T) {
app, wt, _ := gitRepoWithNestedWorktree(t)
write := func(dir, content string) string {
t.Helper()
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, ".git"), []byte(content), 0o644); err != nil {
t.Fatal(err)
}
return dir
}
if err := os.MkdirAll(filepath.Join(app, ".git", "modules", "shared"), 0o755); err != nil {
t.Fatal(err)
}
for _, tc := range []struct {
name string
dir string
want GitFileState
}{
{"repository root (.git directory)", app, NoGitFile},
{"no .git", filepath.Join(app, "lib"), NoGitFile},
{"linked worktree", wt, WorktreeGitFile},
{"submodule", write(filepath.Join(app, "deps", "shared"), "gitdir: ../../.git/modules/shared\n"), PlainGitFile},
{"empty .git file", write(filepath.Join(app, "moving"), ""), UnsettledGitFile},
{"not a gitdir line", write(filepath.Join(app, "odd"), "something else\n"), UnsettledGitFile},
{"symlinked .git", symlinkedGit(t, app), NoGitFile},
} {
if got := GitFile(tc.dir); got != tc.want {
t.Errorf("%s: GitFile = %v, want %v", tc.name, got, tc.want)
}
entries, err := os.ReadDir(tc.dir)
if err != nil {
t.Fatal(err)
}
if got := GitFileFromEntries(tc.dir, entries); got != tc.want {
t.Errorf("%s: GitFileFromEntries = %v, want %v", tc.name, got, tc.want)
}
}
}

// git 2.48 and later rename a worktree on `git worktree move` and then write
// its .git file again in place: a reader can find it empty, and a moment later
// complete. One classification must use one read, so that it cannot see the
// empty file for one question and the complete file for the next, and then
// answer that a worktree is neither a worktree nor unsettled.
func TestGitFileReadsTheFileOnce(t *testing.T) {
_, wt, _ := gitRepoWithNestedWorktree(t)
content, err := os.ReadFile(filepath.Join(wt, ".git"))
if err != nil {
t.Fatal(err)
}
reads := 0
previous := openGitFile
t.Cleanup(func() { openGitFile = previous })
openGitFile = func(string) (io.ReadCloser, error) {
reads++
if reads == 1 {
return io.NopCloser(strings.NewReader("")), nil
}
return io.NopCloser(strings.NewReader(string(content))), nil
}
if got := GitFile(wt); got != UnsettledGitFile || reads != 1 {
t.Errorf("GitFile = %v after %d reads, want %v after one read", got, reads, UnsettledGitFile)
}
reads = 0
entries, err := os.ReadDir(wt)
if err != nil {
t.Fatal(err)
}
if got := GitFileFromEntries(wt, entries); got != UnsettledGitFile || reads != 1 {
t.Errorf("GitFileFromEntries = %v after %d reads, want %v after one read", got, reads, UnsettledGitFile)
}
}

// symlinkedGit makes a directory whose .git is a symlink to an empty file.
func symlinkedGit(t *testing.T, app string) string {
t.Helper()
dir := filepath.Join(app, "linked")
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
target := filepath.Join(app, "empty-gitfile")
if err := os.WriteFile(target, nil, 0o644); err != nil {
t.Fatal(err)
}
if err := os.Symlink(target, filepath.Join(dir, ".git")); err != nil {
t.Fatal(err)
}
return dir
}
4 changes: 3 additions & 1 deletion internal/workspace/runtime.go
Original file line number Diff line number Diff line change
Expand Up @@ -612,7 +612,9 @@ func (r *Runtime) reconcilePath(path string) error {
// A watcher reports a directory when it turns out to be a nested
// worktree. Files indexed from it before its .git file appeared, as cp -r
// can do, are removed with one range read of the path index.
if path != r.root && parser.IsLinkedWorktree(path) {
// A worktree that git is still moving counts too: its .git file can
// be empty when the watcher reports it.
if path != r.root && parser.GitFile(path).Nested() {
under, err := r.store.ListFilePathsUnder(path)
if err != nil {
return err
Expand Down
Loading
Loading