From 82af97799e835927012e9c37938229eaf2b2f8ce Mon Sep 17 00:00:00 2001 From: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Date: Sat, 18 Jul 2026 01:32:49 -0700 Subject: [PATCH 1/2] fix: honor persistence on the incremental path so the first artifact is created Signed-off-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> --- src/pipeline/pipeline.c | 4 ++++ src/pipeline/pipeline.h | 3 +++ src/pipeline/pipeline_incremental.c | 14 ++++++++----- tests/test_pipeline.c | 32 +++++++++++++++++++++++++++++ 4 files changed, 48 insertions(+), 5 deletions(-) diff --git a/src/pipeline/pipeline.c b/src/pipeline/pipeline.c index 9f363922a..f0e7d25a2 100644 --- a/src/pipeline/pipeline.c +++ b/src/pipeline/pipeline.c @@ -263,6 +263,10 @@ const char *cbm_pipeline_project_name(const cbm_pipeline_t *p) { return p ? p->project_name : NULL; } +bool cbm_pipeline_persistence(const cbm_pipeline_t *p) { + return p && p->persistence; +} + const char *cbm_pipeline_repo_path(const cbm_pipeline_t *p) { return p ? p->repo_path : NULL; } diff --git a/src/pipeline/pipeline.h b/src/pipeline/pipeline.h index 67182329e..6c46d2041 100644 --- a/src/pipeline/pipeline.h +++ b/src/pipeline/pipeline.h @@ -65,6 +65,9 @@ void cbm_pipeline_cancel(cbm_pipeline_t *p); * owned by the pipeline. Valid until cbm_pipeline_free(). */ const char *cbm_pipeline_project_name(const cbm_pipeline_t *p); +/* Return whether persistent artifact export is enabled for this pipeline. */ +bool cbm_pipeline_persistence(const cbm_pipeline_t *p); + /* Override the derived project name with a sanitized user-provided label. */ bool cbm_pipeline_set_project_name(cbm_pipeline_t *p, const char *name); diff --git a/src/pipeline/pipeline_incremental.c b/src/pipeline/pipeline_incremental.c index 8b5766978..f74d60644 100644 --- a/src/pipeline/pipeline_incremental.c +++ b/src/pipeline/pipeline_incremental.c @@ -646,7 +646,8 @@ static void run_postpasses(cbm_pipeline_ctx_t *ctx, cbm_file_info_t *changed_fil static void dump_and_persist(cbm_gbuf_t *gbuf, const char *db_path, const char *project, cbm_file_info_t *files, int file_count, const cbm_file_hash_t *mode_skipped, int mode_skipped_count, - const char *repo_path, const cbm_coverage_row_t *cov, int cov_count, + const char *repo_path, bool persistence, + const cbm_coverage_row_t *cov, int cov_count, const cbm_coverage_meta_t *meta_template) { struct timespec t; cbm_clock_gettime(CLOCK_MONOTONIC, &t); @@ -701,9 +702,11 @@ static void dump_and_persist(cbm_gbuf_t *gbuf, const char *db_path, const char * cbm_store_close(hash_store); } - /* Auto-update artifact if one already exists (persistence was enabled previously) */ - if (repo_path && cbm_artifact_exists(repo_path)) { - cbm_artifact_export(db_path, repo_path, project, CBM_ARTIFACT_FAST); + /* Create a requested artifact, or cheaply refresh one that already exists. */ + bool artifact_exists = repo_path && cbm_artifact_exists(repo_path); + if (repo_path && (persistence || artifact_exists)) { + cbm_artifact_export(db_path, repo_path, project, + artifact_exists ? CBM_ARTIFACT_FAST : CBM_ARTIFACT_BEST); } } @@ -1011,7 +1014,8 @@ int cbm_pipeline_run_incremental(cbm_pipeline_t *p, const char *db_path, cbm_fil .coverage_version = 1, }; dump_and_persist(existing, db_path, project, files, file_count, mode_skipped, - mode_skipped_count, cbm_pipeline_repo_path(p), cov, cov_n, &coverage_meta); + mode_skipped_count, cbm_pipeline_repo_path(p), cbm_pipeline_persistence(p), + cov, cov_n, &coverage_meta); free(cov); cbm_store_free_coverage(old_cov, old_cov_count); free_mode_skipped(mode_skipped, mode_skipped_count); diff --git a/tests/test_pipeline.c b/tests/test_pipeline.c index 4f095f385..87eaa096a 100644 --- a/tests/test_pipeline.c +++ b/tests/test_pipeline.c @@ -11,6 +11,7 @@ #include "foundation/mem.h" // cbm_mem_init/budget (back-pressure futile-nap test) #include "pipeline/pipeline.h" #include "pipeline/pipeline_internal.h" +#include "pipeline/artifact.h" #include "store/store.h" #include "git/git_context.h" #include "foundation/dump_verify.h" @@ -5646,6 +5647,36 @@ TEST(incremental_full_then_noop) { PASS(); } +TEST(incremental_persistence_creates_first_artifact) { + /* Populate the local DB without persistence, then change a file and + * enable persistence on the incremental path. The first artifact must be + * created here rather than waiting for a later reindex. */ + if (setup_incremental_repo() != 0) { + FAIL("setup failed"); + } + + cbm_pipeline_t *p = cbm_pipeline_new(g_incr_tmpdir, g_incr_dbpath, CBM_MODE_FULL); + ASSERT_NOT_NULL(p); + ASSERT_EQ(cbm_pipeline_run(p), 0); + cbm_pipeline_free(p); + + ASSERT_FALSE(cbm_artifact_exists(g_incr_tmpdir)); + + char path[512]; + snprintf(path, sizeof(path), "%s/helper.go", g_incr_tmpdir); + ASSERT_EQ(th_append_file(path, "\n// persistence regression marker\n"), 0); + + p = cbm_pipeline_new(g_incr_tmpdir, g_incr_dbpath, CBM_MODE_FULL); + ASSERT_NOT_NULL(p); + cbm_pipeline_set_persistence(p, true); + ASSERT_EQ(cbm_pipeline_run(p), 0); + ASSERT_TRUE(cbm_artifact_exists(g_incr_tmpdir)); + cbm_pipeline_free(p); + + cleanup_incremental_repo(); + PASS(); +} + TEST(incremental_detects_changed_file) { /* Full index, modify one file, re-index → changed file re-parsed */ if (setup_incremental_repo() != 0) { @@ -7091,6 +7122,7 @@ SUITE(pipeline) { RUN_TEST(pipeline_fastapi_depends_edges); /* Incremental */ RUN_TEST(incremental_full_then_noop); + RUN_TEST(incremental_persistence_creates_first_artifact); RUN_TEST(incremental_detects_changed_file); RUN_TEST(incremental_aborts_when_previous_coverage_is_unreadable); RUN_TEST(incremental_detects_deleted_file); From e8472f9025800e336a59a2c1a6cdf56bba9b3cb9 Mon Sep 17 00:00:00 2001 From: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Date: Sat, 18 Jul 2026 01:35:49 -0700 Subject: [PATCH 2/2] fix: propagate incremental export failures and persist on no-op runs Signed-off-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> --- src/pipeline/pipeline_incremental.c | 42 ++++++++++++++++++++--------- tests/test_pipeline.c | 27 +++++++++++++++++++ 2 files changed, 56 insertions(+), 13 deletions(-) diff --git a/src/pipeline/pipeline_incremental.c b/src/pipeline/pipeline_incremental.c index f74d60644..27c76aa44 100644 --- a/src/pipeline/pipeline_incremental.c +++ b/src/pipeline/pipeline_incremental.c @@ -643,12 +643,11 @@ static void run_postpasses(cbm_pipeline_ctx_t *ctx, cbm_file_info_t *changed_fil * Mode-skipped hash rows are preserved across the rebuild so subsequent * reindexes can correctly distinguish "never indexed" from "indexed but * not visited this pass". */ -static void dump_and_persist(cbm_gbuf_t *gbuf, const char *db_path, const char *project, - cbm_file_info_t *files, int file_count, - const cbm_file_hash_t *mode_skipped, int mode_skipped_count, - const char *repo_path, bool persistence, - const cbm_coverage_row_t *cov, int cov_count, - const cbm_coverage_meta_t *meta_template) { +static int dump_and_persist(cbm_gbuf_t *gbuf, const char *db_path, const char *project, + cbm_file_info_t *files, int file_count, + const cbm_file_hash_t *mode_skipped, int mode_skipped_count, + const char *repo_path, bool persistence, const cbm_coverage_row_t *cov, + int cov_count, const cbm_coverage_meta_t *meta_template) { struct timespec t; cbm_clock_gettime(CLOCK_MONOTONIC, &t); @@ -705,9 +704,16 @@ static void dump_and_persist(cbm_gbuf_t *gbuf, const char *db_path, const char * /* Create a requested artifact, or cheaply refresh one that already exists. */ bool artifact_exists = repo_path && cbm_artifact_exists(repo_path); if (repo_path && (persistence || artifact_exists)) { - cbm_artifact_export(db_path, repo_path, project, - artifact_exists ? CBM_ARTIFACT_FAST : CBM_ARTIFACT_BEST); + int arc = cbm_artifact_export(db_path, repo_path, project, + artifact_exists ? CBM_ARTIFACT_FAST : CBM_ARTIFACT_BEST); + if (arc != 0) { + const char *err = cbm_artifact_export_last_error(); + cbm_log_error("pipeline.err", "phase", "artifact_export", "err", err ? err : "unknown"); + return arc; + } } + + return 0; } /* ── Incremental pipeline entry point ────────────────────────────── */ @@ -755,12 +761,22 @@ int cbm_pipeline_run_incremental(cbm_pipeline_t *p, const char *db_path, cbm_fil * that were already preserved by an earlier run) remain intact. */ if (n_changed == 0 && deleted_count == 0) { cbm_log_info("incremental.noop", "reason", "no_changes"); + int arc = 0; + const char *repo_path = cbm_pipeline_repo_path(p); + if (cbm_pipeline_persistence(p) && repo_path && !cbm_artifact_exists(repo_path)) { + arc = cbm_artifact_export(db_path, repo_path, project, CBM_ARTIFACT_BEST); + if (arc != 0) { + const char *err = cbm_artifact_export_last_error(); + cbm_log_error("pipeline.err", "phase", "artifact_export", "err", + err ? err : "unknown"); + } + } free(is_changed); free(deleted); free_mode_skipped(mode_skipped, mode_skipped_count); cbm_store_free_file_hashes(stored, stored_count); cbm_store_close(store); - return 0; + return arc; } cbm_store_free_file_hashes(stored, stored_count); @@ -1013,14 +1029,14 @@ int cbm_pipeline_run_incremental(cbm_pipeline_t *p, const char *db_path, cbm_fil .ignored_files_total = run_ignored_total, .coverage_version = 1, }; - dump_and_persist(existing, db_path, project, files, file_count, mode_skipped, - mode_skipped_count, cbm_pipeline_repo_path(p), cbm_pipeline_persistence(p), - cov, cov_n, &coverage_meta); + int persist_rc = dump_and_persist(existing, db_path, project, files, file_count, mode_skipped, + mode_skipped_count, cbm_pipeline_repo_path(p), + cbm_pipeline_persistence(p), cov, cov_n, &coverage_meta); free(cov); cbm_store_free_coverage(old_cov, old_cov_count); free_mode_skipped(mode_skipped, mode_skipped_count); cbm_gbuf_free(existing); cbm_log_info("incremental.done", "elapsed_ms", itoa_buf((int)elapsed_ms(t0))); - return 0; + return persist_rc; } diff --git a/tests/test_pipeline.c b/tests/test_pipeline.c index 87eaa096a..bb5d3b964 100644 --- a/tests/test_pipeline.c +++ b/tests/test_pipeline.c @@ -5677,6 +5677,32 @@ TEST(incremental_persistence_creates_first_artifact) { PASS(); } +TEST(incremental_noop_persistence_creates_first_artifact) { + /* Populate the local DB without persistence, then request persistence + * without changing any files. The incremental no-op path must still + * create the missing artifact. */ + if (setup_incremental_repo() != 0) { + FAIL("setup failed"); + } + + cbm_pipeline_t *p = cbm_pipeline_new(g_incr_tmpdir, g_incr_dbpath, CBM_MODE_FULL); + ASSERT_NOT_NULL(p); + ASSERT_EQ(cbm_pipeline_run(p), 0); + cbm_pipeline_free(p); + + ASSERT_FALSE(cbm_artifact_exists(g_incr_tmpdir)); + + p = cbm_pipeline_new(g_incr_tmpdir, g_incr_dbpath, CBM_MODE_FULL); + ASSERT_NOT_NULL(p); + cbm_pipeline_set_persistence(p, true); + ASSERT_EQ(cbm_pipeline_run(p), 0); + ASSERT_TRUE(cbm_artifact_exists(g_incr_tmpdir)); + cbm_pipeline_free(p); + + cleanup_incremental_repo(); + PASS(); +} + TEST(incremental_detects_changed_file) { /* Full index, modify one file, re-index → changed file re-parsed */ if (setup_incremental_repo() != 0) { @@ -7123,6 +7149,7 @@ SUITE(pipeline) { /* Incremental */ RUN_TEST(incremental_full_then_noop); RUN_TEST(incremental_persistence_creates_first_artifact); + RUN_TEST(incremental_noop_persistence_creates_first_artifact); RUN_TEST(incremental_detects_changed_file); RUN_TEST(incremental_aborts_when_previous_coverage_is_unreadable); RUN_TEST(incremental_detects_deleted_file);