]> git.ipfire.org Git - thirdparty/ccache.git/commitdiff
feat: Add --dry-run option
authorJoel Rosdahl <joel@rosdahl.net>
Wed, 5 Aug 2026 13:54:03 +0000 (15:54 +0200)
committerJoel Rosdahl <joel@rosdahl.net>
Wed, 5 Aug 2026 19:56:14 +0000 (21:56 +0200)
Closes #1760.

doc/manual.adoc
src/ccache/core/mainoptions.cpp
src/ccache/core/types.hpp
src/ccache/storage/local/localstorage.cpp
src/ccache/storage/local/localstorage.hpp
test/suites/cleanup.bash

index a7faa803d37551d9c2019e3ca500137b5de40b63..6480a4c48cfdee08c91b123bf7d2657639fb2ae3 100644 (file)
@@ -109,6 +109,10 @@ should use the normal compiler options and refer to your compiler's documentatio
     has the same effect as setting the environment variable `CCACHE_DIR`
     temporarily.
 
+*--dry-run*::
+
+    Do not perform any write operations.
+
 *--evict-namespace* _NAMESPACE_::
 
     Remove files created in the given <<config_namespace,*namespace*>> from the
index 736d2f8f2cc44d7486bb54b1508511f5c8573f17..1062e099500c73e672ec147863eb138802180635 100644 (file)
@@ -113,6 +113,7 @@ Common options:
                                default
     -d, --dir PATH             operate on cache directory PATH instead of the
                                default
+        --dry-run              do not perform any write operations
         --evict-namespace NAMESPACE
                                remove files created in namespace NAMESPACE
         --evict-older-than AGE remove files used less recently than AGE
@@ -309,13 +310,17 @@ print_compression_statistics(const Config& config,
 }
 
 static void
