Update policy for replacement of managed files

This commit is contained in:
2026-06-18 13:38:44 -05:00
parent 8b0ce4d134
commit 69043801d0
18 changed files with 330 additions and 107 deletions

View File

@@ -6,7 +6,7 @@ import (
)
func shouldNotify(action publish.Action) bool {
return action == publish.ActionPublishNew || action == publish.ActionReplaceOlder || action == publish.ActionReplaceTakeover || action == publish.ActionForceReplace
return action == publish.ActionPublishNew || action == publish.ActionReplaceOlder || action == publish.ActionReplaceConflict || action == publish.ActionReplaceNewer || action == publish.ActionReplaceTakeover || action == publish.ActionForceReplace
}
func notifyEvent(plan publish.Plan) notify.Event {

View File

@@ -63,7 +63,7 @@ func fixedPathSelectionWarning(pipelineID, destinationID string, selections []de
}
func isDestructiveFixedPathAction(action publish.Action) bool {
return action == publish.ActionReplaceOlder || action == publish.ActionReplaceTakeover || action == publish.ActionForceReplace
return action == publish.ActionReplaceOlder || action == publish.ActionReplaceConflict || action == publish.ActionReplaceNewer || action == publish.ActionReplaceTakeover || action == publish.ActionForceReplace
}
func fixedPathReplacementWarning(plan publish.Plan) OutputWarning {

View File

@@ -11,6 +11,8 @@ type runSummary struct {
planned int
publishNew int
replaceOlder int
replaceConflict int
replaceNewer int
replaceTakeover int
forceReplace int
skipped int
@@ -25,6 +27,10 @@ func (s *runSummary) recordPlan(action publish.Action) {
s.publishNew++
case publish.ActionReplaceOlder:
s.replaceOlder++
case publish.ActionReplaceConflict:
s.replaceConflict++
case publish.ActionReplaceNewer:
s.replaceNewer++
case publish.ActionReplaceTakeover:
s.replaceTakeover++
case publish.ActionForceReplace:
@@ -47,6 +53,8 @@ type RunSummaryCounters struct {
Planned int `json:"planned"`
PublishNew int `json:"publish_new"`
ReplaceOlder int `json:"replace_older"`
ReplaceConflict int `json:"replace_conflict"`
ReplaceNewer int `json:"replace_newer"`
ReplaceTakeover int `json:"replace_takeover"`
ForceReplace int `json:"force_replace"`
Skipped int `json:"skipped"`
@@ -56,7 +64,7 @@ type RunSummaryCounters struct {
}
func (s RunSummaryCounters) Line() string {
return fmt.Sprintf("Final status: %s planned=%d publish_new=%d replace_older=%d replace_takeover=%d force_replace=%d skipped=%d failed=%d dry_run=%t fixed_path=%d", s.Status, s.Planned, s.PublishNew, s.ReplaceOlder, s.ReplaceTakeover, s.ForceReplace, s.Skipped, s.Failed, s.DryRun, s.FixedPath)
return fmt.Sprintf("Final status: %s planned=%d publish_new=%d replace_older=%d replace_conflict=%d replace_newer=%d replace_takeover=%d force_replace=%d skipped=%d failed=%d dry_run=%t fixed_path=%d", s.Status, s.Planned, s.PublishNew, s.ReplaceOlder, s.ReplaceConflict, s.ReplaceNewer, s.ReplaceTakeover, s.ForceReplace, s.Skipped, s.Failed, s.DryRun, s.FixedPath)
}
func (s runSummary) Result() RunSummaryCounters {
@@ -69,6 +77,8 @@ func (s runSummary) Result() RunSummaryCounters {
Planned: s.planned,
PublishNew: s.publishNew,
ReplaceOlder: s.replaceOlder,
ReplaceConflict: s.replaceConflict,
ReplaceNewer: s.replaceNewer,
ReplaceTakeover: s.replaceTakeover,
ForceReplace: s.forceReplace,
Skipped: s.skipped,

View File

@@ -43,7 +43,7 @@ func TestRunDryRunPrintsConfigSummary(t *testing.T) {
"Configured pipelines: 1",
"- pipeline=reports source=local bundles=1 destinations=archive",
"bundle=. destination=archive backend=local action=publish_new outputs=report.md,summary.txt",
"Final status: ok planned=1 publish_new=1 replace_older=0 replace_takeover=0 force_replace=0 skipped=0 failed=0 dry_run=true",
"Final status: ok planned=1 publish_new=1 replace_older=0 replace_conflict=0 replace_newer=0 replace_takeover=0 force_replace=0 skipped=0 failed=0 dry_run=true",
} {
if !strings.Contains(output, want) {
t.Fatalf("Run() output = %q, want substring %q", output, want)
@@ -1358,7 +1358,7 @@ func TestRunContinuesAfterDestinationFailure(t *testing.T) {
for _, want := range []string{
"destination=archive-one backend=local action=error",
"destination=archive-two backend=local action=publish_new",
"Final status: failed planned=1 publish_new=1 replace_older=0 replace_takeover=0 force_replace=0 skipped=0 failed=1 dry_run=false",
"Final status: failed planned=1 publish_new=1 replace_older=0 replace_conflict=0 replace_newer=0 replace_takeover=0 force_replace=0 skipped=0 failed=1 dry_run=false",
} {
if !strings.Contains(output, want) {
t.Fatalf("stdout = %q, want substring %q", output, want)
@@ -1663,6 +1663,109 @@ func TestRunSkipsNewerDestination(t *testing.T) {
testutil.AssertFile(t, filepath.Join(destinationRoot, "report.md"), "newer\n")
}
func TestRunReplacesConflictWhenTransferPolicyAllows(t *testing.T) {
sourceRoot := t.TempDir()
destinationRoot := t.TempDir()
manifest := writeSourceBundle(t, sourceRoot, "", testBundleOptions{})
conflict := manifest
conflict.ID = "other.source"
writeDestinationState(t, destinationRoot, "", conflict)
if err := os.WriteFile(filepath.Join(destinationRoot, "report.md"), []byte("old\n"), 0o600); err != nil {
t.Fatalf("write old output: %v", err)
}
configPath := writeConfigFile(t, `
pipelines:
- id: reports
source:
backend: local
path: `+sourceRoot+`
destinations:
- id: archive
backend: local
path: `+destinationRoot+`
takeover:
mode: never
transfer:
on_conflict: replace
`)
var stdout bytes.Buffer
err := Run(context.Background(), RunOptions{ConfigPath: configPath, DryRun: true, Stdout: &stdout})
if err != nil {
t.Fatalf("Run() error = %v", err)
}
output := stdout.String()
for _, want := range []string{
"action=replace_conflict",
"replace_conflict=1",
"force_replace=0",
} {
if !strings.Contains(output, want) {
t.Fatalf("stdout = %q, want substring %q", output, want)
}
}
var jsonOut bytes.Buffer
err = Run(context.Background(), RunOptions{ConfigPath: configPath, DryRun: true, Stdout: &jsonOut, OutputFormat: OutputFormatJSON})
if err != nil {
t.Fatalf("Run() JSON error = %v", err)
}
result := decodeAppResult(t, jsonOut.String())
actions, ok := result["actions"].([]any)
if !ok || len(actions) != 1 {
t.Fatalf("actions = %#v, want one action", result["actions"])
}
action, ok := actions[0].(map[string]any)
if !ok || action["action"] != "replace_conflict" {
t.Fatalf("action = %#v, want replace_conflict", actions[0])
}
summary, ok := result["summary"].(map[string]any)
if !ok || summary["replace_conflict"] != float64(1) || summary["force_replace"] != float64(0) {
t.Fatalf("summary = %#v, want replace_conflict without force", result["summary"])
}
}
func TestRunReplacesNewerWhenTransferPolicyAllows(t *testing.T) {
sourceRoot := t.TempDir()
destinationRoot := t.TempDir()
manifest := writeSourceBundle(t, sourceRoot, "", testBundleOptions{})
newer := manifest
newer.Created = newer.Created.Add(time.Hour)
writeDestinationState(t, destinationRoot, "", newer)
if err := os.WriteFile(filepath.Join(destinationRoot, "report.md"), []byte("newer\n"), 0o600); err != nil {
t.Fatalf("write newer output: %v", err)
}
configPath := writeConfigFile(t, `
pipelines:
- id: reports
source:
backend: local
path: `+sourceRoot+`
destinations:
- id: archive
backend: local
path: `+destinationRoot+`
transfer:
on_destination_newer: replace
`)
var stdout bytes.Buffer
err := Run(context.Background(), RunOptions{ConfigPath: configPath, DryRun: true, Stdout: &stdout})
if err != nil {
t.Fatalf("Run() error = %v", err)
}
output := stdout.String()
for _, want := range []string{
"action=replace_newer",
"replace_newer=1",
"force_replace=0",
} {
if !strings.Contains(output, want) {
t.Fatalf("stdout = %q, want substring %q", output, want)
}
}
}
func TestRunTakeoverNeverFailsOnConflict(t *testing.T) {
sourceRoot := t.TempDir()
destinationRoot := t.TempDir()
@@ -1799,7 +1902,7 @@ func TestRunExercisesRemoteBackendShapesThroughCommonPath(t *testing.T) {
"pipeline=local-to-ssh source=local",
"destination=ssh-archive backend=ssh action=publish_new",
"pipeline=ssh-to-local source=ssh",
"Final status: ok planned=4 publish_new=4 replace_older=0 replace_takeover=0 force_replace=0 skipped=0 failed=0 dry_run=true",
"Final status: ok planned=4 publish_new=4 replace_older=0 replace_conflict=0 replace_newer=0 replace_takeover=0 force_replace=0 skipped=0 failed=0 dry_run=true",
} {
if !strings.Contains(dryRunOutput.String(), want) {
t.Fatalf("dry-run output = %q, want substring %q", dryRunOutput.String(), want)