<feed xmlns='http://www.w3.org/2005/Atom'>
<title>krino.git, branch v0.0.12</title>
<subtitle>rule-based file sorter in Go, shows the plan before touching anything, applies only the files you choose, and can undo any run, with s-expression rules and a GTK 4 window</subtitle>
<id>https://git.labunix.xyz/krino.git/atom?h=v0.0.12</id>
<link rel='self' href='https://git.labunix.xyz/krino.git/atom?h=v0.0.12'/>
<link rel='alternate' type='text/html' href='https://git.labunix.xyz/krino.git/'/>
<updated>2026-09-17T12:29:01Z</updated>
<entry>
<title>0.0.12: the changelog, the checklist and the status line</title>
<updated>2026-09-17T12:29:01Z</updated>
<author>
<name>Lukasz Kasprzak</name>
<email>lukas@labunix.xyz</email>
</author>
<published>2026-09-17T12:29:01Z</published>
<link rel='alternate' type='text/html' href='https://git.labunix.xyz/krino.git/commit/?id=9ff1297471a483bad1b2b38ccb1e42614f60fc5e'/>
<id>urn:sha1:9ff1297471a483bad1b2b38ccb1e42614f60fc5e</id>
<content type='text'>
</content>
</entry>
<entry>
<title>the applying flag is atomic; its test waits instead of polling</title>
<updated>2026-09-17T12:19:23Z</updated>
<author>
<name>Lukasz Kasprzak</name>
<email>lukas@labunix.xyz</email>
</author>
<published>2026-09-17T12:19:23Z</published>
<link rel='alternate' type='text/html' href='https://git.labunix.xyz/krino.git/commit/?id=40dbf18893c33bc40c8617d0f3e8de16ce2947e2'/>
<id>urn:sha1:40dbf18893c33bc40c8617d0f3e8de16ce2947e2</id>
<content type='text'>
The race detector found my own new flag: Apply writes it on a worker
and Close reads it from the main loop. The test was polling a plain
field too, where the real window hears about the apply on the main
loop, which orders the writes.
</content>
</entry>
<entry>
<title>a file's name is folded once, not once per name test</title>
<updated>2026-09-17T12:17:54Z</updated>
<author>
<name>Lukasz Kasprzak</name>
<email>lukas@labunix.xyz</email>
</author>
<published>2026-09-17T12:17:54Z</published>
<link rel='alternate' type='text/html' href='https://git.labunix.xyz/krino.git/commit/?id=b66850cc8c584cc00bcd796eb3aa238bcf87394a'/>
<id>urn:sha1:b66850cc8c584cc00bcd796eb3aa238bcf87394a</id>
<content type='text'>
Folding is the expensive half of a name test on a name with diacritics,
and every name test of every rule folded the same name again: on Polish
names it was most of the matching work. The per-file facts memoise it,
which is where one file's work belongs - the object is per file and per
goroutine, so no lock.

  4000 Polish names, twelve rules with name tests: 0.33s -&gt; 0.13s
</content>
</entry>
<entry>
<title>Settings changes take effect, and keep this copy respects the plan</title>
<updated>2026-09-17T12:16:19Z</updated>
<author>
<name>Lukasz Kasprzak</name>
<email>lukas@labunix.xyz</email>
</author>
<published>2026-09-17T12:16:19Z</published>
<link rel='alternate' type='text/html' href='https://git.labunix.xyz/krino.git/commit/?id=f74a02254ac15a84d38c9a254ead293ebaad2377'/>
<id>urn:sha1:f74a02254ac15a84d38c9a254ead293ebaad2377</id>
<content type='text'>
Five of the Settings window's controls - the sort order, the three
column toggles and the preview height - were read when it built its new
preferences but never connected to anything, so changing them did
nothing until some other control happened to fire, and then they all
landed at once out of nowhere. Every control is connected now.

The same closure composed a whole Prefs from its own widgets, which
wrote the remembered divider positions back as zeros: dragging the
panes to taste and then ticking any checkbox threw them away. A
settings change is now applied over the preferences as they are, by
model.Prefs.WithDisplay - which is where it can be tested, and is.

