1 Commits
Author SHA1 Message Date
clawbot c6470906d0 Name the database each command uses on stderr (closes #111)
check / check (push) Canceled after 0s
scan, report and trees now print one line on stderr before anything
else, naming the database and whether its path is the default or comes
from SFDUPES_DATABASE; scan also says when the file is new. report and
trees print it before opening the database, so a missing-database error
follows it. A relative path gets the working directory put in front of
it without cleaning: cleaning drops a ".." with the directory before
it, which names another file when that directory is a symlink. README
"Database" lists when scan writes each file.

Unverified: the line on a terminal has no test of its own.

Model: opus-5-5
2026-10-16 12:36:19 +00:00
4 changed files with 12 additions and 107 deletions
+3 -7
View File
@@ -279,13 +279,9 @@ All three subcommands operate on a single SQLite database file:
trees: database /srv/tmp/sfdupes/db.db (from SFDUPES_DATABASE) trees: database /srv/tmp/sfdupes/db.db (from SFDUPES_DATABASE)
``` ```
The working directory put in front is the one `pwd -P` prints, with its `scan` says `new database` when no file is at that path as it starts.
symlinks resolved. After a `cd` through a symlink to a directory, a path `report` and `trees` print the line before they open the database, so the
starting `../` therefore names a file beside the directory the symlink leads error for a missing database comes after it.
to, as it does for any other program. `scan` says `new database` when no
file is at that path as it starts. `report` and `trees` print the line
before they open the database, so the error for a missing database comes
after it.
- `scan` creates the database (and its parent directory) on first use. `report` - `scan` creates the database (and its parent directory) on first use. `report`
and `trees` require an existing database; a missing database file is a fatal and `trees` require an existing database; a missing database file is a fatal
+4 -9
View File
@@ -93,9 +93,9 @@ var errScanRunning = errors.New("another scan is running")
// databasePath resolves the database location: SFDUPES_DATABASE when // databasePath resolves the database location: SFDUPES_DATABASE when
// set and non-empty, the compiled-in default otherwise. It returns the // set and non-empty, the compiled-in default otherwise. It returns the
// path, with the working directory, its symlinks resolved, put in front // path, with the working directory put in front of it when it is
// of it when it is relative, and where the path came from, "default" or // relative, and where the path came from, "default" or "from
// "from SFDUPES_DATABASE", for the line each subcommand prints first on // SFDUPES_DATABASE", for the line each subcommand prints first on
// stderr. // stderr.
func databasePath() (string, string, error) { func databasePath() (string, string, error) {
path, source := defaultDatabasePath, "default" path, source := defaultDatabasePath, "default"
@@ -107,12 +107,7 @@ func databasePath() (string, string, error) {
return path, source, nil return path, source, nil
} }
// The working directory as the kernel reports it, with no symlink wd, err := os.Getwd()
// in it. os.Getwd may return PWD instead, which can name it through
// a symlink; a ".." after that symlink then leads somewhere else
// once the path is cleaned, as scan cleans it to create the
// database's directory.
wd, err := unix.Getwd()
if err != nil { if err != nil {
return "", "", fmt.Errorf("resolve database path %s: %w", path, err) return "", "", fmt.Errorf("resolve database path %s: %w", path, err)
} }
+3 -8
View File
@@ -44,14 +44,9 @@ func openTestDB(t *testing.T) *sql.DB {
func TestDatabasePath(t *testing.T) { func TestDatabasePath(t *testing.T) {
// README §Database: SFDUPES_DATABASE when set and non-empty, the // README §Database: SFDUPES_DATABASE when set and non-empty, the
// default otherwise, with the working directory, its symlinks // default otherwise, with the working directory put in front of a
// resolved, put in front of a relative path. Nothing is cleaned, so // relative path. Nothing is cleaned, so a ".." stays.
// a ".." stays. dir := t.TempDir()
dir, err := filepath.EvalSymlinks(t.TempDir())
if err != nil {
t.Fatal(err)
}
t.Chdir(dir) t.Chdir(dir)
fromEnv := "from " + databaseEnv fromEnv := "from " + databaseEnv
+2 -83
View File
@@ -359,14 +359,10 @@ func TestRunUsesDatabaseThroughSymlinkedDirectory(t *testing.T) {
// use that file, relative or absolute, and its first line must name // use that file, relative or absolute, and its first line must name
// the path as set, with the working directory put in front of a // the path as set, with the working directory put in front of a
// relative one. // relative one.
dir, err := filepath.EvalSymlinks(t.TempDir()) dir := t.TempDir()
if err != nil {
t.Fatal(err)
}
t.Chdir(dir) t.Chdir(dir)
err = os.MkdirAll(filepath.Join(dir, "real", "sub"), 0o750) err := os.MkdirAll(filepath.Join(dir, "real", "sub"), 0o750)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -422,83 +418,6 @@ func TestRunUsesDatabaseThroughSymlinkedDirectory(t *testing.T) {
} }
} }
func TestRunScanFromSymlinkedWorkingDirectory(t *testing.T) {
// README §Database: a relative path gets the working directory, its
// symlinks resolved, put in front of it. With home/proj a symlink
// to data/proj, entered as the working directory through the
// symlink, ../new/db.sqlite names data/new/db.sqlite. scan must
// create that directory and file, and nothing beside home/proj.
dir, err := filepath.EvalSymlinks(t.TempDir())
if err != nil {
t.Fatal(err)
}
err = os.MkdirAll(filepath.Join(dir, "data", "proj"), 0o750)
if err != nil {
t.Fatal(err)
}
err = os.Mkdir(filepath.Join(dir, "home"), 0o750)
if err != nil {
t.Fatal(err)
}
err = os.Symlink(filepath.Join(dir, "data", "proj"),
filepath.Join(dir, "home", "proj"))
if err != nil {
t.Fatal(err)
}
// t.Chdir also sets PWD to the path through the symlink, as a
// shell does after cd.
t.Chdir(filepath.Join(dir, "home", "proj"))
t.Setenv(databaseEnv, "../new/db.sqlite")
want := "scan: new database " + dir + "/data/proj/../new/db.sqlite" +
" (from " + databaseEnv + ")\n"
if got := scanOK(t, t.TempDir()); !strings.HasPrefix(got, want) {
t.Errorf("scan stderr = %q, want it to start %q", got, want)
}
_, err = os.Stat(filepath.Join(dir, "data", "new", "db.sqlite"))
if err != nil {
t.Errorf("database the path names: %v", err)
}
_, err = os.Lstat(filepath.Join(dir, "home", "new"))
if !errors.Is(err, fs.ErrNotExist) {
t.Errorf("home/new: stat error %v, want it missing", err)
}
}
func TestRunEscapesDatabasePath(t *testing.T) {
// README §Database: each subcommand's first line escapes the
// database path like the paths in a report, so a tab, newline or
// backslash in it keeps the line one line.
dir := t.TempDir()
t.Setenv(databaseEnv, dir+"/a\tb\nc\\d.sqlite")
escaped := dir + `/a\tb\nc\\d.sqlite`
from := " (from " + databaseEnv + ")\n"
want := "scan: new database " + escaped + from
if got := scanOK(t, t.TempDir()); !strings.HasPrefix(got, want) {
t.Errorf("scan stderr = %q, want it to start %q", got, want)
}
for _, name := range []string{cmdReport, cmdTrees} {
stderr := captureStderr(t)
code := run([]string{name}, io.Discard, os.Stderr)
want = name + ": database " + escaped + from
if got := stderr(); code != exitOK || !strings.HasPrefix(got, want) {
t.Errorf("run(%s) = %d with stderr %q, want %d and it to "+
"start %q", name, code, got, exitOK, want)
}
}
}
func TestRunNamesDefaultDatabase(t *testing.T) { func TestRunNamesDefaultDatabase(t *testing.T) {
// README §Database: with SFDUPES_DATABASE empty, each subcommand's // README §Database: with SFDUPES_DATABASE empty, each subcommand's
// first line on stderr names the default database. A database may // first line on stderr names the default database. A database may