add options to output aggregated and/or separated heatmaps to files in one go

Thu, 12 Mar 2026 12:00:50 +0100

author
Mike Becker <universe@uap-core.de>
date
Thu, 12 Mar 2026 12:00:50 +0100
changeset 81
1ff88eb9555c
parent 80
fa5f493adfb5
child 82
f3242673777c

add options to output aggregated and/or separated heatmaps to files in one go

resolves #809

CHANGELOG file | annotate | diff | comparison | revisions
src/heatmap.cpp file | annotate | diff | comparison | revisions
src/heatmap.h file | annotate | diff | comparison | revisions
src/html.cpp file | annotate | diff | comparison | revisions
src/html.h file | annotate | diff | comparison | revisions
src/main.cpp file | annotate | diff | comparison | revisions
src/settings.h file | annotate | diff | comparison | revisions
--- a/CHANGELOG	Fri Feb 27 14:38:01 2026 +0100
+++ b/CHANGELOG	Thu Mar 12 12:00:50 2026 +0100
@@ -3,6 +3,7 @@
 - Add --styles-and-script option to output the default CSS and Javascript
 - Add monthly summaries
 - Add reading commit hashes from the commit log
+- Add options to output aggregated and/or separated heatmaps to files in one go
 - Add options to create a CSV export of the raw data
 
 Version 1.1.2 - 2025-12-15
--- a/src/heatmap.cpp	Fri Feb 27 14:38:01 2026 +0100
+++ b/src/heatmap.cpp	Thu Mar 12 12:00:50 2026 +0100
@@ -32,7 +32,6 @@
 
 void fm::heatmap::add(const settings &settings, const std::string &log) {
     using std::string_view_literals::operator ""sv;
-    const std::string repo_key = m_separate ? m_current_repo : "All Repositories";
 
     for (auto line: std::views::split(log, "\n"sv)) {
         if (line.empty()) continue;
@@ -59,15 +58,20 @@
         std::from_chars(date_parts[0].begin(), date_parts[0].end(), year);
         std::from_chars(date_parts[1].begin(), date_parts[1].end(), month);
         std::from_chars(date_parts[2].begin(), date_parts[2].end(), day);
-        auto &[summaries, tags] =
-                m_heatmap[repo_key][author][chrono::year_month_day{
-                    chrono::year{year}, chrono::month{month}, chrono::day{day}
-                }];
-        summaries[m_current_repo].emplace_back(hash_view, summary_view);
-        // special case: if the (only) tag is "tip", we do not add it
-        if (!tags_view.empty() && tags_view != "tip") {
-            tags[m_current_repo].emplace_back(hash_view, tags_view);
-        }
+        const auto ymd = chrono::year_month_day{
+            chrono::year{year}, chrono::month{month}, chrono::day{day}
+        };
+
+        auto add_info = [this, hash_view, summary_view, tags_view] (commits & commit_info) {
+            commit_info.summaries[m_current_repo].emplace_back(hash_view, summary_view);
+            // special case: if the (only) tag is "tip", we do not add it
+            if (!tags_view.empty() && tags_view != "tip") {
+                commit_info.tags[m_current_repo].emplace_back(hash_view, tags_view);
+            }
+        };
+
+        add_info(m_heatmap[m_current_repo][author][ymd]);
+        add_info(m_aggregated_heatmap[aggregated_repo_label][author][ymd]);
     }
 }
 
