From 5310c857d7531986c806404103f118b3c5e364f3 Mon Sep 17 00:00:00 2001 From: Lukasz Kasprzak Date: Tue, 15 Sep 2026 00:00:05 +0200 Subject: krino log marks a run partly undone while reversible steps remain --- internal/journal/read.go | 38 +++++++++++++++++++++++++++++----- internal/journal/read_test.go | 48 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 81 insertions(+), 5 deletions(-) (limited to 'internal') 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) + } +} -- cgit v1.3