aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--Makefile2
-rw-r--r--cmd/krino/check.go3
-rw-r--r--cmd/krino/common.go8
-rw-r--r--cmd/krino/history_test.go56
-rw-r--r--cmd/krino/init.go3
-rw-r--r--cmd/krino/log.go3
-rw-r--r--cmd/krino/main.go21
-rw-r--r--cmd/krino/new.go3
-rw-r--r--cmd/krino/sort.go4
-rw-r--r--cmd/krino/undo.go55
-rw-r--r--docs/design.md5
-rw-r--r--internal/config/dir.go4
-rw-r--r--internal/config/dir_test.go1
-rw-r--r--internal/engine/match.go2
-rw-r--r--internal/extract/extract.go5
-rw-r--r--internal/extract/tools_test.go15
-rw-r--r--internal/plan/conflict.go5
-rw-r--r--internal/plan/enum_test.go27
-rw-r--r--internal/scan/scan.go12
-rw-r--r--internal/scan/scan_test.go21
-rw-r--r--man/krino.112
-rw-r--r--man/krino.conf.57
22 files changed, 240 insertions, 34 deletions
diff --git a/Makefile b/Makefile
index f76aa19..5a1b6fb 100644
--- a/Makefile
+++ b/Makefile
@@ -70,7 +70,7 @@ ci: ## the gate: gofmt, vet, tests, dependencies, no personal data staged, man p
GOOS=freebsd CGO_ENABLED=0 go vet ./...
GOOS=openbsd CGO_ENABLED=0 go vet ./...
go test ./...
- @deps=$$(go list -deps ./... | grep -E '^[a-z0-9-]+\.[a-z]+/' | grep -vE '^golang\.org/x/(term|text|sys)(/|$$)' || true); \
+ @deps=$$(go list -deps -f '{{with .Module}}{{.Path}}{{end}}' ./... | sort -u | grep -vxE '(krino|golang\.org/x/(term|text|sys))?' || true); \
test -z "$$deps" || { echo "unexpected dependencies:"; echo "$$deps"; exit 1; }
@scripts/leak-check
@scripts/man-lint
diff --git a/cmd/krino/check.go b/cmd/krino/check.go
index 4463c73..97c1f16 100644
--- a/cmd/krino/check.go
+++ b/cmd/krino/check.go
@@ -19,6 +19,9 @@ func cmdCheck(g *globals, args []string, stdout, stderr io.Writer) int {
if code, ok := parse(fs, args, stdout, stderr); !ok {
return code
}
+ if g.minAgeSet {
+ return usageError(stderr, "--min-age applies only to sorting and explain")
+ }
e, errs := engine.Load(mainFile(g), fs.Args()...)
if len(errs) > 0 {
printDiags(stderr, errs)
diff --git a/cmd/krino/common.go b/cmd/krino/common.go
index 11e73c5..a9c92f3 100644
--- a/cmd/krino/common.go
+++ b/cmd/krino/common.go
@@ -18,10 +18,12 @@ import (
// 2m, 1h, 1d, 1w), or a bare 0. set is false when the flag was not given.
// Checked before any config is read, as every flag is.
func minAgeOverride(g *globals) (d time.Duration, set bool, err error) {
- switch g.minAge {
- case "":
+ switch {
+ case !g.minAgeSet:
return 0, false, nil
- case "0":
+ case g.minAge == "":
+ return 0, false, fmt.Errorf("--min-age: needs a duration, like 0, 30m or 1d")
+ case g.minAge == "0":
return 0, true, nil
}
d, err = config.ParseDuration(g.minAge)
diff --git a/cmd/krino/history_test.go b/cmd/krino/history_test.go
index 1a9ea2b..fce38ad 100644
--- a/cmd/krino/history_test.go
+++ b/cmd/krino/history_test.go
@@ -143,7 +143,7 @@ func TestFinalizeUndoPlanMarksUnapprovedAsDeclined(t *testing.T) {
{File: "b"}, // index 1: not approved -> declined
{File: "c", Refused: "gone"}, // index 2: refused, never declined
}}
- out := finalizeUndoPlan(up, map[int]bool{0: true})
+ out, _ := finalizeUndoPlan(up, map[int]bool{0: true}, 'c')
if len(out.Files) != 3 {
t.Fatalf("files = %+v, want all three carried through", out.Files)
}
@@ -203,8 +203,8 @@ func TestReviewUndoWriteStopsAsking(t *testing.T) {
if err != nil {
t.Fatal(err)
}
- if action != 'c' || !approved[0] || approved[1] || approved[2] {
- t.Errorf("approved = %v action = %q; want only index 0", approved, action)
+ if action != 'w' || !approved[0] || len(approved) != 1 {
+ t.Errorf("approved = %v action = %q; want 'w' with only index 0 decided", approved, action)
}
if !strings.Contains(out.String(), "'t' is not y, n, a, w or q") || strings.Contains(out.String(), "[t]") {
t.Errorf("undo review should reject t and not offer it:\n%s", out)
@@ -466,3 +466,53 @@ func TestUndoWithoutRunContinuesTheLastUndo(t *testing.T) {
t.Fatalf("undo -n after a failed undo: exit %d\n%s\n%s", code, out, errOut)
}
}
+
+// TestReviewUndoMatchesReview: undo's per-file review behaves as review's
+// does (review cli F2): n is recorded, each choice is echoed in red, and w
+// leaves the files it never reached out of the plan, counted as not
+// reviewed rather than logged as declined.
+func TestReviewUndoMatchesReview(t *testing.T) {
+ out := new(strings.Builder)
+ files := undoFiles("a", "b", "c")
+ approved, action, err := reviewUndoFiles(strings.NewReader("cynw"), out, files, palette{on: true})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if action != 'w' || !approved[0] || approved[1] {
+ t.Fatalf("approved = %v action = %q", approved, action)
+ }
+ if v, ok := approved[1]; !ok || v {
+ t.Errorf("b should be decided as no: %v", approved)
+ }
+ for _, want := range []string{"\x1b[31m→ yes\x1b[0m", "\x1b[31m→ no\x1b[0m"} {
+ if !strings.Contains(out.String(), want) {
+ t.Errorf("no %q echo in:\n%q", want, out)
+ }
+ }
+ up := &engine.UndoPlan{Run: "r", Files: files}
+ plan, notReviewed := finalizeUndoPlan(up, approved, action)
+ if notReviewed != 1 || len(plan.Files) != 2 || plan.Files[0].Declined || !plan.Files[1].Declined {
+ t.Errorf("finalize: notReviewed %d, files %+v; want a, declined b, c left out", notReviewed, plan.Files)
+ }
+}
+
+// TestMinAgeRejectedOutsideSortAndExplain: --min-age only changes sorting
+// and explain; any other command refuses it rather than silently ignoring
+// a mistyped value, and an empty value is an error (review cli F4).
+func TestMinAgeRejectedOutsideSortAndExplain(t *testing.T) {
+ matchingFixture(t)
+ for _, args := range [][]string{
+ {"undo", "-n", "--min-age", "1d"},
+ {"--min-age", "garbage", "undo", "-n"},
+ {"check", "--min-age", "1d"},
+ {"log", "--min-age", "1d"},
+ {"-n", "--min-age="},
+ } {
+ if code, _, errOut := runCLI(t, args...); code != 2 || !strings.Contains(errOut, "--min-age") {
+ t.Errorf("krino %q: exit %d, stderr %q; want 2 naming --min-age", args, code, errOut)
+ }
+ }
+ if code, _, errOut := runCLI(t, "-n", "--min-age", "0"); code != 0 {
+ t.Errorf("-n --min-age 0: exit %d %s", code, errOut)
+ }
+}
diff --git a/cmd/krino/init.go b/cmd/krino/init.go
index 2122ff8..4383a14 100644
--- a/cmd/krino/init.go
+++ b/cmd/krino/init.go
@@ -19,6 +19,9 @@ func cmdInit(g *globals, args []string, stdout, stderr io.Writer) int {
if code, ok := parse(fs, args, stdout, stderr); !ok {
return code
}
+ if g.minAgeSet {
+ return usageError(stderr, "--min-age applies only to sorting and explain")
+ }
if fs.NArg() != 0 {
return usageError(stderr, "init takes no arguments")
}
diff --git a/cmd/krino/log.go b/cmd/krino/log.go
index 2f6c521..2394ec2 100644
--- a/cmd/krino/log.go
+++ b/cmd/krino/log.go
@@ -54,6 +54,9 @@ func cmdLog(g *globals, args []string, stdout, stderr io.Writer) int {
if code, ok := parse(fs, args, stdout, stderr); !ok {
return code
}
+ if g.minAgeSet {
+ return usageError(stderr, "--min-age applies only to sorting and explain")
+ }
if fs.NArg() > 0 {
return usageError(stderr, "usage: krino log [-n N]")
}
diff --git a/cmd/krino/main.go b/cmd/krino/main.go
index 7a6a5f7..29da31a 100644
--- a/cmd/krino/main.go
+++ b/cmd/krino/main.go
@@ -61,7 +61,8 @@ Sort the files in the directories listed in krino.conf by their rules.
type globals struct {
yes, dry, verbose, json bool
noColor, noPager bool
- minAge string // --min-age as given; "" when not
+ minAge string // --min-age as given
+ minAgeSet bool // --min-age was given, even as an empty value
conf string
}
@@ -127,10 +128,26 @@ func flagSet(name string, g *globals) *flag.FlagSet {
fs.BoolVar(&g.noColor, "no-color", g.noColor, "")
fs.BoolVar(&g.noPager, "no-pager", g.noPager, "")
fs.BoolVar(&g.noPager, "P", g.noPager, "")
- fs.StringVar(&g.minAge, "min-age", g.minAge, "")
+ fs.Var(minAgeValue{g}, "min-age", "")
return fs
}
+// minAgeValue is --min-age as a flag.Value, so that a flag given with an
+// empty value (--min-age=) is told apart from one not given at all.
+type minAgeValue struct{ g *globals }
+
+func (v minAgeValue) String() string {
+ if v.g == nil {
+ return ""
+ }
+ return v.g.minAge
+}
+
+func (v minAgeValue) Set(s string) error {
+ v.g.minAge, v.g.minAgeSet = s, true
+ return nil
+}
+
// parse parses args into fs. On -h it prints the usage and returns (0, false);
// on a bad flag it reports it and returns (2, false).
func parse(fs *flag.FlagSet, args []string, stdout, stderr io.Writer) (int, bool) {
diff --git a/cmd/krino/new.go b/cmd/krino/new.go
index 1cd89a6..37f2418 100644
--- a/cmd/krino/new.go
+++ b/cmd/krino/new.go
@@ -18,6 +18,9 @@ func cmdNew(g *globals, args []string, stdout, stderr io.Writer) int {
if code, ok := parse(fs, args, stdout, stderr); !ok {
return code
}
+ if g.minAgeSet {
+ return usageError(stderr, "--min-age applies only to sorting and explain")
+ }
if fs.NArg() != 2 {
return usageError(stderr, "usage: krino new NAME PATH")
}
diff --git a/cmd/krino/sort.go b/cmd/krino/sort.go
index 5e52f84..901b19e 100644
--- a/cmd/krino/sort.go
+++ b/cmd/krino/sort.go
@@ -270,7 +270,9 @@ func cmdSort(g *globals, names []string, stdout, stderr io.Writer) int {
}
fmt.Fprintf(stderr, "krino: %s: %v\n", d.Name, aerr)
exit = 1
- return false
+ // [w] stops krino whether or not its apply succeeded (review
+ // cli F3): no later directory is planned or asked about.
+ return action == 'w'
}
fmt.Fprintln(stdout, withNotReviewed(outcome(p, res.Applied, res.Failed, res.Declined), notReviewed))
// Ruling 1: only an actual step failure makes the run exit 1
diff --git a/cmd/krino/undo.go b/cmd/krino/undo.go
index 4c85185..4cefc82 100644
--- a/cmd/krino/undo.go
+++ b/cmd/krino/undo.go
@@ -47,6 +47,9 @@ func cmdUndo(g *globals, args []string, stdout, stderr io.Writer) int {
if code, ok := parse(fs, args, stdout, stderr); !ok {
return code
}
+ if g.minAgeSet {
+ return usageError(stderr, "--min-age applies only to sorting and explain")
+ }
if g.yes && g.dry {
return usageError(stderr, "-y and -n cannot be used together")
}
@@ -187,7 +190,7 @@ func cmdUndo(g *globals, args []string, stdout, stderr io.Writer) int {
return 0
}
- toApply := finalizeUndoPlan(up, approved)
+ toApply, notReviewed := finalizeUndoPlan(up, approved, action)
// Ruling 2 (Task 7), carried over: journal.Open creates the state
// directory and the log file as a side effect of merely being called,
@@ -220,7 +223,7 @@ func cmdUndo(g *globals, args []string, stdout, stderr io.Writer) int {
fmt.Fprintf(stderr, "krino: %v\n", aerr)
return 1
}
- fmt.Fprintln(stdout, outcome(p, res.Applied, res.Failed, res.Declined))
+ fmt.Fprintln(stdout, withNotReviewed(outcome(p, res.Applied, res.Failed, res.Declined), notReviewed))
if ctx.Err() != nil {
return 130
@@ -306,15 +309,26 @@ func releaseUndoLocks(locks []*lock.Lock) []error {
// though nothing happens to it, the same as the forward path already does,
// so it is kept with Declined set, which tells ApplyUndo to log its steps
// as declined rather than reverse them.
-func finalizeUndoPlan(up *engine.UndoPlan, approved map[int]bool) *engine.UndoPlan {
+//
+// After [w] (action 'w'), a reversible file the review never reached is
+// left out of the plan entirely, as review's [w] leaves a forward file
+// unlogged, and counted in notReviewed (review cli F2).
+func finalizeUndoPlan(up *engine.UndoPlan, approved map[int]bool, action rune) (plan *engine.UndoPlan, notReviewed int) {
out := &engine.UndoPlan{Run: up.Run}
for i, f := range up.Files {
- if f.Refused == "" && !approved[i] {
- f.Declined = true
+ if f.Refused == "" {
+ yes, decided := approved[i]
+ switch {
+ case !decided && action == 'w':
+ notReviewed++
+ continue
+ case !yes:
+ f.Declined = true
+ }
}
out.Files = append(out.Files, f)
}
- return out
+ return out, notReviewed
}
// reviewUndoDir drives the interactive review over the real terminal,
@@ -358,12 +372,15 @@ func reviewUndoFiles(in io.Reader, out io.Writer, files []engine.UndoFile, p pal
case 'q':
return map[int]bool{}, 'q', nil
case 'c':
- approved, quit, err := reviewUndoPerFile(in, out, files, p)
+ approved, end, err := reviewUndoPerFile(in, out, files, p)
if err != nil {
return nil, 0, err
}
- if quit {
+ switch end {
+ case 'q':
return map[int]bool{}, 'q', nil
+ case 'w':
+ return approved, 'w', nil
}
return approved, 'c', nil
default:
@@ -376,8 +393,11 @@ func reviewUndoFiles(in io.Reader, out io.Writer, files []engine.UndoFile, p pal
// refused at planning time is never asked about - spec §10 shows it with
// its reason and reverses nothing of it regardless of anything chosen here
// - but it still gets its own [i/N] line, so the numbering accounts for
-// every file in the plan, not just the reversible ones.
-func reviewUndoPerFile(in io.Reader, out io.Writer, files []engine.UndoFile, p palette) (approved map[int]bool, quit bool, err error) {
+// every file in the plan, not just the reversible ones. It behaves as
+// review.go's reviewPerFile does (review cli F2): a no is recorded as false,
+// each choice is echoed in red, [w] ends with 'w' leaving unreached files
+// out of approved, and [q] ends with 'q'.
+func reviewUndoPerFile(in io.Reader, out io.Writer, files []engine.UndoFile, p palette) (approved map[int]bool, end rune, err error) {
approved = map[int]bool{}
yesRest := false
for i, f := range files {
@@ -400,30 +420,35 @@ func reviewUndoPerFile(in io.Reader, out io.Writer, files []engine.UndoFile, p p
for {
key, kerr := readKey(in)
if kerr != nil {
- return nil, false, kerr
+ return nil, 0, kerr
}
+ var choice string
switch key {
case '\r', '\n':
continue
case 'y':
approved[i] = true
+ choice = "yes"
case 'n':
- // leave unapproved
+ approved[i] = false
+ choice = "no"
case 'a':
approved[i] = true
yesRest = true
+ choice = "yes, and all remaining"
case 'w':
- return approved, false, nil
+ return approved, 'w', nil
case 'q':
- return nil, true, nil
+ return nil, 'q', nil
default:
fmt.Fprintf(out, "%q is not y, n, a, w or q\n", key)
continue
}
+ fmt.Fprintln(out, " "+p.bad("→ "+choice))
break
}
}
- return approved, false, nil
+ return approved, 0, nil
}
// undoPerFileKeys is undo's per-file prompt: review's without [t] and [d],
diff --git a/docs/design.md b/docs/design.md
index 3f10623..9d7abb5 100644
--- a/docs/design.md
+++ b/docs/design.md
@@ -145,7 +145,7 @@ directory, then defaults, then built-in.
| `max-depth` | integer, 1 = root only | unlimited | |
| `min-age` | duration. Skip files modified more recently | `2m` | |
| `max-read` | size. No content extraction above this file size | `50M` | |
-| `max-size` | size. Skip files larger than this, as "too big" | unlimited | |
+| `max-size` | size. Skip files larger than this, as "too big"; `0` means unlimited | unlimited | |
| `busy` | suffixes. Skip `f` when `f<suffix>` exists beside it | `".part" ".aria2" ".crdownload"` | |
| `on-conflict` | `suffix` \| `skip` \| `overwrite` (§7.4) | `suffix` | yes |
@@ -684,7 +684,8 @@ krino undo [RUN] reverse a run (default: the last one)
-c FILE use FILE instead of ~/.config/krino/krino.conf
--no-color never colour the output, as when NO_COLOR is set
-P, --no-pager print the plan straight out, never through the pager
---min-age D for this run, every directory's min-age is D (0, 30s, 1d)
+--min-age D for this run, every directory's min-age is D (0, 30s, 1d);
+ sorting and explain only; a future modification time counts as age 0
-h, --help help
--version print "krino 0.0.1"
```
diff --git a/internal/config/dir.go b/internal/config/dir.go
index 461d912..54ee35e 100644
--- a/internal/config/dir.go
+++ b/internal/config/dir.go
@@ -184,6 +184,10 @@ func parseRule(n *sexp.Node, d *diags) *Rule {
d.at(n, `rule needs a name in quotes first, like (rule "invoices" ...)`)
return nil
}
+ if strings.HasPrefix(args[0].Text, "(") {
+ d.at(n, `rule names cannot start with "(": (review) marks choices made in review`)
+ return nil
+ }
r := &Rule{Name: args[0].Text, Pos: n.Pos}
seen := map[string]*sexp.Node{}
var whenNode *sexp.Node
diff --git a/internal/config/dir_test.go b/internal/config/dir_test.go
index 080905c..cae5f5f 100644
--- a/internal/config/dir_test.go
+++ b/internal/config/dir_test.go
@@ -112,6 +112,7 @@ func TestParseDirErrors(t *testing.T) {
{`(path "/a") (rule "x" (delete) (move "y"))`, `d.conf:1:32: rule "x": (move "y") after delete would never run`},
{`(path "/a") (rule "x" (stop now))`, `d.conf:1:23: rule "x": stop takes nothing: write (stop)`},
{`(path "/a") (rule "x" (fly "y"))`, `d.conf:1:23: rule "x": unknown form (fly ...); a rule has when, copy, move, rename, delete, stop, case, fold and on-conflict`},
+ {`(path "/a") (rule "(review)" (delete))`, `d.conf:1:13: rule names cannot start with "(": (review) marks choices made in review`},
{`(path "/a") (sort "x")`, `d.conf:1:13: unknown form (sort ...); a directory file has path, ignore, exclude, rule and settings like (recursive yes)`},
}
for _, tt := range tests {
diff --git a/internal/engine/match.go b/internal/engine/match.go
index fedc053..162730b 100644
--- a/internal/engine/match.go
+++ b/internal/engine/match.go
@@ -422,7 +422,7 @@ func explainSkip(d *Dir, sf scan.File, excl []string, now time.Time) string {
if isBusy(sf.Path, d.Settings.Busy) {
return "busy"
}
- if now.Sub(sf.ModTime) < d.Settings.MinAge {
+ if scan.Age(now, sf.ModTime) < d.Settings.MinAge {
return "too new"
}
if d.Settings.MaxSize > 0 && sf.Size > d.Settings.MaxSize {
diff --git a/internal/extract/extract.go b/internal/extract/extract.go
index 9768db1..ad51c00 100644
--- a/internal/extract/extract.go
+++ b/internal/extract/extract.go
@@ -99,7 +99,10 @@ func newWithPath(path string) *Extractor {
tools := make(map[string]string, len(toolNames))
for _, name := range toolNames {
for _, dir := range dirs {
- if dir == "" {
+ // A relative entry would find a tool relative to the working
+ // directory - a bin/pdftotext an unpacked download left behind
+ // (review planapply F7) - so only absolute entries count.
+ if dir == "" || !filepath.IsAbs(dir) {
continue
}
p := filepath.Join(dir, name)
diff --git a/internal/extract/tools_test.go b/internal/extract/tools_test.go
index b481e8a..756903d 100644
--- a/internal/extract/tools_test.go
+++ b/internal/extract/tools_test.go
@@ -261,3 +261,18 @@ func TestFingerprintFollowsTools(t *testing.T) {
t.Error("replacing pdftotext did not change the fingerprint")
}
}
+
+// TestToolLookupSkipsRelativePathEntries: a relative PATH entry would make
+// a tool's path relative to the working directory - a bin/pdftotext left by
+// an unpacked download - so it is ignored (review planapply F7).
+func TestToolLookupSkipsRelativePathEntries(t *testing.T) {
+ wd := t.TempDir()
+ if err := os.Mkdir(filepath.Join(wd, "bin"), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ fakeTool(t, filepath.Join(wd, "bin"), "pdftotext", "echo injected")
+ t.Chdir(wd)
+ if e := newWithPath("bin"); e.tools["pdftotext"] != "" {
+ t.Errorf("tool found through a relative PATH entry: %q", e.tools["pdftotext"])
+ }
+}
diff --git a/internal/plan/conflict.go b/internal/plan/conflict.go
index c4c6b64..c660a8b 100644
--- a/internal/plan/conflict.go
+++ b/internal/plan/conflict.go
@@ -120,10 +120,13 @@ func resolveConflict(kind Kind, policy config.Conflict, src, dst string, d Disk,
// displaces nothing.
resolved, skip := suffixed(dst, d, c)
return resolved, skip, ""
- default: // config.ConflictSuffix
+ case config.ConflictSuffix:
resolved, skip := suffixed(dst, d, c)
return resolved, skip, ""
}
+ // Every policy has its own branch (review cli F13): a new one must not
+ // quietly plan as another.
+ panic(fmt.Sprintf("plan: unknown config.Conflict %d", int(policy)))
}
// maxSuffixAttempts bounds suffixed(): it is unbounded by design and
diff --git a/internal/plan/enum_test.go b/internal/plan/enum_test.go
index 6458f5f..5e4f645 100644
--- a/internal/plan/enum_test.go
+++ b/internal/plan/enum_test.go
@@ -45,3 +45,30 @@ func TestEveryKindIsNamed(t *testing.T) {
}()
}
}
+
+// TestEveryConflictPolicyIsPlanned: every config.Conflict value is resolved
+// by its own branch; an unknown value panics instead of quietly planning as
+// suffix (review cli F13). Conflict is an iota block from 0.
+func TestEveryConflictPolicyIsPlanned(t *testing.T) {
+ policies, err := enumtest.Names("../config/settings.go", "Conflict")
+ if err != nil {
+ t.Fatal(err)
+ }
+ d := fakeDisk{exists: map[string]bool{"/r/b.pdf": true}}
+ for i, name := range policies {
+ func() {
+ defer func() {
+ if r := recover(); r != nil {
+ t.Errorf("config.%s is not planned: %v", name, r)
+ }
+ }()
+ resolveConflict(Move, config.Conflict(i), "/r/a.pdf", "/r/b.pdf", d, claimed{})
+ }()
+ }
+ defer func() {
+ if recover() == nil {
+ t.Error("an unknown conflict policy did not panic")
+ }
+ }()
+ resolveConflict(Move, config.Conflict(len(policies)), "/r/a.pdf", "/r/b.pdf", d, claimed{})
+}
diff --git a/internal/scan/scan.go b/internal/scan/scan.go
index 77cca66..bd717e0 100644
--- a/internal/scan/scan.go
+++ b/internal/scan/scan.go
@@ -229,7 +229,7 @@ func (w *walker) walk(dir, relDir string, depth int, entries []os.DirEntry) erro
w.result.Skipped = append(w.result.Skipped, Skipped{Rel: rel, Reason: Busy})
continue
}
- if w.opt.Now.Sub(info.ModTime()) < w.opt.MinAge {
+ if Age(w.opt.Now, info.ModTime()) < w.opt.MinAge {
w.result.Skipped = append(w.result.Skipped, Skipped{Rel: rel, Reason: TooNew})
continue
}
@@ -252,3 +252,13 @@ func (w *walker) isBusy(name string, names map[string]bool) bool {
}
return false
}
+
+// Age is how long ago mtime was, at now. A modification time ahead of the
+// clock (a skewed server, an archive's timestamps) counts as brand new: age
+// 0, so min-age 0 still considers the file (review cli F5).
+func Age(now, mtime time.Time) time.Duration {
+ if a := now.Sub(mtime); a > 0 {
+ return a
+ }
+ return 0
+}
diff --git a/internal/scan/scan_test.go b/internal/scan/scan_test.go
index e9aeb73..35e7a0d 100644
--- a/internal/scan/scan_test.go
+++ b/internal/scan/scan_test.go
@@ -319,3 +319,24 @@ func TestFilesCarryInode(t *testing.T) {
t.Errorf("rename changed the identity: %+v then %+v", first.Files[0], second.Files)
}
}
+
+// TestFutureFileIsNotTooNewAtMinAgeZero: a file whose modification time is
+// ahead of the clock counts as brand new - skipped while min-age is above
+// zero, considered at min-age 0 (review cli F5).
+func TestFutureFileIsNotTooNewAtMinAgeZero(t *testing.T) {
+ root := tree(t)
+ p := filepath.Join(root, "future.txt")
+ if err := os.WriteFile(p, []byte("x"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ ahead := now.Add(time.Hour)
+ if err := os.Chtimes(p, ahead, ahead); err != nil {
+ t.Fatal(err)
+ }
+ if r, _ := Walk(root, Options{Now: now}); len(rels(r)) != 1 {
+ t.Errorf("min-age 0: files %v, skipped %v; want future.txt considered", rels(r), skipped(r))
+ }
+ if r, _ := Walk(root, Options{Now: now, MinAge: time.Minute}); skipped(r)["future.txt"] != TooNew {
+ t.Errorf("min-age 1m: skipped %v; want future.txt too new", skipped(r))
+ }
+}
diff --git a/man/krino.1 b/man/krino.1
index c0bba9d..fb6731f 100644
--- a/man/krino.1
+++ b/man/krino.1
@@ -142,9 +142,11 @@ is
or an integer with one of
.Ql s m h d w ;
.Fl -min-age Cm 0
-considers even a file written a moment ago.
+considers even a file written a moment ago, and a file whose modification
+time is ahead of the clock.
Also honoured by
-.Ic explain .
+.Ic explain ;
+every other command refuses it.
.It Fl h , Fl -help
Print usage and exit.
.It Fl -version
@@ -308,7 +310,11 @@ Its per-file prompt is the one in
without
.Ic t
and
-.Ic d .
+.Ic d ,
+and behaves the same way: choices are echoed,
+.Ic w
+applies what was decided and leaves the files it did not reach out of the
+run, counted as not reviewed.
.Pp
An undo run cannot itself be undone: naming it to
.Ic undo
diff --git a/man/krino.conf.5 b/man/krino.conf.5
index 81bbb28..5153255 100644
--- a/man/krino.conf.5
+++ b/man/krino.conf.5
@@ -207,6 +207,9 @@ Built-in:
.It Ic max-size
A size.
Files above it are skipped as too big: no rule sees them.
+.Sy 0
+means unlimited, so a directory can lift a limit set in
+.Ic defaults .
Built-in: unlimited.
.It Ic busy
One or more suffixes.
@@ -281,6 +284,10 @@ with a warning.
(rule NAME ITEM...)
.Ed
.Pp
+A rule's name cannot start with
+.Ql \&( :
+the log shows choices made in review under the rule name
+.Sy (review) .
Items, in any order except that actions run in the order written:
.Bl -tag -width Ds
.It Ic (when Ar cond No ...)