1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
|
// SPDX-License-Identifier: GPL-3.0-or-later
package main
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"os"
"sort"
"strconv"
"strings"
"golang.org/x/term"
"krino/internal/config"
"krino/internal/engine"
"krino/internal/journal"
"krino/internal/lock"
"krino/internal/xdg"
)
func init() { commands["undo"] = cmdUndo }
// cmdUndo reverses a run: the one named on the command line, or (spec §10)
// the most recent one otherwise. Since the newest run in the log can never
// itself be marked Undone - that would require a still-later run to have
// reversed it - "the most recent run" and "the most recent run that has not
// been undone" are the same run in every case, including the one this
// task's own test exercises: undoing an undo run a second time with no RUN
// argument targets that very undo run, which PlanUndo then refuses by name.
//
// Undo builds a plan like any other, shown and approved the same way (spec
// §10) - reviewUndoDir/-Files/-PerFile below are undo's own counterpart to
// review.go's reviewChains/reviewPerFile, not a call into them: an undo plan
// is []engine.UndoFile, which can span several directories in one flat
// list, so two files from different directories can share the same Rel and
// 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)
// 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
}
if g.yes && g.dry {
return usageError(stderr, "-y and -n cannot be used together")
}
rest := fs.Args()
if len(rest) > 1 {
return usageError(stderr, "usage: krino undo [RUN]")
}
e, errs := engine.Load(mainFile(g))
if len(errs) > 0 {
printDiags(stderr, errs)
return 2
}
p := palette{on: colourOn(g, stdout)}
// Spec §8.4/§10: with neither -y nor -n, krino asks; a non-terminal
// stdin would just hang, so it refuses instead - the same check
// cmdSort makes before it ever shows a plan.
if !g.yes && !g.dry && !term.IsTerminal(int(stdin.Fd())) {
return usageError(stderr, "refusing to prompt: stdin is not a terminal (use -y or -n)")
}
// Installed here, before any paging or review, not just around
// ApplyUndo: Ruling 5 (Task 7) is that SIGTERM landing between
// keystrokes or during the pager needs the terminal restored, and that
// window starts as soon as this command might show something on a
// terminal.
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
stopSignals := installSignalHandler(cancel)
defer stopSignals()
runID := ""
if len(rest) == 1 {
runID = rest[0]
} else {
runs, err := e.Runs(1)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
fmt.Fprintln(stdout, "nothing logged yet; nothing to undo")
return 0
}
fmt.Fprintf(stderr, "krino: %v\n", err)
return 1
}
if len(runs) == 0 {
fmt.Fprintln(stdout, "nothing logged yet; nothing to undo")
return 0
}
runID = runs[0].ID
}
// PlanUndo only reads the log; nothing is touched yet (spec §10), which
// is what makes it safe to call before any lock is taken.
up, err := e.PlanUndo(runID)
if err != nil {
fmt.Fprintf(stderr, "krino: %v\n", err)
return 1
}
// Fix round 2026-09-12 (widened per the coordinator's follow-up
// ruling): an undo moves files just as an apply does, so it needs
// cmdSort's same per-directory guard (spec §11: a second krino on the
// same directory waits for the lock, or fails immediately with -y),
// held across the SAME window cmdSort holds its own lock across - the
// plan display and the review, not just the apply. Failing before the
// plan is even shown is strictly kinder than making the user review
// (potentially hundreds of files) only to be refused afterward, and it
// means a plan actually reviewed cannot go stale under the reader's
// eyes from another krino moving those same files mid-review. -n never
// reaches this: it changes nothing, so it takes no lock either. One
// undo run can span several directories (UndoFile.Dir is per file), so
// every distinct one up.Files touches is locked, in a fixed (sorted)
// order, and released on every path below - including [s]/[q], every
// early return, and a later ApplyUndo failure - by the single defer
// right after acquisition.
var locks []*lock.Lock
if !g.dry {
locks, err = acquireUndoLocks(ctx, e.Config, undoDirNames(up.Files), !g.yes)
if err != nil {
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
return 130
}
fmt.Fprintf(stderr, "krino: %v\n", err)
return 1
}
defer func() {
for _, rerr := range releaseUndoLocks(locks) {
fmt.Fprintf(stderr, "krino: %v\n", rerr)
}
}()
}
fmt.Fprintln(stdout, p.bold("krino: undo "+up.Run))
var buf bytes.Buffer
printUndoPlan(&buf, up)
text := colourRefused(buf.String(), p)
// Ruling 6 (Task 7), carried over: the plan goes through tui.Page for
// -n as much as for -y and the interactive path.
if err := show(g, stdout, text); err != nil {
fmt.Fprintf(stderr, "krino: %v\n", err)
return 1
}
if g.dry {
return 0
}
if undoActionableCount(up.Files) == 0 {
// Every file was refused, or the run touched none at all: neither
// -y nor the interactive menu has anything useful to do (mirrors
// cmdSort's identical check before it ever asks).
fmt.Fprintln(stdout, zeroOutcome)
return 0
}
var approved map[int]bool
var action rune
if g.yes {
approved, action = approveAllUndo(up.Files), 'a'
} else {
var rerr error
approved, action, rerr = reviewUndoDir(stdout, up.Files, p)
if rerr != nil {
fmt.Fprintf(stderr, "krino: %v\n", rerr)
return 1
}
}
if action == 's' || action == 'q' {
fmt.Fprintln(stdout, zeroOutcome)
return 0
}
toApply := finalizeUndoPlan(up, approved)
// Ruling 2 (Task 7), carried over: journal.Open creates the state
// directory and the log file as a side effect of merely being called,
// so it is opened only once we know something will actually be
// applied - never for -n (returned above), and not merely because -y
// or a review session ran, unlike cmdSort's own eager-open (which opens
// before it knows whether anything is actionable, a difference forced
// by cmdSort not yet having a plan to inspect at that point in its
// flow; undo already does, so it opens later, and never opens if
// undoActionableCount was 0 or the user chose [s]/[q] above).
j, err := journal.Open(e.Config.LogFile())
if err != nil {
fmt.Fprintf(stderr, "krino: %v\n", err)
return 1
}
defer func() {
if cerr := j.Close(); cerr != nil {
fmt.Fprintf(stderr, "krino: %v\n", cerr)
}
}()
run := journal.NewRunID(e.Now())
res, aerr := e.ApplyUndo(ctx, toApply, j, run)
if aerr != nil {
if errors.Is(aerr, context.Canceled) || errors.Is(aerr, context.DeadlineExceeded) {
// Interrupted mid-apply: the ctx.Err() check below turns this
// into exit 130, same as cmdSort.
return 130
}
fmt.Fprintf(stderr, "krino: %v\n", aerr)
return 1
}
fmt.Fprintln(stdout, outcome(p, res.Applied, res.Failed, res.Declined))
if ctx.Err() != nil {
return 130
}
if res.Failed > 0 {
return 1
}
return 0
}
// undoActionableCount counts the files in files that PlanUndo has not
// already refused - the same "is there anything to even ask about" gate
// cmdSort's actionableChains serves for a sort plan.
func undoActionableCount(files []engine.UndoFile) int {
n := 0
for _, f := range files {
if f.Refused == "" {
n++
}
}
return n
}
// undoDirNames returns the distinct directory names an undo plan's files
// touch, sorted: a fixed order so two processes each locking a plan that
// shares more than one directory always acquire them in the same sequence,
// the standard way to avoid a lock-order deadlock. UndoFile.Dir is the
// journal's own `dir` column - the directory's config NAME (e.g. "dl"), not
// a filesystem path - which is exactly what config.Config.LockFile takes.
func undoDirNames(files []engine.UndoFile) []string {
seen := map[string]bool{}
var out []string
for _, f := range files {
if f.Dir != "" && !seen[f.Dir] {
seen[f.Dir] = true
out = append(out, f.Dir)
}
}
sort.Strings(out)
return out
}
// acquireUndoLocks takes the lock for every name in dirs, in order,
// mirroring cmdSort's per-directory lock.Acquire call. If any acquisition
// fails - held with wait false, or ctx cancelled while waiting - every lock
// already taken is released before returning, so a partial lock set is
// never left held while the caller reports the error and stops.
func acquireUndoLocks(ctx context.Context, cfg *config.Config, dirs []string, wait bool) ([]*lock.Lock, error) {
locks := make([]*lock.Lock, 0, len(dirs))
for _, name := range dirs {
l, err := lock.Acquire(ctx, cfg.LockFile(name), wait)
if err != nil {
releaseUndoLocks(locks)
return nil, fmt.Errorf("%s: %w", name, err)
}
locks = append(locks, l)
}
return locks, nil
}
// releaseUndoLocks releases every lock in locks and returns any release
// errors, one lock's failure never stopping the rest from being released -
// the same "release on every path" guarantee cmdSort gives its own single
// lock, extended to however many an undo plan needed.
func releaseUndoLocks(locks []*lock.Lock) []error {
var errs []error
for _, l := range locks {
if err := l.Release(); err != nil {
errs = append(errs, err)
}
}
return errs
}
// finalizeUndoPlan builds the *engine.UndoPlan ApplyUndo actually runs,
// preserving up.Files' own order: every refused file rides along unchanged
// (ApplyUndo declines these itself, silently, exactly as it already does
// when handed the unfiltered plan - spec §10's refusal is not this task's
// to make noisier); every actionable file approved marks true rides along
// unchanged too. A file the user said no to, or left unmarked when [d] or
// [q] cut a per-file review short, is not dropped - fix round 2026-09-12,
// item 2 of Task 8's review: spec §9 says a declined file is logged even
// 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 {
out := &engine.UndoPlan{Run: up.Run}
for i, f := range up.Files {
if f.Refused == "" && !approved[i] {
f.Declined = true
}
out.Files = append(out.Files, f)
}
return out
}
// reviewUndoDir drives the interactive review over the real terminal,
// mirroring review.go's reviewDir for the forward path; p styles the
// prompts.
func reviewUndoDir(out io.Writer, files []engine.UndoFile, p palette) (map[int]bool, rune, error) {
return reviewUndoFiles(keyReader{stdin}, out, files, p)
}
// reviewUndoFiles is spec §10's approval flow for an undo plan: the
// top-level
//
// [a] apply all [c] choose per file [s] skip [q] quit
//
// menu, and, for [c], the per-file
//
// [y] yes [n] no [a] yes to this and all remaining [w] write, apply chosen so far [q] quit, apply nothing
//
// prompt - the same shape as review.go's reviewChains/reviewPerFile, over a
// different plan shape (approved is keyed by index into files, not by
// name). action is always one of 'a', 'c', 's' or 'q', with the same [q]
// folding rule reviewChains uses: a [c] session's own [q] becomes the same
// top-level 'q', and approved is emptied to match.
func reviewUndoFiles(in io.Reader, out io.Writer, files []engine.UndoFile, p palette) (map[int]bool, rune, error) {
fmt.Fprintln(out)
for _, l := range wrapped("", "[a] apply all [c] choose per file [s] skip [q] quit", 0, widthPolicy(out), p.keys) {
fmt.Fprintln(out, l)
}
for {
key, err := readKey(in)
if err != nil {
return nil, 0, err
}
switch key {
case '\r', '\n':
continue
case 'a':
return approveAllUndo(files), 'a', nil
case 's':
return map[int]bool{}, 's', nil
case 'q':
return map[int]bool{}, 'q', nil
case 'c':
approved, quit, err := reviewUndoPerFile(in, out, files, p)
if err != nil {
return nil, 0, err
}
if quit {
return map[int]bool{}, 'q', nil
}
return approved, 'c', nil
default:
fmt.Fprintf(out, "%q is not a, c, s or q\n", key)
}
}
}
// reviewUndoPerFile is the per-file half of reviewUndoFiles. A file already
// 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) {
approved = map[int]bool{}
yesRest := false
for i, f := range files {
fmt.Fprintf(out, "\n[%d/%d] %s/%s\n", i+1, len(files), display(f.Dir), display(f.File))
for _, s := range f.Steps {
fmt.Fprintf(out, " %s\n", undoActionCell(s))
}
if f.Refused != "" {
fmt.Fprintf(out, " refused: %s\n", display(f.Refused))
continue
}
if yesRest {
approved[i] = true
continue
}
for _, l := range wrapped(" ", undoPerFileKeys, 2, widthPolicy(out), p.keys) {
fmt.Fprintln(out, l)
}
for {
key, kerr := readKey(in)
if kerr != nil {
return nil, false, kerr
}
switch key {
case '\r', '\n':
continue
case 'y':
approved[i] = true
case 'n':
// leave unapproved
case 'a':
approved[i] = true
yesRest = true
case 'w':
return approved, false, nil
case 'q':
return nil, true, nil
default:
fmt.Fprintf(out, "%q is not y, n, a, w or q\n", key)
continue
}
break
}
}
return approved, false, nil
}
// undoPerFileKeys is undo's per-file prompt: review's without [t] and [d],
// which make no sense for a reversal.
const undoPerFileKeys = "[y] yes [n] no [a] yes to this and all remaining [w] write, apply chosen so far [q] quit, apply nothing"
// approveAllUndo approves every reversible file in files by index - [a]
// apply all, at either the top level or mid per-file review. A refused file
// is never marked true: nothing would happen to it anyway (ApplyUndo skips
// it unconditionally), and leaving it unmarked here keeps this function's
// contract simple - "true means ask ApplyUndo to reverse it" - rather than
// also being the thing that decides refused files ride along regardless
// (finalizeUndoPlan does that, independently of this map).
func approveAllUndo(files []engine.UndoFile) map[int]bool {
approved := make(map[int]bool, len(files))
for i, f := range files {
if f.Refused == "" {
approved[i] = true
}
}
return approved
}
// undoStepWidth is the widest undo action word (padCell aligns every
// arrow), matching render.go's actionKindWidth for the forward table.
const undoStepWidth = len("undo-displace")
// undoActionCell renders one undo step: its own refusal reason when it has
// one (a sibling directory not yet empty for undo-mkdir - spec §10's one
// case where a step's own failure does not refuse its whole file), the
// directory removed for undo-mkdir (no destination to show), the file being
// trashed for undo-copy (fix wave item 3: its Dst is deliberately empty -
// trash.Put only chooses the entry name at execution time - so this is the
// one action with no path to point an arrow at; before this fix the cell
// rendered as a bare "undo-copy → ", the plan's one row that said
// nothing about what it would do to the user's file), or an arrow to where
// the step puts the file back, ~-abbreviated - undo has no single root the
// way a sort plan does (one run can span several directories), so there is
// no root-relative form to render here the way actionCell has.
func undoActionCell(s engine.UndoStep) string {
if s.Refused != "" {
return padCell(s.Action, undoStepWidth) + " refused: " + display(s.Refused)
}
switch s.Action {
case "undo-mkdir":
return padCell(s.Action, undoStepWidth) + " " + display(xdg.Abbrev(s.Src))
case "undo-copy":
return padCell(s.Action, undoStepWidth) + " " + display(xdg.Abbrev(s.Src)) + " → trash"
}
return padCell(s.Action, undoStepWidth) + " → " + display(xdg.Abbrev(s.Dst))
}
// printUndoPlan renders an undo plan the way krino undo shows it, below the
// "krino: undo RUN" header line cmdUndo has already written: a counts line,
// then the numbered table, mirroring printPlan's shape for a sort plan
// (spec §10: "shown ... the same way").
func printUndoPlan(w io.Writer, up *engine.UndoPlan) {
actionable := undoActionableCount(up.Files)
fmt.Fprintf(w, "%d files · %d to reverse · %d refused\n", len(up.Files), actionable, len(up.Files)-actionable)
if len(up.Files) == 0 {
return
}
fmt.Fprintln(w)
printUndoTable(w, up.Files)
}
// undoRow is one line of the undo table: a file's first step (num and file
// set) or a continuation line (both blank), the same layout planRow uses
// for a sort plan.
type undoRow struct {
num, file, action string
}
// undoRows turns files into table rows. A refused file gets exactly one
// row - there is nothing to reverse, so no per-step continuation lines -
// showing its reason in place of any step.
func undoRows(files []engine.UndoFile) []undoRow {
var rows []undoRow
for i, f := range files {
label := display(f.Dir + "/" + f.File)
if f.Refused != "" {
rows = append(rows, undoRow{num: strconv.Itoa(i + 1), file: label, action: "refused: " + display(f.Refused)})
continue
}
for j, s := range f.Steps {
row := undoRow{action: undoActionCell(s)}
if j == 0 {
row.num = strconv.Itoa(i + 1)
row.file = label
}
rows = append(rows, row)
}
}
return rows
}
// printUndoTable prints rows in the same #, file, action layout
// printPlanTable uses for a sort plan, reusing its column-width helpers
// (relWidth/padCell/padLeft/colWidth, render.go) rather than re-deriving
// them.
func printUndoTable(w io.Writer, files []engine.UndoFile) {
rows := undoRows(files)
nums := make([]string, len(rows))
fls := make([]string, len(rows))
for i, r := range rows {
nums[i], fls[i] = r.num, r.file
}
numW := colWidth(nums, 0)
fileW := relWidth(fls)
fmt.Fprintf(w, " %s %s steps\n", padLeft("#", numW), padCell("file", fileW))
for _, r := range rows {
fmt.Fprintf(w, " %s %s %s\n", padLeft(r.num, numW), padCell(r.file, fileW), r.action)
}
}
// colourRefused styles every "refused:" in an undo plan bold red (spec
// §8.2): the one thing an undo plan singles out is the file or step nothing
// will be reversed for. With the plain palette the text is untouched, which
// keeps every escape byte out of a plan piped to a file or read by another
// tool.
func colourRefused(text string, p palette) string {
if !p.on {
return text
}
return strings.ReplaceAll(text, "refused:", p.alarm("refused:"))
}
|