startgitStatic page generator for git repositories |
git clone git://git.dimitrijedobrota.com/startgit.git |
Log | Files | Refs | README | LICENSE | HACKING | CONTRIBUTING | CODE_OF_CONDUCT | BUILDING |
startgit.cpp (20994B)
0 #include <cmath> 1 #include <cstring> 2 #include <filesystem> 3 #include <format> 4 #include <fstream> 5 #include <iostream> 6 #include <string> 7 8 #include <git2wrap/error.hpp> 9 #include <git2wrap/libgit2.hpp> 10 #include <hemplate/classes.hpp> 11 #include <poafloc/poafloc.hpp> 12 13 #include "arguments.hpp" 14 #include "common.hpp" 15 #include "html.hpp" 16 #include "repository.hpp" 17 #include "utils.hpp" 18 19 namespace startgit 20 { 21 22 void write_title(std::ostream& ost, 23 const repository& repo, 24 const branch& branch, 25 const std::string& relpath = "./") 26 { 27 using namespace hemplate; // NOLINT 28 29 ost << html::table(); 30 ost << html::tr().add(html::td() 31 .add(html::h1(repo.get_name())) 32 .add(html::span(repo.get_description()))); 33 ost << html::tr().add( 34 html::td() 35 .add(html::text("git clone ")) 36 .add(html::a(repo.get_url()).set("href", repo.get_url()))); 37 38 ost << html::tr() << html::td(); 39 ost << html::a("Log").set("href", relpath + "log.html"); 40 ost << html::text(" | ") 41 << html::a("Files").set("href", relpath + "files.html"); 42 ost << html::text(" | ") 43 << html::a("Refs").set("href", relpath + "refs.html"); 44 45 for (const auto& file : branch.get_special()) { 46 const auto filename = file.get_path().replace_extension("html").string(); 47 const auto name = file.get_path().replace_extension().string(); 48 ost << html::text(" | ") << html::a(name).set("href", relpath + filename); 49 } 50 51 ost << html::td() << html::tr(); 52 53 ost << html::table(); 54 ost << html::hr(); 55 } 56 57 void write_commit_table(std::ostream& ost, const branch& branch) 58 { 59 using namespace hemplate; // NOLINT 60 61 ost << html::table(); 62 ost << html::thead(); 63 ost << html::tr() 64 .add(html::td("Date")) 65 .add(html::td("Commit message")) 66 .add(html::td("Author")) 67 .add(html::td("Files")) 68 .add(html::td("+")) 69 .add(html::td("-")); 70 ost << html::thead(); 71 ost << html::tbody(); 72 73 for (const auto& commit : branch.get_commits()) { 74 const auto url = std::format("./commit/{}.html", commit.get_id()); 75 76 ost << html::tr() 77 .add(html::td(commit.get_time())) 78 .add(html::td().add( 79 html::a(commit.get_summary()).set("href", url))) 80 .add(html::td(commit.get_author_name())) 81 .add(html::td(commit.get_diff().get_files_changed())) 82 .add(html::td(commit.get_diff().get_insertions())) 83 .add(html::td(commit.get_diff().get_deletions())); 84 } 85 86 ost << html::tbody(); 87 ost << html::table(); 88 } 89 90 void write_files_table(std::ostream& ost, const branch& branch) 91 { 92 using namespace hemplate; // NOLINT 93 94 ost << html::table(); 95 ost << html::thead(); 96 ost << html::tr() 97 .add(html::td("Mode")) 98 .add(html::td("Name")) 99 .add(html::td("Size")); 100 ost << html::thead(); 101 ost << html::tbody(); 102 103 for (const auto& file : branch.get_files()) { 104 const auto url = std::format("./file/{}.html", file.get_path().string()); 105 const auto size = file.is_binary() ? std::format("{}B", file.get_size()) 106 : std::format("{}L", file.get_lines()); 107 108 ost << html::tr() 109 .add(html::td(file.get_filemode())) 110 .add(html::td().add(html::a(file.get_path()).set("href", url))) 111 .add(html::td(size)); 112 } 113 114 ost << html::tbody(); 115 ost << html::table(); 116 } 117 118 void write_branch_table(std::ostream& ost, 119 const repository& repo, 120 const std::string& branch_name) 121 { 122 using namespace hemplate; // NOLINT 123 124 ost << html::h2("Branches"); 125 ost << html::table(); 126 ost << html::thead(); 127 ost << html::tr() 128 .add(html::td(" ")) 129 .add(html::td("Name")) 130 .add(html::td("Last commit date")) 131 .add(html::td("Author")); 132 ost << html::thead(); 133 ost << html::tbody(); 134 135 for (const auto& branch : repo.get_branches()) { 136 const auto& last = branch.get_last_commit(); 137 const auto url = branch.get_name() != branch_name 138 ? std::format("../{}/refs.html", branch.get_name()) 139 : ""; 140 141 ost << html::tr() 142 .add(html::td(branch.get_name() == branch_name ? "*" : " ")) 143 .add(html::td().add(html::a(branch.get_name()).set("href", url))) 144 .add(html::td(last.get_time())) 145 .add(html::td(last.get_author_name())); 146 } 147 148 ost << html::tbody(); 149 ost << html::table(); 150 } 151 152 void write_tag_table(std::ostream& ost, const repository& repo) 153 { 154 using namespace hemplate; // NOLINT 155 156 ost << html::h2("Tags"); 157 ost << html::table(); 158 ost << html::thead(); 159 ost << html::tr() 160 .add(html::td(" ")) 161 .add(html::td("Name")) 162 .add(html::td("Last commit date")) 163 .add(html::td("Author")); 164 ost << html::thead(); 165 ost << html::tbody(); 166 167 for (const auto& tag : repo.get_tags()) { 168 ost << html::tr() 169 .add(html::td(" ")) 170 .add(html::td(tag.get_name())) 171 .add(html::td(tag.get_time())) 172 .add(html::td(tag.get_author())); 173 } 174 175 ost << html::tbody(); 176 ost << html::table(); 177 } 178 179 void write_file_changes(std::ostream& ost, const diff& diff) 180 { 181 using namespace hemplate; // NOLINT 182 183 ost << html::b("Diffstat:"); 184 ost << html::table() << html::tbody(); 185 186 for (const auto& delta : diff.get_deltas()) { 187 static const char* marker = " ADMRC T "; 188 189 const std::string link = std::format("#{}", delta->new_file.path); 190 191 uint32_t add = delta.get_adds(); 192 uint32_t del = delta.get_dels(); 193 const uint32_t changed = add + del; 194 const uint32_t total = 80; 195 if (changed > total) { 196 const double percent = 1.0 * total / changed; 197 198 if (add > 0) { 199 add = static_cast<uint32_t>(std::lround(percent * add) + 1); 200 } 201 202 if (del > 0) { 203 del = static_cast<uint32_t>(std::lround(percent * del) + 1); 204 } 205 } 206 207 ost << html::tr() 208 .add(html::td(std::string(1, marker[delta->status]))) // NOLINT 209 .add(html::td().add( 210 html::a(delta->new_file.path).set("href", link))) 211 .add(html::td("|")) 212 .add(html::td() 213 .add(html::span() 214 .add(html::text(std::string(add, '+'))) 215 .set("class", "add")) 216 .add(html::span() 217 .add(html::text(std::string(del, '-'))) 218 .set("class", "del"))); 219 } 220 221 ost << html::tbody() << html::table(); 222 ost << html::p( 223 std::format("{} files changed, {} insertions(+), {} deletions(-)", 224 diff.get_files_changed(), 225 diff.get_insertions(), 226 diff.get_deletions())); 227 } 228 229 void write_file_diffs(std::ostream& ost, const diff& diff) 230 { 231 using namespace hemplate; // NOLINT 232 233 for (const auto& delta : diff.get_deltas()) { 234 const auto new_link = std::format("../file/{}.html", delta->new_file.path); 235 const auto old_link = std::format("../file/{}.html", delta->old_file.path); 236 237 ost << html::h3().set("id", delta->new_file.path); 238 ost << "diff --git"; 239 ost << " a/" << html::a(delta->new_file.path).set("href", new_link); 240 ost << " b/" << html::a(delta->old_file.path).set("href", old_link); 241 ost << html::h3(); 242 243 for (const auto& hunk : delta.get_hunks()) { 244 const std::string header(hunk->header); // NOLINT 245 246 ost << html::h4(); 247 ost << std::format("@@ -{},{} +{},{} @@ ", 248 hunk->old_start, 249 hunk->old_lines, 250 hunk->new_start, 251 hunk->new_lines); 252 253 xmlencode(ost, header.substr(header.rfind('@') + 2)); 254 ost << html::h4(); 255 256 ost << html::span().set("style", "white-space: pre"); 257 for (const auto& line : hunk.get_lines()) { 258 auto div = html::div(); 259 if (line.is_add()) { 260 div.set("class", "add"); 261 } else if (line.is_del()) { 262 div.set("class", "del"); 263 } 264 265 ost << div; 266 xmlencode(ost, line.get_content()); 267 ost << div; 268 } 269 ost << html::span(); 270 } 271 } 272 } 273 274 void write_commit_diff(std::ostream& ost, const commit& commit) 275 { 276 using namespace hemplate; // NOLINT 277 278 ost << html::table() << html::tbody(); 279 280 const auto url = std::format("../commit/{}.html", commit.get_id()); 281 ost << html::tr() 282 .add(html::td().add(html::b("commit"))) 283 .add(html::td().add(html::a(commit.get_id()).set("href", url))); 284 285 if (commit.get_parentcount() > 0) { 286 const auto purl = std::format("../commit/{}.html", commit.get_parent_id()); 287 ost << html::tr() 288 .add(html::td().add(html::b("parent"))) 289 .add(html::td().add( 290 html::a(commit.get_parent_id()).set("href", purl))); 291 } 292 293 const auto mailto = std::string("mailto:") + commit.get_author_email(); 294 ost << html::tr(); 295 ost << html::td().add(html::b("author")); 296 ost << html::td() << commit.get_author_name() << " <"; 297 ost << html::a(commit.get_author_email()).set("href", mailto); 298 ost << ">" << html::td(); 299 ost << html::tr(); 300 301 ost << html::tr() 302 .add(html::td().add(html::b("date"))) 303 .add(html::td(commit.get_time_long())); 304 ost << html::tbody() << html::table(); 305 306 ost << html::br() << html::p().set("style", "white-space: pre;"); 307 xmlencode(ost, commit.get_message()); 308 ost << html::p(); 309 310 write_file_changes(ost, commit.get_diff()); 311 ost << html::hr(); 312 write_file_diffs(ost, commit.get_diff()); 313 } 314 315 void write_file_title(std::ostream& ost, const file& file) 316 { 317 using namespace hemplate; // NOLINT 318 319 ost << html::h3(std::format( 320 "{} ({}B)", file.get_path().filename().string(), file.get_size())); 321 ost << html::hr(); 322 } 323 324 void write_file_content(std::ostream& ost, const file& file) 325 { 326 using namespace hemplate; // NOLINT 327 328 if (file.is_binary()) { 329 ost << html::h4("Binary file"); 330 return; 331 } 332 333 const std::string str(file.get_content(), file.get_size()); 334 std::stringstream sstr(str); 335 336 std::string line; 337 338 ost << html::span().set("style", "white-space: pre;"); 339 for (int count = 1; std::getline(sstr, line, '\n'); count++) { 340 ost << std::format( 341 R"(<a id="{}" href="#{}">{:5}</a>)", count, count, count); 342 ost << " "; 343 xmlencode(ost, line); 344 ost << '\n'; 345 } 346 ost << html::span(); 347 } 348 349 void write_html(std::ostream& ost, const file& file) 350 { 351 static const auto process_output = 352 +[](const MD_CHAR* str, MD_SIZE size, void* data) 353 { 354 std::ofstream& ofs = *static_cast<std::ofstream*>(data); 355 ofs << std::string(str, size); 356 }; 357 358 md_html(file.get_content(), 359 static_cast<MD_SIZE>(file.get_size()), 360 process_output, 361 &ost, 362 MD_DIALECT_GITHUB, 363 0); 364 } 365 366 void write_log(const std::filesystem::path& base, 367 const repository& repo, 368 const branch& branch) 369 { 370 std::ofstream ofs(base / "log.html"); 371 372 write_header(ofs, repo, branch, "Commit list"); 373 write_title(ofs, repo, branch); 374 write_commit_table(ofs, branch); 375 write_footer(ofs); 376 } 377 378 void write_file(const std::filesystem::path& base, 379 const repository& repo, 380 const branch& branch) 381 { 382 std::ofstream ofs(base / "files.html"); 383 384 write_header(ofs, repo, branch, "File list"); 385 write_title(ofs, repo, branch); 386 write_files_table(ofs, branch); 387 write_footer(ofs); 388 } 389 390 void write_refs(const std::filesystem::path& base, 391 const repository& repo, 392 const branch& branch) 393 { 394 std::ofstream ofs(base / "refs.html"); 395 396 write_header(ofs, repo, branch, "Refs list"); 397 write_title(ofs, repo, branch); 398 write_branch_table(ofs, repo, branch.get_name()); 399 write_tag_table(ofs, repo); 400 write_footer(ofs); 401 } 402 403 bool write_commits(const std::filesystem::path& base, 404 const repository& repo, 405 const branch& branch) 406 { 407 bool changed = false; 408 409 for (const auto& commit : branch.get_commits()) { 410 const std::string file = base / (commit.get_id() + ".html"); 411 if (!args.force && std::filesystem::exists(file)) { 412 break; 413 } 414 std::ofstream ofs(file); 415 416 write_header(ofs, repo, branch, commit.get_summary(), "../"); 417 write_title(ofs, repo, branch, "../"); 418 write_commit_diff(ofs, commit); 419 write_footer(ofs); 420 changed = true; 421 } 422 423 return changed; 424 } 425 426 void write_files(const std::filesystem::path& base, 427 const repository& repo, 428 const branch& branch) 429 { 430 for (const auto& file : branch.get_files()) { 431 const std::filesystem::path path = 432 base / (file.get_path().string() + ".html"); 433 std::filesystem::create_directories(path.parent_path()); 434 std::ofstream ofs(path); 435 436 std::string relpath = "../"; 437 for (const char chr : file.get_path().string()) { 438 if (chr == '/') { 439 relpath += "../"; 440 } 441 } 442 443 write_header(ofs, repo, branch, file.get_path(), relpath); 444 write_title(ofs, repo, branch, relpath); 445 write_file_title(ofs, file); 446 write_file_content(ofs, file); 447 write_footer(ofs); 448 } 449 } 450 451 void write_readme_licence(const std::filesystem::path& base, 452 const repository& repo, 453 const branch& branch) 454 { 455 for (const auto& file : branch.get_special()) { 456 std::ofstream ofs(base / file.get_path().replace_extension("html")); 457 write_header(ofs, repo, branch, file.get_path()); 458 write_title(ofs, repo, branch); 459 write_html(ofs, file); 460 write_footer(ofs); 461 } 462 } 463 464 void write_atom(std::ostream& ost, 465 const branch& branch, 466 const std::string& base_url) 467 { 468 using namespace hemplate; // NOLINT 469 470 ost << atom::feed(); 471 ost << atom::title(args.title); 472 ost << atom::subtitle(args.description); 473 474 ost << atom::id(base_url + '/'); 475 ost << atom::updated(atom::format_time_now()); 476 ost << atom::author().add(atom::name(args.author)); 477 ost << atom::link(" ", {{"rel", "self"}, {"href", base_url + "/atom.xml"}}); 478 ost << atom::link(" ", 479 {{"href", args.resource_url}, 480 {"rel", "alternate"}, 481 {"type", "text/html"}}); 482 483 for (const auto& commit : branch.get_commits()) { 484 const auto url = 485 std::format("{}/commit/{}.html", base_url, commit.get_id()); 486 487 ost << atom::entry() 488 .add(atom::id(url)) 489 .add(atom::updated(atom::format_time(commit.get_time_raw()))) 490 .add(atom::title(commit.get_summary())) 491 .add(atom::link(" ").set("href", url)) 492 .add(atom::author() 493 .add(atom::name(commit.get_author_name())) 494 .add(atom::email(commit.get_author_email()))) 495 .add(atom::content(commit.get_message())); 496 } 497 498 ost << atom::feed(); 499 } 500 501 void write_rss(std::ostream& ost, 502 const branch& branch, 503 const std::string& base_url) 504 { 505 using namespace hemplate; // NOLINT 506 507 ost << xml(); 508 ost << rss::rss(); 509 ost << rss::channel(); 510 511 ost << rss::title(args.title); 512 ost << rss::description(args.description); 513 ost << rss::link(base_url + '/'); 514 ost << rss::generator("startgit"); 515 ost << rss::language("en-us"); 516 ost << rss::atomLink().set("href", base_url + "/atom.xml"); 517 518 for (const auto& commit : branch.get_commits()) { 519 const auto url = 520 std::format("{}/commit/{}.html", base_url, commit.get_id()); 521 522 ost << rss::item() 523 .add(rss::title(commit.get_summary())) 524 .add(rss::link(url)) 525 .add(rss::guid(url)) 526 .add(rss::pubDate(rss::format_time(commit.get_time_raw()))) 527 .add(rss::author(std::format("{} ({})", 528 commit.get_author_email(), 529 commit.get_author_name()))); 530 } 531 532 ost << rss::channel(); 533 ost << rss::rss(); 534 } 535 536 } // namespace startgit 537 538 namespace 539 { 540 541 int parse_opt(int key, const char* arg, poafloc::Parser* parser) 542 { 543 auto* l_args = static_cast<startgit::arguments_t*>(parser->input()); 544 switch (key) { 545 case 'o': 546 l_args->output_dir = arg; 547 break; 548 case 'b': 549 l_args->base_url = arg; 550 if (l_args->base_url.back() == '/') { 551 l_args->base_url.pop_back(); 552 } 553 break; 554 case 'r': 555 l_args->resource_url = arg; 556 if (l_args->resource_url.back() == '/') { 557 l_args->resource_url.pop_back(); 558 } 559 break; 560 case 'a': 561 l_args->author = arg; 562 break; 563 case 'd': 564 l_args->description = arg; 565 break; 566 case 'g': 567 l_args->github = arg; 568 break; 569 case 'f': 570 l_args->force = true; 571 break; 572 case 's': { 573 std::stringstream sstream(arg); 574 std::string crnt; 575 576 l_args->special.clear(); 577 while (std::getline(sstream, crnt, ',')) { 578 l_args->special.emplace(crnt); 579 } 580 581 break; 582 } 583 case poafloc::ARG: 584 if (!l_args->repos.empty()) { 585 std::cerr << std::format("Error: only one repository required\n"); 586 return -1; 587 } 588 589 try { 590 l_args->repos.emplace_back(std::filesystem::canonical(arg)); 591 } catch (const std::filesystem::filesystem_error& arr) { 592 std::cerr << std::format("Error: {} doesn't exist\n", arg); 593 return -1; 594 } 595 break; 596 case poafloc::END: 597 if (l_args->repos.empty()) { 598 std::cerr << std::format("Error: no repository provided\n"); 599 return -1; 600 } 601 break; 602 default: 603 break; 604 } 605 return 0; 606 } 607 608 // NOLINTBEGIN 609 // clang-format off 610 static const poafloc::option_t options[] = { 611 {0, 0, 0, 0, "Output mode", 1}, 612 {"output", 'o', "DIR", 0, "Output directory"}, 613 {"force", 'f', 0, 0, "Force write even if file exists"}, 614 {"special", 's', "NAME", 0, "Comma separated files to be rendered to html"}, 615 {"github", 'g', "USERNAME", 0, "Github username for url translation"}, 616 {0, 0, 0, 0, "General information", 2}, 617 {"base", 'b', "URL", 0, "Absolute destination URL"}, 618 {"resource", 'r', "URL", 0, "URL that houses styles and scripts"}, 619 {"author", 'a', "NAME", 0, "Owner of the repository"}, 620 {"title", 't', "TITLE", 0, "Title for the index page"}, 621 {"description", 'd', "DESC", 0, "Description for the index page"}, 622 {0, 0, 0, 0, "Informational Options", -1}, 623 {0}, 624 }; 625 // clang-format on 626 627 static const poafloc::arg_t arg { 628 options, 629 parse_opt, 630 "repository", 631 "", 632 }; 633 // NOLINTEND 634 635 } // namespace 636 637 int main(int argc, char* argv[]) 638 { 639 using namespace startgit; // NOLINT 640 641 if (poafloc::parse(&arg, argc, argv, 0, &args) != 0) { 642 std::cerr << "There was an error while parsing arguments\n"; 643 return 1; 644 } 645 646 try { 647 const git2wrap::libgit2 libgit; 648 649 auto& output_dir = args.output_dir; 650 std::filesystem::create_directories(output_dir); 651 output_dir = std::filesystem::canonical(output_dir); 652 653 const repository repo(args.repos[0]); 654 const std::filesystem::path base = args.output_dir / repo.get_name(); 655 std::filesystem::create_directory(base); 656 657 for (const auto& branch : repo.get_branches()) { 658 const std::filesystem::path base_branch = base / branch.get_name(); 659 std::filesystem::create_directory(base_branch); 660 661 const std::filesystem::path commit = base_branch / "commit"; 662 std::filesystem::create_directory(commit); 663 664 // always update refs in case of a new branch or tag 665 write_refs(base_branch, repo, branch); 666 667 const bool changed = write_commits(commit, repo, branch); 668 if (!args.force && !changed) { 669 continue; 670 }; 671 672 write_log(base_branch, repo, branch); 673 write_file(base_branch, repo, branch); 674 write_readme_licence(base_branch, repo, branch); 675 676 const std::filesystem::path file = base_branch / "file"; 677 std::filesystem::create_directory(file); 678 679 write_files(file, repo, branch); 680 681 const std::string relative = 682 std::filesystem::relative(base_branch, args.output_dir); 683 const auto absolute = "https://git.dimitrijedobrota.com/" + relative; 684 685 std::ofstream atom(base_branch / "atom.xml"); 686 write_atom(atom, branch, absolute); 687 688 std::ofstream rss(base_branch / "rss.xml"); 689 write_rss(rss, branch, absolute); 690 } 691 } catch (const git2wrap::error<git2wrap::error_code_t::ENOTFOUND>& err) { 692 std::cerr << std::format("Warning: {} is not a repository\n", 693 args.repos[0].string()); 694 } catch (const git2wrap::runtime_error& err) { 695 std::cerr << std::format("Error (git2wrap): {}\n", err.what()); 696 } catch (const std::runtime_error& err) { 697 std::cerr << std::format("Error: {}\n", err.what()); 698 } catch (...) { 699 std::cerr << std::format("Unknown error\n"); 700 } 701 702 return 0; 703 }