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>
8 #include <git2wrap/error.hpp>
9 #include <git2wrap/libgit2.hpp>
10 #include <hemplate/classes.hpp>
11 #include <poafloc/poafloc.hpp>
13 #include "arguments.hpp"
14 #include "common.hpp"
15 #include "html.hpp"
16 #include "repository.hpp"
17 #include "utils.hpp"
19 namespace startgit
20 {
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
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())));
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");
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 }
51 ost << html::td() << html::tr();
53 ost << html::table();
54 ost << html::hr();
55 }
57 void write_commit_table(std::ostream& ost, const branch& branch)
58 {
59 using namespace hemplate; // NOLINT
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();
73 for (const auto& commit : branch.get_commits()) {
74 const auto url = std::format("./commit/{}.html", commit.get_id());
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 }
86 ost << html::tbody();
87 ost << html::table();
88 }
90 void write_files_table(std::ostream& ost, const branch& branch)
91 {
92 using namespace hemplate; // NOLINT
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();
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());
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 }
114 ost << html::tbody();
115 ost << html::table();
116 }
118 void write_branch_table(std::ostream& ost,
119 const repository& repo,
120 const std::string& branch_name)
121 {
122 using namespace hemplate; // NOLINT
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();
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 : "";
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 }
148 ost << html::tbody();
149 ost << html::table();
150 }
152 void write_tag_table(std::ostream& ost, const repository& repo)
153 {
154 using namespace hemplate; // NOLINT
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();
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 }
175 ost << html::tbody();
176 ost << html::table();
177 }
179 void write_file_changes(std::ostream& ost, const diff& diff)
180 {
181 using namespace hemplate; // NOLINT
183 ost << html::b("Diffstat:");
184 ost << html::table() << html::tbody();
186 for (const auto& delta : diff.get_deltas()) {
187 static const char* marker = " ADMRC T ";
189 const std::string link = std::format("#{}", delta->new_file.path);
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;
198 if (add > 0) {
199 add = static_cast<uint32_t>(std::lround(percent * add) + 1);
200 }
202 if (del > 0) {
203 del = static_cast<uint32_t>(std::lround(percent * del) + 1);
204 }
205 }
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 }
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 }
229 void write_file_diffs(std::ostream& ost, const diff& diff)
230 {
231 using namespace hemplate; // NOLINT
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);
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();
243 for (const auto& hunk : delta.get_hunks()) {
244 const std::string header(hunk->header); // NOLINT
246 ost << html::h4();
247 ost << std::format("@@ -{},{} +{},{} @@ ",
248 hunk->old_start,
249 hunk->old_lines,
250 hunk->new_start,
251 hunk->new_lines);
253 xmlencode(ost, header.substr(header.rfind('@') + 2));
254 ost << html::h4();
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 }
265 ost << div;
266 xmlencode(ost, line.get_content());
267 ost << div;
268 }
269 ost << html::span();
270 }
271 }
272 }
274 void write_commit_diff(std::ostream& ost, const commit& commit)
275 {
276 using namespace hemplate; // NOLINT
278 ost << html::table() << html::tbody();
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)));
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 }
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();
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();
306 ost << html::br() << html::p().set("style", "white-space: pre;");
307 xmlencode(ost, commit.get_message());
308 ost << html::p();
310 write_file_changes(ost, commit.get_diff());
311 ost << html::hr();
312 write_file_diffs(ost, commit.get_diff());
313 }
315 void write_file_title(std::ostream& ost, const file& file)
316 {
317 using namespace hemplate; // NOLINT
319 ost << html::h3(std::format(
320 "{} ({}B)", file.get_path().filename().string(), file.get_size()));
321 ost << html::hr();
322 }
324 void write_file_content(std::ostream& ost, const file& file)
325 {
326 using namespace hemplate; // NOLINT
328 if (file.is_binary()) {
329 ost << html::h4("Binary file");
330 return;
331 }
333 const std::string str(file.get_content(), file.get_size());
334 std::stringstream sstr(str);
336 std::string line;
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 }
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 };
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 }
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");
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 }
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");
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 }
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");
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 }
403 bool write_commits(const std::filesystem::path& base,
404 const repository& repo,
405 const branch& branch)
406 {
407 bool changed = false;
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);
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 }
423 return changed;
424 }
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);
436 std::string relpath = "../";
437 for (const char chr : file.get_path().string()) {
438 if (chr == '/') {
439 relpath += "../";
440 }
441 }
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 }
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 }
464 void write_atom(std::ostream& ost,
465 const branch& branch,
466 const std::string& base_url)
467 {
468 using namespace hemplate; // NOLINT
470 ost << atom::feed();
471 ost << atom::title(args.title);
472 ost << atom::subtitle(args.description);
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"}});
483 for (const auto& commit : branch.get_commits()) {
484 const auto url =
485 std::format("{}/commit/{}.html", base_url, commit.get_id());
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 }
498 ost << atom::feed();
499 }
501 void write_rss(std::ostream& ost,
502 const branch& branch,
503 const std::string& base_url)
504 {
505 using namespace hemplate; // NOLINT
507 ost << xml();
508 ost << rss::rss();
509 ost << rss::channel();
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");
518 for (const auto& commit : branch.get_commits()) {
519 const auto url =
520 std::format("{}/commit/{}.html", base_url, commit.get_id());
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 }
532 ost << rss::channel();
533 ost << rss::rss();
534 }
536 } // namespace startgit
538 namespace
539 {
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;
576 l_args->special.clear();
577 while (std::getline(sstream, crnt, ',')) {
578 l_args->special.emplace(crnt);
579 }
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 }
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 }
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
627 static const poafloc::arg_t arg {
628 options,
629 parse_opt,
630 "repository",
631 "",
632 };
633 // NOLINTEND
635 } // namespace
637 int main(int argc, char* argv[])
638 {
639 using namespace startgit; // NOLINT
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 }
646 try {
647 const git2wrap::libgit2 libgit;
649 auto& output_dir = args.output_dir;
650 std::filesystem::create_directories(output_dir);
651 output_dir = std::filesystem::canonical(output_dir);
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);
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);
661 const std::filesystem::path commit = base_branch / "commit";
662 std::filesystem::create_directory(commit);
664 // always update refs in case of a new branch or tag
665 write_refs(base_branch, repo, branch);
667 const bool changed = write_commits(commit, repo, branch);
668 if (!args.force && !changed) {
669 continue;
670 };
672 write_log(base_branch, repo, branch);
673 write_file(base_branch, repo, branch);
674 write_readme_licence(base_branch, repo, branch);
676 const std::filesystem::path file = base_branch / "file";
677 std::filesystem::create_directory(file);
679 write_files(file, repo, branch);
681 const std::string relative =
682 std::filesystem::relative(base_branch, args.output_dir);
683 const auto absolute = "https://git.dimitrijedobrota.com/" + relative;
685 std::ofstream atom(base_branch / "atom.xml");
686 write_atom(atom, branch, absolute);
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 }
702 return 0;
703 }