From c66a842ce4679a3ffa5504dad39b1402bea75e9f Mon Sep 17 00:00:00 2001 From: Lukasz Kasprzak Date: Mon, 14 Sep 2026 23:07:57 +0200 Subject: plan 10 re-check: cut run column, damaged undo run, emptied directory cleanup, text turning binary, explain flags, interrupt docs --- internal/engine/apply.go | 18 +++++++- internal/engine/exclude_test.go | 26 ++++++++++++ internal/engine/undo_identity_test.go | 80 +++++++++++++++++++++++++++++++++++ 3 files changed, 123 insertions(+), 1 deletion(-) (limited to 'internal/engine') diff --git a/internal/engine/apply.go b/internal/engine/apply.go index f9cb9ef..46026ba 100644 --- a/internal/engine/apply.go +++ b/internal/engine/apply.go @@ -308,6 +308,14 @@ func (e *Engine) Runs(n int) ([]journal.Run, error) { type UndoPlan struct { Run string Files []UndoFile + + // Cleanup holds files with nothing left to reverse but directories the + // run made that something else occupied when the plan was built (re-review + // undo F3). They are not offered - that would repeat on every undo - but + // ApplyUndo removes any of those directories the other reversals leave + // empty, and logs it (plan 10 re-check R1). A front end that rebuilds the + // plan must carry Cleanup over. + Cleanup []UndoFile } // UndoFile is the reversal of one file's chain, last original step first. @@ -414,6 +422,7 @@ func (e *Engine) PlanUndo(runID string) (*UndoPlan, error) { // the run made that something else still occupies: offering them // would repeat on every undo (re-review undo F3). An empty one is // still offered, and removed. + up.Cleanup = append(up.Cleanup, uf) continue } up.Files = append(up.Files, uf) @@ -445,7 +454,9 @@ func onlyOccupiedDirectoryRemovals(steps []UndoStep) bool { func isUndoRun(entries []journal.Entry) bool { any := false for _, en := range entries { - if en.Action == "run-start" || en.Action == "run-end" { + // A damaged line says nothing about which kind of run this is (plan + // 10 re-check R3). + if en.Action == "run-start" || en.Action == "run-end" || en.Action == "damaged" { continue } any = true @@ -873,6 +884,11 @@ func (e *Engine) ApplyUndo(ctx context.Context, up *UndoPlan, j *journal.Writer, } } + for _, f := range up.Cleanup { + for i, us := range f.Steps { + retries = append(retries, dirRetry{dir: us.Src, dirName: f.Dir, file: f.File, step: i + 1, log: true}) + } + } if err := e.retryDirRemovals(j, run, retries); err != nil { return result, fmt.Errorf("engine: apply undo: %w", err) } diff --git a/internal/engine/exclude_test.go b/internal/engine/exclude_test.go index 4bb1512..4e9f6e7 100644 --- a/internal/engine/exclude_test.go +++ b/internal/engine/exclude_test.go @@ -302,3 +302,29 @@ func TestNoTextFormatIsNoMatch(t *testing.T) { t.Errorf("explain: Excluded %q, want none", x.Excluded) } } + +// TestTextTurningBinaryFailsClosed: a file with no known extension whose +// first 8 KiB read as text but which holds a NUL further on is unreadable, +// not "no text": a content exclude still sets it aside (plan 10 re-check R4). +func TestTextTurningBinaryFailsClosed(t *testing.T) { + mixed := "confidential " + strings.Repeat("x", 9000) + "\x00tail" + h, _ := excludeTree(t, map[string]string{"mixed": mixed}) + main := writeConfig(t, h, `(include "dl")`, map[string]string{"dl": ` +(path "~/dl") +(exclude (content "confidential")) +(rule "all" (move "Out")) +`}) + e, errs := Load(main) + if len(errs) > 0 { + t.Fatal(errs) + } + r, err := e.Match(context.Background(), e.Dirs[0]) + if err != nil { + t.Fatal(err) + } + for _, fm := range r.Matched { + if fm.File.Rel == "mixed" && (!strings.HasSuffix(fm.Excluded, "(content unreadable)") || len(fm.Rules) != 0) { + t.Errorf("mixed: Excluded %q, rules %d; want set aside as unreadable", fm.Excluded, len(fm.Rules)) + } + } +} diff --git a/internal/engine/undo_identity_test.go b/internal/engine/undo_identity_test.go index f9d6307..f42c5da 100644 --- a/internal/engine/undo_identity_test.go +++ b/internal/engine/undo_identity_test.go @@ -492,3 +492,83 @@ func TestUndoRefusesATrashEntryRecordedForAnotherPath(t *testing.T) { t.Errorf("Refused = %q; want the trash entry named as another file's", f.Refused) } } + +// TestFinishedUndoRemovesADirectoryLeftEmpty: a directory made by one file's +// chain and still holding another file is not offered on its own, but once +// that other file's reversal empties it, the undo removes it (plan 10 +// re-check R1). +func TestFinishedUndoRemovesADirectoryLeftEmpty(t *testing.T) { + e, run, h, logPath := appliedRun(t, map[string]map[string]string{"dl": {"a.pdf": "one", "b.pdf": "two"}}, + map[string]string{"dl": "(path \"~/dl\")\n(rule \"r\" (move \"Out\"))\n"}) + out := filepath.Join(h, "dl", "Out") + undo := func(decline string) { + t.Helper() + up, err := e.PlanUndo(run) + if err != nil { + t.Fatal(err) + } + for i := range up.Files { + up.Files[i].Declined = up.Files[i].File == decline + } + j, err := journal.Open(logPath) + if err != nil { + t.Fatal(err) + } + defer j.Close() + if _, err := e.ApplyUndo(context.Background(), up, j, journal.NewRunID(time.Now())); err != nil { + t.Fatal(err) + } + } + up, err := e.PlanUndo(run) + if err != nil { + t.Fatal(err) + } + other := "" + for _, f := range up.Files { + made := false + for _, s := range f.Steps { + made = made || s.Action == "undo-mkdir" + } + if !made { + other = f.File + } + } + undo(other) // the file that made Out goes back; the other still holds Out + if _, err := os.Stat(out); err != nil { + t.Fatalf("Out went while %s still held it: %v", other, err) + } + undo("") // the other goes back, leaving Out empty + if _, err := os.Lstat(out); !os.IsNotExist(err) { + t.Errorf("Out is still there after the undo finished: %v", err) + } +} + +// TestUndoRunWithADamagedLineIsStillAnUndo: a damaged line in an undo run's +// log does not make that run look like an ordinary one that can be undone +// (plan 10 re-check R3). +func TestUndoRunWithADamagedLineIsStillAnUndo(t *testing.T) { + e, run, _, logPath := appliedRun(t, map[string]map[string]string{"dl": {"a.pdf": "one"}}, + map[string]string{"dl": "(path \"~/dl\")\n(rule \"r\" (rename \"r-{name}\") (move \"Out\"))\n"}) + up, err := e.PlanUndo(run) + if err != nil { + t.Fatal(err) + } + j, err := journal.Open(logPath) + if err != nil { + t.Fatal(err) + } + undoRun := journal.NewRunID(time.Now().Add(time.Second)) + if _, err := e.ApplyUndo(context.Background(), up, j, undoRun); err != nil { + t.Fatal(err) + } + j.Close() + f, err := os.OpenFile(logPath, os.O_APPEND|os.O_WRONLY, 0) + if err != nil { + t.Fatal(err) + } + f.WriteString(time.Now().UTC().Format(time.RFC3339) + "\t" + undoRun + "\tdl\ta.pdf\t9\tundo-mo\n") + f.Close() + if _, err := e.PlanUndo(undoRun); err == nil || !strings.Contains(err.Error(), "itself an undo") { + t.Errorf("PlanUndo(undo run with a damaged line) = %v; want refused as an undo", err) + } +} -- cgit v1.3