424 lines
13 KiB
Go
424 lines
13 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"
|
|
"slices"
|
|
|
|
"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 and dwim store what they find in a database " +
|
|
"file. report, trees\nand info read it. The file is $" + databaseEnv +
|
|
" when that is set and\nnot empty, and otherwise " +
|
|
defaultDatabasePath + ", where creating it\nneeds 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 each row stays one line:
|
|
|
|
\\ a backslash
|
|
\t a tab
|
|
\n a newline
|
|
\r a carriage return
|
|
`
|
|
|
|
// 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
|
|
|
|
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
|
|
|
|
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, then rounded, as 2147483648 (2.0 GiB)
|
|
-wal file size in bytes, then rounded; only if 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 their total size, in bytes, then rounded
|
|
`
|
|
|
|
dwimHelp = `dwim runs what "sfdupes scan /" runs with the same flags, 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 --workers value below 1.
|
|
var errWorkersBelowOne = errors.New("--workers must be at least 1")
|
|
|
|
// errEmptyExcludeValue is the usage error for an empty --exclude value,
|
|
// as from --exclude "$DIR" with DIR unset. Taken as a path, it
|
|
// would name the working directory.
|
|
var errEmptyExcludeValue = errors.New("--exclude must not be empty")
|
|
|
|
// 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)
|
|
}),
|
|
}
|
|
|
|
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
|
|
|
|
root.AddCommand(newScanCommand(), reportCmd, treesCmd, infoCmd,
|
|
newDwimCommand(stdout))
|
|
|
|
return root
|
|
}
|
|
|
|
// scanOptions holds the values of the flags that scan and dwim share:
|
|
// --workers, -x, --no-default-excludes and --exclude.
|
|
type scanOptions struct {
|
|
workers int
|
|
oneFS bool
|
|
noDefaultExcludes bool
|
|
excludePaths []string
|
|
}
|
|
|
|
// scanFlagsUsage names the flags that scan and dwim share, for their
|
|
// usage lines.
|
|
const scanFlagsUsage = "[--workers N] [-x] [--no-default-excludes] " +
|
|
"[--exclude PATH]..."
|
|
|
|
// addScanFlags adds the flags that scan and dwim share to cmd, which
|
|
// stores their values in opts.
|
|
func addScanFlags(cmd *cobra.Command, opts *scanOptions) {
|
|
cmd.Flags().IntVar(&opts.workers, "workers", runtime.NumCPU(),
|
|
"concurrent workers for the walk and hash phases")
|
|
cmd.Flags().BoolVarP(&opts.oneFS, "one-file-system", "x", false,
|
|
"do not cross filesystem boundaries")
|
|
cmd.Flags().BoolVar(&opts.noDefaultExcludes, "no-default-excludes",
|
|
false, "enter the default excludes, the paths listed above")
|
|
// A string array, not a slice, so a comma in a path is not a
|
|
// separator.
|
|
cmd.Flags().StringArrayVar(&opts.excludePaths, "exclude", nil,
|
|
"never enter or record `PATH` or anything below it; repeatable")
|
|
}
|
|
|
|
// check rejects the flag values that are usage errors, before the
|
|
// command runs. It is the PreRunE of scan and dwim.
|
|
func (o *scanOptions) check(cmd *cobra.Command, _ []string) error {
|
|
err := checkScanWorkers(cmd, o.workers)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return rejectEmptyExcludeValue(cmd, o.excludePaths)
|
|
}
|
|
|
|
// newScanCommand builds the scan subcommand. 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 opts scanOptions
|
|
|
|
short := "Walk trees and synchronize the scan database"
|
|
|
|
scanCmd := &cobra.Command{
|
|
Use: cmdScan + " " + scanFlagsUsage + " PATH...",
|
|
// The Use line already names every flag, so cobra must not
|
|
// append its generic "[flags]" to it.
|
|
DisableFlagsInUseLine: true,
|
|
Short: short,
|
|
Long: short + ".\n\n" + databaseHelp + "\n" +
|
|
defaultExcludesHelp(runtime.GOOS),
|
|
Args: cobra.MinimumNArgs(1),
|
|
PreRunE: opts.check,
|
|
RunE: runE(func(ctx context.Context, args []string) error {
|
|
ctx, stop := interruptContext(ctx)
|
|
defer stop()
|
|
|
|
return runScan(ctx, args, opts)
|
|
}),
|
|
}
|
|
addScanFlags(scanCmd, &opts)
|
|
|
|
return scanCmd
|
|
}
|
|
|
|
// newDwimCommand builds the dwim subcommand, which takes the scan flags
|
|
// with the meaning they have for scan. Its help says what dwim runs,
|
|
// then lists the default excludes and the filesystem types the scan
|
|
// never records anything on.
|
|
func newDwimCommand(stdout io.Writer) *cobra.Command {
|
|
var opts scanOptions
|
|
|
|
short := "Scan /, then print the duplicate-tree report"
|
|
|
|
dwimCmd := &cobra.Command{
|
|
Use: cmdDwim + " " + scanFlagsUsage,
|
|
DisableFlagsInUseLine: true,
|
|
Short: short,
|
|
Long: short + ".\n\n" + dwimHelp + "\n" +
|
|
defaultExcludesHelp(runtime.GOOS),
|
|
Args: cobra.NoArgs,
|
|
PreRunE: opts.check,
|
|
RunE: runE(func(ctx context.Context, _ []string) error {
|
|
return runDwim(ctx, "/", opts, stdout)
|
|
}),
|
|
}
|
|
addScanFlags(dwimCmd, &opts)
|
|
|
|
return dwimCmd
|
|
}
|
|
|
|
// checkScanWorkers rejects a --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)
|
|
}
|
|
|
|
// rejectEmptyExcludeValue rejects an empty --exclude value. That
|
|
// is a usage error reported in one line, as for --workers below 1.
|
|
func rejectEmptyExcludeValue(cmd *cobra.Command, excludePaths []string) error {
|
|
if !slices.Contains(excludePaths, "") {
|
|
return nil
|
|
}
|
|
|
|
cmd.SilenceUsage = true
|
|
|
|
return errEmptyExcludeValue
|
|
}
|
|
|
|
// 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 }
|