From 256881c571dc91d4511bd9909da514b5bb796cff Mon Sep 17 00:00:00 2001 From: sneak Date: Wed, 7 Oct 2026 14:12:53 +0000 Subject: [PATCH] Resolve check and freshen paths against the manifest's directory (closes #177) Without --base, check and freshen now look for a manifest's files in the directory that holds the manifest, the file named or the one found in a directory argument, instead of the current directory. check of a manifest given by URL still uses the current directory, and a --base given on the command line, the current directory included, still overrides. The --base help text of both commands and the README's Tool Examples state the default; the README gains a freshen entry to do so. Model: opus-5-5 --- README.md | 8 ++++ internal/cli/check.go | 12 +++++- internal/cli/entry_test.go | 78 ++++++++++++++++++++++++++++++++++++ internal/cli/freshen.go | 8 +++- internal/cli/freshen_test.go | 36 +++++++++++++++++ internal/cli/mfer.go | 14 ++++--- 6 files changed, 148 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index cdc8d8d..0f9ffdb 100644 --- a/README.md +++ b/README.md @@ -273,9 +273,17 @@ are now tracked only in the [issues](https://git.eeqj.de/sneak/mfer/issues). - verifies checksums of all files in manifest, displaying error and exiting nonzero if any files are missing or corrupted, or have permission bits other than the mode the manifest records, unless that is `0000` + - looks for those files under the base directory: the one `--base` names, or + 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 +- `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 + changed - `mfer fetch https://example.com/stuff/` - fetches `/stuff/index.mf` and downloads all files listed in manifest into the current directory, or the one given with `--dest`, and assures diff --git a/internal/cli/check.go b/internal/cli/check.go index b629a93..f8ca7a7 100644 --- a/internal/cli/check.go +++ b/internal/cli/check.go @@ -299,6 +299,17 @@ func (mfa *CLIApp) checkManifestOperation( return fmt.Errorf("check: %w", err) } + // Without --base, the manifest's paths are resolved against the + // directory holding it, or the current directory for a manifest URL. + basePath := "." + + switch { + case cmd.IsSet(flagBase): + basePath = cmd.String(flagBase) + case !isHTTPURL(manifestPath): + basePath = filepath.Dir(manifestPath) + } + // URL manifests need to be downloaded to a temp file for the checker if isHTTPURL(manifestPath) { tmpPath, tmpErr := mfa.fetchManifestToTemp(ctx, manifestPath) @@ -311,7 +322,6 @@ func (mfa *CLIApp) checkManifestOperation( manifestPath = tmpPath } - basePath := cmd.String("base") showProgress := cmd.Bool("progress") log.Infof("checking manifest %s with base %s", manifestPath, basePath) diff --git a/internal/cli/entry_test.go b/internal/cli/entry_test.go index dabfb17..9b99cea 100644 --- a/internal/cli/entry_test.go +++ b/internal/cli/entry_test.go @@ -8,6 +8,7 @@ import ( "fmt" "io" "math/rand" + "net/http/httptest" "os" "path/filepath" "slices" @@ -979,6 +980,83 @@ func TestCheckNeverReportsManifest(t *testing.T) { } } +// setupManifestInSubdir makes a temp dir holding a.txt and sub/b.txt, where +// sub/index.mf is the manifest gen writes for sub, and makes it the working +// directory, so a test calling it cannot run in parallel. It returns the +// temp dir. +func setupManifestInSubdir(t *testing.T) string { + t.Helper() + + root := t.TempDir() + sub := filepath.Join(root, "sub") + + fs := afero.NewOsFs() + require.NoError(t, fs.MkdirAll(sub, 0o750)) + writeTestFile(t, fs, filepath.Join(root, "a.txt"), "not in the manifest") + writeTestFile(t, fs, filepath.Join(sub, "b.txt"), "in the manifest") + + opts := testOpts([]string{ + testApp, cmdGenerate, "-q", "-o", filepath.Join(sub, defaultManifestName), sub, + }, fs) + require.Equal(t, 0, runCLI(opts), "stderr: %s", testStderr(t, opts)) + + t.Chdir(root) + + return root +} + +// TestCheckResolvesEntriesAgainstManifestDirectory runs check from the +// directory above sub, on the manifest in sub. Without --base, the +// manifest's entries are looked for in sub, whether check is given the +// manifest or sub, and the files above sub are not reported. --base names +// the directory to look in instead, the current one included. +// +//nolint:paralleltest // changes the process-global working directory +func TestCheckResolvesEntriesAgainstManifestDirectory(t *testing.T) { + root := setupManifestInSubdir(t) + + for _, tc := range []struct { + args []string + exitCode int + failure string // a line check must print, if any + }{ + {[]string{"sub"}, 0, ""}, + {[]string{"sub/index.mf"}, 0, ""}, + {[]string{filepath.Join(root, "sub")}, 0, ""}, + {[]string{testFlagBase, "sub", "sub/index.mf"}, 0, ""}, + {[]string{testFlagBase, ".", "sub/index.mf"}, 1, "MISSING: b.txt"}, + } { + t.Run(strings.Join(tc.args, " "), func(t *testing.T) { + opts := testOpts(slices.Concat( + []string{testApp, cmdCheck, testFlagNoExtra}, tc.args, + ), afero.NewOsFs()) + assert.Equal(t, tc.exitCode, runCLI(opts), "stderr: %s", testStderr(t, opts)) + assert.Contains(t, testStderr(t, opts), tc.failure) + }) + } +} + +// TestCheckURLManifestResolvesEntriesAgainstCurrentDirectory runs check on +// a manifest given by URL, from a directory holding the file it lists: the +// file is looked for there. +// +//nolint:paralleltest // changes the process-global working directory +func TestCheckURLManifestResolvesEntriesAgainstCurrentDirectory(t *testing.T) { + files := map[string][]byte{testFileTxt: []byte("hello")} + + server := httptest.NewServer(fetchTestHandler(manifestOf(t, files), files)) + defer server.Close() + + cwd := chdirTemp(t) + require.NoError(t, + os.WriteFile(filepath.Join(cwd, testFileTxt), files[testFileTxt], 0o600)) + + opts := testOpts([]string{ + testApp, cmdCheck, testFlagNoExtra, server.URL + "/" + defaultManifestName, + }, afero.NewOsFs()) + assert.Equal(t, 0, runCLI(opts), "stderr: %s", testStderr(t, opts)) +} + // unlistableDirFs is a filesystem on which one directory cannot be listed. type unlistableDirFs struct { afero.Fs diff --git a/internal/cli/freshen.go b/internal/cli/freshen.go index e39fbb5..1061bdd 100644 --- a/internal/cli/freshen.go +++ b/internal/cli/freshen.go @@ -497,7 +497,6 @@ func (mfa *CLIApp) freshenManifestOperation( ) error { log.Debug("freshenManifestOperation()") - basePath := cmd.String("base") showProgress := cmd.Bool("progress") // Find manifest file @@ -506,6 +505,13 @@ func (mfa *CLIApp) freshenManifestOperation( return fmt.Errorf("freshen: %w", err) } + // Without --base, the manifest's paths are resolved against the + // directory holding it. + basePath := filepath.Dir(manifestPath) + if cmd.IsSet(flagBase) { + basePath = cmd.String(flagBase) + } + //nolint:contextcheck // mfer loads a manifest without a context existingByPath, err := mfa.loadExistingEntries(manifestPath) if err != nil { diff --git a/internal/cli/freshen_test.go b/internal/cli/freshen_test.go index bb86985..6f397b8 100644 --- a/internal/cli/freshen_test.go +++ b/internal/cli/freshen_test.go @@ -7,6 +7,7 @@ import ( "os" "path/filepath" "slices" + "strings" "testing" "time" @@ -300,6 +301,41 @@ func TestFreshenLeavesLeftoverTempFileOutOfListing(t *testing.T) { 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 +// is given the manifest or sub. --base names the directory to list instead, +// the current one included. +// +//nolint:paralleltest // changes the process-global working directory +func TestFreshenResolvesEntriesAgainstManifestDirectory(t *testing.T) { + for _, tc := range []struct { + args []string + want []string // the paths the manifest lists afterwards + }{ + {[]string{"sub"}, []string{"b.txt", "c.txt"}}, + {[]string{"sub/index.mf"}, []string{"b.txt", "c.txt"}}, + { + []string{testFlagBase, ".", "sub/index.mf"}, + []string{"a.txt", "sub/b.txt", "sub/c.txt"}, + }, + } { + t.Run(strings.Join(tc.args, " "), func(t *testing.T) { + fs := afero.NewOsFs() + root := setupManifestInSubdir(t) + writeTestFile(t, fs, filepath.Join(root, "sub", "c.txt"), "added") + + opts := testOpts(slices.Concat( + []string{testApp, cmdFreshen, "-q"}, tc.args, + ), fs) + require.Equal(t, 0, runCLI(opts), "stderr: %s", testStderr(t, opts)) + + assert.ElementsMatch(t, tc.want, manifestPaths(t, fs, + filepath.Join(root, "sub", defaultManifestName))) + }) + } +} + // TestFreshenRecordEntryMtimePresence pins the behavior of recordEntry // with respect to MFFilePath.Mtime, which is a message pointer with // proto3 field presence and may legitimately be absent. diff --git a/internal/cli/mfer.go b/internal/cli/mfer.go index c2a0366..1b8d83d 100644 --- a/internal/cli/mfer.go +++ b/internal/cli/mfer.go @@ -24,6 +24,7 @@ const ( cmdList = "list" cmdVersion = "version" + flagBase = "base" flagProgress = "progress" flagTimeout = "timeout" flagDest = "dest" @@ -259,10 +260,11 @@ func (mfa *CLIApp) checkCommand() *cli.Command { }, Flags: append(commonFlags(), &cli.StringFlag{ - Name: "base", + Name: flagBase, Aliases: []string{"b"}, - Value: ".", - Usage: "Base directory for resolving relative paths from manifest", + Usage: "Base directory for resolving relative paths from manifest " + + "(by default the directory holding the manifest, or the " + + "current directory for a manifest URL)", }, &cli.BoolFlag{ Name: flagProgress, @@ -292,10 +294,10 @@ func (mfa *CLIApp) freshenCommand() *cli.Command { }, Flags: append(commonFlags(), &cli.StringFlag{ - Name: "base", + Name: flagBase, Aliases: []string{"b"}, - Value: ".", - Usage: "Base directory for resolving relative paths", + Usage: "Base directory for resolving relative paths " + + "(by default the directory holding the manifest)", }, &cli.BoolFlag{ Name: "follow-symlinks",