diff --git a/src/pipeline/pipeline.c b/src/pipeline/pipeline.c index 0c3041959..76422337a 100644 --- a/src/pipeline/pipeline.c +++ b/src/pipeline/pipeline.c @@ -296,6 +296,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 78cfd37fd..687a7e420 100644 --- a/src/pipeline/pipeline.h +++ b/src/pipeline/pipeline.h @@ -72,6 +72,9 @@ void cbm_pipeline_bind_cancel_flag(cbm_pipeline_t *p, atomic_int *cancelled); * 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 21740ba64..7ece4a1de 100644 --- a/src/pipeline/pipeline_incremental.c +++ b/src/pipeline/pipeline_incremental.c @@ -13,6 +13,7 @@ enum { INCR_RING_BUF = 4, INCR_RING_MASK = 3, INCR_TS_BUF = 24 }; #include "pipeline/pipeline.h" +#include "pipeline/artifact.h" #include #include #include "pipeline/pipeline_internal.h" @@ -640,8 +641,8 @@ static void run_postpasses(cbm_pipeline_ctx_t *ctx, cbm_file_info_t *changed_fil 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 cbm_coverage_row_t *cov, int cov_count, - const cbm_coverage_meta_t *meta_template) { + 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,6 +702,20 @@ static int dump_and_persist(cbm_gbuf_t *gbuf, const char *db_path, const char *p } } cbm_store_close(hash_store); + + /* Create a requested artifact, or cheaply refresh one that already exists. + * Runs after the store is closed so the export reads a settled DB file. */ + bool artifact_exists = repo_path && cbm_artifact_exists(repo_path); + if (repo_path && (persistence || artifact_exists)) { + 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 rc; } @@ -749,12 +764,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); @@ -1007,7 +1032,8 @@ int cbm_pipeline_run_incremental(cbm_pipeline_t *p, const char *db_path, cbm_fil .coverage_version = 1, }; int persist_rc = dump_and_persist(existing, db_path, project, files, file_count, mode_skipped, - mode_skipped_count, 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 6cb2a6150..704482d01 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" @@ -5761,6 +5762,62 @@ 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_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) { @@ -7506,6 +7563,8 @@ 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_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);