"Keep this copy, replace the other" wrote a Displaces straight into the
chain. internal/plan refuses to build two steps that displace one path,
because the second destroys what the first put there; the window went
round that code, so choosing it for two duplicates of one file left
both rows saying "done" with the first file in the Trash. It now
refuses the second, naming the file that has the place.
</content>
</entry>
<entry>
<title>editing one form no longer takes its neighbour with it</title>
<updated>2026-09-17T12:14:26Z</updated>
<author>
<name>Lukasz Kasprzak</name>
<email>lukas@labunix.xyz</email>
</author>
<published>2026-09-17T12:14:26Z</published>
<link rel='alternate' type='text/html' href='https://git.labunix.xyz/krino.git/commit/?id=6d28cf285f9944eb26c7ed0efcce558521b51cb9'/>
<id>urn:sha1:6d28cf285f9944eb26c7ed0efcce558521b51cb9</id>
<content type='text'>
A form's span was whole lines, which is wrong the moment two forms
share one.

Delete: "(exclude A) (exclude B)" on one line, delete the first, both
went. The dialog named one. The file still parsed, the live check said
no errors and Save lit, so an exclusion could disappear silently and
the next run would sort the files it had been protecting.

Clear a setting: the same shape, and the Settings window walked into it
itself - it writes "(defaults\n  (case ignore))", so the closing paren
of defaults sits on the setting's line, and putting that setting back
to "default" deleted the line and broke the file it had just written.
Every later change then silently reverted until Settings was reopened.
A hand-written "(defaults (case ignore) (fold yes))" lost fold the same
way, and that one still loaded, so it was saveable.

Both now take the whole line only when the line holds nothing else, and
otherwise take the form and the spaces after it. One helper, used by
the main file and by a directory's settings alike.
</content>
</entry>
<entry>
<title>the window cannot pull the lock out from under a running apply</title>
<updated>2026-09-17T12:11:52Z</updated>
<author>
<name>Lukasz Kasprzak</name>
<email>lukas@labunix.xyz</email>
</author>
<published>2026-09-17T12:11:52Z</published>
<link rel='alternate' type='text/html' href='https://git.labunix.xyz/krino.git/commit/?id=ed86f44a926fd1f0d438cbe5e2e10ad5b063db55'/>
<id>urn:sha1:ed86f44a926fd1f0d438cbe5e2e10ad5b063db55</id>
<content type='text'>
Close releases the directory lock and closes the log. The window could
reach it while an apply was still running - saving rules, saving
settings, adding a directory, or closing the window - and the engine
then went on moving files with the log shut underneath: a file moved
that no krino undo can see, the rest of the plan silently abandoned,
and the directory unlocked while krino was still working in it.

PlanTab and UndoTab refuse to close while their apply is in flight
(model.ErrApplying), the window's close request and reloadEngine honour
the refusal instead of ignoring it, and the tabs and Settings are greyed
out for the duration so a button that cannot work says so by being
unavailable rather than by an error afterwards.

The test starts an apply, calls Close from another goroutine while it
is in flight, and requires the refusal.
</content>
</entry>
<entry>
<title>max-read bounds what a file becomes, not only what is read</title>
<updated>2026-09-17T12:07:37Z</updated>
<author>
<name>Lukasz Kasprzak</name>
<email>lukas@labunix.xyz</email>
</author>
<published>2026-09-17T12:07:37Z</published>
<link rel='alternate' type='text/html' href='https://git.labunix.xyz/krino.git/commit/?id=4c6fadfab5434317357ad0272ace7927d2945942'/>
<id>urn:sha1:4c6fadfab5434317357ad0272ace7927d2945942</id>
<content type='text'>
max-read gates on file size before reading, then the text was read
whole and decoded: a file that is not valid UTF-8 decodes one byte per
code point and doubles, and normalising and folding copy that again per
set of options, with GOMAXPROCS files in flight. Twelve 40 MB files
reached 3.3 GB - enough to put a laptop into the OOM killer, with no
attacker involved, just a few big .log or .csv files.

