aboutsummaryrefslogtreecommitdiff
path: root/internal/plan/conflict.go
diff options
context:
space:
mode:
Diffstat (limited to 'internal/plan/conflict.go')
-rw-r--r--internal/plan/conflict.go134
1 files changed, 134 insertions, 0 deletions
diff --git a/internal/plan/conflict.go b/internal/plan/conflict.go
new file mode 100644
index 0000000..8747669
--- /dev/null
+++ b/internal/plan/conflict.go
@@ -0,0 +1,134 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package plan
+
+import (
+ "fmt"
+ "os"
+ "path/filepath"
+
+ "krino/internal/config"
+ "krino/internal/dup"
+)
+
+// Disk is what Build needs from the filesystem to resolve conflicts, so
+// tests can supply a stub and Build stays pure otherwise.
+type Disk interface {
+ Exists(path string) bool
+ SameContent(a, b string) (bool, error)
+}
+
+// OS is the real filesystem.
+type OS struct{}
+
+// Exists reports whether path names an existing file or directory, without
+// following a symlink at path itself: a dangling or otherwise unwanted
+// symlink still counts as "something is there".
+func (OS) Exists(path string) bool {
+ _, err := os.Lstat(path)
+ return err == nil
+}
+
+// SameContent delegates to internal/dup, the one place content identity is
+// decided.
+func (OS) SameContent(a, b string) (bool, error) {
+ return dup.SameContent(a, b)
+}
+
+// NoDisk is the empty filesystem: nothing exists, and nothing is ever the
+// same content. For tests that are not about conflicts.
+type NoDisk struct{}
+
+func (NoDisk) Exists(string) bool { return false }
+func (NoDisk) SameContent(string, string) (bool, error) { return false, nil }
+
+// claimed is the set of destination paths already spoken for by an earlier
+// step of this plan.
+type claimed map[string]bool
+
+// resolveConflict decides what a step whose destination is contested does,
+// per spec §7.4. src is the step's source (its current path, before this
+// step runs); dst is the target the action computed. It returns the
+// resolved destination (possibly unchanged), a Skip reason (non-empty when
+// the step must not run) and Displaces (non-empty only for overwrite of a
+// file that exists on disk).
+func resolveConflict(kind Kind, policy config.Conflict, src, dst string, d Disk, c claimed) (resolved, skip, displaces string) {
+ // A1/A2: the file is already where this step would put it, so its own
+ // existence must not read as a conflict with itself. Without this guard a
+ // move or rename plans a rename to stem_1 and every later run adds
+ // another generation; under (on-conflict overwrite) the step records the
+ // file as its own Displaces, which plan 4 would trash before moving from
+ // a path that no longer exists. Checked before the policy switch, so
+ // overwrite never reaches its own branch.
+ if dst == src {
+ return dst, "already there", ""
+ }
+
+ onDisk := d.Exists(dst)
+
+ if kind == Copy && onDisk {
+ // A SameContent error (the source vanished, a permission problem,
+ // ...) is treated the same as "different content": the step falls
+ // through to the ordinary conflict policy below instead of failing
+ // outright. A wrong "different" verdict costs at worst an
+ // unnecessary suffixed copy, never data loss, so resolving the
+ // conflict anyway is an acceptable trade-off here (D8) - a caller
+ // that wants the failure itself visible would need it surfaced as
+ // a chain warning instead.
+ if same, err := d.SameContent(src, dst); err == nil && same {
+ return dst, "already there", ""
+ }
+ }
+
+ if !onDisk && !c[dst] {
+ return dst, "", ""
+ }
+
+ switch policy {
+ case config.ConflictSkip:
+ return dst, "target exists", ""
+ case config.ConflictOverwrite:
+ if onDisk && !c[dst] {
+ // The existing file is trashed first (plan 4). Only the first
+ // step to reach this path may displace it: once another step
+ // in this same plan has already claimed dst, that path will
+ // hold that step's own output by the time this one runs, so
+ // displacing it again would destroy it.
+ return dst, "", dst
+ }
+ // Either claimed in-plan only (nothing on disk to displace — an
+ // in-plan claim is never displaced), or on disk but already
+ // claimed by an earlier step of this plan (displacing it again
+ // would destroy that step's output): either way the two chains
+ // cannot share one destination, so fall back to a free name,
+ // exactly as suffix would. A step that takes a free name
+ // displaces nothing.
+ resolved, skip := suffixed(dst, d, c)
+ return resolved, skip, ""
+ default: // config.ConflictSuffix
+ resolved, skip := suffixed(dst, d, c)
+ return resolved, skip, ""
+ }
+}
+
+// maxSuffixAttempts bounds suffixed(): it is unbounded by design and
+// terminates on a real filesystem, but C2 - without a cap, a Disk that
+// always reports existence (or a directory A1 had been filling before its
+// fix) turns planning quadratic instead of failing fast.
+const maxSuffixAttempts = 10000
+
+// suffixed finds the first stem_N.ext (N starting at 1) that is free:
+// neither on disk nor already claimed by an earlier step in this plan. It
+// gives up after maxSuffixAttempts, returning a Skip reason and no path
+// (C2).
+func suffixed(dst string, d Disk, c claimed) (resolved, skip string) {
+ dir, base := filepath.Split(dst)
+ stem, ext := splitExt(base)
+ for n := 1; n <= maxSuffixAttempts; n++ {
+ candidate := filepath.Join(dir, fmt.Sprintf("%s_%d%s", stem, n, ext))
+ if !d.Exists(candidate) && !c[candidate] {
+ return candidate, ""
+ }
+ }
+ return "", "too many conflicting names"
+}