From 5ab30f183d87ef536a2b60411ee42e9d8e0fa57a Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Tue, 13 Oct 2026 17:47:08 +0000 Subject: [PATCH] Report only symlinks to regular files as extra files in check (closes #192) gen never lists a symlink to a directory or a broken one, and lists a symlink to a regular file only with --follow-symlinks. check's search for extra files reported every symlink, so on a tree holding a directory symlink no manifest gen writes could pass check --no-extra-files. The search now resolves a symlink and reports an entry only if it is a regular file, which also covers the pipes, sockets and devices it already left out. The README's check entry says what is reported. Judgement call: one regular-file check replaces the separate test for pipes, sockets and devices, so a file of unknown type is not reported either; gen does not list one. Model: opus-5-5 --- README.md | 9 +++++--- internal/cli/entry_test.go | 47 ++++++++++++++++++++++++++++++++++++++ mfer/checker.go | 23 +++++++++++-------- 3 files changed, 66 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index fdc96a7..10c0940 100644 --- a/README.md +++ b/README.md @@ -289,9 +289,12 @@ are now tracked only in the [issues](https://git.eeqj.de/sneak/mfer/issues). else the directory holding the manifest, or the current directory for a manifest given by URL. So `mfer check /media/drive` checks a drive against the `index.mf` at its root, from any directory - - warns about each file under the base directory that the manifest does not - list, hidden files included; with `--no-extra-files` each one is a failure - instead + - warns about each regular file under the base directory that the manifest + does not list, hidden files included, and each symlink there to a regular + file, which gen lists only with `--follow-symlinks`; with + `--no-extra-files` each one is a failure instead. A named pipe, socket or + device, a symlink to a directory and a broken symlink are never reported, + since gen cannot list them - `mfer freshen` / `mfer freshen .` - rewrites `index.mf` to list the files now under the directory holding it, or under the one `--base` names, hashing only the files that are new or diff --git a/internal/cli/entry_test.go b/internal/cli/entry_test.go index 600e571..d360ca5 100644 --- a/internal/cli/entry_test.go +++ b/internal/cli/entry_test.go @@ -960,6 +960,53 @@ func TestCheckWarnsAboutUnlistedFiles(t *testing.T) { } } +// TestCheckReportsUnlistedSymlinkToFileOnly makes a tree holding a symlink +// to a directory, one to a file and a broken one. gen lists the symlink to +// the file only with --follow-symlinks, and never lists the other two, so +// check --no-extra-files passes on the manifest gen --follow-symlinks writes +// and fails only on the symlink to the file on the one plain gen writes. +func TestCheckReportsUnlistedSymlinkToFileOnly(t *testing.T) { + t.Parallel() + + for name, tc := range map[string]struct { + genFlags []string + exitCode int + extras []string // what check reports as not in the manifest + }{ + "gen --follow-symlinks": {[]string{"--follow-symlinks"}, 0, nil}, + "plain gen": {nil, 1, []string{"filelink"}}, + } { + t.Run(name, func(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + fs := afero.NewOsFs() + writeTestTree(t, fs, dir) + require.NoError(t, os.Symlink(testSubdir, filepath.Join(dir, "dirlink"))) + require.NoError(t, os.Symlink(testFileTxt, filepath.Join(dir, "filelink"))) + require.NoError(t, + os.Symlink("nowhere", filepath.Join(dir, "brokenlink"))) + + opts := testOpts(slices.Concat( + []string{testApp, cmdGenerate, "-q"}, tc.genFlags, []string{dir}, + ), fs) + require.Equal(t, 0, runCLI(opts), "stderr: %s", testStderr(t, opts)) + + opts = testOpts([]string{testApp, cmdCheck, testFlagNoExtra, dir}, fs) + exitCode := runCLI(opts) + stderr := testStderr(t, opts) + + assert.Equal(t, tc.exitCode, exitCode, "stderr: %s", stderr) + assert.Equal(t, len(tc.extras), + strings.Count(stderr, "not in manifest"), stderr) + + for _, extra := range tc.extras { + assert.Contains(t, stderr, "EXTRA: "+extra) + } + }) + } +} + // TestCheckNeverReportsManifest keeps the manifest inside the checked tree, // under several names and path spellings, and names the tree both directly // and through a symlink: the manifest is never reported, even under diff --git a/mfer/checker.go b/mfer/checker.go index 8cf79f9..a34b689 100644 --- a/mfer/checker.go +++ b/mfer/checker.go @@ -264,11 +264,12 @@ func (c *Checker) Check( return nil } -// FindExtraFiles walks the filesystem and reports files not in the manifest, -// hidden files and directories included. The manifest file itself is not -// reported. Anything the search cannot read, such as a directory that cannot -// be listed, is reported with StatusError and the search goes on. Results are -// sent to the results channel. The channel is closed when done. +// FindExtraFiles walks the filesystem and reports the regular files, and +// symlinks to them, that are not in the manifest, hidden files and +// directories included. The manifest file itself is not reported. Anything +// the search cannot read, such as a directory that cannot be listed, is +// reported with StatusError and the search goes on. Results are sent to the +// results channel. The channel is closed when done. func (c *Checker) FindExtraFiles(ctx context.Context, results chan<- Result) error { if results != nil { defer close(results) @@ -318,11 +319,6 @@ func (c *Checker) FindExtraFiles(ctx context.Context, results chan<- Result) err return nil } - // gen never lists a named pipe, a socket or a device. - if info.Mode()&(os.ModeNamedPipe|os.ModeSocket|os.ModeDevice) != 0 { - return nil - } - // A symlink is compared by what it points to, so a manifest reached // through one is not reported either. if info.Mode()&os.ModeSymlink != 0 { @@ -332,6 +328,13 @@ func (c *Checker) FindExtraFiles(ctx context.Context, results chan<- Result) err } } + // gen lists regular files and symlinks to them only, so a named + // pipe, a socket, a device, a symlink to a directory and a broken + // symlink are never reported. + if !info.Mode().IsRegular() { + return nil + } + // Skip the manifest file itself, however its path is spelled if os.SameFile(info, c.manifestInfo) { return nil