aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorLukasz Kasprzak <lukas@labunix.xyz>2026-09-14 23:42:35 +0200
committerLukasz Kasprzak <lukas@labunix.xyz>2026-09-14 23:42:35 +0200
commit166565025832c8a8faf7397766aeaa878980dd2d (patch)
tree8184682259223341e41763434b01e6baa2fb4ec9
parentbb8b547023b0867f31d7452faceef5769a45b94b (diff)
downloadkrino-166565025832c8a8faf7397766aeaa878980dd2d.tar.gz
krino-166565025832c8a8faf7397766aeaa878980dd2d.zip
a real run's later directories are not blocked by earlier claims; -n across directories documented
-rw-r--r--CHANGELOG.md2
-rw-r--r--cmd/krino/sort.go17
-rw-r--r--cmd/krino/sort_test.go33
-rw-r--r--docs/design.md13
-rw-r--r--man/krino.15
5 files changed, 62 insertions, 8 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 392d933..ff9f900 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -15,6 +15,8 @@
could change its answer; `explain` shows `?`. A document read only in part
answers the keywords found in what was read, and leaves the others
unknown.
+- In a real run, a later directory is no longer blocked by paths an earlier
+ one moved files away from, or planned and did not apply ("target exists").
- `krino explain` says when a duplicate's delete would be skipped, no longer
removes a directory's unused keyword cache (it holds no lock), and walks
the directory only when a duplicate test needs it.
diff --git a/cmd/krino/sort.go b/cmd/krino/sort.go
index 637587d..f600475 100644
--- a/cmd/krino/sort.go
+++ b/cmd/krino/sort.go
@@ -119,10 +119,11 @@ func cmdSort(g *globals, names []string, stdout, stderr io.Writer) int {
exit := 0
printed := false
jsonDirs := []plan.JSONDir{} // never nil: the document's "dirs" must marshal as [], not null
- // A3: one Claims for the whole run, shared across every directory's
- // Plan call below, so two directories that both plan a move to the
- // same destination resolve the collision at planning time instead of
- // each independently believing it owns that path.
+ // A3: in a dry run one Claims is shared across every directory's Plan
+ // call below, so two directories that both plan a move to the same
+ // destination resolve the collision at planning time instead of each
+ // independently believing it owns that path. A real run applies each
+ // directory before planning the next and starts fresh claims after it.
claims := plan.NewClaims()
for _, d := range e.Dirs {
@@ -281,6 +282,14 @@ func cmdSort(g *globals, names []string, stdout, stderr io.Writer) int {
if quit {
break
}
+ if !g.dry {
+ // This directory is applied (or skipped) now, so the disk is the
+ // truth for the next one: its claims - sources it moved away,
+ // destinations it planned but declined or failed - must not
+ // block a later directory (triage 28i). A dry run applies
+ // nothing, so there they carry over.
+ claims = plan.NewClaims()
+ }
}
if g.json {
diff --git a/cmd/krino/sort_test.go b/cmd/krino/sort_test.go
index 12edb24..2a72e6a 100644
--- a/cmd/krino/sort_test.go
+++ b/cmd/krino/sort_test.go
@@ -232,3 +232,36 @@ func TestDryRunShowsNeverDeletedSkip(t *testing.T) {
t.Errorf("explain should name the skipped delete for exactly one copy:\n%s\n%s", outA, outB)
}
}
+
+// TestLaterDirectoryIsNotBlockedByAnEarlierOnesClaims: in a real run each
+// directory is applied before the next is planned, so the disk is the
+// truth; a path an earlier directory moved a file away from, or planned and
+// did not apply, is not "taken" for a later one (triage 28i).
+func TestLaterDirectoryIsNotBlockedByAnEarlierOnesClaims(t *testing.T) {
+ h := home(t)
+ old := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
+ for _, p := range []string{"ca/x.txt", "cb/x.txt"} {
+ full := filepath.Join(h, p)
+ os.MkdirAll(filepath.Dir(full), 0o755)
+ os.WriteFile(full, []byte(p), 0o644)
+ os.Chtimes(full, old, old)
+ }
+ if code, _, errOut := runCLI(t, "init"); code != 0 {
+ t.Fatal(errOut)
+ }
+ for _, n := range []string{"ca", "cb"} {
+ if code, _, errOut := runCLI(t, "new", n, filepath.Join(h, n)); code != 0 {
+ t.Fatal(errOut)
+ }
+ }
+ dirs := filepath.Join(h, ".config", "krino", "dirs")
+ os.WriteFile(filepath.Join(dirs, "ca.conf"), []byte("(path \"~/ca\")\n(rule \"away\" (move \"~/elsewhere\"))\n"), 0o644)
+ os.WriteFile(filepath.Join(dirs, "cb.conf"), []byte("(path \"~/cb\")\n(on-conflict skip)\n(rule \"in\" (move \"~/ca\"))\n"), 0o644)
+ code, out, errOut := runCLI(t, "-y", "ca", "cb")
+ if code != 0 {
+ t.Fatalf("exit %d\n%s\n%s", code, out, errOut)
+ }
+ if b, err := os.ReadFile(filepath.Join(h, "ca", "x.txt")); err != nil || string(b) != "cb/x.txt" {
+ t.Errorf("cb's x.txt did not move into the place ca's left free: %q, %v\n%s", b, err, out)
+ }
+}
diff --git a/docs/design.md b/docs/design.md
index 4196894..cfb2c55 100644
--- a/docs/design.md
+++ b/docs/design.md
@@ -470,10 +470,15 @@ Conflicts between files in the same plan are resolved when planning, so the
plan shows final names. An in-plan claim is never displaced: when `overwrite`
finds a target another step of the same plan has already claimed, it takes the
next free name instead, because that path will hold the other step's output by
-the time either runs, and displacing it would destroy that step's result. The
-claim set spans the whole run, so two configured directories cannot plan the
-same final name. The executor re-checks at execution time; if the
-name has to change, the log records the actual name.
+the time either runs, and displacing it would destroy that step's result. A
+real run applies each directory before planning the next, so a later
+directory sees the earlier one's result on disk and its claims end with it.
+A dry run (`-n`) applies nothing: its claim set spans every directory, so two
+configured directories cannot show the same final name, but each directory is
+otherwise planned as if no earlier one had been applied (a file the first
+moves into the second is not in the second's plan). The executor re-checks
+at execution time; if the name has to change, the log records the actual
+name.
## 8. Plan, review, approval
diff --git a/man/krino.1 b/man/krino.1
index 2761048..d4da67e 100644
--- a/man/krino.1
+++ b/man/krino.1
@@ -478,6 +478,11 @@ leaves the directory behind, empty.
prints invalid UTF-8 in a file name as U+FFFD, so two such names can look
the same in the JSON plan.
.Pp
+.Fl n
+plans every directory as if no earlier directory of the run had been
+applied: a file the first directory's rules move into the second is not in
+the second's plan, as it would be in a real run.
+.Pp
Undo identifies a file by its directory and its path within it.
A file one directory's rules move into another included directory, which
sorts it again in the same run, is two files to undo; the first may then be