summaryrefslogtreecommitdiff
path: root/internal/engine/apply.go
diff options
context:
space:
mode:
authorLukasz Kasprzak <lukas@labunix.xyz>2026-09-13 02:31:32 +0200
committerLukasz Kasprzak <lukas@labunix.xyz>2026-09-13 02:31:32 +0200
commit26c94eb3db62ec6eebbf8d22c11afe691d9520c4 (patch)
tree165e5bf69234b4f96c9b74deb4898d7143ddf120 /internal/engine/apply.go
parenta6e442a645902011b2081c216daaec052cdc6ce6 (diff)
downloadkrino-0.0.1.tar.gz
krino-0.0.1.zip
krino: release 0.0.1 — man pages, install, examples, cross and release, README, changelogv0.0.1
Also: undo removes the directories its run created; a hardlink is never a duplicate of its own other name; a flag written before "undo" is honoured; --version prints no leading v. Duplicate conditions with different scopes not sharing an original is documented as a known limitation.
Diffstat (limited to 'internal/engine/apply.go')
-rw-r--r--internal/engine/apply.go118
1 files changed, 118 insertions, 0 deletions
diff --git a/internal/engine/apply.go b/internal/engine/apply.go
index c74414d..878cb18 100644
--- a/internal/engine/apply.go
+++ b/internal/engine/apply.go
@@ -9,6 +9,7 @@ import (
"io"
"os"
"path/filepath"
+ "sort"
"strings"
"syscall"
"time"
@@ -660,6 +661,50 @@ func refuseIfSrcExists(us UndoStep, proj *undoProjection) string {
// declined - a single pass, not two, so the two kinds of file interleave in
// the log exactly as the run touched them, the same as Apply's own
// approved-and-declined chains do.
+//
+// Task 1 (plan 5): after every file's reversal has been attempted, a second,
+// run-wide pass retries the directory removals that were refused as
+// non-empty. planUndoFile puts the undo-mkdir step for a shared destination
+// on whichever file's chain first created it (spec §9: only the step that
+// actually created a directory logs a "mkdir" entry, so only that file's
+// reversal carries the matching undo-mkdir); when that file reverses first,
+// its siblings are usually still inside, the removal is correctly refused as
+// non-empty (spec §10), and - without this pass - nothing ever retries it,
+// leaving empty directories behind even though every file came back. This
+// mirrors planUndoFile's own undoProjection insight (see its comment) one
+// level up: a removal judged too early is judging the wrong world, whether
+// that "too early" is mid-file (what the projection fixes) or mid-run (what
+// this retry fixes).
+//
+// The retry is a run-level tidy-up, never a re-run of a step: it does not
+// touch what the first undo-mkdir attempt already logged (that entry, ok or
+// failed, stands exactly as it was written), and a directory the retry does
+// manage to remove gets an ADDITIONAL journal entry - never a rewrite - so
+// the log never disagrees with reality (my ruling on the point the brief
+// left open: spec §9 logs every step, and a directory removed while the log
+// still says its removal was refused would be a false record). Because
+// journal.ranAnyUndoStep already excludes "undo-mkdir" from what marks a run
+// "(undone)", this extra "ok" entry cannot change that marking either -
+// TestApplyUndoRetryLogsBothMkdirEntriesAndStillMarksOriginalRunUndone pins
+// it rather than assuming it. A retried removal is likewise never folded
+// into ApplyResult: it is
+// collected from candidates whose first attempt already went through
+// tallyFile once (via isFileAffecting's exemption), and counting it again
+// here would double-count a directory that failed once and then quietly
+// tidied itself away.
+//
+// Candidates are collected only from directories this run's own reversal
+// created - by construction, since every candidate comes from an undo-mkdir
+// step, and an undo-mkdir step exists only for a directory the forward run's
+// Made recorded - never a directory the retry merely happens to find empty.
+// They are retried deepest path first (retryDirRemovals), so a nested
+// directory - e.g. Work/Sub under Work - is removed before its
+// now-possibly-empty parent, the same outermost-created/innermost-removed
+// discipline logStep and undoFile already keep within one file's own chain,
+// applied here across files. A directory still non-empty at retry time
+// genuinely holds something else (or the retry runs before every sibling
+// happens to have reversed, on a later undo of a different run) and simply
+// stays, with its original refusal the only record of it.
func (e *Engine) ApplyUndo(ctx context.Context, up *UndoPlan, j *journal.Writer, run string) (*ApplyResult, error) {
result := &ApplyResult{}
@@ -695,6 +740,7 @@ func (e *Engine) ApplyUndo(ctx context.Context, up *UndoPlan, j *journal.Writer,
return result, fmt.Errorf("engine: apply undo: %w", err)
}
+ var retries []dirRetry
for _, f := range actionable {
if err := ctx.Err(); err != nil {
return result, err
@@ -714,6 +760,15 @@ func (e *Engine) ApplyUndo(ctx context.Context, up *UndoPlan, j *journal.Writer,
}
result.Files = append(result.Files, fr)
tallyFile(result, fr.Steps, func(i int) bool { return isFileAffecting(f.Steps[i].Action) })
+ for i, us := range f.Steps {
+ if us.Action == "undo-mkdir" && fr.Steps[i].Status == "failed" {
+ retries = append(retries, dirRetry{dir: us.Src, dirName: f.Dir, file: f.File, step: i + 1})
+ }
+ }
+ }
+
+ if err := e.retryDirRemovals(j, run, retries); err != nil {
+ return result, fmt.Errorf("engine: apply undo: %w", err)
}
if err := j.Append(journal.Entry{Time: e.Now(), Run: run, Action: "run-end", Status: "ok"}); err != nil {
@@ -722,6 +777,69 @@ func (e *Engine) ApplyUndo(ctx context.Context, up *UndoPlan, j *journal.Writer,
return result, nil
}
+// dirRetry names one directory whose undo-mkdir was refused (as non-empty)
+// during ApplyUndo's main pass, kept for the run-wide retry once every
+// file's reversal has been attempted. file and dirName are the file and
+// config directory name that owned the original undo-mkdir step, carried
+// forward so retryDirRemovals's journal entry - if the retry succeeds -
+// names the same file and directory the original refusal did, not an
+// arbitrary one; step is that same step's 1-based index, so the two entries
+// (the original "failed" and, if the retry succeeds, this "ok") read
+// together under the same File/Step in the log.
+type dirRetry struct {
+ dir string
+ dirName string
+ file string
+ step int
+}
+
+// retryDirRemovals is ApplyUndo's run-wide second pass (Task 1, plan 5): once
+// every file's reversal has run, some directories an undo-mkdir step could
+// not remove earlier may now be empty, because a sibling file that shared
+// the directory has since reversed too. candidates is sorted deepest path
+// first (by descending path-segment count) so a nested directory is removed
+// before its parent, exactly the order a real cleanup needs; a directory
+// still non-empty at its turn genuinely holds something else and is left
+// exactly as its first attempt recorded it - no second entry, no error.
+//
+// This never rewrites or removes the original undo-mkdir entry (ok or
+// failed, whichever the first attempt logged): a directory the retry does
+// manage to remove gets one ADDITIONAL entry instead (my ruling on the point
+// the brief left open - see ApplyUndo's comment), so the log always agrees
+// with what is actually on disk. The new entry's own Action is still
+// "undo-mkdir", so journal.ranAnyUndoStep - which excludes that action on
+// principle, not by accident (see its own comment) - continues to treat this
+// exactly like any other undo-mkdir for the purpose of marking a run
+// "(undone)": tidying up an empty directory, on the first attempt or the
+// retry, is still not a restoration.
+func (e *Engine) retryDirRemovals(j *journal.Writer, run string, candidates []dirRetry) error {
+ sort.SliceStable(candidates, func(i, j int) bool {
+ return pathDepth(candidates[i].dir) > pathDepth(candidates[j].dir)
+ })
+ for _, c := range candidates {
+ if err := os.Remove(c.dir); err != nil {
+ // Still not empty (or gone, or otherwise unremovable): the
+ // original refusal already recorded this, and it stands.
+ continue
+ }
+ if err := j.Append(journal.Entry{
+ Time: e.Now(), Run: run, Dir: c.dirName, File: c.file, Step: c.step,
+ Action: "undo-mkdir", Status: "ok", Src: c.dir,
+ }); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+// pathDepth counts path's separators after cleaning it, so retryDirRemovals
+// can sort deepest first: a nested directory (more separators) is always
+// removed before the parent it sits under, whatever the two paths' common
+// root.
+func pathDepth(path string) int {
+ return strings.Count(filepath.Clean(path), string(filepath.Separator))
+}
+
// declineUndoFile logs f's reversal as declined without carrying out any of
// it - spec §9's "declined files are logged even though nothing happens to
// them", extended to undo (fix round 2026-09-12, item 2 of Task 8's review):