1 Commits
Author SHA1 Message Date
sneak 2b692fad7c Never list index.mf at the top of the tree in gen and freshen (closes #233)
check / check (push) Canceled after 0s
fetch saves the manifest as index.mf at the top of the tree and refuses a
manifest that lists a file there. gen given --output, and freshen with its
manifest outside the base directory, listed such a file, so they wrote
manifests fetch would not take.

gen now leaves out index.mf in each directory it is given, or beside each
file it is given, the same way it leaves out the file it writes. freshen
leaves out index.mf at the top of the base directory. index.mf in a
subdirectory is still listed. The README says so for both commands.

Model: opus-5-5
2026-10-15 21:35:00 +00:00
6 changed files with 51 additions and 163 deletions
+16 -92
View File
@@ -27,20 +27,19 @@ import (
)
const (
testApp = "mfer"
testGitrev = "abc123"
testDir = "/testdir"
testFile1 = "/testdir/file1.txt"
testMF = "/testdir/test.mf"
testOutput = "/output.mf"
testManifest = "/manifest.mf"
testFlagBase = "--base"
testFlagNoExtra = "--no-extra-files"
testFlagFollowSymlinks = "--follow-symlinks"
testFlagVersion = "--version"
testFlagVerbose = "--verbose"
testFlagHelp = "--help"
testCmdHelp = "help"
testApp = "mfer"
testGitrev = "abc123"
testDir = "/testdir"
testFile1 = "/testdir/file1.txt"
testMF = "/testdir/test.mf"
testOutput = "/output.mf"
testManifest = "/manifest.mf"
testFlagBase = "--base"
testFlagNoExtra = "--no-extra-files"
testFlagVersion = "--version"
testFlagVerbose = "--verbose"
testFlagHelp = "--help"
testCmdHelp = "help"
)
var errSimulatedWrite = errors.New("simulated write failure")
@@ -1003,7 +1002,7 @@ func TestCheckReportsUnlistedSymlinkToFileOnly(t *testing.T) {
exitCode int
extras []string // what check reports as not in the manifest
}{
"gen --follow-symlinks": {[]string{testFlagFollowSymlinks}, 0, nil},
"gen --follow-symlinks": {[]string{"--follow-symlinks"}, 0, nil},
"plain gen": {nil, 1, []string{"filelink"}},
} {
t.Run(name, func(t *testing.T) {
@@ -1424,7 +1423,8 @@ func TestGenerateLeavesLeftoverTempFileOutOfListing(t *testing.T) {
// 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.
// 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()
@@ -1480,82 +1480,6 @@ func TestGenerateLeavesTopIndexMFOutOfListing(t *testing.T) {
}
}
// TestGenerateListsTargetOfTopIndexMFSymlink makes index.mf at the top of
// the tree a symlink to another file in it, and runs gen with --output
// outside the tree: the file it points to is listed under its own name,
// and the symlink, which gen lists only with --follow-symlinks, is left out
// like any index.mf at the top of the tree.
func TestGenerateListsTargetOfTopIndexMFSymlink(t *testing.T) {
t.Parallel()
// Paths are relative to tree, which holds file.txt, sub/index.mf and
// index.mf, a symlink to target.
for name, tc := range map[string]struct {
target string
flags []string
path string // the path gen is given
listed []string
}{
"directory given": {
testSubdirManifest, nil, ".",
[]string{testFileTxt, testSubdirManifest},
},
"directory given, --follow-symlinks": {
testSubdirManifest,
[]string{testFlagFollowSymlinks},
".",
[]string{testFileTxt, testSubdirManifest},
},
"file given": {
testFileTxt, nil, testFileTxt,
[]string{testFileTxt},
},
} {
t.Run(name, func(t *testing.T) {
t.Parallel()
root := t.TempDir()
tree := filepath.Join(root, "tree")
fs := afero.NewOsFs()
require.NoError(t, fs.MkdirAll(filepath.Join(tree, testSubdir), 0o750))
writeTestFile(t, fs, filepath.Join(tree, testFileTxt), "hello")
writeTestFile(t, fs, filepath.Join(tree, testSubdirManifest), "an ordinary file")
require.NoError(t,
os.Symlink(tc.target, filepath.Join(tree, defaultManifestName)))
output := filepath.Join(root, "other.mf")
opts := testOpts(slices.Concat(
[]string{testApp, cmdGenerate, "-q", "-o", output}, tc.flags,
[]string{filepath.Join(tree, tc.path)},
), fs)
require.Equal(t, 0, runCLI(opts), "stderr: %s", testStderr(t, opts))
assert.ElementsMatch(t, tc.listed, manifestPaths(t, fs, output))
})
}
}
// TestGenerateListsIndexMFBesideFileGiven runs gen with --output outside
// the tree, given sub/b.txt and the tree: sub/index.mf, beside the file
// given, is listed, since a file given is left out only when its own name
// is index.mf.
//
//nolint:paralleltest // changes the process-global working directory
func TestGenerateListsIndexMFBesideFileGiven(t *testing.T) {
setupManifestInSubdir(t)
fs := afero.NewOsFs()
output := filepath.Join(t.TempDir(), "other.mf")
opts := testOpts([]string{
testApp, cmdGenerate, "-q", "-o", output, filepath.Join(testSubdir, "b.txt"), ".",
}, fs)
require.Equal(t, 0, runCLI(opts), "stderr: %s", testStderr(t, opts))
assert.Contains(t, manifestPaths(t, fs, output), testSubdirManifest)
}
// writeTestTree writes file.txt and sub/nested.txt under dir.
func writeTestTree(t *testing.T, fs afero.Fs, dir string) {
t.Helper()
+9 -10
View File
@@ -192,12 +192,6 @@ func (s *freshenScanner) walk(path string, info fs.FileInfo, walkErr error) erro
return nil
}
// Leave out a file listed as index.mf, at the top of the base
// directory, since fetch saves the manifest there, as gen does.
if relPath == defaultManifestName {
return nil
}
// Handle symlinks
if info.Mode()&fs.ModeSymlink != 0 {
realInfo, keep := s.resolveSymlink(path)
@@ -417,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)
+2 -45
View File
@@ -416,7 +416,8 @@ func TestFreshenLeavesLeftoverTempFileOutOfListing(t *testing.T) {
// 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.
// 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()
@@ -440,50 +441,6 @@ func TestFreshenLeavesTopIndexMFOutOfListing(t *testing.T) {
manifestPaths(t, fs, manifestPath))
}
// TestFreshenListsTargetOfTopIndexMFSymlink freshens a manifest kept
// outside the directory --base names, after sub/index.mf is added there
// and index.mf at the top is made a symlink to it: sub/index.mf is listed,
// and the symlink, which freshen lists only with --follow-symlinks, is left
// out like any index.mf at the top.
func TestFreshenListsTargetOfTopIndexMFSymlink(t *testing.T) {
t.Parallel()
for name, flags := range map[string][]string{
"plain freshen": nil,
"freshen --follow-symlinks": {testFlagFollowSymlinks},
} {
t.Run(name, func(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, testSubdirManifest), "an ordinary file")
require.NoError(t, os.Symlink(testSubdirManifest,
filepath.Join(tree, defaultManifestName)))
opts = testOpts(slices.Concat(
[]string{testApp, cmdFreshen, "-q"}, flags,
[]string{testFlagBase, tree, manifestPath},
), fs)
require.Equal(t, 0, runCLI(opts), "stderr: %s", testStderr(t, opts))
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
+20 -7
View File
@@ -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
@@ -137,9 +141,18 @@ func (mfa *CLIApp) buildScannerOptions(
// Neither a manifest being replaced nor a temp file left by an
// interrupted run belongs in the new manifest.
ExcludePaths: []string{output, manifestTempPath(output)},
// Nor does a file listed as index.mf, at the top of the tree, since
// fetch saves the manifest there and refuses a manifest that lists it.
ExcludeRelPaths: []mfer.RelFilePath{defaultManifestName},
}
// 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
+1 -1
View File
@@ -81,7 +81,7 @@ func TestFreshenSkipsPipes(t *testing.T) {
require.NoError(t, os.Symlink(target, filepath.Join(root, testLink)))
opts := testOpts([]string{
testApp, cmdFreshen, "-v", testFlagFollowSymlinks,
testApp, cmdFreshen, "-v", "--follow-symlinks",
testFlagBase, root, manifestPath,
}, fs)
require.Equal(t, 0, runCLI(opts), "stderr: %s", testStderr(t, opts))
+3 -8
View File
@@ -8,7 +8,6 @@ import (
"os"
"path"
"path/filepath"
"slices"
"strings"
"sync"
"time"
@@ -65,9 +64,6 @@ type ScannerOptions struct {
Seed string
// ExcludePaths lists files to leave out of the listing, matched with os.SameFile.
ExcludePaths []string
// ExcludeRelPaths lists paths to leave out of the listing, matched
// against the path a file would be listed by, with no file looked up.
ExcludeRelPaths []RelFilePath
}
// FileEntry represents a file that has been enumerated.
@@ -443,10 +439,9 @@ func (s *Scanner) enumerateFileWithInfo(
}
// Clean the path - remove leading slash if present
cleanPath := strings.TrimPrefix(filePath, "/")
if slices.Contains(s.options.ExcludeRelPaths, RelFilePath(cleanPath)) {
return nil
cleanPath := filePath
if len(cleanPath) > 0 && cleanPath[0] == '/' {
cleanPath = cleanPath[1:]
}
// Compute absolute path for file reading