Two bounds. The decoded text is cut to max-read at a rune boundary, so
the ceiling means what a reader takes it to mean. And extraction of
files at or above 4 MiB is rationed to two at a time, since holding
several large texts at once is what multiplies the ceiling; smaller
files, which is nearly all of them, are untouched.

  twelve 40 MB files: peak RSS 3294 MB -&gt; 728 MB, wall 27s -&gt; 46s
  two thousand small files: 0.05s both ways

The wall-clock cost falls entirely on large files needing extraction,
and buys a program that finishes instead of being killed.
</content>
</entry>
<entry>
<title>{now} is the start of the run, as the spec always said</title>
<updated>2026-09-17T12:03:44Z</updated>
<author>
<name>Lukasz Kasprzak</name>
<email>lukas@labunix.xyz</email>
</author>
<published>2026-09-17T12:03:44Z</published>
<link rel='alternate' type='text/html' href='https://git.labunix.xyz/krino.git/commit/?id=33d771438f1da363af7c9ff2d5f4c368c326e84d'/>
<id>urn:sha1:33d771438f1da363af7c9ff2d5f4c368c326e84d</id>
<content type='text'>
The clock was read once per directory, so a run of several directories
stamped several different times - and a review that took a few seconds
could put one run's files into two folders, or at midnight two dates.
The spec (§7.3) says "the start of the run"; krino.conf(5) documented
the behaviour rather than the intent, so the two contradicted each
other.

The session now carries the run's clock and every directory plans with
it. Engine.Plan keeps its meaning for a caller with no session of its
own; Session.Plan passes the run's own start.
</content>
</entry>
<entry>
<title>a symlink in the sorted directory no longer redirects a step</title>
<updated>2026-09-17T12:02:24Z</updated>
<author>
<name>Lukasz Kasprzak</name>
<email>lukas@labunix.xyz</email>
</author>
<published>2026-09-17T12:02:24Z</published>
<link rel='alternate' type='text/html' href='https://git.labunix.xyz/krino.git/commit/?id=a65e8a0587d3e5c971bef3062dd0d8a3b5cb53b7'/>
<id>urn:sha1:a65e8a0587d3e5c971bef3062dd0d8a3b5cb53b7</id>
<content type='text'>
Placeholders were already stopped from sending a file out of the
directory a rule named. A symlink is a name too, and the directory
krino sorts is by the threat model's own premise a place the internet
writes into: a link named after a rule's destination sent moves and
copies anywhere, and under (on-conflict overwrite) trashed a file
OUTSIDE the sorted directory - while the plan showed the in-tree text
and the run reported success.

A step whose destination passes through a symlink at or below the
directory being sorted now fails. A destination the configuration names
outside it - ~/docs on another disk - is the user's own arrangement and
is followed as before; both cases have a test.

End to end, the review's scenario (Out -&gt; ~/secret, overwrite):

  before: 1 applied, the user's file replaced and trashed
  after:  0 applied 1 failed, the file untouched, nothing trashed

Two bookkeeping bugs in MkdirAllTracked went with it: a dangling
symlink read as a missing directory and was then recorded as one krino
had created - undo would have unlinked a link krino never made - and a
directory created by someone else between the check and the mkdir was
recorded the same way.
</content>
</entry>
<entry>
<title>a duplicate test in an exclude protects like one in a rule</title>
<updated>2026-09-17T11:59:52Z</updated>
<author>
<name>Lukasz Kasprzak</name>
<email>lukas@labunix.xyz</email>
</author>
<published>2026-09-17T11:59:52Z</published>
<link rel='alternate' type='text/html' href='https://git.labunix.xyz/krino.git/commit/?id=b44222fc2b061382dc601014cde287cc41b857ca'/>
<id>urn:sha1:b44222fc2b061382dc601014cde287cc41b857ca</id>
<content type='text'>
The scopes that drive "no rule deletes a file krino found to be a
duplicate" were collected from rules only. A directory whose duplicate
tests lived in (exclude ...) forms had no scopes at all, so the
protection never engaged: krino explain said "yes duplicate" and the
next rule permanently deleted every copy. The README's promise was
false in that shape, and the spec's wording permitted it.

What matters is what krino knows, not which form taught it. The spec
and krino.conf(5) now say so too.
</content>
</entry>
</feed>
