diff options
| author | Lukasz Kasprzak <lukas@labunix.xyz> | 2026-09-13 02:31:32 +0200 |
|---|---|---|
| committer | Lukasz Kasprzak <lukas@labunix.xyz> | 2026-09-13 02:31:32 +0200 |
| commit | 26c94eb3db62ec6eebbf8d22c11afe691d9520c4 (patch) | |
| tree | 165e5bf69234b4f96c9b74deb4898d7143ddf120 /cmd | |
| parent | a6e442a645902011b2081c216daaec052cdc6ce6 (diff) | |
| download | krino-26c94eb3db62ec6eebbf8d22c11afe691d9520c4.tar.gz krino-26c94eb3db62ec6eebbf8d22c11afe691d9520c4.zip | |
krino: release 0.0.1 — man pages, install, examples, cross and release, README, changelogv0.0.1
Also: undo removes the directories its run created; a hardlink is never a
duplicate of its own other name; a flag written before "undo" is honoured;
--version prints no leading v. Duplicate conditions with different scopes
not sharing an original is documented as a known limitation.
Diffstat (limited to 'cmd')
| -rw-r--r-- | cmd/krino/history_test.go | 63 | ||||
| -rw-r--r-- | cmd/krino/main.go | 22 | ||||
| -rw-r--r-- | cmd/krino/main_test.go | 21 | ||||
| -rw-r--r-- | cmd/krino/sort_test.go | 63 | ||||
| -rw-r--r-- | cmd/krino/undo.go | 6 |
5 files changed, 172 insertions, 3 deletions
diff --git a/cmd/krino/history_test.go b/cmd/krino/history_test.go index 2667b63..23b1e5f 100644 --- a/cmd/krino/history_test.go +++ b/cmd/krino/history_test.go @@ -315,3 +315,66 @@ func TestReviewUndoInvalidKeyReprompts(t *testing.T) { t.Errorf("no mention of the rejected key:\n%s", out) } } + +// TestGlobalDryRunBeforeUndo: -n written before the subcommand, the way +// krino.1 teaches flags, is a dry run of the undo. It shows the plan, +// exits 0, and moves nothing back. +func TestGlobalDryRunBeforeUndo(t *testing.T) { + h := matchingFixture(t) + if code, _, errOut := runCLI(t, "-y"); code != 0 { + t.Fatalf("apply: %d %s", code, errOut) + } + filed := filepath.Join(h, "dl", "Work", "Acme", "inv1.txt") + + code, out, errOut := runCLI(t, "-n", "undo") + if code != 0 || !strings.Contains(out, "undo-move") { + t.Errorf("-n undo: %d %q\n%s", code, errOut, out) + } + if _, err := os.Stat(filed); err != nil { + t.Errorf("-n undo moved a file: %v", err) + } + if _, out, _ = runCLI(t, "log"); strings.Contains(out, "undone") { + t.Errorf("-n undo marked the run undone:\n%s", out) + } +} + +// TestGlobalDryRunBeforeUndoConflictsWithYes: -n before the subcommand and +// -y after it is the same conflict as both after it: exit 2, nothing +// changed. +func TestGlobalDryRunBeforeUndoConflictsWithYes(t *testing.T) { + h := matchingFixture(t) + if code, _, errOut := runCLI(t, "-y"); code != 0 { + t.Fatalf("apply: %d %s", code, errOut) + } + filed := filepath.Join(h, "dl", "Work", "Acme", "inv1.txt") + + code, _, errOut := runCLI(t, "-n", "undo", "-y") + if code != 2 || !strings.Contains(errOut, "-y and -n cannot be used together") { + t.Errorf("-n undo -y: %d %q", code, errOut) + } + if _, err := os.Stat(filed); err != nil { + t.Errorf("-n undo -y moved a file: %v", err) + } + if _, out, _ := runCLI(t, "log"); strings.Contains(out, "undone") { + t.Errorf("-n undo -y marked the run undone:\n%s", out) + } +} + +// TestGlobalYesBeforeUndo: -y before the subcommand applies the undo. +func TestGlobalYesBeforeUndo(t *testing.T) { + h := matchingFixture(t) + if code, _, errOut := runCLI(t, "-y"); code != 0 { + t.Fatalf("apply: %d %s", code, errOut) + } + filed := filepath.Join(h, "dl", "Work", "Acme", "inv1.txt") + + if code, _, errOut := runCLI(t, "-y", "undo"); code != 0 { + t.Fatalf("-y undo: %d %s", code, errOut) + } + if _, err := os.Stat(filepath.Join(h, "dl", "inv1.txt")); err != nil { + t.Errorf("-y undo did not put the file back: %v", err) + } + if _, err := os.Stat(filed); !os.IsNotExist(err) { + t.Error("the filed copy survived -y undo") + } +} diff --git a/cmd/krino/main.go b/cmd/krino/main.go index e204b5f..f5fdfea 100644 --- a/cmd/krino/main.go +++ b/cmd/krino/main.go @@ -9,12 +9,32 @@ import ( "fmt" "io" "os" + "runtime/debug" "strings" ) // version is stamped by the Makefile with -ldflags "-X main.version=...". var version = "dev" +// displayVersion returns the version to print for --version. The Makefile +// stamps a git tag, which carries a leading "v" (required for +// go install ...@v0.0.1, per design.md §14); that "v" belongs on the tag, +// not in the output, so it is stripped here. A "go install" build carries no +// ldflags, leaving version at its built-in "dev"; in that case fall back to +// the build info Go embeds automatically. +func displayVersion() string { + v := version + if v == "dev" { + if info, ok := debug.ReadBuildInfo(); ok && info.Main.Version != "" && info.Main.Version != "(devel)" { + v = info.Main.Version + } + } + if len(v) > 1 && v[0] == 'v' && v[1] >= '0' && v[1] <= '9' { + v = v[1:] + } + return v +} + const usage = `usage: krino [-y | -n] [-v] [--json] [-c FILE] [NAME...] krino init krino new NAME PATH @@ -63,7 +83,7 @@ func run(args []string, stdout, stderr io.Writer) int { return code } if *showVersion { - fmt.Fprintf(stdout, "krino %s\n", version) + fmt.Fprintf(stdout, "krino %s\n", displayVersion()) return 0 } rest := fs.Args() diff --git a/cmd/krino/main_test.go b/cmd/krino/main_test.go index 1c01fa0..6e4df44 100644 --- a/cmd/krino/main_test.go +++ b/cmd/krino/main_test.go @@ -62,3 +62,24 @@ func TestSortNoConfig(t *testing.T) { t.Fatalf("got %d %q", code, errOut) } } + +func TestVersionStripsTheTagPrefix(t *testing.T) { + old := version + t.Cleanup(func() { version = old }) + for _, tc := range []struct{ stamped, want string }{ + {"v0.0.1", "krino 0.0.1\n"}, + {"v0.0.1-3-gabc1234", "krino 0.0.1-3-gabc1234\n"}, + {"v0.0.1-dirty", "krino 0.0.1-dirty\n"}, + {"0.0.1", "krino 0.0.1\n"}, + {"dev", "krino dev\n"}, + } { + version = tc.stamped + var out, errOut strings.Builder + if code := run([]string{"--version"}, &out, &errOut); code != 0 { + t.Errorf("%s: exit %d", tc.stamped, code) + } + if out.String() != tc.want { + t.Errorf("stamped %q: got %q, want %q", tc.stamped, out.String(), tc.want) + } + } +} diff --git a/cmd/krino/sort_test.go b/cmd/krino/sort_test.go index e482f44..fa9a7ca 100644 --- a/cmd/krino/sort_test.go +++ b/cmd/krino/sort_test.go @@ -110,3 +110,66 @@ func TestAllSkippedDirectoryReportsZeroAndLogsNothing(t *testing.T) { t.Errorf("journal gained entries for a directory with nothing to act on:\n%s", data) } } + +// TestPermanentDeleteOfDuplicatesUnderOverlappingDirKeepsACopy drives the +// whole run end to end on the shape where a (duplicate "DIR") overlaps the +// scanned tree: a recursive root holding Archive/x.pdf and a loose, older +// x-copy.pdf with the same bytes, and a rule that permanently deletes +// duplicates of anything under Archive. Whatever the verdicts, applying +// the plan must leave the content on disk; the expected outcome is that +// the archived copy stays and only the loose one goes. +func TestPermanentDeleteOfDuplicatesUnderOverlappingDirKeepsACopy(t *testing.T) { + h := home(t) + dl := filepath.Join(h, "dl") + content := []byte("%PDF acme statement") + archived := filepath.Join(dl, "Archive", "x.pdf") + loose := filepath.Join(dl, "x-copy.pdf") + old := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + for p, mt := range map[string]time.Time{archived: old.Add(time.Hour), loose: old} { + if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(p, content, 0o644); err != nil { + t.Fatal(err) + } + if err := os.Chtimes(p, mt, mt); err != nil { + t.Fatal(err) + } + } + if code, _, errOut := runCLI(t, "init"); code != 0 { + t.Fatal(errOut) + } + if code, _, errOut := runCLI(t, "new", "dl", dl); code != 0 { + t.Fatal(errOut) + } + rules := "(path \"~/dl\")\n(recursive yes)\n(min-age 0s)\n" + + "(rule \"dups\" (when (duplicate \"Archive\")) (delete permanent))\n" + if err := os.WriteFile(filepath.Join(h, ".config", "krino", "dirs", "dl.conf"), []byte(rules), 0o644); err != nil { + t.Fatal(err) + } + + code, out, errOut := runCLI(t, "-y") + + var copies []string + err := filepath.WalkDir(dl, func(p string, d os.DirEntry, err error) error { + if err != nil || !d.Type().IsRegular() { + return err + } + if b, err := os.ReadFile(p); err == nil && string(b) == string(content) { + copies = append(copies, p) + } + return nil + }) + if err != nil { + t.Fatal(err) + } + if len(copies) == 0 { + t.Fatalf("every copy was deleted (exit %d)\nstdout:\n%s\nstderr:\n%s", code, out, errOut) + } + if code != 0 { + t.Fatalf("run: %d %s", code, errOut) + } + if len(copies) != 1 || copies[0] != archived { + t.Errorf("copies left = %v, want only %s", copies, archived) + } +} diff --git a/cmd/krino/undo.go b/cmd/krino/undo.go index 7ffe428..c773f9a 100644 --- a/cmd/krino/undo.go +++ b/cmd/krino/undo.go @@ -41,8 +41,10 @@ func init() { commands["undo"] = cmdUndo } // approval here is keyed by index rather than by name. func cmdUndo(g *globals, args []string, stdout, stderr io.Writer) int { fs := flagSet("undo", g) - fs.BoolVar(&g.yes, "y", false, "") - fs.BoolVar(&g.dry, "n", false, "") + // The defaults are the values run() already parsed, so -y or -n written + // before "undo" survives registering them again here. + fs.BoolVar(&g.yes, "y", g.yes, "") + fs.BoolVar(&g.dry, "n", g.dry, "") if code, ok := parse(fs, args, stdout, stderr); !ok { return code } |
