1 Commits
Author SHA1 Message Date
sneak 23df19763f Write gen DIR's manifest to DIR/index.mf (closes #178)
check / check (push) Waiting to run
Without --output, gen given one directory now writes index.mf in it,
and given one file writes index.mf beside it; with no path or several,
it still writes index.mf in the current directory. What gen lists
depends only on its arguments and the tree, never on where the manifest
is written, so gen DIR followed by check DIR passes.

Scanner.EnumeratePaths lists a file argument by its name, as
EnumerateFile does. Before, it listed the file under an empty path and
gen stopped with "path cannot be empty".

The --output help text and the README's Tool Examples state the default.

Model: opus-5-5
2026-10-07 21:09:06 +00:00
6 changed files with 200 additions and 10 deletions
+9
View File
@@ -269,6 +269,15 @@ are now tracked only in the [issues](https://git.eeqj.de/sneak/mfer/issues).
- recurses under current directory and writes out an `index.mf`
- records every file's mode as `0000` unless given `--include-permissions`,
which records each file's permission bits (`0777` at most)
- `mfer gen /media/drive`
- writes `/media/drive/index.mf`, listing each file by its path under
`/media/drive`, so `mfer check /media/drive` verifies it. Given a file,
gen writes `index.mf` beside it and lists the file by its name; given
several paths, it writes `index.mf` in the current directory
- `--output` names another file to write instead. What gen lists depends
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
- `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
+121
View File
@@ -1289,6 +1289,127 @@ func TestGenerateLeavesLeftoverTempFileOutOfListing(t *testing.T) {
assert.Equal(t, []string{testFileTxt}, 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()
require.NoError(t, fs.MkdirAll(filepath.Join(dir, testSubdir), 0o750))
writeTestFile(t, fs, filepath.Join(dir, testFileTxt), "hello")
writeTestFile(t, fs, filepath.Join(dir, testSubdir, "nested.txt"), "in sub")
}
// TestGenerateDefaultOutput runs gen without --output on one directory or
// one file: it writes index.mf in that directory, or beside that file,
// listing each file by its path under the directory index.mf is in, and
// check given that directory passes.
func TestGenerateDefaultOutput(t *testing.T) {
t.Parallel()
// Paths are relative to a temp dir holding file.txt and sub/nested.txt.
for name, tc := range map[string]struct {
input, output string
listed []string
}{
"directory": {
".", defaultManifestName, []string{testFileTxt, "sub/nested.txt"},
},
"subdirectory": {testSubdir, testSubdirManifest, []string{"nested.txt"}},
"file": {testFileTxt, defaultManifestName, []string{testFileTxt}},
} {
t.Run(name, func(t *testing.T) {
t.Parallel()
root := t.TempDir()
fs := afero.NewOsFs()
writeTestTree(t, fs, root)
opts := testOpts([]string{
testApp, cmdGenerate, "-q", filepath.Join(root, tc.input),
}, fs)
require.Equal(t, 0, runCLI(opts), "stderr: %s", testStderr(t, opts))
output := filepath.Join(root, tc.output)
assert.ElementsMatch(t, tc.listed, manifestPaths(t, fs, output))
opts = testOpts([]string{
testApp, cmdCheck, "-q", filepath.Dir(output),
}, fs)
assert.Equal(t, 0, runCLI(opts), "stderr: %s", testStderr(t, opts))
})
}
}
// 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
// only on the tree, so every manifest has the same bytes.
func TestGenerateBytesDoNotDependOnOutput(t *testing.T) {
t.Parallel()
root := t.TempDir()
tree := filepath.Join(root, "tree")
defaultOutput := filepath.Join(tree, defaultManifestName)
fs := afero.NewOsFs()
writeTestTree(t, fs, tree)
var first []byte
for _, output := range []string{
defaultOutput,
filepath.Join(tree, "listing.mf"),
filepath.Join(tree, testSubdir, "listing.mf"),
filepath.Join(root, "outside.mf"),
} {
writeTestFile(t, fs, output, "previous manifest")
writeTestFile(t, fs, manifestTempPath(output), "part of a manifest")
args := []string{testApp, cmdGenerate, "-q", "-f", "--seed", "mfer"}
if output != defaultOutput {
args = append(args, "-o", output)
}
args = append(args, tree)
opts := testOpts(args, fs)
require.Equal(t, 0, runCLI(opts),
"output %s, stderr: %s", output, testStderr(t, opts))
got, err := afero.ReadFile(fs, output)
require.NoError(t, err)
require.NoError(t, fs.Remove(output))
if first == nil {
first = got
}
assert.Equal(t, first, got, "output %s", output)
}
}
// TestGenerateRefusesExistingDefaultOutput runs gen without --output or
// --force on a directory already holding index.mf: gen fails, naming that
// file, and leaves it as it was.
func TestGenerateRefusesExistingDefaultOutput(t *testing.T) {
t.Parallel()
output := filepath.Join(testDir, defaultManifestName)
fs := afero.NewMemMapFs()
require.NoError(t, fs.MkdirAll(testDir, 0o755))
writeTestFile(t, fs, testFile1, "hello")
writeTestFile(t, fs, output, "previous manifest")
opts := testOpts([]string{testApp, cmdGenerate, "-q", testDir}, fs)
assert.Equal(t, 1, runCLI(opts))
assert.Contains(t, testStderr(t, opts),
"output file "+output+" already exists (use --force to overwrite)")
content, err := afero.ReadFile(fs, output)
require.NoError(t, err)
assert.Equal(t, "previous manifest", string(content))
}
func TestGenerateAtomicWriteUsesTemp(t *testing.T) {
t.Parallel()
+30 -5
View File
@@ -88,9 +88,34 @@ func (mfa *CLIApp) collectInputPaths(args cli.Args) ([]string, error) {
return paths, nil
}
// buildScannerOptions constructs scanner options from the CLI flags.
func (mfa *CLIApp) buildScannerOptions(cmd *cli.Command) *mfer.ScannerOptions {
output := cmd.String("output")
// outputPath returns the file gen writes the manifest to: the one --output
// names, or else index.mf in the directory the only argument names, or
// beside the file it names, or else in the current directory.
func (mfa *CLIApp) outputPath(cmd *cli.Command) string {
if output := cmd.String("output"); output != "" {
return output
}
if cmd.Args().Len() != 1 {
return defaultManifestName
}
arg := cmd.Args().First()
// A path that does not exist is refused when it is enumerated.
info, err := mfa.Fs.Stat(arg)
if err == nil && !info.IsDir() {
return filepath.Join(filepath.Dir(arg), defaultManifestName)
}
return filepath.Join(arg, defaultManifestName)
}
// buildScannerOptions constructs scanner options from the CLI flags and
// the path the manifest is written to.
func (mfa *CLIApp) buildScannerOptions(
cmd *cli.Command, output string,
) *mfer.ScannerOptions {
opts := &mfer.ScannerOptions{
IncludeDotfiles: cmd.Bool("include-dotfiles"),
FollowSymLinks: cmd.Bool("follow-symlinks"),
@@ -207,7 +232,8 @@ func (mfa *CLIApp) generateManifestOperation(
) error {
log.Debug("generateManifestOperation()")
s := mfer.NewScannerWithOptions(mfa.buildScannerOptions(cmd))
outputPath := mfa.outputPath(cmd)
s := mfer.NewScannerWithOptions(mfa.buildScannerOptions(cmd, outputPath))
// Phase 1: Enumeration - collect paths and stat files
err := mfa.runEnumeratePhase(cmd, s)
@@ -218,7 +244,6 @@ func (mfa *CLIApp) generateManifestOperation(
showProgress := cmd.Bool("progress")
// Check if output file exists
outputPath := cmd.String("output")
if exists, _ := afero.Exists(mfa.Fs, outputPath); exists && !cmd.Bool("force") {
return fmt.Errorf("output file %s %w", outputPath, errOutputExists)
}
+3 -2
View File
@@ -211,9 +211,10 @@ func (mfa *CLIApp) generateCommand() *cli.Command {
},
&cli.StringFlag{
Name: "output",
Value: defaultManifestName,
Aliases: []string{"o"},
Usage: "Specify output filename",
Usage: "File to write the manifest to (default: index.mf in " +
"the directory given, or beside the file given; with no " +
"path or several, index.mf in the current directory)",
},
&cli.BoolFlag{
Name: "force",
+15 -3
View File
@@ -160,7 +160,9 @@ func (s *Scanner) EnumeratePath(
return s.enumerateFS(afs, abs, progress)
}
// EnumeratePaths walks multiple directory paths and adds all files to the scanner.
// EnumeratePaths adds to the scanner the files under each directory path,
// listed by their paths under it, and each file path, listed by its name
// as EnumerateFile lists it.
// If progress is non-nil, status updates are sent as files are discovered.
// The progress channel is closed when the method returns.
func (s *Scanner) EnumeratePaths(
@@ -177,9 +179,19 @@ func (s *Scanner) EnumeratePaths(
return err
}
afs := afero.NewReadOnlyFs(afero.NewBasePathFs(s.fs, abs))
info, err := s.fs.Stat(abs)
if err != nil {
return err
}
if info.IsDir() {
afs := afero.NewReadOnlyFs(afero.NewBasePathFs(s.fs, abs))
err = s.enumerateFS(afs, abs, progress)
} else {
err = s.enumerateFileWithInfo(
filepath.Base(abs), filepath.Dir(abs), info, progress)
}
err = s.enumerateFS(afs, abs, progress)
if err != nil {
return err
}
+22
View File
@@ -134,6 +134,28 @@ func TestScannerEnumeratePaths(t *testing.T) {
assert.Equal(t, FileCount(2), s.FileCount())
}
// TestScannerEnumeratePathsFile gives EnumeratePaths a directory and a
// file: the file is listed by its name, as EnumerateFile lists it.
func TestScannerEnumeratePathsFile(t *testing.T) {
t.Parallel()
fs := afero.NewMemMapFs()
require.NoError(t, fs.MkdirAll("/dir/sub", 0o755))
require.NoError(t, fs.MkdirAll("/other", 0o755))
require.NoError(t, afero.WriteFile(fs, "/dir/sub/one.txt", []byte("1"), 0o644))
require.NoError(t, afero.WriteFile(fs, "/other/two.txt", []byte("2"), 0o644))
s := NewScannerWithOptions(&ScannerOptions{Fs: fs})
require.NoError(t, s.EnumeratePaths(nil, "/dir", "/other/two.txt"))
paths := make([]RelFilePath, 0, s.FileCount())
for _, f := range s.Files() {
paths = append(paths, f.Path)
}
assert.Equal(t, []RelFilePath{"sub/one.txt", "two.txt"}, paths)
}
func TestScannerExcludeDotfiles(t *testing.T) {
t.Parallel()