From f360f1c070b99b8d0bb7d1b98365dad3a0f1e588 Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Fri, 16 Oct 2026 00:42:24 +0200 Subject: [PATCH] Never report index.mf at the top of the base as unlisted in check (closes #229) gen and fetch write the manifest as index.mf at the top of the tree. check left out only the manifest file it was given, so given a URL, or a manifest stored elsewhere, it reported that index.mf as not in the manifest, and --no-extra-files failed a tree fetch had just verified. check now drops that one report for index.mf at the top of the base directory. A manifest that lists index.mf still has it checked, and index.mf in a subdirectory or fetch's leftover .index.mf.tmp are still reported. The change is in package cli, which owns the default manifest name; the Checker in package mfer is unchanged. Model: opus-5-5 --- README.md | 3 + internal/cli/check.go | 9 ++- internal/cli/entry_test.go | 147 +++++++++++++++++++++++++++++++++++++ 3 files changed, 158 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index bd928fc..c24a2ab 100644 --- a/README.md +++ b/README.md @@ -301,6 +301,9 @@ are now tracked only in the [issues](https://git.eeqj.de/sneak/mfer/issues). `--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 + - never reports `index.mf` at the top of the base directory as a file the + manifest does not list, even when given another manifest or a URL, since + gen and fetch write the manifest there - `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/check.go b/internal/cli/check.go index c848307..9104a5c 100644 --- a/internal/cli/check.go +++ b/internal/cli/check.go @@ -195,7 +195,10 @@ func countCheckFailures( // findExtraFiles reports files present on disk but absent from the // manifest, and anything the search cannot read: each is a failure, logged -// at error level, under --no-extra-files, otherwise a warning. +// at error level, under --no-extra-files, otherwise a warning. index.mf at +// the top of the base directory is never reported as absent from the +// manifest, even when check is given another manifest or a URL, since gen +// and fetch write the manifest there. func findExtraFiles( ctx context.Context, cmd *cli.Command, chk *mfer.Checker, failures *int64, ) error { @@ -204,6 +207,10 @@ func findExtraFiles( go func() { for result := range extraResults { + if result.Status == mfer.StatusExtra && result.Path == defaultManifestName { + continue + } + if cmd.Bool("no-extra-files") { *failures++ diff --git a/internal/cli/entry_test.go b/internal/cli/entry_test.go index 768087e..f025383 100644 --- a/internal/cli/entry_test.go +++ b/internal/cli/entry_test.go @@ -1190,6 +1190,99 @@ func TestCheckURLManifestWithoutTempDirectory(t *testing.T) { assert.Equal(t, 0, runCLI(opts), "stderr: %s", testStderr(t, opts)) } +// TestCheckNeverReportsIndexMFAtTopOfBase checks a tree holding index.mf at +// its top, where gen and fetch write the manifest, against a manifest that +// does not list it, given by URL and by a path outside the tree: check never +// reports it, and passes with and without --no-extra-files. index.mf in a +// subdirectory, and the temp file fetch saves index.mf through, are still +// reported. +func TestCheckNeverReportsIndexMFAtTopOfBase(t *testing.T) { + t.Parallel() + + files := map[string][]byte{testFileTxt: []byte("hello")} + manifest := manifestOf(t, files) + + server := httptest.NewServer(fetchTestHandler(manifest, files)) + t.Cleanup(server.Close) + + manifestOutsideTree := filepath.Join(t.TempDir(), "other.mf") + require.NoError(t, os.WriteFile(manifestOutsideTree, manifest, 0o600)) + + for name, manifestArg := range map[string]string{ + "manifest by URL": server.URL + "/" + defaultManifestName, + "manifest by path": manifestOutsideTree, + } { + t.Run(name, func(t *testing.T) { + t.Parallel() + + base := t.TempDir() + require.NoError(t, os.WriteFile( + filepath.Join(base, testFileTxt), files[testFileTxt], 0o600)) + require.NoError(t, os.WriteFile( + filepath.Join(base, defaultManifestName), manifest, 0o600)) + + check := func(flags ...string) (int, string) { + opts := testOpts(slices.Concat( + []string{testApp, cmdCheck}, flags, + []string{testFlagBase, base, manifestArg}, + ), afero.NewOsFs()) + + return runCLI(opts), testStderr(t, opts) + } + + exitCode, stderr := check() + assert.Equal(t, 0, exitCode, "stderr: %s", stderr) + assert.NotContains(t, stderr, "EXTRA: "+defaultManifestName) + + exitCode, stderr = check(testFlagNoExtra) + assert.Equal(t, 0, exitCode, "stderr: %s", stderr) + assert.NotContains(t, stderr, "EXTRA: "+defaultManifestName) + + reported := []string{ + testSubdirManifest, + tempPathFor(defaultManifestName), + } + + require.NoError(t, os.Mkdir(filepath.Join(base, testSubdir), 0o750)) + + for _, path := range reported { + require.NoError(t, + os.WriteFile(filepath.Join(base, path), manifest, 0o600)) + } + + exitCode, stderr = check(testFlagNoExtra) + assert.Equal(t, 1, exitCode, "stderr: %s", stderr) + assert.NotContains(t, stderr, "EXTRA: "+defaultManifestName) + + for _, path := range reported { + assert.Contains(t, stderr, "EXTRA: "+path) + } + }) + } +} + +// TestCheckChecksListedIndexMF checks a tree against a manifest that lists +// index.mf at the top of the tree, where that file has changed since: check +// reports it like any other changed file. +func TestCheckChecksListedIndexMF(t *testing.T) { + t.Parallel() + + manifestPath := filepath.Join(t.TempDir(), "other.mf") + require.NoError(t, os.WriteFile(manifestPath, manifestOf(t, map[string][]byte{ + defaultManifestName: []byte("listed"), + }), 0o600)) + + base := t.TempDir() + require.NoError(t, os.WriteFile( + filepath.Join(base, defaultManifestName), []byte("edited"), 0o600)) + + opts := testOpts([]string{ + testApp, cmdCheck, testFlagBase, base, manifestPath, + }, afero.NewOsFs()) + assert.Equal(t, 1, runCLI(opts), "stderr: %s", testStderr(t, opts)) + assert.Contains(t, testStderr(t, opts), "HASH_MISMATCH: "+defaultManifestName) +} + // unlistableDirFs is a filesystem on which one directory cannot be listed. type unlistableDirFs struct { afero.Fs @@ -1238,6 +1331,60 @@ func TestCheckWithUnlistableDirectory(t *testing.T) { assert.Contains(t, testStderr(t, opts), "unlisted.txt") } +// TestCheckReportsUnreadableIndexMFAtTopOfBase has a directory named +// index.mf at the top of the base that cannot be listed. check leaves out +// only the report that index.mf is not in the manifest, so it reports the +// error and fails under --no-extra-files. +func TestCheckReportsUnreadableIndexMFAtTopOfBase(t *testing.T) { + t.Parallel() + + unreadable := filepath.Join(testDir, defaultManifestName) + + mem := afero.NewMemMapFs() + require.NoError(t, mem.MkdirAll(unreadable, 0o755)) + writeTestFile(t, mem, testFile1, "hello") + + opts := testOpts([]string{ + testApp, cmdGenerate, "-q", "-o", testManifest, testDir, + }, mem) + require.Equal(t, 0, runCLI(opts), "stderr: %s", testStderr(t, opts)) + + fs := unlistableDirFs{Fs: mem, dir: unreadable} + + opts = testOpts([]string{ + testApp, cmdCheck, testFlagNoExtra, testFlagBase, testDir, testManifest, + }, fs) + assert.Equal(t, 1, runCLI(opts), "stderr: %s", testStderr(t, opts)) + assert.Contains(t, testStderr(t, opts), "ERROR: "+defaultManifestName) +} + +// TestCheckReportsUpperCaseIndexMFAtTopOfBase checks a tree holding +// INDEX.MF at its top against a manifest that does not list it. Only the +// name index.mf, in exactly that letter case, is left out, so check reports +// INDEX.MF. The tree is in memory, where the two names are two files +// whatever the letter case rules of the host's filesystem. +func TestCheckReportsUpperCaseIndexMFAtTopOfBase(t *testing.T) { + t.Parallel() + + upperCase := strings.ToUpper(defaultManifestName) + + fs := afero.NewMemMapFs() + writeTestFile(t, fs, testFile1, "hello") + + opts := testOpts([]string{ + testApp, cmdGenerate, "-q", "-o", testManifest, testDir, + }, fs) + require.Equal(t, 0, runCLI(opts), "stderr: %s", testStderr(t, opts)) + + writeTestFile(t, fs, filepath.Join(testDir, upperCase), "not listed") + + opts = testOpts([]string{ + testApp, cmdCheck, testFlagNoExtra, testFlagBase, testDir, testManifest, + }, fs) + assert.Equal(t, 1, runCLI(opts), "stderr: %s", testStderr(t, opts)) + assert.Contains(t, testStderr(t, opts), "EXTRA: "+upperCase) +} + func TestGenerateAtomicWriteNoTempFileOnSuccess(t *testing.T) { t.Parallel()