summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorLukasz Kasprzak <lukas@labunix.xyz>2026-09-15 00:00:05 +0200
committerLukasz Kasprzak <lukas@labunix.xyz>2026-09-15 00:00:05 +0200
commit5310c857d7531986c806404103f118b3c5e364f3 (patch)
tree4022cb838184c7e889793698a8a3cd3c4b6deebc
parent67ada0b5bc25cb6cff3ab780d82cb0bfe64e4968 (diff)
downloadkrino-5310c857d7531986c806404103f118b3c5e364f3.tar.gz
krino-5310c857d7531986c806404103f118b3c5e364f3.zip
krino log marks a run partly undone while reversible steps remain
-rw-r--r--CHANGELOG.md2
-rw-r--r--cmd/krino/log.go15
-rw-r--r--docs/design.md6
-rw-r--r--internal/journal/read.go38
-rw-r--r--internal/journal/read_test.go48
-rw-r--r--man/krino.118
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