@@ -77,7 +81,8 @@
     chrono::year year
 ) const {
     std::array<commit_summary, 12> result{};
-    for (auto &&[ymd, commits]: m_heatmap.at(repo).at(author)) {
+    for (const auto &heatmap_data = repo == aggregated_repo_label ? m_aggregated_heatmap : m_heatmap;
+            auto &&[ymd, commits]: heatmap_data.at(repo).at(author)) {
         if (ymd.year() != year) continue;
         commit_summary &cs = result[static_cast<unsigned int>(ymd.month()) - 1];
         for (auto &&[reponame, summaries]: commits.summaries) {
--- a/src/heatmap.h	Fri Feb 27 14:38:01 2026 +0100
+++ b/src/heatmap.h	Thu Mar 12 12:00:50 2026 +0100
@@ -45,20 +45,31 @@
                 std::chrono::year_month_day, // date
                 commits
     >>> m_heatmap;
+    std::map<
+        std::string, // dummy string - so that both maps have the same type
+        std::map<
+            std::string, // author
+            std::map<
+                std::chrono::year_month_day, // date
+                commits
+    >>>  m_aggregated_heatmap;
     std::string m_current_repo;
-    bool m_separate;
+
+    static constexpr auto aggregated_repo_label = "All Repositories";
 public:
-    explicit heatmap(bool separate) noexcept : m_separate(separate) {}
-
     void set_repo(const std::string& repo) {
         m_current_repo.assign(repo);
     }
     void add(const settings &settings, const std::string& log);
 
-    [[nodiscard]] const auto& data() const {
+    [[nodiscard]] const auto& separated() const {
         return m_heatmap;
     }
 
+    [[nodiscard]] const auto& aggregated() const {
+        return m_aggregated_heatmap;
+    }
+
     [[nodiscard]] std::array<commit_summary, 12> commits_per_month(
         const std::string &repo,
         const std::string& author,
--- a/src/html.cpp	Fri Feb 27 14:38:01 2026 +0100
+++ b/src/html.cpp	Thu Mar 12 12:00:50 2026 +0100
@@ -27,18 +27,42 @@
 #include <ranges>
 #include <cstdio>
 #include <cassert>
+#include <iostream>
 
 using namespace std::chrono;
 
 namespace html {
     static constexpr const char* weekdays[] = {"Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"};
 
+    struct output_reference {
+        FILE *file;
+        explicit output_reference(FILE *f) : file{f} {}
+        output_reference(output_reference const &) = delete;
+        output_reference(output_reference && other) noexcept : file(other.file) {
+            other.file = nullptr;
+        }
+        output_reference &operator=(output_reference const &) = delete;
+        output_reference &operator=(output_reference && other) noexcept {
+            using std::swap;
+            swap(other.file, file);
+            return *this;
+        }
+        ~output_reference() {
+            if (file != nullptr && file != stdout) {
+                fclose(file);
+                file = nullptr;
+            }
+        }
+    };
+
+    static output_reference output{stdout};
+
     static unsigned indentation;
     static const char *tabs = "                                                                ";
     static void indent(int change = 0) {
         indentation += change;
         assert(indentation <= max_indentation);
-        fwrite(tabs, 4, indentation, stdout);
+        fwrite(tabs, 4, indentation, output.file);
     }
 
     static std::string encode(const std::string &data) {
@@ -158,8 +182,21 @@
     }
 }
 
+int html::set_output(std::string const &path) {
+    if (path.empty()) {
+        output = output_reference{stdout};
+        return 0;
+    }
+    FILE * f = fopen(path.c_str(), "w");
+    if (f == nullptr) {
+        return -1;
+    }
+    output = output_reference{f};
+    return 0;
+}
+
 void html::styles_and_script() {
-    puts(R"(        <style>
+    fputs(R"(        <style>
             table.heatmap {
                 table-layout: fixed;
                 border-collapse: separate;
@@ -387,23 +424,27 @@
                     }
                 });
             });
-        </script>)");
+        </script>
+)", output.file);
 }
 
 void html::open(bool fragment, unsigned char fragment_indent) {
+    indentation = 0;
     if (fragment) {
         indent(fragment_indent);
-        puts("<div class=\"heatmap-content\">");
+        fputs("<div class=\"heatmap-content\">\n", output.file);
         indentation++;
     } else {
-        puts(R"(<!DOCTYPE html>
+        fputs(R"(<!DOCTYPE html>
 <html>
     <head>
-        <meta charset="UTF-8">)");
+        <meta charset="UTF-8">
+)", output.file);
         styles_and_script();
-        puts(R"(    </head>
+        fputs(R"(    </head>
     <body>
-        <div class="heatmap-content">)");
+        <div class="heatmap-content">
+)", output.file);
         indentation = 3;
     }
 }
@@ -411,32 +452,33 @@
 void html::close(bool fragment) {
     if (fragment) {
         indent(-1);
-        puts("</div>");
+        fputs("</div>\n", output.file);
     } else {
-        puts("        </div>\n    </body>\n</html>");
+        fputs("        </div>\n    </body>\n</html>\n", output.file);
     }
 }
 
 void html::chart_begin(const std::string& repo, const std::string& author) {
     indent();
-    printf("<div class=\"chart\" data-repo=\"%s\" data-author=\"%s\">\n",
+    fprintf(output.file,
+        "<div class=\"chart\" data-repo=\"%s\" data-author=\"%s\">\n",
         encode(repo).c_str(), encode(author).c_str());
     indentation++;
 }
 
 void html::chart_end() {
     indent(-1);
-    puts("</div>");
+    fputs("</div>\n", output.file);
 }
 
 void html::heading_repo(const std::string& repo) {
     indent();
-    printf("<h1 data-repo=\"%1$s\">%1$s</h1>\n", encode(repo).c_str());
+    fprintf(output.file, "<h1 data-repo=\"%1$s\">%1$s</h1>\n", encode(repo).c_str());
 }
 
 void html::heading_author(const std::string& author, unsigned int total_commits) {
     indent();
-    printf("<h2 title=\"Total commits: %u\">%s</h2>\n",
+    fprintf(output.file, "<h2 title=\"Total commits: %u\">%s</h2>\n",
         total_commits,
         encode(author).c_str());
 }
@@ -459,11 +501,11 @@
 
     // now render the table heading
     indent();
-    puts("<table class=\"heatmap\">");
+    fputs("<table class=\"heatmap\">\n", output.file);
     indent(1);
-    puts("<tr>");
+    fputs("<tr>\n", output.file);
     indent(1);
-    puts("<th></th>");
+    fputs("<th></th>\n", output.file);
     for (unsigned i = 0 ; i < 12 ; i++) {
         indent();
         const fm::commit_summary &summary = commits_per_month[i];
@@ -483,39 +525,41 @@
                 commit_summary = std::format("data-commits=\"{}\"", encode(commit_summary_json));
             }
             std::string tags = build_tag_summary(summary.tags_with_date, hide_repo_names);
-            printf("<th scope=\"col\" title=\"Total commits: %u\" colspan=\"%d\" data-total=\"%u\" %s data-tags=\"%s\">%s</th>\n",
+            fprintf(output.file,
+                "<th scope=\"col\" title=\"Total commits: %u\" colspan=\"%d\" data-total=\"%u\" %s data-tags=\"%s\">%s</th>\n",
                 total, colspans[i], total,
                 commit_summary.c_str(),
                 encode(tags).c_str(), months[i]);
         } else {
-            printf("<th scope=\"col\" class=\"zero-commits\" colspan=\"%d\">%s</th>\n",
+            fprintf(output.file,
+                "<th scope=\"col\" class=\"zero-commits\" colspan=\"%d\">%s</th>\n",
                 colspans[i], months[i]);
         }
     }
     indent(-1);
-    puts("</tr>");
+    fputs("</tr>\n", output.file);
 }
 
 void html::table_end() {
     indent(-1);
-    puts("</table>");
+    fputs("</table>\n", output.file);
 }
 
 void html::row_begin(unsigned int row) {
     indent();
-    puts("<tr>");
+    fputs("<tr>\n", output.file);
     indent(1);
-    printf("<th scope=\"row\">%s</th>\n", weekdays[row]);
+    fprintf(output.file, "<th scope=\"row\">%s</th>\n", weekdays[row]);
 }
 
 void html::row_end() {
     indent(-1);
-    puts("</tr>");
+    fputs("</tr>\n", output.file);
 }
 
 void html::cell_out_of_range() {
     indent();
-    puts("<td class=\"out-of-range\"></td>");
+    fputs("<td class=\"out-of-range\"></td>\n", output.file);
 }
 
 void html::cell(year_month_day ymd, bool hide_repo_names, const fm::commits &commits) {
@@ -573,7 +617,8 @@
         static_cast<unsigned>(ymd.day()));
     const unsigned total = commits.count();
     indent();
-    printf("<td class=\"%s\" title=\"%s: %u %s\" data-total=\"%u\" data-date=\"%s\" data-summaries=\"%s\" data-tags=\"%s\"></td>\n",
+    fprintf(output.file,
+        "<td class=\"%s\" title=\"%s: %u %s\" data-total=\"%u\" data-date=\"%s\" data-summaries=\"%s\" data-tags=\"%s\"></td>\n",
         color_class,
         date_str.c_str(),
         total,
--- a/src/html.h	Fri Feb 27 14:38:01 2026 +0100
+++ b/src/html.h	Thu Mar 12 12:00:50 2026 +0100
@@ -37,6 +37,7 @@
     static constexpr unsigned max_external_indentation = max_indentation - 4;
     static constexpr unsigned columns = 53;
 
+    int set_output(std::string const &path);
     void open(bool fragment, unsigned char fragment_indent = 0);
     void styles_and_script();
     void close(bool fragment);
--- a/src/main.cpp	Fri Feb 27 14:38:01 2026 +0100
+++ b/src/main.cpp	Thu Mar 12 12:00:50 2026 +0100
@@ -45,21 +45,23 @@
     fputs(
         "Usage: repoheat [OPTION]... [PATH]...\n\n"
         "Options:\n"
-        "   -a, --author <name>       Only report this author\n"
-        "                             (repeat option to report multiple authors)\n"
-        "   -A, --authormap <file>    Apply an author mapping file\n"
-        "       --csv                 Output the gathered data as CSV to stdout\n"
-        "       --csv-path <file>     Write CSV data to file instead of stdout\n"
-        "   -d, --depth <num>         The search depth (default: 1, max: 255)\n"
-        "   -f, --fragment [indent]   Output as fragment (HTML only)\n"
-        "   -h, --help                Print this help message\n"
-        "   -p, --pull                Try to pull the repositories\n"
-        "   -s, --separate            Output a separate heat map for each repository\n"
-        "       --styles-and-script   Output the default CSS and Javascript and quit\n"
-        "   -V, --version             Output the version of this program and exit\n"
-        "   -y, --year <year>         The year for which to create the heat map\n"
-        "       --hg <path>           Path to hg binary (default: /usr/bin/hg)\n"
-        "       --git <path>          Path to git binary (default: /usr/bin/git)\n\n"
+        "  -a, --author <name>          Only report this author\n"
+        "                               (repeat option to report multiple authors)\n"
+        "  -A, --authormap <file>       Apply an author mapping file\n"
+        "      --csv                    Output the gathered data as CSV to stdout\n"
+        "      --csv-output <file>      Write CSV data to file instead of stdout\n"
+        "  -d, --depth <num>            The search depth (default: 1, max: 255)\n"
+        "  -f, --fragment [indent]      Output as fragment (HTML only)\n"
+        "  -h, --help                   Print this help message\n"
+        "  -o, --output <file>          Write output to file instead of stdout\n"
+        "  -p, --pull                   Try to pull the repositories\n"
+        "  -s, --separate               Output a separate heat map for each repository\n"
+        "  -S, --separate-output <file> Write results for -s to file instead of stdout\n"
+        "      --styles-and-script      Output the default CSS and Javascript and quit\n"
+        "  -V, --version                Output the version of this program and exit\n"
+        "  -y, --year <year>            The year for which to create the heat map\n"
+        "      --hg <path>              Path to hg binary (default: /usr/bin/hg)\n"
+        "      --git <path>             Path to git binary (default: /usr/bin/git)\n\n"
         "Scans all specified paths recursively for Mercurial and Git repositories and\n"
         "creates a commit heat map for the specified \033[1myear\033[22m or the current year.\n"
         "By default, the recursion \033[1mdepth\033[22m is one, meaning that this tool assumes that\n"
@@ -88,16 +90,20 @@
         "Finally, this tool prints an HTML page to stdout. A separate heap map is\n"
         "generated for each author showing commits across all repositories, unless the\n"
         "\033[1m--separate\033[22m option is specified in which case each repository is displayed with\n"
-        "its own heat map. By using the \033[1m--fragment\033[22m option, the tool only outputs a\n"
-        "single HTML div container without any header or footer that can be embedded in\n"
-        "your custom web page. You can optionally specify an indentation for that\n"
-        "container (default is 0 and maximum is 12).\n"
+        "its own heat map.\n"
+        "If you use \033[1m--separate-output\033[22m you can specify a file instead, and both the\n"
+        "aggregated and separated heat maps are written. The aggregated output is still\n"
+        "written to stdout unless you specify a file with \033[1m--output\033[22m.\n"
+        "By using the \033[1m--fragment\033[22m option, the tool only outputs a single HTML div\n"
+        "container without any header or footer that can be embedded in your custom web\n"
+        "page. You can optionally specify an indentation for that container (default is\n"
+        "0 and maximum is 12).\n"
         "When you want to combine this with the default style and scripts, you can use\n"
         "the \033[1m--styles-and-script\033[22m option print the defaults to stdout and redirect them\n"
         "into a file when you are composing your custom HTML page.\n\n"
         "In case you want to work with the raw data, you can advise the tool to output\n"
         "the data in CSV format with the \033[1m--csv\033[22m option. The CSV data will be written to\n"
-        "stdout instead of the HTML unless you specify a different file with \033[1m--csv-path\033[22m.\n"
+        "stdout instead of the HTML. You can use \033[1m--csv-output\033[22m to specify a file, instead.\n"
         , stderr);
 }
 
@@ -144,14 +150,21 @@
             }
         } else if (chk_arg(argv[i], "--csv", nullptr)) {
             settings.csv = true;
-        } else if (chk_arg(argv[i], "--csv-path", nullptr)) {
-            settings.csv = true;
+        } else if (chk_arg(argv[i], "--csv-output", nullptr)) {
             if (i + 1 < argc) {
+                settings.csv = true;
                 settings.csv_path.assign(argv[++i]);
             } else {
                 fputs("missing csv path\n", stderr);
                 return -1;
             }
+        } else if (chk_arg(argv[i], "-o", "--output")) {
+            if (i + 1 < argc) {
+                settings.out_path.assign(argv[++i]);
+            } else {
+                fputs("missing output path\n", stderr);
+                return -1;
+            }
         } else if (chk_arg(argv[i], "-p", "--pull")) {
             settings.update_repos = true;
         } else if (chk_arg(argv[i], "-f", "--fragment")) {
@@ -164,6 +177,14 @@
             }
         } else if (chk_arg(argv[i], "-s", "--separate")) {
             settings.separate = true;
+        } else if (chk_arg(argv[i], "-S", "--separate-output")) {
+            if (i + 1 < argc) {
+                settings.separate = true;
+                settings.separate_path.assign(argv[++i]);
+            } else {
+                fputs("missing separate output path\n", stderr);
+                return -1;
+            }
         } else if (chk_arg(argv[i], "-A", "--authormap")) {
             if (i + 1 < argc) {
                 if (settings.parse_authormap(argv[++i])) {
@@ -216,9 +237,9 @@
         const fm::settings &settings,
         year report_year, year_month_day report_begin, year_month_day report_end,
         const fm::heatmap &heatmap) {
-
+    const auto &heatmap_data = settings.separate ? heatmap.separated() : heatmap.aggregated();
     html::open(settings.fragment, settings.fragment_indent);
-    for (const auto &[repo, authors] : heatmap.data()) {
+    for (const auto &[repo, authors] : heatmap_data) {
         bool h1_rendered = false;
         for (const auto &[author, entries] : authors) {
             if (settings.exclude_author(author)) continue;
@@ -317,7 +338,7 @@
     };
 
     fprintf(out, "repository,author,date,hash,summary,is_tag\n");
-    for (const auto &authors: heatmap.data() | std::views::values) {
+    for (const auto &authors: heatmap.aggregated() | std::views::values) {
         for (const auto &[author, entries]: authors) {
             for (const auto &[date, commits]: entries) {
                 for (const auto &[repo, infos] : commits.summaries) {
@@ -362,6 +383,7 @@
 
     // check special options
     if (settings.styles_and_script) {
+        // note: no need to set output path - not supported for styles and scripts
         html::styles_and_script();
         return 0;
     }
@@ -425,7 +447,7 @@
     year_month_day report_end{report_year, December, 31d};
 
     // read the commit logs
-    fm::heatmap heatmap{settings.separate};
+    fm::heatmap heatmap;
     for (auto &&repo : repos.list()) {
         heatmap.set_repo(repo.name);
         proc.chdir(repo.path);
@@ -454,14 +476,33 @@
         }
     }
 
-    bool html = true;
-    if (settings.csv) {
+    const bool write_csv = settings.csv;
+    const bool write_separate = settings.separate;
+    const bool write_default = !settings.out_path.empty() || (
+        !(write_csv && settings.csv_path.empty())
+        && !(write_separate && settings.separate_path.empty())
+    );
+
+    if (write_csv) {
         if (generate_csv(settings, heatmap)) {
+            perror("Cannot open file for writing");
             return EXIT_FAILURE;
         }
-        html = !settings.csv_path.empty();
     }
-    if (html) {
+    if (write_separate) {
+        settings.separate = true;
+        if (html::set_output(settings.separate_path)) {
+            perror("Cannot open file for writing");
+            return EXIT_FAILURE;
+        }
+        generate_html(settings, report_year, report_begin, report_end, heatmap);
+    }
+    if (write_default) {
+        settings.separate = false;
+        if (html::set_output(settings.out_path)) {
+            perror("Cannot open file for writing");
+            return EXIT_FAILURE;
+        }
         generate_html(settings, report_year, report_begin, report_end, heatmap);
     }
 
--- a/src/settings.h	Fri Feb 27 14:38:01 2026 +0100
+++ b/src/settings.h	Thu Mar 12 12:00:50 2026 +0100
@@ -38,6 +38,8 @@
     std::string hg{"/usr/bin/hg"};
     std::string git{"/usr/bin/git"};
     std::string csv_path{};
+    std::string out_path{};
+    std::string separate_path{};
     std::vector<std::string> paths;
     std::vector<std::string> authors;
     std::unordered_map<std::string, std::string> authormap;

mercurial