aboutsummaryrefslogtreecommitdiff
path: root/internal/apply/apply.go
diff options
context:
space:
mode:
authorLukasz Kasprzak <lukas@labunix.xyz>2026-09-17 13:53:01 +0200
committerLukasz Kasprzak <lukas@labunix.xyz>2026-09-17 13:53:01 +0200
commita80d470cbad7335fd5e534f32d935e5a49aadb24 (patch)
treec7d92ba026ebb90dfce4ffb8cb547a1db49812b2 /internal/apply/apply.go
parent78d8313791f05defc9e0a9f2bad8e9710f741a60 (diff)
downloadkrino-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.go36
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 {