Implement catalog force replacement

This commit is contained in:
2026-06-19 16:39:01 +00:00
parent 4e673dda76
commit 9a2eaf8e5e
13 changed files with 307 additions and 35 deletions

View File

@@ -597,7 +597,6 @@ func TestRunJSONIncludesWorkflowActionAndSummary(t *testing.T) {
}
func TestRunFixedPathFailsUnmanagedWithoutForce(t *testing.T) {
t.Skip("catalog workflow protects planned path collisions rather than unrelated unplanned content")
sourceRoot := t.TempDir()
destinationRoot := t.TempDir()
writeSourceBundle(t, sourceRoot, "bundle", testBundleOptions{})
@@ -612,7 +611,6 @@ func TestRunFixedPathFailsUnmanagedWithoutForce(t *testing.T) {
}
func TestRunFixedPathForceReplacementStaysWithinDestinationRoot(t *testing.T) {
t.Skip("force reporting and execution behavior is covered by the catalog reporting work")
sourceRoot := t.TempDir()
parent := t.TempDir()
destinationRoot := filepath.Join(parent, "latest")
@@ -639,6 +637,10 @@ func TestRunFixedPathForceReplacementStaysWithinDestinationRoot(t *testing.T) {
if _, err := os.Stat(filepath.Join(destinationRoot, "unmanaged.txt")); !os.IsNotExist(err) {
t.Fatalf("unmanaged stat error = %v, want removed", err)
}
catalog := readLocalCatalogState(t, destinationRoot)
if catalog.SchemaVersion != state.CatalogSchemaVersion || catalog.State.Mode != state.StateModeCatalog {
t.Fatalf("catalog identity = schema %d mode %s", catalog.SchemaVersion, catalog.State.Mode)
}
}
func TestRunFixedPathRemoteBackendsUseBackendRoots(t *testing.T) {
@@ -1384,7 +1386,6 @@ func TestRunSkipsWhenDestinationStateMatches(t *testing.T) {
}
func TestRunFailsOnUnmanagedDestination(t *testing.T) {
t.Skip("catalog workflow protects planned path collisions rather than unrelated unplanned content")
sourceRoot := t.TempDir()
destinationRoot := t.TempDir()
writeSourceBundle(t, sourceRoot, "", testBundleOptions{})
@@ -1399,7 +1400,6 @@ func TestRunFailsOnUnmanagedDestination(t *testing.T) {
}
func TestRunForceReplacesUnmanagedDestination(t *testing.T) {
t.Skip("force reporting and execution behavior is covered by the catalog reporting work")
sourceRoot := t.TempDir()
destinationRoot := t.TempDir()
writeSourceBundle(t, sourceRoot, "", testBundleOptions{})
@@ -1419,10 +1419,50 @@ func TestRunForceReplacesUnmanagedDestination(t *testing.T) {
if !strings.Contains(stdout.String(), "action=force_replace") {
t.Fatalf("stdout = %q, want force_replace", stdout.String())
}
if !strings.Contains(stdout.String(), "Final status: ok planned=1 publish_new=0 upsert_additive=0 replace_catalog=0 skip_same=0 force_replace=1 fail_unmanaged=0 fail_conflict=0 failed=0 dry_run=false") {
t.Fatalf("stdout = %q, want force_replace counter only", stdout.String())
}
if _, err := os.Stat(filepath.Join(destinationRoot, "unmanaged.txt")); !os.IsNotExist(err) {
t.Fatalf("unmanaged file stat error = %v, want not exist", err)
}
testutil.AssertFile(t, filepath.Join(destinationRoot, "report.md"), "# Report\nSunny.\n")
catalog := readLocalCatalogState(t, destinationRoot)
if catalog.SchemaVersion != state.CatalogSchemaVersion || catalog.State.Mode != state.StateModeCatalog {
t.Fatalf("catalog identity = schema %d mode %s", catalog.SchemaVersion, catalog.State.Mode)
}
}
func TestRunForceReplacementJSONOutput(t *testing.T) {
sourceRoot := t.TempDir()
destinationRoot := t.TempDir()
writeSourceBundle(t, sourceRoot, "", testBundleOptions{})
if err := os.WriteFile(filepath.Join(destinationRoot, "unmanaged.txt"), []byte("old"), 0o600); err != nil {
t.Fatalf("write unmanaged file: %v", err)
}
var stdout bytes.Buffer
err := Run(context.Background(), RunOptions{
ConfigPath: writeLocalConfig(t, sourceRoot, destinationRoot),
Force: true,
Stdout: &stdout,
OutputFormat: OutputFormatJSON,
})
if err != nil {
t.Fatalf("Run() error = %v", err)
}
result := decodeAppResult(t, stdout.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"] != "force_replace" || action["workflow"] != "additive" {
t.Fatalf("action = %#v, want force_replace additive", actions[0])
}
summary, ok := result["summary"].(map[string]any)
if !ok || summary["force_replace"] != float64(1) || summary["publish_new"] != float64(0) {
t.Fatalf("summary = %#v, want force_replace only", result["summary"])
}
}
func TestRunFansOutToLocalDestinations(t *testing.T) {
@@ -1527,7 +1567,6 @@ func TestRunExercisesRemoteBackendShapesThroughCommonPath(t *testing.T) {
}
func TestRunForceReplacementStaysWithinRemoteBundlePaths(t *testing.T) {
t.Skip("force reporting and execution behavior is covered by the catalog reporting work")
localSourceRoot := t.TempDir()
writeSourceBundle(t, localSourceRoot, "bundle", testBundleOptions{})
s3Destination := fake.New()
@@ -1569,6 +1608,12 @@ func TestRunForceReplacementStaysWithinRemoteBundlePaths(t *testing.T) {
testutil.AssertFakeFile(t, sshDestination, "bundle/report.md", "# Report\nSunny.\n")
testutil.AssertFakeMissing(t, sshDestination, "bundle/old.txt")
testutil.AssertFakeFile(t, sshDestination, "bundle-sibling/keep.txt", "keep")
for name, backend := range map[string]*fake.Backend{"s3": s3Destination, "ssh": sshDestination} {
catalog := readFakeCatalogStateAt(t, backend, "bundle")
if catalog.SchemaVersion != state.CatalogSchemaVersion || catalog.State.Mode != state.StateModeCatalog {
t.Fatalf("%s catalog identity = schema %d mode %s", name, catalog.SchemaVersion, catalog.State.Mode)
}
}
}
func TestRunDryRunDoesNotWrite(t *testing.T) {
@@ -1772,6 +1817,36 @@ func readStateFile(t *testing.T, path string) testDestinationState {
return view
}
func readLocalCatalogState(t *testing.T, destinationRoot string) state.CatalogState {
t.Helper()
data, err := os.ReadFile(filepath.Join(destinationRoot, storage.StateFileName))
if err != nil {
t.Fatalf("read catalog state: %v", err)
}
catalog, err := state.ParseCatalog(data)
if err != nil {
t.Fatalf("parse catalog state: %v", err)
}
return catalog
}
func readFakeCatalogStateAt(t *testing.T, backend *fake.Backend, bundlePath string) state.CatalogState {
t.Helper()
statePath, err := storage.StatePath(bundlePath)
if err != nil {
t.Fatalf("state path: %v", err)
}
data, err := backend.ReadFile(context.Background(), statePath)
if err != nil {
t.Fatalf("read catalog state: %v", err)
}
catalog, err := state.ParseCatalog(data)
if err != nil {
t.Fatalf("parse catalog state: %v", err)
}
return catalog
}
func outputsByPath(outputs []testStateOutput) map[string]testStateOutput {
byPath := make(map[string]testStateOutput, len(outputs))
for _, output := range outputs {