check / check (push) Failing after 1s
mfer gen now writes index.mf instead of .index.mf, the name fetch requests and the README calls the standard filename. Given a directory, check, freshen, list and export look only for index.mf; .index.mf is no longer recognized. Because index.mf is not hidden, gen and freshen now leave the manifest they write out of its own listing by file identity (os.SameFile), so it is recognized however its path is spelled, through a symlink included, while an ordinary file of the same name elsewhere in the tree is still listed. Model: opus-5-5
289 lines
7.1 KiB
Go
289 lines
7.1 KiB
Go
package cli
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"os/signal"
|
|
"path/filepath"
|
|
"sync"
|
|
"syscall"
|
|
"time"
|
|
|
|
"github.com/dustin/go-humanize"
|
|
"github.com/spf13/afero"
|
|
"github.com/urfave/cli/v2"
|
|
"sneak.berlin/go/mfer/internal/log"
|
|
"sneak.berlin/go/mfer/mfer"
|
|
)
|
|
|
|
var (
|
|
// errPathNotExist indicates an input path that does not exist.
|
|
errPathNotExist = errors.New("path does not exist")
|
|
// errOutputExists indicates the output file already exists and
|
|
// --force was not given. It is wrapped mid-sentence so that the
|
|
// rendered message stays exactly as mfer has always printed it.
|
|
errOutputExists = errors.New(
|
|
"already exists (use --force to overwrite)")
|
|
)
|
|
|
|
// reportEnumProgress renders enumeration progress until the channel
|
|
// closes.
|
|
func reportEnumProgress(progress <-chan mfer.EnumerateStatus, wg *sync.WaitGroup) {
|
|
defer wg.Done()
|
|
|
|
for status := range progress {
|
|
log.Progressf("Enumerating: %d files, %s",
|
|
status.FilesFound,
|
|
humanize.IBytes(safeUint64(int64(status.BytesFound))))
|
|
}
|
|
|
|
log.ProgressDone()
|
|
}
|
|
|
|
// reportScanProgress renders scan progress until the channel closes.
|
|
func reportScanProgress(progress <-chan mfer.ScanStatus, wg *sync.WaitGroup) {
|
|
defer wg.Done()
|
|
|
|
for status := range progress {
|
|
if status.ETA > 0 {
|
|
log.Progressf("Scanning: %d/%d files, %s/s, ETA %s",
|
|
status.ScannedFiles,
|
|
status.TotalFiles,
|
|
humanize.IBytes(safeRateUint64(status.BytesPerSec)),
|
|
status.ETA.Round(time.Second))
|
|
} else {
|
|
log.Progressf("Scanning: %d/%d files, %s/s",
|
|
status.ScannedFiles,
|
|
status.TotalFiles,
|
|
humanize.IBytes(safeRateUint64(status.BytesPerSec)))
|
|
}
|
|
}
|
|
|
|
log.ProgressDone()
|
|
}
|
|
|
|
// collectInputPaths validates the input path arguments and returns them
|
|
// as absolute paths.
|
|
func (mfa *CLIApp) collectInputPaths(args cli.Args) ([]string, error) {
|
|
paths := make([]string, 0, args.Len())
|
|
|
|
for i := range args.Len() {
|
|
inputPath := args.Get(i)
|
|
|
|
ap, err := filepath.Abs(inputPath)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("generate: invalid path %q: %w", inputPath, err)
|
|
}
|
|
// Validate path exists before adding to list
|
|
if exists, _ := afero.Exists(mfa.Fs, ap); !exists {
|
|
return nil, fmt.Errorf("%w: %s", errPathNotExist, inputPath)
|
|
}
|
|
|
|
log.Debugf("enumerating path: %s", ap)
|
|
paths = append(paths, ap)
|
|
}
|
|
|
|
return paths, nil
|
|
}
|
|
|
|
// buildScannerOptions constructs scanner options from the CLI flags.
|
|
func (mfa *CLIApp) buildScannerOptions(ctx *cli.Context) *mfer.ScannerOptions {
|
|
opts := &mfer.ScannerOptions{
|
|
IncludeDotfiles: ctx.Bool("include-dotfiles"),
|
|
FollowSymLinks: ctx.Bool("follow-symlinks"),
|
|
IncludeTimestamps: ctx.Bool("include-timestamps"),
|
|
Fs: mfa.Fs,
|
|
OutputPath: ctx.String("output"),
|
|
}
|
|
|
|
// Set seed for deterministic UUID if provided
|
|
if seed := ctx.String("seed"); seed != "" {
|
|
opts.Seed = seed
|
|
|
|
log.Infof("using deterministic seed for manifest UUID")
|
|
}
|
|
|
|
// Set up signing options if sign-key is provided
|
|
if signKey := ctx.String("sign-key"); signKey != "" {
|
|
opts.SigningOptions = &mfer.SigningOptions{
|
|
KeyID: mfer.GPGKeyID(signKey),
|
|
}
|
|
log.Infof("signing manifest with GPG key: %s", signKey)
|
|
}
|
|
|
|
return opts
|
|
}
|
|
|
|
// enumerateInputs runs the enumeration phase over the argument paths,
|
|
// or the current directory when no arguments are given.
|
|
func (mfa *CLIApp) enumerateInputs(
|
|
s *mfer.Scanner, args cli.Args, enumProgress chan mfer.EnumerateStatus,
|
|
) error {
|
|
if args.Len() == 0 {
|
|
// Default to current directory
|
|
err := s.EnumeratePath(".", enumProgress)
|
|
if err != nil {
|
|
return fmt.Errorf(
|
|
"generate: failed to enumerate current directory: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// Collect and validate all paths first
|
|
paths, err := mfa.collectInputPaths(args)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
err = s.EnumeratePaths(enumProgress, paths...)
|
|
if err != nil {
|
|
return fmt.Errorf("generate: failed to enumerate paths: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// cleanupOnSignal installs a handler that removes the temp output file
|
|
// and exits when the process is interrupted. It returns the signal
|
|
// channel so the caller can stop and close it when done.
|
|
func (mfa *CLIApp) cleanupOnSignal(outFile afero.File, tmpPath string) chan os.Signal {
|
|
sigChan := make(chan os.Signal, 1)
|
|
|
|
signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM)
|
|
|
|
go func() {
|
|
sig, ok := <-sigChan
|
|
if !ok || sig == nil {
|
|
return // Channel closed normally, not a signal
|
|
}
|
|
|
|
_ = outFile.Close()
|
|
_ = mfa.Fs.Remove(tmpPath)
|
|
|
|
os.Exit(1)
|
|
}()
|
|
|
|
return sigChan
|
|
}
|
|
|
|
// runEnumeratePhase enumerates all input paths with optional progress
|
|
// reporting and logs the totals.
|
|
func (mfa *CLIApp) runEnumeratePhase(ctx *cli.Context, s *mfer.Scanner) error {
|
|
// Set up enumeration progress reporting
|
|
var (
|
|
enumProgress chan mfer.EnumerateStatus
|
|
enumWg sync.WaitGroup
|
|
)
|
|
|
|
if ctx.Bool("progress") {
|
|
enumProgress = make(chan mfer.EnumerateStatus, 1)
|
|
|
|
enumWg.Add(1)
|
|
|
|
go reportEnumProgress(enumProgress, &enumWg)
|
|
}
|
|
|
|
err := mfa.enumerateInputs(s, ctx.Args(), enumProgress)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
enumWg.Wait()
|
|
|
|
log.Infof("enumerated %d files, %s total", s.FileCount(),
|
|
humanize.IBytes(safeUint64(int64(s.TotalBytes()))))
|
|
|
|
return nil
|
|
}
|
|
|
|
func (mfa *CLIApp) generateManifestOperation(ctx *cli.Context) error {
|
|
log.Debug("generateManifestOperation()")
|
|
|
|
s := mfer.NewScannerWithOptions(mfa.buildScannerOptions(ctx))
|
|
|
|
// Phase 1: Enumeration - collect paths and stat files
|
|
err := mfa.runEnumeratePhase(ctx, s)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
showProgress := ctx.Bool("progress")
|
|
|
|
// Check if output file exists
|
|
outputPath := ctx.String("output")
|
|
if exists, _ := afero.Exists(mfa.Fs, outputPath); exists && !ctx.Bool("force") {
|
|
return fmt.Errorf("output file %s %w", outputPath, errOutputExists)
|
|
}
|
|
|
|
// Create temp file for atomic write
|
|
tmpPath := outputPath + ".tmp"
|
|
|
|
outFile, err := mfa.Fs.Create(tmpPath)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to create temp file: %w", err)
|
|
}
|
|
|
|
// Set up signal handler to clean up temp file on Ctrl-C
|
|
sigChan := mfa.cleanupOnSignal(outFile, tmpPath)
|
|
|
|
// Clean up temp file on any error or interruption
|
|
success := false
|
|
|
|
defer func() {
|
|
signal.Stop(sigChan)
|
|
close(sigChan)
|
|
|
|
_ = outFile.Close()
|
|
|
|
if !success {
|
|
_ = mfa.Fs.Remove(tmpPath)
|
|
}
|
|
}()
|
|
|
|
// Phase 2: Scan - read file contents and generate manifest
|
|
var (
|
|
scanProgress chan mfer.ScanStatus
|
|
scanWg sync.WaitGroup
|
|
)
|
|
|
|
if showProgress {
|
|
scanProgress = make(chan mfer.ScanStatus, 1)
|
|
|
|
scanWg.Add(1)
|
|
|
|
go reportScanProgress(scanProgress, &scanWg)
|
|
}
|
|
|
|
err = s.ToManifest(ctx.Context, outFile, scanProgress)
|
|
|
|
scanWg.Wait()
|
|
|
|
if err != nil {
|
|
return fmt.Errorf("failed to generate manifest: %w", err)
|
|
}
|
|
|
|
// Close file before rename to ensure all data is flushed
|
|
err = outFile.Close()
|
|
if err != nil {
|
|
return fmt.Errorf("failed to close temp file: %w", err)
|
|
}
|
|
|
|
// Atomic rename
|
|
err = mfa.Fs.Rename(tmpPath, outputPath)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to rename temp file: %w", err)
|
|
}
|
|
|
|
success = true
|
|
|
|
elapsed := time.Since(mfa.startupTime).Seconds()
|
|
rate := float64(s.TotalBytes()) / elapsed
|
|
log.Infof("wrote %d files (%s) to %s in %.1fs (%s/s)", s.FileCount(),
|
|
humanize.IBytes(safeUint64(int64(s.TotalBytes()))), outputPath, elapsed,
|
|
humanize.IBytes(safeRateUint64(rate)))
|
|
|
|
return nil
|
|
}
|