-trim_dir(const std::string& dir,
+trim_dir(core::DryRun dry_run,
+         const std::string& dir,
          const uint64_t trim_max_size,
          const util::SizeUnitPrefixType suffix_type,
          const bool trim_lru_mtime,
          std::optional<std::optional<int8_t>> recompress_level,
          uint32_t threads)
 {
+  ASSERT(dry_run == DryRun::no
+         || recompress_level == std::nullopt); // Verified by caller
+
   std::vector<DirEntry> files;
   uint64_t initial_size = 0;
 
@@ -390,7 +395,7 @@ trim_dir(const std::string& dir,
       if (final_size <= trim_max_size) {
         break;
       }
-      if (util::remove(file.path())) {
+      if (dry_run == DryRun::yes || util::remove(file.path())) {
         ++removed_files;
         final_size -= file.size_on_disk();
       }
@@ -440,6 +445,7 @@ get_usage_text(const std::string_view ccache_name)
 enum : uint8_t {
   CHECKSUM_FILE,
   CONFIG_PATH,
+  DRY_RUN,
   EVICT_NAMESPACE,
   EVICT_OLDER_THAN,
   EXTRACT_RESULT,
@@ -469,6 +475,7 @@ const option long_options[] = {
   {"config-path",             REQUIRED,    nullptr, CONFIG_PATH         },
   {"dir",                     REQUIRED,    nullptr, 'd'                 },
   {"directory",               REQUIRED,    nullptr, 'd'                 }, // compat
+  {"dry-run",                 NO_ARGUMENT, nullptr, DRY_RUN             },
   {"dump-manifest",           REQUIRED,    nullptr, INSPECT             }, // compat
   {"dump-result",             REQUIRED,    nullptr, INSPECT             }, // compat
   {"evict-namespace",         REQUIRED,    nullptr, EVICT_NAMESPACE     },
@@ -517,6 +524,7 @@ process_main_options(int argc, const char* const* argv)
   std::optional<util::SizeUnitPrefixType> trim_suffix_type;
   bool trim_lru_mtime = false;
   std::optional<std::optional<int8_t>> trim_recompress;
+  core::DryRun dry_run = DryRun::no;
 
   std::optional<std::string> evict_namespace;
   std::optional<uint64_t> evict_max_age;
@@ -534,6 +542,9 @@ process_main_options(int argc, const char* const* argv)
     case 'd': // --dir
       util::setenv("CCACHE_DIR", arg);
       break;
+    case DRY_RUN:
+      dry_run = DryRun::yes;
+      break;
     case FORMAT:
       if (arg == "tab") {
         format = StatisticsFormat::Tab;
@@ -599,6 +610,7 @@ process_main_options(int argc, const char* const* argv)
     switch (c) {
     case CONFIG_PATH:
     case 'd': // --dir
+    case DRY_RUN:
     case FORMAT:
     case THREADS:
     case TRIM_MAX_SIZE:
@@ -640,6 +652,11 @@ process_main_options(int argc, const char* const* argv)
     }
 
     case EXTRACT_RESULT: {
+      if (dry_run == DryRun::yes) {
+        PRINT(stderr, "--dry-run cannot be used with --extract-result\n");
+        return EXIT_FAILURE;
+      }
+
       umask_scope.release(); // Use original umask for files outside cache dir
       const auto cache_entry_data = read_from_path_or_stdin(arg);
       if (!cache_entry_data) {
@@ -693,15 +710,16 @@ process_main_options(int argc, const char* const* argv)
     {
       ProgressBar progress_bar("Cleaning...");
       storage::local::LocalStorage(config).clean_all(
-        [&](double progress) { progress_bar.update(progress); });
-      if (isatty(STDOUT_FILENO)) {
-        PRINT(stdout, "\n");
-      }
+        dry_run, [&](double progress) { progress_bar.update(progress); });
       break;
     }
 
     case 'C': // --clear
     {
+      if (dry_run == DryRun::yes) {
+        PRINT(stderr, "--dry-run cannot be used with --clear\n");
+        return EXIT_FAILURE;
+      }
       ProgressBar progress_bar("Clearing...");
       storage::local::LocalStorage(config).wipe_all(
         [&](double progress) { progress_bar.update(progress); });
@@ -720,6 +738,10 @@ process_main_options(int argc, const char* const* argv)
       break;
 
     case 'F': { // --max-files
+      if (dry_run == DryRun::yes) {
+        PRINT(stderr, "--dry-run cannot be used with --max-files\n");
+        return EXIT_FAILURE;
+      }
       auto files = util::value_or_throw<Error>(util::parse_unsigned(arg));
       config.set_value_in_file(
         util::pstr(config.config_path()), "max_files", arg);
@@ -732,6 +754,10 @@ process_main_options(int argc, const char* const* argv)
     }
 
     case 'M': { // --max-size
+      if (dry_run == DryRun::yes) {
+        PRINT(stderr, "--dry-run cannot be used with --max-size\n");
+        return EXIT_FAILURE;
+      }
       auto [size, suffix_type] =
         util::value_or_throw<Error>(util::parse_size(arg));
       uint64_t max_size = size;
@@ -748,6 +774,10 @@ process_main_options(int argc, const char* const* argv)
     }
 
     case 'o': { // --set-config
+      if (dry_run == DryRun::yes) {
+        PRINT(stderr, "--dry-run cannot be used with --set-config\n");
+        return EXIT_FAILURE;
+      }
       // Start searching for equal sign at position 1 to improve error message
       // for the -o=K=V case (key "=K" and value "V").
       size_t eq_pos = arg.find('=', 1);
@@ -803,16 +833,26 @@ process_main_options(int argc, const char* const* argv)
     }
 
     case STOP_STORAGE_HELPERS: {
+      if (dry_run == DryRun::yes) {
+        PRINT(stderr, "--dry-run cannot be used with --stop-storage-helpers\n");
+        return EXIT_FAILURE;
+      }
       storage::Storage storage(config, fs::path(argv[0]).parent_path());
       storage.stop_remote_storage_helpers();
       break;
     }
 
     case TRIM_DIR:
+      if (dry_run == DryRun::yes && trim_recompress) {
+        PRINT(stderr, "--dry-run cannot be used with --trim-recompress\n");
+        return EXIT_FAILURE;
+      }
+
       if (!trim_max_size) {
         throw Error("please specify --trim-max-size when using --trim-dir");
       }
-      trim_dir(arg,
+      trim_dir(dry_run,
+               arg,
                *trim_max_size,
                *trim_suffix_type,
                trim_lru_mtime,
@@ -847,6 +887,10 @@ process_main_options(int argc, const char* const* argv)
 
     case 'X': // --recompress
     {
+      if (dry_run == DryRun::yes) {
+        PRINT(stderr, "--dry-run cannot be used with --recompress\n");
+        return EXIT_FAILURE;
+      }
       auto wanted_level = parse_compression_level(arg);
 
       ProgressBar progress_bar("Recompressing...");
@@ -858,6 +902,10 @@ process_main_options(int argc, const char* const* argv)
     }
 
     case 'z': // --zero-stats
+      if (dry_run == DryRun::yes) {
+        PRINT(stderr, "--dry-run cannot be used with --zero-stats\n");
+        return EXIT_FAILURE;
+      }
       storage::local::LocalStorage(config).zero_all_statistics();
       PRINT(stdout, "Statistics zeroed\n");
       break;
@@ -874,6 +922,7 @@ process_main_options(int argc, const char* const* argv)
 
     ProgressBar progress_bar("Evicting...");
     storage::local::LocalStorage(config).evict(
+      dry_run,
       [&](double progress) { progress_bar.update(progress); },
       evict_max_age,
       evict_namespace);
index 8d0297f395899abbacc30aa5d4901a155b4ca034..86dd677b37c8f3b5aa7eb3f8e86af4fbe5194e5a 100644 (file)
@@ -1,4 +1,4 @@
-// Copyright (C) 2021-2022 Joel Rosdahl and other contributors
+// Copyright (C) 2021-2026 Joel Rosdahl and other contributors
 //
 // See doc/authors.adoc for a complete list of contributors.
 //
@@ -25,6 +25,8 @@ class Config;
 
 namespace core {
 
+enum class DryRun { yes, no };
+
 enum class CacheEntryType : uint8_t { result = 0, manifest = 1 };
 
 std::string to_string(CacheEntryType type);
index 22dc7a9882bc3a0fab8c8465aed069b3f8025b2b..14a225ed544186c6732eae048bbace8d76b31c63 100644 (file)
@@ -203,10 +203,17 @@ calculate_wanted_cache_level(const uint64_t files_in_level_1)
 }
 
 static void
-delete_file(const DirEntry& dir_entry,
+delete_file(core::DryRun dry_run,
+            const DirEntry& dir_entry,
             uint64_t& cache_size,
             uint64_t& files_in_cache)
 {
+  if (dry_run == core::DryRun::yes) {
+    cache_size -= dir_entry.size_on_disk();
+    --files_in_cache;
+    return;
+  }
+
   const auto result =
     util::remove_nfs_safe(dir_entry.path(), util::LogFailure::no);
   if (!result && result.error().value() != ENOENT
@@ -323,6 +330,7 @@ result_path_from_raw_file(const std::string& path)
 
 static CleanDirResult
 clean_dir(
+  core::DryRun dry_run,
   const fs::path& l2_dir,
   const uint64_t max_size,
   const uint64_t max_files,
@@ -337,7 +345,13 @@ clean_dir(
 
   uint64_t cache_size = 0;
   uint64_t files_in_cache = 0;
+  uint64_t stale_tmp_size = 0;
+  uint64_t stale_tmp_files = 0;
   auto current_time = util::now();
+  auto is_stale_tmp_file = [&](const DirEntry& file) {
+    return file.mtime() + 1h < current_time
+           && util::TemporaryFile::is_tmp_file(file.path());
+  };
   std::unordered_map<std::string /*result_file*/,
                      std::vector<fs::path> /*associated_raw_files*/>
     raw_files_map;
@@ -352,9 +366,12 @@ clean_dir(
     }
 
     // Delete any tmp files older than 1 hour right away.
-    if (file.mtime() + 1h < current_time
-        && util::TemporaryFile::is_tmp_file(file.path())) {
-      std::ignore = util::remove(file.path());
+    if (is_stale_tmp_file(file)) {
+      stale_tmp_size += file.size_on_disk();
+      ++stale_tmp_files;
+      if (dry_run == core::DryRun::no) {
+        std::ignore = util::remove(file.path());
+      }
       continue;
     }
 
@@ -377,14 +394,16 @@ clean_dir(
   LOG("Before cleanup: {:.0f} KiB, {:.0f} files",
       static_cast<double>(cache_size) / 1024,
       static_cast<double>(files_in_cache));
-  Level2Counters counters_before{files_in_cache, cache_size};
+  Level2Counters counters_before{files_in_cache + stale_tmp_files,
+                                 cache_size + stale_tmp_size};
 
   bool cleaned = false;
   for (size_t i = 0; i < files.size();
        ++i, progress_receiver(2.0 / 3 + 1.0 * ratio(i, files.size()) / 3)) {
     const auto& file = files[i];
 
-    if (!file || file.is_directory()) {
+    if (!file || file.is_directory()
+        || (file.is_regular_file() && is_stale_tmp_file(file))) {
       continue;
     }
 
@@ -412,12 +431,12 @@ clean_dir(
       const auto entry = raw_files_map.find(util::pstr(file.path()));
       if (entry != raw_files_map.end()) {
         for (const auto& raw_file : entry->second) {
-          delete_file(DirEntry(raw_file), cache_size, files_in_cache);
+          delete_file(dry_run, DirEntry(raw_file), cache_size, files_in_cache);
         }
       }
     }
 
-    delete_file(file, cache_size, files_in_cache);
+    delete_file(dry_run, file, cache_size, files_in_cache);
     cleaned = true;
   }
 
@@ -769,17 +788,20 @@ LocalStorage::get_all_statistics() const
 }
 
 void
-LocalStorage::evict(const ProgressReceiver& progress_receiver,
+LocalStorage::evict(core::DryRun dry_run,
+                    const ProgressReceiver& progress_receiver,
                     std::optional<uint64_t> max_age,
                     std::optional<std::string> namespace_)
 {
-  do_clean_all(progress_receiver, 0, 0, max_age, namespace_);
+  do_clean_all(dry_run, progress_receiver, 0, 0, max_age, namespace_);
 }
 
 void
-LocalStorage::clean_all(const ProgressReceiver& progress_receiver)
+LocalStorage::clean_all(core::DryRun dry_run,
+                        const ProgressReceiver& progress_receiver)
 {
-  do_clean_all(progress_receiver,
+  do_clean_all(dry_run,
+               progress_receiver,
                m_config.max_size(),
                m_config.max_files(),
                std::nullopt,
@@ -1269,8 +1291,11 @@ LocalStorage::perform_automatic_cleanup()
   const uint64_t target_files = static_cast<uint64_t>(
     0.9 * static_cast<double>(evaluation->total_files) / 256);
 
-  auto clean_dir_result = clean_dir(
-    get_subdir(evaluation->l1_index, largest_level_2_index), 0, target_files);
+  auto clean_dir_result =
+    clean_dir(core::DryRun::no,
+              get_subdir(evaluation->l1_index, largest_level_2_index),
+              0,
+              target_files);
 
   stats_file.update([&](auto& cs) {
     const auto old_files =
@@ -1295,7 +1320,8 @@ LocalStorage::perform_automatic_cleanup()
 }
 
 void
-LocalStorage::do_clean_all(const ProgressReceiver& progress_receiver,
+LocalStorage::do_clean_all(core::DryRun dry_run,
+                           const ProgressReceiver& progress_receiver,
                            uint64_t max_size,
                            uint64_t max_files,
                            std::optional<uint64_t> max_age,
@@ -1327,7 +1353,8 @@ LocalStorage::do_clean_all(const ProgressReceiver& progress_receiver,
             current_size > max_size ? max_size / 256 : 0;
           uint64_t level_2_max_files =
             current_files > max_files ? max_files / 256 : 0;
-          auto clean_dir_result = clean_dir(get_subdir(l1_index, l2_index),
+          auto clean_dir_result = clean_dir(dry_run,
+                                            get_subdir(l1_index, l2_index),
                                             level_2_max_size,
                                             level_2_max_files,
                                             max_age,
@@ -1352,18 +1379,22 @@ LocalStorage::do_clean_all(const ProgressReceiver& progress_receiver,
             ++level_1_counters.cleanups;
           }
 
-          // Fix erroneous files/size counters for raw files in L2 stats files.
-          // See also comments in finalize().
-          get_stats_file(l1_index, l2_index)
-            .update(
-              [](auto& cs) {
-                cs.set(Statistic::cache_size_kibibyte, 0);
-                cs.set(Statistic::files_in_cache, 0);
-              },
-              StatsFile::OnlyIfChanged::yes);
+          if (dry_run == core::DryRun::no) {
+            // Fix erroneous files/size counters for raw files in L2 stats
+            // files. See also comments in finalize().
+            get_stats_file(l1_index, l2_index)
+              .update(
+                [](auto& cs) {
+                  cs.set(Statistic::cache_size_kibibyte, 0);
+                  cs.set(Statistic::files_in_cache, 0);
+                },
+                StatsFile::OnlyIfChanged::yes);
+          }
         });
 
-      set_counters(get_stats_file(l1_index), level_1_counters);
+      if (dry_run == core::DryRun::no) {
+        set_counters(get_stats_file(l1_index), level_1_counters);
+      }
     });
 
   if (isatty(STDOUT_FILENO)) {
@@ -1381,10 +1412,12 @@ LocalStorage::do_clean_all(const ProgressReceiver& progress_receiver,
 
   using C = util::TextTable::Cell;
   util::TextTable table;
-  table.add_row({"Removed data:",
+  const char* description =
+    dry_run == core::DryRun::yes ? "Would remove" : "Removed";
+  table.add_row({FMT("{} data:", description),
                  C(removed_size_quantity).right_align(),
                  *removed_size_unit});
-  table.add_row({"Removed files:", C(total_removed_files)});
+  table.add_row({FMT("{} files:", description), C(total_removed_files)});
   PRINT(stdout, "{}", table.render());
 }
 
index fa137a7ad5d4398465896775b987192d66d11c03..6b3d8fd1181319fe21d210121372e2eb5afce587 100644 (file)
@@ -111,11 +111,13 @@ public:
 
   // --- Cleanup ---
 
-  void evict(const ProgressReceiver& progress_receiver,
+  void evict(core::DryRun dry_run,
+             const ProgressReceiver& progress_receiver,
              std::optional<uint64_t> max_age,
              std::optional<std::string> namespace_);
 
-  void clean_all(const ProgressReceiver& progress_receiver);
+  void clean_all(core::DryRun dry_run,
+                 const ProgressReceiver& progress_receiver);
 
   void wipe_all(const ProgressReceiver& progress_receiver);
 
@@ -173,7 +175,8 @@ private:
 
   void perform_automatic_cleanup();
 
-  void do_clean_all(const ProgressReceiver& progress_receiver,
+  void do_clean_all(core::DryRun dry_run,
+                    const ProgressReceiver& progress_receiver,
                     uint64_t max_size,
                     uint64_t max_files,
                     std::optional<uint64_t> max_age,
index 9f2dd20a6c8f3feb524d5b2cc869fec11a15a953..4ec05686c7607e0c73a7917bcd59a194e8ba528b 100644 (file)
@@ -98,6 +98,27 @@ SUITE_cleanup() {
     expect_missing $CCACHE_DIR/a/a/abcd.tmp.efgh
     expect_stat files_in_cache 2560
 
+    # -------------------------------------------------------------------------
+    TEST "Dry-run eviction of tmp file"
+
+    $CCACHE -C >/dev/null
+    mkdir -p $CCACHE_DIR/a/a
+    printf x >$CCACHE_DIR/a/a/abcd.tmp.efgh
+    $CCACHE -c >/dev/null # update counters
+    expect_stat files_in_cache 1
+
+    backdate $CCACHE_DIR/a/a/abcd.tmp.efgh
+    $CCACHE --dry-run --evict-older-than 1h >dry-run.txt
+    expect_exists $CCACHE_DIR/a/a/abcd.tmp.efgh
+    expect_stat files_in_cache 1
+
+    $CCACHE --evict-older-than 1h >real-run.txt
+    expect_missing $CCACHE_DIR/a/a/abcd.tmp.efgh
+    expect_stat files_in_cache 0
+    expect_stat cache_size_kibibyte 0
+    expect_content_pattern dry-run.txt $'Would remove data: *\nWould remove files: *1'
+    expect_content_pattern real-run.txt $'Removed data: *\nRemoved files: *1'
+
     # -------------------------------------------------------------------------
     TEST "No cleanup of .nfs* files"