Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2b46d374b1 | ||
|
|
ee09cda355 |
@@ -29,6 +29,19 @@
|
|||||||
|
|
||||||
# Completed Steps
|
# Completed Steps
|
||||||
|
|
||||||
|
- `README.md` rewritten from scratch, sentence by sentence, as a newcomer reads
|
||||||
|
it. It leads with what the program is and how to use it. A reference section
|
||||||
|
covers every setting and every subcommand. Development and testing come last.
|
||||||
|
Every statement was checked against the code on `next`. Five comments in
|
||||||
|
`main_test.go` cite "Settings", where their rules now live. One comment each
|
||||||
|
in `scan_test.go`, `trees_test.go` and `exclude_test.go` cites what the README
|
||||||
|
now says (2026-10-26, https://git.eeqj.de/sneak/sfdupes/issues/135)
|
||||||
|
|
||||||
|
- an operand at or inside a directory named `.zfs` is never scanned, whether
|
||||||
|
given alone or under another operand. `scan` warns about it, counts it as
|
||||||
|
skipped and does not walk it (2026-10-26,
|
||||||
|
https://git.eeqj.de/sneak/sfdupes/issues/142)
|
||||||
|
|
||||||
- a file or directory removed while the scan runs is not an error, in the walk
|
- a file or directory removed while the scan runs is not an error, in the walk
|
||||||
or in the hash phase. The scan prints no line for it and does not count it as
|
or in the hash phase. The scan prints no line for it and does not count it as
|
||||||
skipped. When the first of several hard links to a file is gone, the hash
|
skipped. When the first of several hard links to a file is gone, the hash
|
||||||
|
|||||||
+3
-1
@@ -17,7 +17,9 @@ import (
|
|||||||
func TestDefaultExcludes(t *testing.T) {
|
func TestDefaultExcludes(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
// README "Rules for the walk": each system's list.
|
// README "Rules for the walk": the Linux and macOS lists, and none
|
||||||
|
// on any other system. The Windows list exists in the code only: the
|
||||||
|
// binary does not build for Windows, so the README leaves it out.
|
||||||
cases := map[string][]string{
|
cases := map[string][]string{
|
||||||
goosLinux: {"/proc", "/sys", "/dev", "/run", "/tmp", "/var/tmp"},
|
goosLinux: {"/proc", "/sys", "/dev", "/run", "/tmp", "/var/tmp"},
|
||||||
goosDarwin: {
|
goosDarwin: {
|
||||||
|
|||||||
+136
-25
@@ -393,7 +393,7 @@ func TestRunNamesRelativeDatabase(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestRunUsesDatabaseThroughSymlinkedDirectory(t *testing.T) {
|
func TestRunUsesDatabaseThroughSymlinkedDirectory(t *testing.T) {
|
||||||
// README §Database: the path names the file exactly. With link a
|
// README §Settings: the path names the file exactly. With link a
|
||||||
// symlink to the directory real/sub, link/../db.sqlite names
|
// symlink to the directory real/sub, link/../db.sqlite names
|
||||||
// real/db.sqlite, not db.sqlite beside link. Every subcommand must
|
// real/db.sqlite, not db.sqlite beside link. Every subcommand must
|
||||||
// use that file, relative or absolute, and its first line must name
|
// use that file, relative or absolute, and its first line must name
|
||||||
@@ -648,7 +648,7 @@ func TestRunUsageErrors(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestRunScanRejectsWorkersBelowOne(t *testing.T) {
|
func TestRunScanRejectsWorkersBelowOne(t *testing.T) {
|
||||||
// README §scan mode: --workers below 1 is a usage error reported in
|
// README §Settings: --workers below 1 is a usage error reported in
|
||||||
// one line on stderr, before the scan opens the database.
|
// one line on stderr, before the scan opens the database.
|
||||||
for _, workers := range []string{"0", "-1"} {
|
for _, workers := range []string{"0", "-1"} {
|
||||||
t.Run(workers, func(t *testing.T) {
|
t.Run(workers, func(t *testing.T) {
|
||||||
@@ -686,8 +686,8 @@ func TestRunScanRejectsWorkersBelowOne(t *testing.T) {
|
|||||||
func TestRunHelp(t *testing.T) {
|
func TestRunHelp(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
// README §Subcommands: help goes to stderr, exits 0, and leaves
|
// README §Settings: help goes to stderr, exits 0, and leaves stdout
|
||||||
// stdout empty.
|
// empty.
|
||||||
cases := [][]string{{"--help"}, {"-h"}, {cmdScan, "--help"}}
|
cases := [][]string{{"--help"}, {"-h"}, {cmdScan, "--help"}}
|
||||||
|
|
||||||
for _, args := range cases {
|
for _, args := range cases {
|
||||||
@@ -713,8 +713,8 @@ func TestRunHelp(t *testing.T) {
|
|||||||
func TestRunVersion(t *testing.T) {
|
func TestRunVersion(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
// README §Subcommands: the version is one line on stdout, with
|
// README §Settings: the version is one line on stdout, with nothing
|
||||||
// nothing on stderr, and exits 0.
|
// on stderr, and exits 0.
|
||||||
info, _ := debug.ReadBuildInfo()
|
info, _ := debug.ReadBuildInfo()
|
||||||
|
|
||||||
for _, arg := range []string{"--version", "-v"} {
|
for _, arg := range []string{"--version", "-v"} {
|
||||||
@@ -773,8 +773,8 @@ func TestBuildVersion(t *testing.T) {
|
|||||||
func TestRunVersionWriteFailureIsFatal(t *testing.T) {
|
func TestRunVersionWriteFailureIsFatal(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
// README §Error handling: a stdout write failure exits 1, reported
|
// README §Error handling: a failed write to stdout is a fatal error,
|
||||||
// in one line on stderr.
|
// exit 1, reported on stderr starting with "sfdupes:".
|
||||||
var stderr bytes.Buffer
|
var stderr bytes.Buffer
|
||||||
|
|
||||||
code := run([]string{"--version"}, failingWriter{}, &stderr)
|
code := run([]string{"--version"}, failingWriter{}, &stderr)
|
||||||
@@ -928,24 +928,21 @@ func TestRunScanWalksOperandUnderSymlinkOperand(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestRunScanSkipsZFSOperand(t *testing.T) {
|
func TestRunScanSkipsZFSOperand(t *testing.T) {
|
||||||
path := testDBPath(t)
|
|
||||||
t.Setenv(databaseEnv, path)
|
|
||||||
|
|
||||||
zfs := filepath.Join(t.TempDir(), ".zfs")
|
zfs := filepath.Join(t.TempDir(), ".zfs")
|
||||||
snapshot := filepath.Join(zfs, "snapshot", "hourly")
|
f := writeFile(t, zfs, "snapshot/hourly/f", pattern(1, 10))
|
||||||
f := writeFile(t, snapshot, "f", pattern(1, 10))
|
|
||||||
|
|
||||||
// An operand beneath a .zfs directory is walked, because it is not
|
// No scan records a file inside a .zfs directory, so the record
|
||||||
// itself named .zfs.
|
// beneath the operand is written straight into the database.
|
||||||
scanOK(t, snapshot)
|
path := seedDatabase(t, []scanRec{{size: 10, path: f}})
|
||||||
|
t.Setenv(databaseEnv, path)
|
||||||
|
|
||||||
assertOperandSkipped(t, path, zfs, ".zfs directory", f)
|
assertOperandSkipped(t, path, zfs, ".zfs directory", f)
|
||||||
}
|
}
|
||||||
|
|
||||||
// assertOperandSkipped scans operand alone and checks that it is skipped
|
// assertOperandSkipped scans operand alone and checks that it is skipped
|
||||||
// as kind: a warning naming it, one skip in the summary, exit 0, and the
|
// as kind: a warning naming it, one skip in the summary, exit 0, and the
|
||||||
// record for kept, which an earlier scan stored beneath operand, still
|
// record for kept, stored beneath operand before this scan, still in the
|
||||||
// in the database at dbPath.
|
// database at dbPath.
|
||||||
func assertOperandSkipped(t *testing.T, dbPath, operand, kind,
|
func assertOperandSkipped(t *testing.T, dbPath, operand, kind,
|
||||||
kept string,
|
kept string,
|
||||||
) {
|
) {
|
||||||
@@ -974,6 +971,121 @@ func assertOperandSkipped(t *testing.T, dbPath, operand, kind,
|
|||||||
recordByPath(t, dbRecords(t, db), kept)
|
recordByPath(t, dbRecords(t, db), kept)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestRunScanSkipsOperandInsideZFS scans a directory inside a .zfs
|
||||||
|
// directory, first alone and then under another operand. Each time it
|
||||||
|
// is warned about and counted as skipped, and nothing in it is walked.
|
||||||
|
// The other operand is scanned as usual.
|
||||||
|
func TestRunScanSkipsOperandInsideZFS(t *testing.T) {
|
||||||
|
path := testDBPath(t)
|
||||||
|
t.Setenv(databaseEnv, path)
|
||||||
|
|
||||||
|
dir := t.TempDir()
|
||||||
|
hourly := filepath.Join(dir, ".zfs", "snapshot", "hourly")
|
||||||
|
writeFile(t, hourly, "f", pattern(1, 10))
|
||||||
|
f := writeFile(t, dir, "f", pattern(2, 10))
|
||||||
|
|
||||||
|
warning := "walk " + hourly + ": skipping operand inside a .zfs directory\n"
|
||||||
|
|
||||||
|
for _, tc := range []struct {
|
||||||
|
operands []string
|
||||||
|
summary string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
[]string{hourly},
|
||||||
|
"scan: 0 files seen (0 added, 0 updated, 0 removed, " +
|
||||||
|
"0 unchanged), 1 skipped\n",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
[]string{dir, hourly},
|
||||||
|
"scan: 1 files seen (1 added, 0 updated, 0 removed, " +
|
||||||
|
"0 unchanged), 1 skipped\n",
|
||||||
|
},
|
||||||
|
} {
|
||||||
|
stderr := scanOK(t, tc.operands...)
|
||||||
|
if !strings.Contains(stderr, warning) ||
|
||||||
|
!strings.Contains(stderr, tc.summary) {
|
||||||
|
t.Errorf("scan %q: stderr = %q, want %q and %q", tc.operands,
|
||||||
|
stderr, warning, tc.summary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
db, err := openDB(path, reportParams)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Cleanup(func() { _ = db.Close() })
|
||||||
|
|
||||||
|
if got := recordPaths(dbRecords(t, db)); !slices.Equal(got, []string{f}) {
|
||||||
|
t.Errorf("records = %q, want %q", got, []string{f})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRunScanSkipsOperandThroughSymlinkIntoZFS scans snaps/daily, where
|
||||||
|
// snaps is a symlink to pool/.zfs/snapshot. The operand is warned about
|
||||||
|
// by the path given, counted as skipped, and not walked.
|
||||||
|
func TestRunScanSkipsOperandThroughSymlinkIntoZFS(t *testing.T) {
|
||||||
|
t.Setenv(databaseEnv, testDBPath(t))
|
||||||
|
|
||||||
|
dir := t.TempDir()
|
||||||
|
writeFile(t, dir, "pool/.zfs/snapshot/daily/f", pattern(1, 10))
|
||||||
|
|
||||||
|
snaps := filepath.Join(dir, "snaps")
|
||||||
|
|
||||||
|
err := os.Symlink(filepath.Join(dir, "pool", ".zfs", "snapshot"), snaps)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
daily := filepath.Join(snaps, "daily")
|
||||||
|
stderr := scanOK(t, daily)
|
||||||
|
|
||||||
|
warning := "walk " + daily + ": skipping operand inside a .zfs directory\n"
|
||||||
|
summary := "scan: 0 files seen (0 added, 0 updated, 0 removed, " +
|
||||||
|
"0 unchanged), 1 skipped\n"
|
||||||
|
|
||||||
|
if !strings.Contains(stderr, warning) ||
|
||||||
|
!strings.Contains(stderr, summary) {
|
||||||
|
t.Errorf("stderr = %q, want %q and %q", stderr, warning, summary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRunScanWalksOperandThroughSymlink scans data/daily, where data is
|
||||||
|
// a symlink to a directory outside any .zfs directory. The operand is
|
||||||
|
// scanned as usual, and its file is recorded by the path given.
|
||||||
|
func TestRunScanWalksOperandThroughSymlink(t *testing.T) {
|
||||||
|
path := testDBPath(t)
|
||||||
|
t.Setenv(databaseEnv, path)
|
||||||
|
|
||||||
|
dir := t.TempDir()
|
||||||
|
writeFile(t, dir, "pool/data/daily/f", pattern(1, 10))
|
||||||
|
|
||||||
|
data := filepath.Join(dir, "data")
|
||||||
|
|
||||||
|
err := os.Symlink(filepath.Join(dir, "pool", "data"), data)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
daily := filepath.Join(data, "daily")
|
||||||
|
stderr := scanOK(t, daily)
|
||||||
|
|
||||||
|
summary := "scan: 1 files seen (1 added, 0 updated, 0 removed, " +
|
||||||
|
"0 unchanged), 0 skipped\n"
|
||||||
|
if !strings.Contains(stderr, summary) {
|
||||||
|
t.Errorf("stderr = %q, want %q", stderr, summary)
|
||||||
|
}
|
||||||
|
|
||||||
|
db, err := openDB(path, reportParams)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Cleanup(func() { _ = db.Close() })
|
||||||
|
|
||||||
|
recordByPath(t, dbRecords(t, db), filepath.Join(daily, "f"))
|
||||||
|
}
|
||||||
|
|
||||||
func TestRunReportSucceeds(t *testing.T) {
|
func TestRunReportSucceeds(t *testing.T) {
|
||||||
path := testDBPath(t)
|
path := testDBPath(t)
|
||||||
t.Setenv(databaseEnv, path)
|
t.Setenv(databaseEnv, path)
|
||||||
@@ -1122,7 +1234,7 @@ func assertRunsUseDatabase(t *testing.T, dir, name string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestRunDatabasePathUsedAsGiven(t *testing.T) {
|
func TestRunDatabasePathUsedAsGiven(t *testing.T) {
|
||||||
// README §Database: the path names the database file exactly. In
|
// README §Settings: the path names the database file exactly. In
|
||||||
// SQLite's connection string an unescaped ? or # would end the file
|
// SQLite's connection string an unescaped ? or # would end the file
|
||||||
// name and % would start an escape, and a path starting with //
|
// name and % would start an escape, and a path starting with //
|
||||||
// could be read as a host name. %25 is a valid escape, so unescaped
|
// could be read as a host name. %25 is a valid escape, so unescaped
|
||||||
@@ -1167,9 +1279,8 @@ func holdScanLock(t *testing.T, path string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestRunSecondScanFails(t *testing.T) {
|
func TestRunSecondScanFails(t *testing.T) {
|
||||||
// README §Database: while one scan holds the lock, a second scan
|
// README §Database: a second scan that finds the lock held fails
|
||||||
// fails at once, naming the lock file, without opening the
|
// at once, naming the lock file, without opening the database.
|
||||||
// database.
|
|
||||||
path := testDBPath(t)
|
path := testDBPath(t)
|
||||||
t.Setenv(databaseEnv, path)
|
t.Setenv(databaseEnv, path)
|
||||||
|
|
||||||
@@ -1228,7 +1339,7 @@ func assertSecondScanFails(t *testing.T, path string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestRunSecondScanThroughSymlinkFails(t *testing.T) {
|
func TestRunSecondScanThroughSymlinkFails(t *testing.T) {
|
||||||
// README §Database: every path to one database names the same lock
|
// README §Database: a path through a symlink names the same lock
|
||||||
// file, so while a scan holds it through one path, a scan through
|
// file, so while a scan holds it through one path, a scan through
|
||||||
// another fails, naming the lock file beside the database's real
|
// another fails, naming the lock file beside the database's real
|
||||||
// path.
|
// path.
|
||||||
@@ -1333,8 +1444,8 @@ func TestRunReportsDuringScan(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestRunStdoutClosedIsFatal(t *testing.T) {
|
func TestRunStdoutClosedIsFatal(t *testing.T) {
|
||||||
// README §Error handling: a stdout write failure exits 1, reported
|
// README §Error handling: a failed write to stdout is a fatal error,
|
||||||
// in one line on stderr.
|
// exit 1, reported on stderr starting with "sfdupes:".
|
||||||
for _, name := range []string{cmdReport, cmdTrees, cmdInfo} {
|
for _, name := range []string{cmdReport, cmdTrees, cmdInfo} {
|
||||||
t.Run(name, func(t *testing.T) {
|
t.Run(name, func(t *testing.T) {
|
||||||
t.Setenv(databaseEnv, testDBPath(t))
|
t.Setenv(databaseEnv, testDBPath(t))
|
||||||
|
|||||||
@@ -383,7 +383,8 @@ func (s *scanState) runPhases(ctx context.Context, roots []string,
|
|||||||
) error {
|
) error {
|
||||||
// Types and filesystems are checked before pruning so that an
|
// Types and filesystems are checked before pruning so that an
|
||||||
// operand under a dropped one is still scanned, not dropped as lying
|
// operand under a dropped one is still scanned, not dropped as lying
|
||||||
// under it.
|
// under it, and so that an operand inside a .zfs directory gets its
|
||||||
|
// warning even when it lies under another operand.
|
||||||
roots = pruneRoots(s.walkableRoots(roots, rules), oneFS, rules,
|
roots = pruneRoots(s.walkableRoots(roots, rules), oneFS, rules,
|
||||||
deviceOfPath)
|
deviceOfPath)
|
||||||
|
|
||||||
@@ -411,15 +412,15 @@ func (s *scanState) runPhases(ctx context.Context, roots []string,
|
|||||||
}
|
}
|
||||||
|
|
||||||
// walkableRoots returns the operands the walk can start from: regular
|
// walkableRoots returns the operands the walk can start from: regular
|
||||||
// files, and directories not named .zfs. Every other operand is warned
|
// files, and directories not named .zfs, none of them inside a
|
||||||
// about, counted as skipped, and dropped. A dropped operand is no
|
// directory named .zfs. Every other operand is warned about, counted as
|
||||||
// longer a root, so the records stored beneath it are treated as if it
|
// skipped, and dropped. A dropped operand is no longer a root, so the
|
||||||
// had not been given: those under no other root count as outside the
|
// records stored beneath it are treated as if it had not been given:
|
||||||
// roots and are not deleted as unverified. An operand on a filesystem
|
// those under no other root count as outside the roots and are not
|
||||||
// in rules that is not persistent is dropped the same way, with a line
|
// deleted as unverified. An operand on a filesystem in rules that is not
|
||||||
// saying so that is not a warning, and is not counted as skipped. An
|
// persistent is dropped the same way, with a line saying so that is not
|
||||||
// operand that fails lstat here is kept, and the walk warns about it
|
// a warning, and is not counted as skipped. An operand that fails lstat
|
||||||
// unless it no longer exists.
|
// here is kept, and the walk warns about it unless it no longer exists.
|
||||||
func (s *scanState) walkableRoots(roots []string, rules walkRules) []string {
|
func (s *scanState) walkableRoots(roots []string, rules walkRules) []string {
|
||||||
kept := make([]string, 0, len(roots))
|
kept := make([]string, 0, len(roots))
|
||||||
|
|
||||||
@@ -874,9 +875,24 @@ func sendEvent(ctx context.Context, events chan<- walkEvent,
|
|||||||
|
|
||||||
// operandWarning returns the one-line warning for an operand the walk
|
// operandWarning returns the one-line warning for an operand the walk
|
||||||
// does not start from, naming the path and what it is, or "" for one it
|
// does not start from, naming the path and what it is, or "" for one it
|
||||||
// does: a regular file, or a directory not named .zfs. Symlinks are
|
// does: a regular file, or a directory not named .zfs, that is not
|
||||||
// never followed, including as operands.
|
// inside a directory named .zfs, neither as given nor with its symlinks
|
||||||
|
// resolved. Symlinks are never followed, including as operands.
|
||||||
func operandWarning(root string, fi fs.FileInfo) string {
|
func operandWarning(root string, fi fs.FileInfo) string {
|
||||||
|
// The walk never enters a .zfs directory, so it starts from nothing
|
||||||
|
// inside one either, not even through a symlink. An operand whose
|
||||||
|
// symlinks cannot be resolved is checked as given.
|
||||||
|
resolved, err := filepath.EvalSymlinks(root)
|
||||||
|
if err != nil {
|
||||||
|
resolved = root
|
||||||
|
}
|
||||||
|
|
||||||
|
if slices.Contains(strings.Split(filepath.Dir(root), "/"), ".zfs") ||
|
||||||
|
slices.Contains(strings.Split(filepath.Dir(resolved), "/"), ".zfs") {
|
||||||
|
return fmt.Sprintf("walk %s: skipping operand inside a .zfs directory",
|
||||||
|
root)
|
||||||
|
}
|
||||||
|
|
||||||
var kind string
|
var kind string
|
||||||
|
|
||||||
switch mode := fi.Mode(); {
|
switch mode := fi.Mode(); {
|
||||||
|
|||||||
+3
-2
@@ -1064,8 +1064,9 @@ func TestScanOneFilesystemWalksNestedFileOperand(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// buildSmokeTree recreates the README smoke-test filesystem layout
|
// buildSmokeTree recreates the tree of the test under README
|
||||||
// with deterministic content and returns the tree root.
|
// "Definition of done" with deterministic content and returns the tree
|
||||||
|
// root.
|
||||||
func buildSmokeTree(t *testing.T) string {
|
func buildSmokeTree(t *testing.T) string {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
|
|||||||
+3
-2
@@ -15,8 +15,9 @@ import (
|
|||||||
const treesOpenLine = "[1/4] open: opening the database, elapsed 0s, " +
|
const treesOpenLine = "[1/4] open: opening the database, elapsed 0s, " +
|
||||||
"remain unknown"
|
"remain unknown"
|
||||||
|
|
||||||
// smokeTreeRecs mirrors the README smoke-test tree layout: /d/t1 and
|
// smokeTreeRecs mirrors the tree of the test under README "Definition
|
||||||
// /d/t2 are identical, /d/t3 differs from them only by one filename.
|
// of done": /d/t1 and /d/t2 are identical, /d/t3 differs from them only
|
||||||
|
// by one filename.
|
||||||
func smokeTreeRecs() []scanRec {
|
func smokeTreeRecs() []scanRec {
|
||||||
f1Head, f1Tail, f1Content := testHash("f1h"), testHash("f1t"), testHash("f1c")
|
f1Head, f1Tail, f1Content := testHash("f1h"), testHash("f1t"), testHash("f1c")
|
||||||
f2Head, f2Tail, f2Content := testHash("f2h"), testHash("f2t"), testHash("f2c")
|
f2Head, f2Tail, f2Content := testHash("f2h"), testHash("f2t"), testHash("f2c")
|
||||||
|
|||||||
Reference in New Issue
Block a user