summaryrefslogtreecommitdiff
path: root/internal/engine
diff options
context:
space:
mode:
authorLukasz Kasprzak <lukas@labunix.xyz>2026-09-14 23:07:57 +0200
committerLukasz Kasprzak <lukas@labunix.xyz>2026-09-14 23:07:57 +0200
commitc66a842ce4679a3ffa5504dad39b1402bea75e9f (patch)
tree9e9db5e569b9f16a4ca4e66d0a5b0f456a186ece /internal/engine
parentb013e5fb87580e4cab0d85a0d2c8bb402c610413 (diff)
downloadkrino-c66a842ce4679a3ffa5504dad39b1402bea75e9f.tar.gz
krino-c66a842ce4679a3ffa5504dad39b1402bea75e9f.zip
plan 10 re-check: cut run column, damaged undo run, emptied directory cleanup, text turning binary, explain flags, interrupt docsv0.0.7
Diffstat (limited to 'internal/engine')
-rw-r--r--internal/engine/apply.go18
-rw-r--r--internal/engine/exclude_test.go26
-rw-r--r--internal/engine/undo_identity_test.go80
3 files changed, 123 insertions, 1 deletions
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)
+ }
+}