diff options
| author | Lukasz Kasprzak <lukas@labunix.xyz> | 2026-09-17 13:53:01 +0200 |
|---|---|---|
| committer | Lukasz Kasprzak <lukas@labunix.xyz> | 2026-09-17 13:53:01 +0200 |
| commit | a80d470cbad7335fd5e534f32d935e5a49aadb24 (patch) | |
| tree | c7d92ba026ebb90dfce4ffb8cb547a1db49812b2 /internal/apply/apply.go | |
| parent | 78d8313791f05defc9e0a9f2bad8e9710f741a60 (diff) | |
| download | krino-a80d470cbad7335fd5e534f32d935e5a49aadb24.tar.gz krino-a80d470cbad7335fd5e534f32d935e5a49aadb24.zip | |
a file trashed to make room is logged the moment it is trashed
The displace was carried out first and logged only when the whole step
finished - for a copy or a cross-device move, the entire data transfer
later. A process killed in that window left the user's file in the
Trash with nothing recording it: krino log said "nothing applied" and
undo offered nothing.
It is its own action with its own line (spec §9), so it is now written
the moment trash.Put returns, through a hook ChainLogged calls before
the step that needed the name begins. A displace that cannot be logged
fails the step rather than compounding an unrecorded destructive act
with a second one.
Verified by killing krino -9 mid-copy with 600 MB in flight:
before: krino log "nothing applied", 0 displace lines
after: krino log "1 displaced", 1 displace line
Diffstat (limited to 'internal/apply/apply.go')
| -rw-r--r-- | internal/apply/apply.go | 36 |
1 files changed, 29 insertions, 7 deletions
diff --git a/internal/apply/apply.go b/internal/apply/apply.go index c0055a2..b36035b 100644 --- a/internal/apply/apply.go +++ b/internal/apply/apply.go @@ -45,7 +45,7 @@ type StepResult struct { // marking the rest skipped. It never touches a file whose size or mtime no // longer matches what the plan recorded. It is ChainLogged with no done. func Chain(c plan.Chain) []StepResult { - results, _ := ChainLogged(context.Background(), c, nil) + results, _ := ChainLogged(context.Background(), c, nil, nil) return results } @@ -63,7 +63,7 @@ func Chain(c plan.Chain) []StepResult { // Once ctx is cancelled (an interrupt), the step already under way finishes // and every later step is skipped as "interrupted": an interrupt stops after // the current step, not after the file's whole chain (spec §11). -func ChainLogged(ctx context.Context, c plan.Chain, done func(i int, sr StepResult) error) ([]StepResult, error) { +func ChainLogged(ctx context.Context, c plan.Chain, done func(i int, sr StepResult) error, displaced func(i int, step plan.Step, entry string) error) ([]StepResult, error) { results := make([]StepResult, len(c.Steps)) stopWhy := "" @@ -86,7 +86,12 @@ func ChainLogged(ctx context.Context, c plan.Chain, done func(i int, sr StepResu stopWhy = "an earlier step in this chain failed" break } - res := runStep(step) + res := runStep(step, func(entry string) error { + if displaced == nil { + return nil + } + return displaced(i, step, entry) + }) results[i] = res switch { case res.Status == "failed": @@ -133,11 +138,13 @@ func checkUnchanged(src string, f scan.File) error { } // runStep dispatches one already-checked, non-skipped step to the code that -// actually carries it out. -func runStep(step plan.Step) StepResult { +// actually carries it out. reportDisplace is called the moment a displaced +// file has reached the Trash, before the step that needed its name begins; +// a step whose displace cannot be reported does not go on to use the name. +func runStep(step plan.Step, reportDisplace func(entry string) error) StepResult { switch step.Kind { case plan.Copy, plan.Move, plan.Rename: - return runFileStep(step) + return runFileStep(step, reportDisplace) case plan.Trash: return runTrashStep(step) case plan.DeletePermanent: @@ -155,7 +162,13 @@ func runStep(step plan.Step) StepResult { // overwriting a file the plan never accounted for. Missing destination // directories are created and recorded in Made, outermost first, whether or // not the step that needed them goes on to succeed. -func runFileStep(step plan.Step) StepResult { +// +// The displace is reported through reportDisplace as soon as trash.Put +// returns, not when this step finishes: the user's file is in the Trash +// from that moment, durably, and for a copy or a cross-device move the rest +// of the step is the whole data transfer. A process killed in that window +// used to leave the file in the Trash with nothing in the log to say so. +func runFileStep(step plan.Step, reportDisplace func(entry string) error) StepResult { dst := step.Dst var displacedEntry string @@ -178,6 +191,15 @@ func runFileStep(step plan.Step) StepResult { return StepResult{Step: step, Status: "failed", Detail: "displacing the existing file: " + err.Error()} } displacedEntry = entry + if reportDisplace != nil { + if err := reportDisplace(entry); err != nil { + // The file is already in the Trash and cannot be recorded. + // Using the name now would compound an unlogged destructive + // act with a second one. + return StepResult{Step: step, Status: "failed", DisplacedEntry: entry, + Detail: "the file it replaces went to the Trash but could not be logged, so undo cannot see it: " + err.Error()} + } + } } else if _, err := os.Lstat(dst); err == nil { free, err := nextFreeName(dst) if err != nil { |
