Compare commits

..
Author SHA1 Message Date
sneak a179fbaa9c Say "skipped" in scan's warnings for skipped paths (closes #170)
check / check (push) Canceled after 0s
A warning for a path the summary counts as skipped now reads
`scan: skipped PATH (REASON)`, naming the path once. This covers
unreadable directories, failed lstats, failed reads and dropped
operands. Each path of a hard-linked file that cannot be read gets its
own line, since each is counted. Lines for paths left out on purpose
(`--exclude`, default excludes, filesystems that are not persistent)
now read `scan: left out PATH (...)`, so a search for `scan: skipped`
finds only the counted paths.

Model: opus-5-5
2026-11-11 10:55:35 +00:00
5 changed files with 48 additions and 206 deletions

No files matched your search

+13 -14
View File
@@ -253,9 +253,8 @@ In order:
`trees` check only that each copy they name still exists, and leave out a `trees` check only that each copy they name still exists, and leave out a
copy that is gone. `scan` is built to run from `cron`. The reports run at any copy that is gone. `scan` is built to run from `cron`. The reports run at any
time, against what the database holds. time, against what the database holds.
4. **Keep the streams apart.** stdout holds only what was asked for: 4. **Keep the streams apart.** Everything on stdout is machine-readable data.
machine-readable data, or help. Everything else for a human reader, such as Everything for a human reader, such as progress and warnings, goes to stderr.
progress and warnings, goes to stderr.
### Subcommands ### Subcommands
@@ -325,13 +324,12 @@ The reference below is also the program's specification.
"Operands"). "Operands").
- `--version`, `-v` prints `sfdupes VERSION` (see "Install") to stdout and - `--version`, `-v` prints `sfdupes VERSION` (see "Install") to stdout and
exits 0. It writes nothing to stderr. exits 0. It writes nothing to stderr.
- `--help`, `-h`, alone or after a subcommand, prints the help text to stdout - `--help`, `-h`, alone or after a subcommand, prints the help text to stderr
and exits 0. It writes nothing to stderr. `sfdupes help` and and exits 0. It writes nothing to stdout. The help of `sfdupes` and of `scan`
`sfdupes help COMMAND` do the same. The help of `sfdupes` and of `scan` names names the database. The help of `report`, `trees`, `info` and `dwim` says what
the database. The help of `report`, `trees`, `info` and `dwim` says what each each prints. `sfdupes scan --help` and `sfdupes dwim --help` also list the
prints. `sfdupes scan --help` and `sfdupes dwim --help` also list the default default excludes of the system they run on, and the filesystem types the walk
excludes of the system they run on, and the filesystem types the walk never never records anything on.
records anything on.
### Duplicate detection ### Duplicate detection
@@ -691,10 +689,11 @@ scan: skipped /srv/a/closed (permission denied)
scan: skipped /srv/a/pipe (a FIFO) scan: skipped /srv/a/pipe (a FIFO)
``` ```
The summary counts each path named in such a warning. A line for a path left out The summary counts each path named in such a warning. When a file with several
on purpose starts with `scan: left out` instead, and is not counted. A search of hard links cannot be read, each of its paths gets a warning. A line for a path
the log for `scan: skipped` therefore finds the paths the summary counts. When a left out on purpose starts with `scan: left out` instead, and is not counted. A
file with several hard links cannot be read, each of its paths gets a warning. search of the log for `scan: skipped` therefore finds the paths the summary
counts.
#### Stopping a scan #### Stopping a scan
+4 -78
View File
@@ -3,7 +3,6 @@
package sfdupes package sfdupes
import ( import (
"bytes"
"context" "context"
"errors" "errors"
"fmt" "fmt"
@@ -177,32 +176,9 @@ func run(args []string, stdout, stderr io.Writer) int {
} }
root := newRootCommand(stdout, stderr) root := newRootCommand(stdout, stderr)
// Help that was asked for, with --help, -h or the help subcommand,
// goes to stdout. Execute returns no error after help, so helpErr
// keeps the one printHelp returns. Cobra's own help subcommand is
// replaced because it answers "sfdupes help scna" with the help of
// sfdupes.
var helpErr error
cobraHelp := root.HelpFunc()
root.SetHelpFunc(func(cmd *cobra.Command, _ []string) {
helpErr = printHelp(cmd, cmd.Flags().Args(), cobraHelp, stdout)
})
root.SetHelpCommand(&cobra.Command{
Use: "help [command]",
Short: "Help about any command",
Run: func(_ *cobra.Command, names []string) {
helpErr = printHelp(root, names, cobraHelp, stdout)
},
})
root.SetArgs(args) root.SetArgs(args)
err := root.Execute() err := root.Execute()
if err == nil {
err = helpErr
}
var fatal fatalError var fatal fatalError
@@ -219,62 +195,14 @@ func run(args []string, stdout, stderr io.Writer) int {
return exitFatal return exitFatal
default: default:
// A usage error, which cobra or printHelp has already reported // A usage error, which cobra has already reported on stderr.
// on stderr.
return exitUsage return exitUsage
} }
} }
// printHelp prints help that was asked for to stdout. It is the help of
// cmd, or, for a command with subcommands, of the subcommand that
// operands name: scan in "sfdupes help scan". Operands that name none,
// as in "sfdupes help scna" or "sfdupes scna --help", are a usage
// error, reported on stderr as for "sfdupes scna".
//
// cobraHelp is cobra's own help function. It prints to the command's
// output writer, which is stderr, and drops a failed write. So the help
// goes into a buffer first, and a failed write of the buffer to stdout
// is a fatal error.
func printHelp(
cmd *cobra.Command, operands []string,
cobraHelp func(*cobra.Command, []string), stdout io.Writer,
) error {
if cmd.HasSubCommands() {
cmd, operands, _ = cmd.Find(operands)
}
// When the help subcommand runs instead of cmd, cobra has not yet
// added the --help flag that cmd's help and usage text list.
cmd.InitDefaultHelpFlag()
// Operands left over here name no subcommand. They are reported as
// cobra reports "sfdupes scna", which cobra.NoArgs rejects.
if cmd.HasSubCommands() && len(operands) > 0 {
err := cobra.NoArgs(cmd, operands)
cmd.PrintErrln(cmd.ErrPrefix(), err.Error())
cmd.Println(cmd.UsageString())
return err
}
out := cmd.OutOrStderr()
help := new(bytes.Buffer)
cmd.SetOut(help)
cobraHelp(cmd, nil)
cmd.SetOut(out)
_, err := stdout.Write(help.Bytes())
if err != nil {
return fatalError{err: fmt.Errorf("write stdout: %w", err)}
}
return nil
}
// newRootCommand builds the command tree. Everything on stdout is // newRootCommand builds the command tree. Everything on stdout is
// machine-readable data, the version line included, or help that was // machine-readable data, the version line included; all human-facing
// asked for, which run prints. The rest of the human-facing output // output (help, usage, errors) goes to stderr.
// (usage, errors) goes to stderr.
func newRootCommand(stdout, stderr io.Writer) *cobra.Command { func newRootCommand(stdout, stderr io.Writer) *cobra.Command {
var showVersion bool var showVersion bool
@@ -307,13 +235,11 @@ func newRootCommand(stdout, stderr io.Writer) *cobra.Command {
return errNoSubcommand return errNoSubcommand
}, },
} }
// Cobra prints the usage text of a usage error to the command's
// output writer, so that writer is stderr.
root.SetOut(stderr) root.SetOut(stderr)
root.SetErr(stderr) root.SetErr(stderr)
root.CompletionOptions.DisableDefaultCmd = true root.CompletionOptions.DisableDefaultCmd = true
// Cobra's built-in version flag prints to the output writer, // Cobra's built-in version flag prints through the help writer,
// stderr; this one prints to stdout. // stderr; this one prints to stdout.
root.Flags().BoolVarP(&showVersion, "version", "v", false, root.Flags().BoolVarP(&showVersion, "version", "v", false,
"print the version to stdout") "print the version to stdout")
+22 -105
View File
@@ -26,9 +26,6 @@ const usageMarker = "Usage:"
// helpFlag asks for the help text. // helpFlag asks for the help text.
const helpFlag = "--help" const helpFlag = "--help"
// helpCommand is the subcommand that prints the help text.
const helpCommand = "help"
// workersFlag is the scan and dwim flag that sets the worker count. // workersFlag is the scan and dwim flag that sets the worker count.
const workersFlag = "--workers" const workersFlag = "--workers"
@@ -628,14 +625,6 @@ func TestRunNamesDefaultDatabase(t *testing.T) {
func TestRunUsageErrors(t *testing.T) { func TestRunUsageErrors(t *testing.T) {
// Usage errors keep exiting 2 with cobra's own report on stderr. // Usage errors keep exiting 2 with cobra's own report on stderr.
// An unknown subcommand is reported the same way with or without a
// request for help: the line naming it, then the usage text.
const (
unknown = "nope"
unknownReport = "unknown command \"" + unknown +
"\" for \"sfdupes\"\n" + usageMarker
)
cases := map[string]struct { cases := map[string]struct {
args []string args []string
want string want string
@@ -645,13 +634,7 @@ func TestRunUsageErrors(t *testing.T) {
"info with args": {[]string{cmdInfo, "x"}, usageMarker}, "info with args": {[]string{cmdInfo, "x"}, usageMarker},
"dwim with args": {[]string{cmdDwim, "x"}, usageMarker}, "dwim with args": {[]string{cmdDwim, "x"}, usageMarker},
"unknown flag": {[]string{cmdScan, "--nope", "/"}, usageMarker}, "unknown flag": {[]string{cmdScan, "--nope", "/"}, usageMarker},
"unknown subcommand": {[]string{unknown}, unknownReport}, "unknown subcommand": {[]string{"nope"}, "unknown command"},
"unknown subcommand with --help": {
[]string{unknown, helpFlag}, unknownReport,
},
"help for an unknown subcommand": {
[]string{helpCommand, unknown}, unknownReport,
},
} }
for name, tc := range cases { for name, tc := range cases {
@@ -717,12 +700,9 @@ func TestRunScanRejectsWorkersBelowOne(t *testing.T) {
func TestRunHelp(t *testing.T) { func TestRunHelp(t *testing.T) {
t.Parallel() t.Parallel()
// README §Settings: help goes to stdout, exits 0, and leaves stderr // README §Settings: help goes to stderr, exits 0, and leaves stdout
// empty. // empty.
cases := [][]string{ cases := [][]string{{helpFlag}, {"-h"}, {cmdScan, helpFlag}}
{helpFlag}, {"-h"}, {helpCommand},
{cmdScan, helpFlag}, {helpCommand, cmdScan},
}
for _, args := range cases { for _, args := range cases {
var stdout, stderr bytes.Buffer var stdout, stderr bytes.Buffer
@@ -732,20 +712,14 @@ func TestRunHelp(t *testing.T) {
t.Errorf("run(%v) = %d, want %d", args, code, exitOK) t.Errorf("run(%v) = %d, want %d", args, code, exitOK)
} }
if !strings.Contains(stdout.String(), usageMarker) { if !strings.Contains(stderr.String(), usageMarker) {
t.Errorf("run(%v) stdout = %q, want the help text", t.Errorf("run(%v) stderr = %q, want the help text",
args, stdout.String()) args, stderr.String())
} }
// The help subcommand prints the same help as --help, which if got := stdout.String(); got != "" {
// lists --help itself. t.Errorf("run(%v) stdout = %q, want nothing (data only)",
if !strings.Contains(stdout.String(), "-h, "+helpFlag) { args, got)
t.Errorf("run(%v) stdout = %q, want it to list %s",
args, stdout.String(), helpFlag)
}
if got := stderr.String(); got != "" {
t.Errorf("run(%v) stderr = %q, want nothing", args, got)
} }
// README §Location: the help of sfdupes and of scan says where // README §Location: the help of sfdupes and of scan says where
@@ -755,9 +729,9 @@ func TestRunHelp(t *testing.T) {
"scan and dwim store", databaseEnv, rootDatabasePath, "scan and dwim store", databaseEnv, rootDatabasePath,
stateHomeEnv, "~/.local/state", "not an absolute path is an error", stateHomeEnv, "~/.local/state", "not an absolute path is an error",
} { } {
if !strings.Contains(stdout.String(), want) { if !strings.Contains(stderr.String(), want) {
t.Errorf("run(%v) stdout = %q, want it to name %q", t.Errorf("run(%v) stderr = %q, want it to name %q",
args, stdout.String(), want) args, stderr.String(), want)
} }
} }
} }
@@ -767,48 +741,17 @@ func TestRunHelp(t *testing.T) {
run([]string{cmdScan, "-h"}, &stdout, &stderr) run([]string{cmdScan, "-h"}, &stdout, &stderr)
if !strings.Contains(stdout.String(), excludeFlag+" PATH") { if !strings.Contains(stderr.String(), excludeFlag+" PATH") {
t.Errorf("scan -h stdout = %q, want %s PATH", stdout.String(), t.Errorf("scan -h stderr = %q, want %s PATH", stderr.String(),
excludeFlag) excludeFlag)
} }
} }
func TestRunHelpScanPrintsScanHelp(t *testing.T) {
t.Parallel()
// "sfdupes help scan" and "sfdupes --help scan" print exactly what
// "sfdupes scan --help" prints: the help of scan, not of sfdupes.
var want bytes.Buffer
run([]string{cmdScan, helpFlag}, &want, io.Discard)
for _, args := range [][]string{
{helpCommand, cmdScan}, {helpFlag, cmdScan},
} {
var stdout, stderr bytes.Buffer
code := run(args, &stdout, &stderr)
if code != exitOK {
t.Errorf("run(%v) = %d, want %d", args, code, exitOK)
}
if got := stdout.String(); got != want.String() {
t.Errorf("run(%v) stdout = %q, want what scan %s prints, %q",
args, got, helpFlag, want.String())
}
if got := stderr.String(); got != "" {
t.Errorf("run(%v) stderr = %q, want nothing", args, got)
}
}
}
func TestRunScanUsageLine(t *testing.T) { func TestRunScanUsageLine(t *testing.T) {
t.Parallel() t.Parallel()
// README §Subcommands shows this usage line for scan, and the help // README §Subcommands shows this usage line for scan, and the help
// and the usage error both print it. TestRunHelp and // and the usage error both print it.
// TestRunUsageErrors check which stream each goes to.
const want = "\n sfdupes scan [--workers N] [-x] [--no-default-excludes] " + const want = "\n sfdupes scan [--workers N] [-x] [--no-default-excludes] " +
"[--exclude PATH]... PATH...\n" "[--exclude PATH]... PATH...\n"
@@ -821,13 +764,13 @@ func TestRunScanUsageLine(t *testing.T) {
t.Run(name, func(t *testing.T) { t.Run(name, func(t *testing.T) {
t.Parallel() t.Parallel()
var output bytes.Buffer var stdout, stderr bytes.Buffer
run(args, &output, &output) run(args, &stdout, &stderr)
if !strings.Contains(output.String(), want) { if !strings.Contains(stderr.String(), want) {
t.Errorf("run(%v) output = %q, want the usage line %q", t.Errorf("run(%v) stderr = %q, want the usage line %q",
args, output.String(), want) args, stderr.String(), want)
} }
}) })
} }
@@ -893,9 +836,9 @@ func TestRunHelpDescribesOutput(t *testing.T) {
} }
for _, want := range tc.want { for _, want := range tc.want {
if !strings.Contains(stdout.String(), want) { if !strings.Contains(stderr.String(), want) {
t.Errorf("run(%v) help lacks %q:\n%s", t.Errorf("run(%v) help lacks %q:\n%s",
args, want, stdout.String()) args, want, stderr.String())
} }
} }
} }
@@ -979,32 +922,6 @@ func TestRunVersionWriteFailureIsFatal(t *testing.T) {
} }
} }
func TestRunHelpWriteFailureIsFatal(t *testing.T) {
t.Parallel()
// README §Error handling: a failed write to stdout is a fatal error,
// exit 1, reported on stderr starting with "sfdupes:". Help goes to
// stdout, so that holds for help too.
cases := [][]string{
{helpFlag}, {"-h"}, {helpCommand},
{cmdScan, helpFlag}, {helpCommand, cmdScan},
}
for _, args := range cases {
var stderr bytes.Buffer
code := run(args, failingWriter{}, &stderr)
if code != exitFatal {
t.Errorf("run(%v) = %d, want %d", args, code, exitFatal)
}
want := "sfdupes: write stdout: " + errWriteFailed.Error() + "\n"
if got := stderr.String(); got != want {
t.Errorf("run(%v) stderr = %q, want %q", args, got, want)
}
}
}
// scanFixture builds a small tree holding one duplicate pair and one // scanFixture builds a small tree holding one duplicate pair and one
// unreadable file and scans it into the database the caller has // unreadable file and scans it into the database the caller has
// pointed SFDUPES_DATABASE at. The unreadable file makes the scan warn // pointed SFDUPES_DATABASE at. The unreadable file makes the scan warn
+7 -7
View File
@@ -190,21 +190,21 @@ func TestRunDwimHelpListsScanFlags(t *testing.T) {
run([]string{cmdDwim, "-h"}, &stdout, &stderr) run([]string{cmdDwim, "-h"}, &stdout, &stderr)
if !strings.Contains(stdout.String(), want) { if !strings.Contains(stderr.String(), want) {
t.Errorf("dwim -h stdout = %q, want the usage line %q", t.Errorf("dwim -h stderr = %q, want the usage line %q",
stdout.String(), want) stderr.String(), want)
} }
if !strings.Contains(stdout.String(), excludeFlag+" PATH") { if !strings.Contains(stderr.String(), excludeFlag+" PATH") {
t.Errorf("dwim -h stdout = %q, want %s PATH", stdout.String(), t.Errorf("dwim -h stderr = %q, want %s PATH", stderr.String(),
excludeFlag) excludeFlag)
} }
// README "Settings": the help lists the default excludes and the // README "Settings": the help lists the default excludes and the
// filesystem types the walk never records anything on. // filesystem types the walk never records anything on.
help := strings.TrimSpace(defaultExcludesHelp(runtime.GOOS)) help := strings.TrimSpace(defaultExcludesHelp(runtime.GOOS))
if !strings.Contains(stdout.String(), help) { if !strings.Contains(stderr.String(), help) {
t.Errorf("dwim -h stdout = %q, want it to hold %q", stdout.String(), t.Errorf("dwim -h stderr = %q, want it to hold %q", stderr.String(),
help) help)
} }
} }
+2 -2
View File
@@ -196,8 +196,8 @@ func TestDefaultExcludesHelp(t *testing.T) {
run([]string{cmdScan, "-h"}, &stdout, &stderr) run([]string{cmdScan, "-h"}, &stdout, &stderr)
help := strings.TrimSpace(defaultExcludesHelp(runtime.GOOS)) help := strings.TrimSpace(defaultExcludesHelp(runtime.GOOS))
if !strings.Contains(stdout.String(), help) { if !strings.Contains(stderr.String(), help) {
t.Errorf("scan --help = %q, want it to hold %q", stdout.String(), help) t.Errorf("scan --help = %q, want it to hold %q", stderr.String(), help)
} }
} }