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 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
This commit is contained in:
2026-10-07 14:12:53 +00:00
parent dce5e050c3
commit 256881c571
6 changed files with 148 additions and 8 deletions
+11 -1
View File
@@ -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)
+78
View File
@@ -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
+7 -1
View File
@@ -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 {
+36
View File
@@ -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.
+8 -6
View File
@@ -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",