summaryrefslogtreecommitdiff
path: root/cmd
diff options
context:
space:
mode:
Diffstat (limited to 'cmd')
-rw-r--r--cmd/krino/history_test.go63
-rw-r--r--cmd/krino/main.go22
-rw-r--r--cmd/krino/main_test.go21
-rw-r--r--cmd/krino/sort_test.go63
-rw-r--r--cmd/krino/undo.go6
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
}