Resolve check and freshen paths against the manifest's directory (closes #177)
check / check (push) Waiting to run

Without --base, check and freshen now look for a manifest's files in the
directory that holds it, the file named or the one found in a directory
argument, instead of the current directory. So `mfer check /media/drive`
checks a drive against its own index.mf from anywhere. check of a manifest
given by URL still uses the current directory, and --base still overrides,
even when it names the current directory. The --base help text of both
commands and the README's Tool Examples state the default; the README gains
a freshen entry for this.

Model: opus-5-5
This commit is contained in:
2026-10-07 14:29:13 +00:00
parent dce5e050c3
commit a2a4d97f2f
7 changed files with 157 additions and 9 deletions
+8
View File
@@ -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 - verifies checksums of all files in manifest, displaying error and exiting
nonzero if any files are missing or corrupted, or have permission bits nonzero if any files are missing or corrupted, or have permission bits
other than the mode the manifest records, unless that is `0000` 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 - 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 list, hidden files included; with `--no-extra-files` each one is a failure
instead 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/` - `mfer fetch https://example.com/stuff/`
- fetches `/stuff/index.mf` and downloads all files listed in manifest into - fetches `/stuff/index.mf` and downloads all files listed in manifest into
the current directory, or the one given with `--dest`, and assures the current directory, or the one given with `--dest`, and assures
+4 -1
View File
@@ -299,6 +299,10 @@ func (mfa *CLIApp) checkManifestOperation(
return fmt.Errorf("check: %w", err) return fmt.Errorf("check: %w", err)
} }
// Done before a URL is swapped for the temp file it is downloaded to,
// whose directory is not the base.
basePath := resolveBasePath(cmd, manifestPath)
// URL manifests need to be downloaded to a temp file for the checker // URL manifests need to be downloaded to a temp file for the checker
if isHTTPURL(manifestPath) { if isHTTPURL(manifestPath) {
tmpPath, tmpErr := mfa.fetchManifestToTemp(ctx, manifestPath) tmpPath, tmpErr := mfa.fetchManifestToTemp(ctx, manifestPath)
@@ -311,7 +315,6 @@ func (mfa *CLIApp) checkManifestOperation(
manifestPath = tmpPath manifestPath = tmpPath
} }
basePath := cmd.String("base")
showProgress := cmd.Bool("progress") showProgress := cmd.Bool("progress")
log.Infof("checking manifest %s with base %s", manifestPath, basePath) log.Infof("checking manifest %s with base %s", manifestPath, basePath)
+85
View File
@@ -8,6 +8,7 @@ import (
"fmt" "fmt"
"io" "io"
"math/rand" "math/rand"
"net/http/httptest"
"os" "os"
"path/filepath" "path/filepath"
"slices" "slices"
@@ -979,6 +980,90 @@ func TestCheckNeverReportsManifest(t *testing.T) {
} }
} }
// The directory setupManifestInSubdir makes and the manifest it writes there,
// relative to the working directory it sets.
const (
testSubdir = "sub"
testSubdirManifest = testSubdir + "/" + defaultManifestName
)
// setupManifestInSubdir makes a temp dir holding file.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, testSubdir)
fs := afero.NewOsFs()
require.NoError(t, fs.MkdirAll(sub, 0o750))
writeTestFile(t, fs, filepath.Join(root, testFileTxt), "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{testSubdir}, 0, ""},
{[]string{testSubdirManifest}, 0, ""},
{[]string{filepath.Join(root, testSubdir)}, 0, ""},
{[]string{testFlagBase, testSubdir, testSubdirManifest}, 0, ""},
{[]string{testFlagBase, ".", testSubdirManifest}, 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. // unlistableDirFs is a filesystem on which one directory cannot be listed.
type unlistableDirFs struct { type unlistableDirFs struct {
afero.Fs afero.Fs
+1 -2
View File
@@ -497,7 +497,6 @@ func (mfa *CLIApp) freshenManifestOperation(
) error { ) error {
log.Debug("freshenManifestOperation()") log.Debug("freshenManifestOperation()")
basePath := cmd.String("base")
showProgress := cmd.Bool("progress") showProgress := cmd.Bool("progress")
// Find manifest file // Find manifest file
@@ -512,7 +511,7 @@ func (mfa *CLIApp) freshenManifestOperation(
return err return err
} }
absBase, err := filepath.Abs(basePath) absBase, err := filepath.Abs(resolveBasePath(cmd, manifestPath))
if err != nil { if err != nil {
return fmt.Errorf("freshen: invalid base path: %w", err) return fmt.Errorf("freshen: invalid base path: %w", err)
} }
+36
View File
@@ -7,6 +7,7 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"slices" "slices"
"strings"
"testing" "testing"
"time" "time"
@@ -300,6 +301,41 @@ func TestFreshenLeavesLeftoverTempFileOutOfListing(t *testing.T) {
manifestPaths(t, fs, manifestPath)) 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{testSubdir}, []string{"b.txt", "c.txt"}},
{[]string{testSubdirManifest}, []string{"b.txt", "c.txt"}},
{
[]string{testFlagBase, ".", testSubdirManifest},
[]string{testFileTxt, "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, testSubdir, "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, testSubdirManifest)))
})
}
}
// TestFreshenRecordEntryMtimePresence pins the behavior of recordEntry // TestFreshenRecordEntryMtimePresence pins the behavior of recordEntry
// with respect to MFFilePath.Mtime, which is a message pointer with // with respect to MFFilePath.Mtime, which is a message pointer with
// proto3 field presence and may legitimately be absent. // proto3 field presence and may legitimately be absent.
+15
View File
@@ -6,6 +6,7 @@ import (
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
"path/filepath"
"strings" "strings"
"time" "time"
@@ -88,3 +89,17 @@ func (mfa *CLIApp) resolveManifestArg(cmd *cli.Command) (string, error) {
return findManifest(mfa.Fs, ".") return findManifest(mfa.Fs, ".")
} }
// resolveBasePath returns the directory a manifest's paths are resolved
// against: the one --base names, or else the directory holding the manifest,
// or the current directory for a manifest URL.
func resolveBasePath(cmd *cli.Command, manifestPath string) string {
switch {
case cmd.IsSet(flagBase):
return cmd.String(flagBase)
case isHTTPURL(manifestPath):
return "."
default:
return filepath.Dir(manifestPath)
}
}
+8 -6
View File
@@ -24,6 +24,7 @@ const (
cmdList = "list" cmdList = "list"
cmdVersion = "version" cmdVersion = "version"
flagBase = "base"
flagProgress = "progress" flagProgress = "progress"
flagTimeout = "timeout" flagTimeout = "timeout"
flagDest = "dest" flagDest = "dest"
@@ -259,10 +260,11 @@ func (mfa *CLIApp) checkCommand() *cli.Command {
}, },
Flags: append(commonFlags(), Flags: append(commonFlags(),
&cli.StringFlag{ &cli.StringFlag{
Name: "base", Name: flagBase,
Aliases: []string{"b"}, 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{ &cli.BoolFlag{
Name: flagProgress, Name: flagProgress,
@@ -292,10 +294,10 @@ func (mfa *CLIApp) freshenCommand() *cli.Command {
}, },
Flags: append(commonFlags(), Flags: append(commonFlags(),
&cli.StringFlag{ &cli.StringFlag{
Name: "base", Name: flagBase,
Aliases: []string{"b"}, 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{ &cli.BoolFlag{
Name: "follow-symlinks", Name: "follow-symlinks",