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