diff options
| author | Lukasz Kasprzak <lukas@labunix.xyz> | 2026-09-14 21:43:23 +0200 |
|---|---|---|
| committer | Lukasz Kasprzak <lukas@labunix.xyz> | 2026-09-14 21:43:23 +0200 |
| commit | c497d173b24b1b8247fac9e996e5c0fe690c1769 (patch) | |
| tree | 2318ff1cf393e3a6c2a7d54c89e205ac6a9a5c73 | |
| parent | 360591d6e18d8676a2f86185ed42f46852387f85 (diff) | |
| download | krino-c497d173b24b1b8247fac9e996e5c0fe690c1769.tar.gz krino-c497d173b24b1b8247fac9e996e5c0fe690c1769.zip | |
plan 9: undo review matches review, --min-age validated, future mtimes, rule names, conflict enum, dependency gate, absolute tool paths
| -rw-r--r-- | Makefile | 2 | ||||
| -rw-r--r-- | cmd/krino/check.go | 3 | ||||
| -rw-r--r-- | cmd/krino/common.go | 8 | ||||
| -rw-r--r-- | cmd/krino/history_test.go | 56 | ||||
| -rw-r--r-- | cmd/krino/init.go | 3 | ||||
| -rw-r--r-- | cmd/krino/log.go | 3 | ||||
| -rw-r--r-- | cmd/krino/main.go | 21 | ||||
| -rw-r--r-- | cmd/krino/new.go | 3 | ||||
| -rw-r--r-- | cmd/krino/sort.go | 4 | ||||
| -rw-r--r-- | cmd/krino/undo.go | 55 | ||||
| -rw-r--r-- | docs/design.md | 5 | ||||
| -rw-r--r-- | internal/config/dir.go | 4 | ||||
| -rw-r--r-- | internal/config/dir_test.go | 1 | ||||
| -rw-r--r-- | internal/engine/match.go | 2 | ||||
| -rw-r--r-- | internal/extract/extract.go | 5 | ||||
| -rw-r--r-- | internal/extract/tools_test.go | 15 | ||||
| -rw-r--r-- | internal/plan/conflict.go | 5 | ||||
| -rw-r--r-- | internal/plan/enum_test.go | 27 | ||||
| -rw-r--r-- | internal/scan/scan.go | 12 | ||||
| -rw-r--r-- | internal/scan/scan_test.go | 21 | ||||
| -rw-r--r-- | man/krino.1 | 12 | ||||
| -rw-r--r-- | man/krino.conf.5 | 7 |
22 files changed, 240 insertions, 34 deletions
@@ -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 ...) |
