diff options
| author | Lukasz Kasprzak <lukas@labunix.xyz> | 2026-09-13 02:31:32 +0200 |
|---|---|---|
| committer | Lukasz Kasprzak <lukas@labunix.xyz> | 2026-09-13 02:31:32 +0200 |
| commit | 26c94eb3db62ec6eebbf8d22c11afe691d9520c4 (patch) | |
| tree | 165e5bf69234b4f96c9b74deb4898d7143ddf120 /internal/engine/apply.go | |
| parent | a6e442a645902011b2081c216daaec052cdc6ce6 (diff) | |
| download | krino-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.go | 118 |
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): |
