From 5e0b851003ebf7584a9eee84144578024a4a5bab Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Thu, 8 Oct 2026 02:45:47 +0000 Subject: [PATCH] List a directory named through a symlink in gen and freshen (closes #185) The walk in gen and freshen does not follow a symlink at its top. A directory given as a symlink, or a working directory whose path names one, was listed as empty and gen exited 0, while freshen removed every entry from the manifest. Each walk now resolves its starting directory with filepath.EvalSymlinks first, as check's search for extra files does, and starts at the path as named if that fails. Symlinks inside the tree are still skipped unless --follow-symlinks is given. Model: opus-5-5 --- internal/cli/entry_test.go | 41 +++++++++++++++++++++++++++++++++++ internal/cli/freshen.go | 10 ++++++++- internal/cli/freshen_test.go | 42 ++++++++++++++++++++++++++++++++++++ mfer/scanner.go | 16 ++++++++++++++ 4 files changed, 108 insertions(+), 1 deletion(-) diff --git a/internal/cli/entry_test.go b/internal/cli/entry_test.go index b27ea7e..d3e4e79 100644 --- a/internal/cli/entry_test.go +++ b/internal/cli/entry_test.go @@ -1369,6 +1369,47 @@ func TestGenerateSeveralPathsDefaultOutput(t *testing.T) { } } +// TestGenerateDirectoryNamedThroughSymlink runs gen on a directory named +// through a symlink, given as the argument or as the working directory: the +// manifest lists the files in the directory the symlink points to, and +// leaves out a symlink inside it, as gen does without --follow-symlinks. +// +//nolint:paralleltest // changes the process-global working directory +func TestGenerateDirectoryNamedThroughSymlink(t *testing.T) { + // Paths are relative to a temp dir holding data and link, a symlink to + // data. + for name, tc := range map[string]struct { + workDir string + args []string + }{ + "argument": {".", []string{"link"}}, + "working directory": {"link", nil}, + } { + t.Run(name, func(t *testing.T) { + root := t.TempDir() + data := filepath.Join(root, "data") + + fs := afero.NewOsFs() + writeTestTree(t, fs, data) + require.NoError(t, + os.Symlink(testFileTxt, filepath.Join(data, "alias.txt"))) + require.NoError(t, os.Symlink(data, filepath.Join(root, "link"))) + + // t.Chdir sets PWD to the path it is given, as a shell does, and + // os.Getwd returns PWD when it names the working directory. + t.Chdir(filepath.Join(root, tc.workDir)) + + opts := testOpts(slices.Concat( + []string{testApp, cmdGenerate, "-q"}, tc.args, + ), fs) + require.Equal(t, 0, runCLI(opts), "stderr: %s", testStderr(t, opts)) + + assert.ElementsMatch(t, []string{testFileTxt, "sub/nested.txt"}, + manifestPaths(t, fs, filepath.Join(data, defaultManifestName))) + }) + } +} + // TestGenerateBytesDoNotDependOnOutput runs gen --seed on one tree, each // time writing to another file, over a file already there and beside an // earlier run's temp file: neither is listed, and what is listed depends diff --git a/internal/cli/freshen.go b/internal/cli/freshen.go index 5e0e2e0..eace12a 100644 --- a/internal/cli/freshen.go +++ b/internal/cli/freshen.go @@ -379,6 +379,14 @@ func (mfa *CLIApp) freshenScan( } } + // The walk does not follow a symlink at its top, so a base directory + // named through one is resolved first. If that fails, the base is + // walked as named and the walk reports the problem. + resolved, err := filepath.EvalSymlinks(absBase) + if err == nil { + absBase = resolved + } + scanner := &freshenScanner{ fs: mfa.Fs, absBase: absBase, @@ -390,7 +398,7 @@ func (mfa *CLIApp) freshenScan( existingByPath: existingByPath, } - err := afero.Walk(mfa.Fs, absBase, scanner.walk) + err = afero.Walk(mfa.Fs, absBase, scanner.walk) if showProgress { log.ProgressDone() diff --git a/internal/cli/freshen_test.go b/internal/cli/freshen_test.go index addac24..6a66d87 100644 --- a/internal/cli/freshen_test.go +++ b/internal/cli/freshen_test.go @@ -336,6 +336,48 @@ func TestFreshenResolvesEntriesAgainstManifestDirectory(t *testing.T) { } } +// TestFreshenDirectoryNamedThroughSymlink adds a file to a tree after gen +// made its manifest, then freshens it with the tree named through a symlink, +// given as the argument or as the working directory: the manifest lists the +// files in the tree, and leaves out a symlink inside it, as freshen does +// without --follow-symlinks. +// +//nolint:paralleltest // changes the process-global working directory +func TestFreshenDirectoryNamedThroughSymlink(t *testing.T) { + // Paths are relative to a temp dir holding link, a symlink to the tree. + for name, tc := range map[string]struct { + workDir string + args []string + }{ + "argument": {".", []string{"link"}}, + "working directory": {"link", nil}, + } { + t.Run(name, func(t *testing.T) { + fs := afero.NewOsFs() + tree, manifestPath := setupFreshenDir(t, fs, + map[string]string{testFileTxt: "content1"}) + writeTestFile(t, fs, filepath.Join(tree, "added.txt"), "added") + require.NoError(t, + os.Symlink(testFileTxt, filepath.Join(tree, "alias.txt"))) + + root := t.TempDir() + require.NoError(t, os.Symlink(tree, filepath.Join(root, "link"))) + + // t.Chdir sets PWD to the path it is given, as a shell does, and + // os.Getwd returns PWD when it names the working directory. + t.Chdir(filepath.Join(root, tc.workDir)) + + opts := testOpts(slices.Concat( + []string{testApp, cmdFreshen, "-q"}, tc.args, + ), fs) + require.Equal(t, 0, runCLI(opts), "stderr: %s", testStderr(t, opts)) + + assertManifestLists(t, fs, tree, manifestPath, + map[string]string{testFileTxt: "content1", "added.txt": "added"}) + }) + } +} + // TestFreshenRecordEntryMtimePresence pins the behavior of recordEntry // with respect to MFFilePath.Mtime, which is a message pointer with // proto3 field presence and may legitimately be absent. diff --git a/mfer/scanner.go b/mfer/scanner.go index 058c227..d8be7bc 100644 --- a/mfer/scanner.go +++ b/mfer/scanner.go @@ -155,6 +155,14 @@ func (s *Scanner) EnumeratePath( return err } + // The walk does not follow a symlink at its top, so a directory named + // through one is resolved first. If that fails, the directory is walked + // as named and the walk reports the problem. + resolved, err := filepath.EvalSymlinks(abs) + if err == nil { + abs = resolved + } + afs := afero.NewReadOnlyFs(afero.NewBasePathFs(s.fs, abs)) return s.enumerateFS(afs, abs, progress) @@ -185,6 +193,14 @@ func (s *Scanner) EnumeratePaths( } if info.IsDir() { + // The walk does not follow a symlink at its top, so a directory + // named through one is resolved first. If that fails, the + // directory is walked as named and the walk reports the problem. + resolved, evalErr := filepath.EvalSymlinks(abs) + if evalErr == nil { + abs = resolved + } + afs := afero.NewReadOnlyFs(afero.NewBasePathFs(s.fs, abs)) err = s.enumerateFS(afs, abs, progress) } else {