From 2b692fad7caa21242b5106f88d7f699e26f2ce22 Mon Sep 17 00:00:00 2001 From: sneak Date: Thu, 15 Oct 2026 21:35:00 +0000 Subject: [PATCH] Never list index.mf at the top of the tree in gen and freshen (closes #233) fetch saves the manifest as index.mf at the top of the tree and refuses a manifest that lists a file there. gen given --output, and freshen with its manifest outside the base directory, listed such a file, so they wrote manifests fetch would not take. gen now leaves out index.mf in each directory it is given, or beside each file it is given, the same way it leaves out the file it writes. freshen leaves out index.mf at the top of the base directory. index.mf in a subdirectory is still listed. The README says so for both commands. Model: opus-5-5 --- README.md | 6 +++- internal/cli/entry_test.go | 60 ++++++++++++++++++++++++++++++++++++ internal/cli/freshen.go | 13 +++++--- internal/cli/freshen_test.go | 28 +++++++++++++++++ internal/cli/gen.go | 24 ++++++++++++--- 5 files changed, 122 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index bd928fc..75b6559 100644 --- a/README.md +++ b/README.md @@ -287,6 +287,9 @@ are now tracked only in the [issues](https://git.eeqj.de/sneak/mfer/issues). only on the paths it is given and the files under them, so with the same `--seed` and an unchanged tree it writes the same bytes wherever the manifest goes. The file it writes to is never listed + - never lists `index.mf` at the top of a directory it is given, or a file it + is given by that name, wherever it writes the manifest, since `fetch` + saves the manifest there; `index.mf` in a subdirectory is listed - `mfer check` / `mfer check .` - verifies checksums of all files in manifest, displaying error and exiting nonzero if any files are missing or corrupted, or have permission bits @@ -305,7 +308,8 @@ are now tracked only in the [issues](https://git.eeqj.de/sneak/mfer/issues). - 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 changed - - lists regular files only, as gen does + - lists regular files only, and never `index.mf` at the top of that + directory, as gen does - leaves out hidden files unless given `--include-dotfiles`, and symlinks unless given `--follow-symlinks`, which lists each symlink to a regular file under its own name with the contents of the file it points to diff --git a/internal/cli/entry_test.go b/internal/cli/entry_test.go index 768087e..c3255cb 100644 --- a/internal/cli/entry_test.go +++ b/internal/cli/entry_test.go @@ -1420,6 +1420,66 @@ func TestGenerateLeavesLeftoverTempFileOutOfListing(t *testing.T) { assert.Equal(t, []string{testFileTxt}, manifestPaths(t, fs, output)) } +// TestGenerateLeavesTopIndexMFOutOfListing runs gen with --output outside +// the paths it is given: index.mf at the top of each directory given, and +// a file given by that name, are not listed, since fetch saves the manifest +// as index.mf at the top of the tree, while sub/index.mf is listed. The +// files are recognized by file identity, which needs the real filesystem. +func TestGenerateLeavesTopIndexMFOutOfListing(t *testing.T) { + t.Parallel() + + // Paths are relative to a temp dir holding tree, which holds file.txt, + // index.mf and sub/index.mf, and second, which holds second.txt and + // index.mf. + for name, tc := range map[string]struct { + paths []string + listed []string + }{ + "directory": { + []string{"tree"}, + []string{testFileTxt, testSubdirManifest}, + }, + "two directories": { + []string{"tree", "second"}, + []string{testFileTxt, testSubdirManifest, "second.txt"}, + }, + "files": { + []string{"tree/" + testFileTxt, "tree/" + defaultManifestName}, + []string{testFileTxt}, + }, + } { + t.Run(name, func(t *testing.T) { + t.Parallel() + + root := t.TempDir() + tree := filepath.Join(root, "tree") + second := filepath.Join(root, "second") + + fs := afero.NewOsFs() + require.NoError(t, fs.MkdirAll(filepath.Join(tree, testSubdir), 0o750)) + require.NoError(t, fs.MkdirAll(second, 0o750)) + writeTestFile(t, fs, filepath.Join(tree, testFileTxt), "hello") + writeTestFile(t, fs, filepath.Join(tree, defaultManifestName), "an ordinary file") + writeTestFile(t, fs, filepath.Join(tree, testSubdirManifest), "an ordinary file") + writeTestFile(t, fs, filepath.Join(second, "second.txt"), "second") + writeTestFile(t, fs, filepath.Join(second, defaultManifestName), "an ordinary file") + + paths := make([]string, 0, len(tc.paths)) + for _, path := range tc.paths { + paths = append(paths, filepath.Join(root, path)) + } + + output := filepath.Join(root, "other.mf") + opts := testOpts(slices.Concat( + []string{testApp, cmdGenerate, "-q", "-o", output}, paths, + ), fs) + require.Equal(t, 0, runCLI(opts), "stderr: %s", testStderr(t, opts)) + + assert.ElementsMatch(t, tc.listed, manifestPaths(t, fs, output)) + }) + } +} + // writeTestTree writes file.txt and sub/nested.txt under dir. func writeTestTree(t *testing.T, fs afero.Fs, dir string) { t.Helper() diff --git a/internal/cli/freshen.go b/internal/cli/freshen.go index 6e01b53..dfa495c 100644 --- a/internal/cli/freshen.go +++ b/internal/cli/freshen.go @@ -411,12 +411,17 @@ func (mfa *CLIApp) freshenScan( startScan := time.Now() showProgress := cmd.Bool("progress") - // Leave out the manifest and a temp file left by an interrupted run, - // as gen does. A path that cannot be stat'd, normally because no file - // is there, needs no leaving out. + // Leave out the manifest, a temp file left by an interrupted run, and + // index.mf at the top of the base directory, where fetch saves the + // manifest, as gen does. A path that cannot be stat'd, normally because + // no file is there, needs no leaving out. var excluded []fs.FileInfo - for _, p := range []string{manifestPath, manifestTempPath(manifestPath)} { + for _, p := range []string{ + manifestPath, + manifestTempPath(manifestPath), + filepath.Join(absBase, defaultManifestName), + } { info, err := mfa.Fs.Stat(p) if err == nil { excluded = append(excluded, info) diff --git a/internal/cli/freshen_test.go b/internal/cli/freshen_test.go index e31d480..a5325d0 100644 --- a/internal/cli/freshen_test.go +++ b/internal/cli/freshen_test.go @@ -413,6 +413,34 @@ func TestFreshenLeavesLeftoverTempFileOutOfListing(t *testing.T) { manifestPaths(t, fs, manifestPath)) } +// TestFreshenLeavesTopIndexMFOutOfListing freshens a manifest kept outside +// the directory --base names, after index.mf and sub/index.mf are added +// there: index.mf at the top is not listed, since fetch saves the manifest +// there, while sub/index.mf is listed. The file is recognized by file +// identity, which needs the real filesystem. +func TestFreshenLeavesTopIndexMFOutOfListing(t *testing.T) { + t.Parallel() + + root := t.TempDir() + tree := filepath.Join(root, "tree") + manifestPath := filepath.Join(root, "other.mf") + + fs := afero.NewOsFs() + require.NoError(t, fs.MkdirAll(filepath.Join(tree, testSubdir), 0o750)) + writeTestFile(t, fs, filepath.Join(tree, testFileTxt), "hello") + + opts := testOpts([]string{testApp, cmdGenerate, "-q", "-o", manifestPath, tree}, fs) + require.Equal(t, 0, runCLI(opts), "stderr: %s", testStderr(t, opts)) + + writeTestFile(t, fs, filepath.Join(tree, defaultManifestName), "an ordinary file") + writeTestFile(t, fs, filepath.Join(tree, testSubdirManifest), "an ordinary file") + + runFreshen(t, fs, tree, manifestPath) + + assert.ElementsMatch(t, []string{testFileTxt, testSubdirManifest}, + manifestPaths(t, fs, manifestPath)) +} + // TestFreshenResolvesEntriesAgainstManifestDirectory adds sub/c.txt, then // runs freshen from the directory above sub, on the manifest in sub. // Without --base, the manifest then lists the files in sub, whether freshen diff --git a/internal/cli/gen.go b/internal/cli/gen.go index 0dda37a..897e397 100644 --- a/internal/cli/gen.go +++ b/internal/cli/gen.go @@ -111,15 +111,19 @@ func (mfa *CLIApp) outputPath(cmd *cli.Command) (string, error) { return defaultManifestName, nil } - arg := cmd.Args().First() + return mfa.topIndexMFPath(cmd.Args().First()), nil +} +// topIndexMFPath returns index.mf in the directory path names, or beside +// the file it names, which is the top of the tree gen lists for that path. +func (mfa *CLIApp) topIndexMFPath(path string) string { // A path that does not exist is refused when it is enumerated. - info, err := mfa.Fs.Stat(arg) + info, err := mfa.Fs.Stat(path) if err == nil && !info.IsDir() { - return filepath.Join(filepath.Dir(arg), defaultManifestName), nil + return filepath.Join(filepath.Dir(path), defaultManifestName) } - return filepath.Join(arg, defaultManifestName), nil + return filepath.Join(path, defaultManifestName) } // buildScannerOptions constructs scanner options from the CLI flags and @@ -139,6 +143,18 @@ func (mfa *CLIApp) buildScannerOptions( ExcludePaths: []string{output, manifestTempPath(output)}, } + // Nor does index.mf at the top of the tree, since fetch saves the + // manifest there and refuses a manifest that lists it. With no path + // given, gen lists the current directory. + paths := cmd.Args().Slice() + if len(paths) == 0 { + paths = []string{"."} + } + + for _, path := range paths { + opts.ExcludePaths = append(opts.ExcludePaths, mfa.topIndexMFPath(path)) + } + // Set seed for deterministic UUID if provided if seed := cmd.String("seed"); seed != "" { opts.Seed = seed