diff options
| author | Lukasz Kasprzak <lukas@labunix.xyz> | 2026-09-12 12:58:14 +0200 |
|---|---|---|
| committer | Lukasz Kasprzak <lukas@labunix.xyz> | 2026-09-12 12:58:14 +0200 |
| commit | 24a84671ace373ae331fa83a1ff484990f4dff0e (patch) | |
| tree | a6b6e3949d7dd241f1d13e079dfb982d758c89a2 /internal/plan/conflict.go | |
| parent | 3b36a48b7ce5a53a9366f3b31f94311f178e2553 (diff) | |
| download | krino-24a84671ace373ae331fa83a1ff484990f4dff0e.tar.gz krino-24a84671ace373ae331fa83a1ff484990f4dff0e.zip | |
krino: planning โ chains, placeholders, conflicts, JSON
Diffstat (limited to 'internal/plan/conflict.go')
| -rw-r--r-- | internal/plan/conflict.go | 134 |
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" +} |
