check / check (push) Waiting to run
report and trees take optional PATH operands and use only the records at or below them. Operands are made absolute and cleaned as text, never looked up on the filesystem; each reported copy is still checked on disk. An empty operand is a usage error, as --exclude "" is, since it would name the working directory. With operands, trees reports only directories at or below an operand: any other directory lies above an operand and can hold unused files, so its digest neither gets reported nor suppresses its children's group. The summary still counts every record read. Model: opus-5-5
448 lines
14 KiB
Go
448 lines
14 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
|
|
`
|
|
|
|
// operandsHelp is the paragraph of the report and trees help on their
|
|
// PATH operands (README "Operands of `report` and `trees`").
|
|
const operandsHelp = `With PATH operands, only the records at or below them are used. A
|
|
relative PATH is joined to the working directory. No PATH is looked up
|
|
on the filesystem, so a symlink in one is not followed.
|
|
`
|
|
|
|
// 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 + "\n" + operandsHelp
|
|
|
|
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 + "\n" + operandsHelp
|
|
|
|
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")
|
|
|
|
// errEmptyOperand is the usage error for an empty PATH operand of
|
|
// report or trees, as from "$DIR" with DIR unset. Taken as a path, it
|
|
// would name the working directory.
|
|
var errEmptyOperand = errors.New("PATH 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 + " [PATH...]",
|
|
Short: "Read the scan database and print the file-level duplicates report",
|
|
Args: rejectEmptyOperand,
|
|
RunE: runE(func(ctx context.Context, args []string) error {
|
|
return runReport(ctx, args, stdout)
|
|
}),
|
|
}
|
|
|
|
treesCmd := &cobra.Command{
|
|
Use: cmdTrees + " [PATH...]",
|
|
Short: "Read the scan database and print the duplicate-tree report",
|
|
Args: rejectEmptyOperand,
|
|
RunE: runE(func(ctx context.Context, args []string) error {
|
|
return runTrees(ctx, args, 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
|
|
}
|
|
|
|
// rejectEmptyOperand rejects an empty PATH operand of report or trees.
|
|
// That is a usage error reported in one line, as for --workers below 1.
|
|
func rejectEmptyOperand(cmd *cobra.Command, args []string) error {
|
|
if !slices.Contains(args, "") {
|
|
return nil
|
|
}
|
|
|
|
cmd.SilenceUsage = true
|
|
|
|
return errEmptyOperand
|
|
}
|
|
|
|
// 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 }
|