diff options
| author | Lukasz Kasprzak <lukas@labunix.xyz> | 2026-09-15 00:00:05 +0200 |
|---|---|---|
| committer | Lukasz Kasprzak <lukas@labunix.xyz> | 2026-09-15 00:00:05 +0200 |
| commit | 5310c857d7531986c806404103f118b3c5e364f3 (patch) | |
| tree | 4022cb838184c7e889793698a8a3cd3c4b6deebc | |
| parent | 67ada0b5bc25cb6cff3ab780d82cb0bfe64e4968 (diff) | |
| download | krino-5310c857d7531986c806404103f118b3c5e364f3.tar.gz krino-5310c857d7531986c806404103f118b3c5e364f3.zip | |
krino log marks a run partly undone while reversible steps remain
| -rw-r--r-- | CHANGELOG.md | 2 | ||||
| -rw-r--r-- | cmd/krino/log.go | 15 | ||||
| -rw-r--r-- | docs/design.md | 6 | ||||
| -rw-r--r-- | internal/journal/read.go | 38 | ||||
| -rw-r--r-- | internal/journal/read_test.go | 48 | ||||
| -rw-r--r-- | man/krino.1 | 18 |
6 files changed, 108 insertions, 19 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b3ac7b..bb6050b 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. +- `krino log` marks a run "(partly undone)" while some of its reversible + steps are not reversed, and "(undone)" only once every one is. - Tests: a genuine apply error exits 1, and `w` in review stops krino even when applying fails, both through the real command; the undo projection's "about to be occupied" half. diff --git a/cmd/krino/log.go b/cmd/krino/log.go index 903f8e8..bfe61a2 100644 --- a/cmd/krino/log.go +++ b/cmd/krino/log.go @@ -102,11 +102,15 @@ func cmdLog(g *globals, args []string, stdout, stderr io.Writer) int { // styleUndone styles the "(undone)" formatRun appends, faint (spec §8.2); // a line without it is returned as is. func styleUndone(line string, p palette) string { - const mark = " (undone)" - if !p.on || !strings.HasSuffix(line, mark) { + if !p.on { return line } - return strings.TrimSuffix(line, mark) + " " + p.faint("(undone)") + for _, mark := range []string{"(partly undone)", "(undone)"} { + if strings.HasSuffix(line, " "+mark) { + return strings.TrimSuffix(line, " "+mark) + " " + p.faint(mark) + } + } + return line } // formatRun renders one journal.Run as krino log lists it: id, start time, @@ -114,7 +118,10 @@ func styleUndone(line string, p palette) string { // "(undone)" appended when a later run has reversed it. func formatRun(r journal.Run) string { line := fmt.Sprintf("%s %s %s %s", display(r.ID), r.Start.Format("2006-01-02 15:04"), display(strings.Join(r.Dirs, ", ")), countsText(r.Counts)) - if r.Undone { + switch { + case r.PartlyUndone: + line += " (partly undone)" + case r.Undone: line += " (undone)" } return line diff --git a/docs/design.md b/docs/design.md index 60bfc92..59c64bd 100644 --- a/docs/design.md +++ b/docs/design.md @@ -579,7 +579,7 @@ not acted on: 3 busy · 12 ignored · 210 unmatched (-v lists them) | outcome line | the applied count green when above 0, the failed count red when above 0 | | prompt keys `[a]` `[y]` … | bold | | the choice echoed in review, `→ yes` … | red | - | `krino log`'s `(undone)` | faint | + | `krino log`'s `(undone)` and `(partly undone)` | faint | Permanent deletes are always marked in capitals, so they stand out without colour too. @@ -658,7 +658,9 @@ not logged; declined files are. ## 10. Undo -- `krino log` lists recent runs: id, time, directories, counts. +- `krino log` lists recent runs: id, time, directories, counts, and + `(undone)` once undo has reversed every reversible step of a run, or + `(partly undone)` while some are not (declined, refused or failed). - `krino undo` reverses the most recent run, in every directory it touched. An older run is undone by naming it: `krino undo RUN`. Undo runs cannot themselves be undone: naming one is refused. diff --git a/internal/journal/read.go b/internal/journal/read.go index 9099c0d..438e6ba 100644 --- a/internal/journal/read.go +++ b/internal/journal/read.go @@ -43,7 +43,19 @@ type Run struct { Dirs []string Counts map[string]int // action -> count of status "ok" Undone bool // a later run reversed this one - UndoOf string // for an undo run, the run it reverses; "" otherwise + // PartlyUndone is set with Undone while fewer of the run's reversible + // steps have been reversed, over all its undo runs, than it took + // (triage 34l): some were declined, refused or failed. + PartlyUndone bool + UndoOf string // for an undo run, the run it reverses; "" otherwise +} + +// reversible names the undo action of each logged action undo can reverse; +// delete (permanent) has none, and mkdir's removal is tidiness, not a +// restoration (ranAnyUndoStep). +var reversible = map[string]string{ + "move": "undo-move", "rename": "undo-rename", "copy": "undo-copy", + "trash": "undo-trash", "displace": "undo-displace", } // ReversedKey identifies one reversal an undo run carried out: the file's @@ -247,11 +259,19 @@ func Runs(path string, n int) ([]Run, error) { // run-start line - and therefore its claim on pendingUndo - is always // written before its own step entries, so whether it actually reversed // anything cannot be known until its Counts are complete. - undoes := make(map[string]bool) // run IDs actually reversed by some later run + undoes := make(map[string]bool) // run IDs actually reversed by some later run + reversed := make(map[string]int) // run ID -> reversals carried out by all its undo runs for undoRun, orig := range pendingUndo { - if r, ok := byID[undoRun]; ok && ranAnyUndoStep(r.Counts) { + r, ok := byID[undoRun] + if !ok { + continue + } + if ranAnyUndoStep(r.Counts) { undoes[orig] = true } + for _, undo := range reversible { + reversed[orig] += r.Counts[undo] + } } runs := make([]Run, len(order)) @@ -260,8 +280,16 @@ func Runs(path string, n int) ([]Run, error) { } sort.SliceStable(runs, func(i, j int) bool { return runs[i].Start.After(runs[j].Start) }) for i := range runs { - runs[i].Undone = undoes[runs[i].ID] - runs[i].UndoOf = pendingUndo[runs[i].ID] + r := &runs[i] + r.Undone = undoes[r.ID] + r.UndoOf = pendingUndo[r.ID] + if r.Undone { + took := 0 + for action := range reversible { + took += r.Counts[action] + } + r.PartlyUndone = reversed[r.ID] < took + } } if n > 0 && n < len(runs) { diff --git a/internal/journal/read_test.go b/internal/journal/read_test.go index 676b2bd..efb5a15 100644 --- a/internal/journal/read_test.go +++ b/internal/journal/read_test.go @@ -731,3 +731,51 @@ func TestEntriesRefusesALineCutInsideItsRunColumn(t *testing.T) { t.Errorf("Entries = %+v, no error; a line cut inside its run column must refuse the run", got) } } + +// TestRunsMarksAPartlyUndoneRun: a run whose undo reversed some of its +// reversible steps but not all is partly undone; once a later undo reverses +// the rest, it is undone in full. A permanent delete counts toward neither +// (triage 34l). +func TestRunsMarksAPartlyUndoneRun(t *testing.T) { + path := filepath.Join(t.TempDir(), "krino.log") + w, _ := Open(path) + t0 := time.Date(2026, 9, 11, 9, 0, 0, 0, time.UTC) + for _, e := range []Entry{ + {Time: t0, Run: "A", Action: "run-start", Status: "ok"}, + {Time: t0, Run: "A", Dir: "dl", File: "x.pdf", Step: 1, Action: "move", Status: "ok", Src: "/a/x.pdf", Dst: "/b/x.pdf"}, + {Time: t0, Run: "A", Dir: "dl", File: "y.pdf", Step: 1, Action: "rename", Status: "ok", Src: "/a/y.pdf", Dst: "/a/z.pdf"}, + {Time: t0, Run: "A", Dir: "dl", File: "old.pdf", Step: 1, Action: "delete", Status: "ok", Src: "/a/old.pdf"}, + {Time: t0, Run: "A", Action: "run-end", Status: "ok"}, + {Time: t0.Add(time.Hour), Run: "B", Action: "run-start", Status: "ok", Detail: "undo of A"}, + {Time: t0.Add(time.Hour), Run: "B", Dir: "dl", File: "x.pdf", Step: 1, Action: "undo-move", Status: "ok", Src: "/b/x.pdf", Dst: "/a/x.pdf"}, + {Time: t0.Add(time.Hour), Run: "B", Dir: "dl", File: "y.pdf", Step: 1, Action: "undo-rename", Status: "declined", Src: "/a/z.pdf", Dst: "/a/y.pdf"}, + {Time: t0.Add(time.Hour), Run: "B", Action: "run-end", Status: "ok"}, + } { + w.Append(e) + } + runA := func() Run { + t.Helper() + runs, err := Runs(path, 0) + if err != nil { + t.Fatal(err) + } + for _, r := range runs { + if r.ID == "A" { + return r + } + } + t.Fatal("no run A") + return Run{} + } + if a := runA(); !a.Undone || !a.PartlyUndone { + t.Errorf("after one of two reversals: %+v, want undone, partly", a) + } + t2 := t0.Add(2 * time.Hour) + w.Append(Entry{Time: t2, Run: "C", Action: "run-start", Status: "ok", Detail: "undo of A"}) + w.Append(Entry{Time: t2, Run: "C", Dir: "dl", File: "y.pdf", Step: 1, Action: "undo-rename", Status: "ok", Src: "/a/z.pdf", Dst: "/a/y.pdf"}) + w.Append(Entry{Time: t2, Run: "C", Action: "run-end", Status: "ok"}) + w.Close() + if a := runA(); !a.Undone || a.PartlyUndone { + t.Errorf("after both reversals: %+v, want undone in full", a) + } +} diff --git a/man/krino.1 b/man/krino.1 index 711b441..b9b8113 100644 --- a/man/krino.1 +++ b/man/krino.1 @@ -211,10 +211,12 @@ See List the most recent runs, newest first .Pq Ar count No defaults to 10 : the run id, its start time, the directories it touched, and what it did. -A run that a later +A run whose reversible steps a later .Ic undo -has reversed at least one file of is marked -.Pq undone . +has all reversed is marked +.Pq undone ; +one it reversed only some of is marked +.Pq partly undone . .It Ic undo Op Ar run Reverse .Ar run . @@ -352,12 +354,12 @@ attempted for it. .Pp .Ic krino log marks a run +.Pq partly undone +while some of its reversible steps are not reversed \(em declined during the +undo's own review, refused or failed \(em and .Pq undone -once its undo run has reversed at least one file's chain \(em not that -every file in it was restored: a run left partly reversed, because some -files were declined during the undo's own review, is still shown as -.Pq undone -in full. +once every one is. +A permanent delete is not reversible and counts toward neither. .Sh ENVIRONMENT .Bl -tag -width Ds .It Ev XDG_CONFIG_HOME |
