check / check (push) Canceled after 0s
The help of sfdupes and of scan names the database, its default path and SFDUPES_DATABASE. The help of report and trees lists the columns, the order of the rows and the four escapes. The help of info lists its lines. The help of dwim says that it scans /, should run as root and keeps the scan in the database. Model: opus-5-5
355 lines
11 KiB
Go
355 lines
11 KiB
Go
// Package sfdupes implements the sfdupes command and the database its
|
|
// subcommands share.
|
|
package sfdupes
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"runtime"
|
|
"runtime/debug"
|
|
|
|
"github.com/spf13/cobra"
|
|
)
|
|
|
|
// Exit codes: exitOK is success (even with per-file warnings),
|
|
// exitFatal is a fatal error, exitUsage is a usage error.
|
|
const (
|
|
exitOK = 0
|
|
exitFatal = 1
|
|
exitUsage = 2
|
|
)
|
|
|
|
// The subcommand names, as typed on the command line.
|
|
const (
|
|
cmdScan = "scan"
|
|
cmdReport = "report"
|
|
cmdTrees = "trees"
|
|
cmdInfo = "info"
|
|
cmdDwim = "dwim"
|
|
)
|
|
|
|
// databaseHelp is the paragraph of the root and scan help that names
|
|
// the database (README "Location").
|
|
const databaseHelp = "scan stores what it finds in a database file. " +
|
|
"report, trees, info and dwim\nread it. The file is $" + databaseEnv +
|
|
" when that is set and not empty,\nand otherwise " +
|
|
defaultDatabasePath + ", where creating it needs root.\n"
|
|
|
|
// escapesHelp is the paragraph of the report and trees help on how
|
|
// paths are escaped (README "Report output format").
|
|
const escapesHelp = `Four characters in a path are escaped, so that each
|
|
row stays one line: a backslash as \\, a tab as \t, a newline as \n
|
|
and a carriage return as \r.
|
|
`
|
|
|
|
// reportHelp, treesHelp, infoHelp and dwimHelp follow the one-line
|
|
// summary in the help of their subcommands.
|
|
const reportHelp = `The report is tab-separated text on stdout: a header
|
|
line, then one row per duplicate file. The columns are:
|
|
|
|
first the path that sorts first, byte by byte, in its group of copies
|
|
dupe another copy of that file
|
|
size the file's size in bytes
|
|
|
|
Rows are ordered by size, largest first, then by path.
|
|
|
|
` + escapesHelp
|
|
|
|
const treesHelp = `The report is tab-separated text on stdout: a header
|
|
line, then one row per duplicate directory tree. The columns are:
|
|
|
|
first the path that sorts first, byte by byte, in its group of copies
|
|
dupe another copy of that tree
|
|
files the number of regular files in one copy
|
|
size their total size in bytes
|
|
disk_size the bytes the dupe copy takes on disk
|
|
|
|
Rows are ordered by size, largest first, then by path.
|
|
|
|
` + escapesHelp
|
|
|
|
const infoHelp = `The details go to stdout, one per line. Each line holds a name and its
|
|
value, separated by a tab:
|
|
|
|
database the path, and where it came from
|
|
database file size in bytes
|
|
-wal file size in bytes, only while a -wal file exists
|
|
modified when the database file last changed, in UTC
|
|
schema version the version of the database's layout
|
|
records the number of records
|
|
size of recorded files the total size of the recorded files, in bytes
|
|
`
|
|
|
|
const dwimHelp = `dwim runs what "sfdupes scan /" runs, then what "sfdupes trees" runs.
|
|
Run it as root, so that the scan can read every file. The scan is stored
|
|
in the database, as scan stores it, so "sfdupes trees" prints the report
|
|
again later.
|
|
`
|
|
|
|
// errNoSubcommand is returned by the root command when it is invoked
|
|
// without a subcommand. That is a usage error, and the usage text
|
|
// cobra prints for it is the whole message.
|
|
var errNoSubcommand = errors.New("no subcommand")
|
|
|
|
// errWorkersBelowOne is the usage error for a scan --workers value
|
|
// below 1.
|
|
var errWorkersBelowOne = errors.New("--workers must be at least 1")
|
|
|
|
// Version is the build version, injected at link time via -ldflags
|
|
// (see the Makefile); empty when the linker set none, as for a binary
|
|
// built by go install. buildVersion turns it into what --version prints.
|
|
//
|
|
//nolint:gochecknoglobals // written only by the linker
|
|
var Version string
|
|
|
|
// buildVersion returns the version that sfdupes --version prints
|
|
// (README "Install"): the version the linker set, when it set one;
|
|
// otherwise the main module's version that Go recorded in the binary,
|
|
// a tag such as v0.0.2 or the pseudo-version of an @main build; and
|
|
// "dev" when neither is known. info is what debug.ReadBuildInfo
|
|
// returns, nil when the binary carries none. Go records "(devel)" for
|
|
// a build that has no version, such as one inside a copy of the source
|
|
// without git.
|
|
func buildVersion(linkerVersion string, info *debug.BuildInfo) string {
|
|
if linkerVersion != "" {
|
|
return linkerVersion
|
|
}
|
|
|
|
if info == nil || info.Main.Version == "" ||
|
|
info.Main.Version == "(devel)" {
|
|
return "dev"
|
|
}
|
|
|
|
return info.Main.Version
|
|
}
|
|
|
|
// Main runs the command line in os.Args and returns the process exit
|
|
// code.
|
|
func Main() int {
|
|
// Once the reader of a stdout pipe has gone, as in "sfdupes report |
|
|
// head", the Go runtime ends the process with SIGPIPE on the next
|
|
// write instead of returning an error (README "Error handling").
|
|
// Registering for SIGPIPE with os/signal would change that.
|
|
return run(os.Args[1:], os.Stdout, os.Stderr)
|
|
}
|
|
|
|
// run executes args against the command tree and returns the process
|
|
// exit code. It is the program's single exit point: the subcommands
|
|
// return their errors instead of exiting, so every deferred cleanup —
|
|
// above all closing the database, which checkpoints the SQLite WAL —
|
|
// runs before the process ends. The report, trees, info and dwim
|
|
// subcommands write their data to stdout.
|
|
func run(args []string, stdout, stderr io.Writer) int {
|
|
// A nil slice makes cobra fall back to os.Args, which would let a
|
|
// test binary's own flags reach the command tree.
|
|
if args == nil {
|
|
args = []string{}
|
|
}
|
|
|
|
root := newRootCommand(stdout, stderr)
|
|
root.SetArgs(args)
|
|
|
|
err := root.Execute()
|
|
|
|
var fatal fatalError
|
|
|
|
switch {
|
|
case err == nil:
|
|
return exitOK
|
|
case errors.Is(err, errInterrupted):
|
|
// The interrupted scan has printed its own line.
|
|
return exitFatal
|
|
case errors.As(err, &fatal):
|
|
// The command ran and failed: a runtime error, reported
|
|
// without the usage text that a usage error gets.
|
|
_, _ = fmt.Fprintf(stderr, "sfdupes: %v\n", err)
|
|
|
|
return exitFatal
|
|
default:
|
|
// A usage error, which cobra has already reported on stderr.
|
|
return exitUsage
|
|
}
|
|
}
|
|
|
|
// newRootCommand builds the command tree. Everything on stdout is
|
|
// machine-readable data, the version line included; all human-facing
|
|
// output (help, usage, errors) goes to stderr.
|
|
func newRootCommand(stdout, stderr io.Writer) *cobra.Command {
|
|
var showVersion bool
|
|
|
|
printVersion := runE(func(context.Context, []string) error {
|
|
info, _ := debug.ReadBuildInfo()
|
|
|
|
_, err := fmt.Fprintf(stdout, "sfdupes %s\n",
|
|
buildVersion(Version, info))
|
|
if err != nil {
|
|
return fmt.Errorf("write stdout: %w", err)
|
|
}
|
|
|
|
return nil
|
|
})
|
|
|
|
root := &cobra.Command{
|
|
Use: "sfdupes",
|
|
Short: "Find candidate duplicate files by size and head/tail/content SHA-256",
|
|
Args: cobra.NoArgs,
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
if showVersion {
|
|
return printVersion(cmd, args)
|
|
}
|
|
|
|
// A missing subcommand prints usage and exits 2: cobra
|
|
// prints the usage text for the returned error, and run
|
|
// maps everything that is not a fatal error to exit 2.
|
|
cmd.SilenceErrors = true
|
|
|
|
return errNoSubcommand
|
|
},
|
|
}
|
|
root.SetOut(stderr)
|
|
root.SetErr(stderr)
|
|
root.CompletionOptions.DisableDefaultCmd = true
|
|
|
|
// Cobra's built-in version flag prints through the help writer,
|
|
// stderr; this one prints to stdout.
|
|
root.Flags().BoolVarP(&showVersion, "version", "v", false,
|
|
"print the version to stdout")
|
|
|
|
reportCmd := &cobra.Command{
|
|
Use: cmdReport,
|
|
Short: "Read the scan database and print the file-level duplicates report",
|
|
Args: cobra.NoArgs,
|
|
RunE: runE(func(ctx context.Context, _ []string) error {
|
|
return runReport(ctx, stdout)
|
|
}),
|
|
}
|
|
|
|
treesCmd := &cobra.Command{
|
|
Use: cmdTrees,
|
|
Short: "Read the scan database and print the duplicate-tree report",
|
|
Args: cobra.NoArgs,
|
|
RunE: runE(func(ctx context.Context, _ []string) error {
|
|
return runTrees(ctx, stdout)
|
|
}),
|
|
}
|
|
|
|
infoCmd := &cobra.Command{
|
|
Use: cmdInfo,
|
|
Short: "Read the scan database and print its details",
|
|
Args: cobra.NoArgs,
|
|
RunE: runE(func(ctx context.Context, _ []string) error {
|
|
return runInfo(ctx, stdout)
|
|
}),
|
|
}
|
|
|
|
dwimCmd := &cobra.Command{
|
|
Use: cmdDwim,
|
|
Short: "Scan / with the default excludes, then print the duplicate-tree report",
|
|
Args: cobra.NoArgs,
|
|
RunE: runE(func(ctx context.Context, _ []string) error {
|
|
return runDwim(ctx, "/", stdout)
|
|
}),
|
|
}
|
|
|
|
root.Long = root.Short + ".\n\n" + databaseHelp
|
|
reportCmd.Long = reportCmd.Short + ".\n\n" + reportHelp
|
|
treesCmd.Long = treesCmd.Short + ".\n\n" + treesHelp
|
|
infoCmd.Long = infoCmd.Short + ".\n\n" + infoHelp
|
|
dwimCmd.Long = dwimCmd.Short + ".\n\n" + dwimHelp
|
|
|
|
root.AddCommand(newScanCommand(), reportCmd, treesCmd, infoCmd, dwimCmd)
|
|
|
|
return root
|
|
}
|
|
|
|
// newScanCommand builds the scan subcommand, with its --workers, -x and
|
|
// --no-default-excludes flags. Its help names the database, and lists
|
|
// the default excludes of the system it runs on and the filesystem
|
|
// types it never records anything on.
|
|
func newScanCommand() *cobra.Command {
|
|
var (
|
|
scanWorkers int
|
|
scanOneFS bool
|
|
scanNoDefaultExcludes bool
|
|
)
|
|
|
|
short := "Walk trees and synchronize the scan database"
|
|
|
|
scanCmd := &cobra.Command{
|
|
Use: cmdScan + " [--workers N] [-x] [--no-default-excludes] PATH...",
|
|
Short: short,
|
|
Long: short + ".\n\n" + databaseHelp + "\n" +
|
|
defaultExcludesHelp(runtime.GOOS),
|
|
Args: cobra.MinimumNArgs(1),
|
|
PreRunE: func(cmd *cobra.Command, _ []string) error {
|
|
return checkScanWorkers(cmd, scanWorkers)
|
|
},
|
|
RunE: runE(func(ctx context.Context, args []string) error {
|
|
ctx, stop := interruptContext(ctx)
|
|
defer stop()
|
|
|
|
return runScan(ctx, args, scanWorkers, scanOneFS,
|
|
scanNoDefaultExcludes)
|
|
}),
|
|
}
|
|
scanCmd.Flags().IntVar(&scanWorkers, "workers", runtime.NumCPU(),
|
|
"concurrent workers for the walk and hash phases")
|
|
scanCmd.Flags().BoolVarP(&scanOneFS, "one-file-system", "x", false,
|
|
"do not cross filesystem boundaries")
|
|
scanCmd.Flags().BoolVar(&scanNoDefaultExcludes, "no-default-excludes",
|
|
false, "enter the default excludes, the paths listed above")
|
|
|
|
return scanCmd
|
|
}
|
|
|
|
// checkScanWorkers rejects a scan --workers value below 1. That is a
|
|
// usage error reported in one line: cobra prints the returned message
|
|
// without the usage text, and run exits 2.
|
|
func checkScanWorkers(cmd *cobra.Command, workers int) error {
|
|
if workers >= 1 {
|
|
return nil
|
|
}
|
|
|
|
cmd.SilenceUsage = true
|
|
|
|
return fmt.Errorf("%w, got %d", errWorkersBelowOne, workers)
|
|
}
|
|
|
|
// runE adapts a subcommand implementation, or the version print, to
|
|
// cobra's RunE. Cobra prints the error and the command's usage text for
|
|
// every error RunE returns, but a subcommand that ran and failed has no
|
|
// usage problem to report: both are silenced here, and the error is
|
|
// marked fatal so that run reports it on stderr and exits 1 rather than
|
|
// 2. The command's context is handed to the implementation: cancelling
|
|
// it unwinds the scan's worker pools.
|
|
func runE(
|
|
fn func(ctx context.Context, args []string) error,
|
|
) func(*cobra.Command, []string) error {
|
|
return func(cmd *cobra.Command, args []string) error {
|
|
cmd.SilenceUsage = true
|
|
cmd.SilenceErrors = true
|
|
|
|
err := fn(cmd.Context(), args)
|
|
if err != nil {
|
|
return fatalError{err: err}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// fatalError marks a runtime failure, as opposed to the usage errors
|
|
// cobra itself produces while parsing arguments and flags. Both come
|
|
// out of Execute as plain errors, so the wrapper is what tells run to
|
|
// report this one as "sfdupes: ..." and exit 1.
|
|
type fatalError struct {
|
|
err error
|
|
}
|
|
|
|
func (e fatalError) Error() string { return e.err.Error() }
|
|
|
|
func (e fatalError) Unwrap() error { return e.err }
|