diff --git a/CMakeLists.txt b/CMakeLists.txt index f493636..52b5d3b 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -12,6 +12,7 @@ endif() find_package(PkgConfig REQUIRED) pkg_check_modules(GIT2 REQUIRED libgit2) +find_package(ZLIB REQUIRED) # cppcms / booster ship no pkg-config or CMake package, so locate them directly. find_path(CPPCMS_INCLUDE_DIR cppcms/application.h @@ -44,6 +45,7 @@ add_executable(git_frontend src/main.cpp src/application.cpp src/git_repo.cpp + src/archive.cpp src/repo_scanner.cpp src/util.cpp ${VIEWS_CPP}) @@ -57,6 +59,7 @@ target_link_libraries(git_frontend PRIVATE ${CPPCMS_LIBRARY} ${BOOSTER_LIBRARY} ${GIT2_LIBRARIES} + ZLIB::ZLIB pthread) target_compile_options(git_frontend PRIVATE ${GIT2_CFLAGS_OTHER}) diff --git a/Dockerfile b/Dockerfile index 11b5aa8..29cb70f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -60,6 +60,11 @@ WORKDIR /app # Mount a directory of git repositories here. ENV GIT_ROOT=/srv/git VOLUME ["/srv/git"] + +# Generated tag archives are cached here; mount a volume to persist them. +ENV GIT_CACHE_ROOT=/var/cache/git_frontend +VOLUME ["/var/cache/git_frontend"] + EXPOSE 11000 CMD ["/app/git_frontend", "-c", "/app/config.json"] diff --git a/README.md b/README.md index 6deb439..b31ee7b 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,7 @@ and **committed content** are shown — the working directory is never read. | `/repos//commit/.patch` | Raw unified patch (`text/plain`) | | `/repos//tags/` | List of tags | | `/repos//tags/` | Tag details (target commit, message) | +| `/repos//tags/.tar.gz` | Download the tag as a gzipped tarball, submodules included (built on first access, then cached) | | `/repos//object/` | Inspect a blob, tree, commit or tag | Every page accepts `?id=` to view at a specific commit instead of the @@ -37,6 +38,10 @@ default branch HEAD. - `git.root` — directory to scan for repositories. Overridden by the `GIT_ROOT` environment variable; falls back to `$HOME/git`. - `git.static_root` — directory containing the bundled CSS/JS assets. +- `git.cache_root` — directory where generated tag archives are cached, + created on demand. Overridden by the `GIT_CACHE_ROOT` environment variable; + when neither is set it defaults to `$XDG_CACHE_HOME/git_frontend` (or + `$HOME/.cache/git_frontend`, falling back to `/tmp/git_frontend`). - `SHOW_EMAILS` (env) — author/committer email addresses are hidden by default; set `SHOW_EMAILS=1` to show them. @@ -47,7 +52,7 @@ are rendered in the normal file view; `raw` and `blame` always show the source. ## Build from source -Requires CppCMS (with `cppcms_tmpl_cc`), libgit2, CMake and a C++17 compiler. +Requires CppCMS (with `cppcms_tmpl_cc`), libgit2, zlib, CMake and a C++17 compiler. ```sh cmake -S . -B build -DCMAKE_BUILD_TYPE=Release diff --git a/config.json b/config.json index a8cf1f3..9084482 100644 --- a/config.json +++ b/config.json @@ -9,6 +9,7 @@ }, "git": { "root": "", - "static_root": "./static" + "static_root": "./static", + "cache_root": "" } } diff --git a/src/application.cpp b/src/application.cpp index 87478b2..c851d9b 100644 --- a/src/application.cpp +++ b/src/application.cpp @@ -10,12 +10,31 @@ #include #include +#include +#include +#include #include +#include namespace { constexpr int LOG_LIMIT = 200; const std::string SKIN = "git"; +// Cache directory resolution order: GIT_CACHE_ROOT env (e.g. set by the +// Dockerfile), config key "git.cache_root", then XDG_CACHE_HOME/git_frontend, +// then $HOME/.cache/git_frontend, falling back to a temp directory. +std::string resolve_cache_root(const std::string &configured) { + if (const char *env = std::getenv("GIT_CACHE_ROOT"); env && *env) + return env; + if (!configured.empty()) + return configured; + if (const char *xdg = std::getenv("XDG_CACHE_HOME"); xdg && *xdg) + return std::string(xdg) + "/git_frontend"; + if (const char *home = std::getenv("HOME"); home && *home) + return std::string(home) + "/.cache/git_frontend"; + return "/tmp/git_frontend"; +} + // Minimal content-type table for the assets we ship. std::string mime_for(const std::string &path) { const size_t dot = path.find_last_of('.'); @@ -32,6 +51,7 @@ std::string mime_for(const std::string &path) { GitApp::GitApp(cppcms::service &srv, const RepoScanner *scanner) : cppcms::application(srv), scanner_(scanner) { static_root_ = settings().get("git.static_root", "./static"); + cache_root_ = resolve_cache_root(settings().get("git.cache_root", "")); // Root arrives as "" because script_name "/" is stripped; accept both. dispatcher().assign("^/?$", &GitApp::frontpage, this); @@ -45,6 +65,7 @@ GitApp::GitApp(cppcms::service &srv, const RepoScanner *scanner) dispatcher().assign("^/repos/([^/]+)/commit/([0-9a-f]+)\\.patch$", &GitApp::commit_patch, this, 1, 2); dispatcher().assign("^/repos/([^/]+)/commit/([0-9a-f]+)$", &GitApp::commit, this, 1, 2); dispatcher().assign("^/repos/([^/]+)/tags/?$", &GitApp::tags, this, 1); + dispatcher().assign("^/repos/([^/]+)/tags/(.+)\\.tar\\.gz$", &GitApp::tag_archive, this, 1, 2); dispatcher().assign("^/repos/([^/]+)/tags/(.+)$", &GitApp::tag, this, 1, 2); dispatcher().assign("^/repos/([^/]+)/branches/?$", &GitApp::branches, this, 1); dispatcher().assign("^/repos/([^/]+)/?$", &GitApp::repo_home, this, 1); @@ -165,6 +186,13 @@ void GitApp::tree(std::string name, std::string path) { c.text = is_binary ? "" : text; c.size = text.size(); c.tags = repo->tags_for_commit(commit); + c.branches = repo->branches_for_commit(commit); + for (const BranchInfo &b : c.branches) { + if (b.is_default) { + c.on_default_branch = true; + break; + } + } render(SKIN, "file_view", c); return; } @@ -179,6 +207,13 @@ void GitApp::tree(std::string name, std::string path) { c.parent_path = util::parent_path(clean); c.entries = repo->list_tree(commit, clean); c.tags = repo->tags_for_commit(commit); + c.branches = repo->branches_for_commit(commit); + for (const BranchInfo &b : c.branches) { + if (b.is_default) { + c.on_default_branch = true; + break; + } + } render(SKIN, "tree_view", c); } catch (const GitError &e) { show_error(404, e.what()); @@ -293,6 +328,75 @@ void GitApp::tag(std::string name, std::string tag_name) { } } +void GitApp::tag_archive(std::string name, std::string tag_name) { + std::unique_ptr repo = open(name); + if (!repo) + return; + + std::string oid; + try { + // tag_detail validates that the tag exists and resolves its commit. + oid = repo->tag_detail(tag_name).target_oid; + } catch (const GitError &e) { + show_error(404, e.what()); + return; + } + if (oid.empty()) { + show_error(404, "tag does not resolve to a commit: " + tag_name); + return; + } + + const std::string base = + util::sanitize_filename(name) + "_" + util::sanitize_filename(tag_name); + const std::string download_name = base + ".tar.gz"; + // The commit oid is part of the cache key so a moved tag never serves stale + // contents. + const std::string cache_file = cache_root_ + "/" + base + "_" + oid + ".tar.gz"; + + std::error_code ec; + std::filesystem::create_directories(cache_root_, ec); + if (ec) { + show_error(500, "cannot create cache directory: " + ec.message()); + return; + } + + if (!std::filesystem::exists(cache_file)) { + // Build to a unique temp file, then rename atomically so a crashed or + // concurrent build never leaves a truncated archive at the cache path. + static std::atomic counter{0}; + const std::string tmp = cache_file + ".tmp." + + std::to_string(counter.fetch_add(1)) + "." + + std::to_string(std::hash{}( + std::this_thread::get_id())); + try { + repo->write_archive(oid, base, tmp); + } catch (const std::exception &e) { + std::filesystem::remove(tmp, ec); + show_error(500, std::string("failed to build archive: ") + e.what()); + return; + } + std::filesystem::rename(tmp, cache_file, ec); + if (ec) { + // A concurrent request may have produced it first; drop our copy. + std::filesystem::remove(tmp, ec); + if (!std::filesystem::exists(cache_file)) { + show_error(500, "failed to store archive"); + return; + } + } + } + + std::ifstream file(cache_file, std::ios::binary); + if (!file) { + show_error(500, "archive unavailable"); + return; + } + response().content_type("application/gzip"); + response().set_header("Content-Disposition", + "attachment; filename=\"" + download_name + "\""); + response().out() << file.rdbuf(); +} + void GitApp::branches(std::string name) { std::unique_ptr repo = open(name); if (!repo) diff --git a/src/application.h b/src/application.h index 2b66f1d..3eda0db 100644 --- a/src/application.h +++ b/src/application.h @@ -29,6 +29,7 @@ private: void object(std::string name, std::string oid); void tags(std::string name); void tag(std::string name, std::string tag_name); + void tag_archive(std::string name, std::string tag_name); void branches(std::string name); void commit(std::string name, std::string oid); void commit_patch(std::string name, std::string oid); @@ -45,4 +46,5 @@ private: const RepoScanner *scanner_; std::string static_root_; + std::string cache_root_; // where generated tag archives are cached }; diff --git a/src/archive.cpp b/src/archive.cpp new file mode 100644 index 0000000..ba5cb06 --- /dev/null +++ b/src/archive.cpp @@ -0,0 +1,224 @@ +#include "archive.h" + +#include + +#include +#include + +namespace { + +constexpr size_t BLOCK_SIZE = 512; +constexpr size_t NAME_FIELD = 100; // ustar name field width +constexpr size_t PREFIX_FIELD = 155; // ustar prefix field width +constexpr int GZIP_LEVEL = 6; +constexpr int GZIP_WINDOW_BITS = 15 + 16; // 16 selects the gzip wrapper + +// GNU extension typeflags and the magic name their header carries. +constexpr char GNU_LONGNAME = 'L'; +constexpr char GNU_LONGLINK = 'K'; +const char *const GNU_LONG_MAGIC_NAME = "././@LongLink"; + +struct TarHeader { + char name[100]; + char mode[8]; + char uid[8]; + char gid[8]; + char size[12]; + char mtime[12]; + char chksum[8]; + char typeflag; + char linkname[100]; + char magic[6]; + char version[2]; + char uname[32]; + char gname[32]; + char devmajor[8]; + char devminor[8]; + char prefix[155]; + char pad[12]; +}; +static_assert(sizeof(TarHeader) == BLOCK_SIZE, "tar header must be 512 bytes"); + +// Writes `value` as zero-padded octal into a fixed-width field followed by a +// trailing NUL (the conventional ustar encoding). +void write_octal(char *field, size_t width, uint64_t value) { + for (size_t i = width - 1; i-- > 0;) { + field[i] = static_cast('0' + (value & 7)); + value >>= 3; + } + field[width - 1] = '\0'; +} + +void set_field(char *field, size_t width, const std::string &value) { + std::memcpy(field, value.data(), std::min(width, value.size())); +} + +unsigned header_checksum(const TarHeader &h) { + const unsigned char *bytes = reinterpret_cast(&h); + unsigned sum = 0; + for (size_t i = 0; i < BLOCK_SIZE; ++i) + sum += bytes[i]; + return sum; +} + +// Fits `path` into the ustar name/prefix fields by splitting at a '/'. Returns +// false when no split keeps both parts within their limits, signalling that the +// GNU long-name extension is required. +bool split_ustar_path(const std::string &path, std::string &prefix, std::string &name) { + if (path.size() <= NAME_FIELD) { + prefix.clear(); + name = path; + return true; + } + if (path.size() > NAME_FIELD + PREFIX_FIELD + 1) + return false; + const size_t earliest = path.size() - NAME_FIELD - 1; + for (size_t i = earliest; i < path.size(); ++i) { + if (path[i] != '/') + continue; + if (i > PREFIX_FIELD) + return false; + prefix = path.substr(0, i); + name = path.substr(i + 1); + return name.size() <= NAME_FIELD; + } + return false; +} + +} // namespace + +TarGzWriter::TarGzWriter(const std::string &path) { + file_ = std::fopen(path.c_str(), "wb"); + if (!file_) + throw ArchiveError("cannot open archive file for writing: " + path); + + z_stream *strm = new z_stream{}; + if (deflateInit2(strm, GZIP_LEVEL, Z_DEFLATED, GZIP_WINDOW_BITS, 8, + Z_DEFAULT_STRATEGY) != Z_OK) { + delete strm; + std::fclose(file_); + file_ = nullptr; + throw ArchiveError("failed to initialise gzip stream"); + } + stream_ = strm; +} + +TarGzWriter::~TarGzWriter() { + if (stream_) { + deflateEnd(static_cast(stream_)); + delete static_cast(stream_); + } + if (file_) + std::fclose(file_); +} + +void TarGzWriter::deflate_chunk(const void *data, size_t size, int flush) { + z_stream *strm = static_cast(stream_); + strm->next_in = reinterpret_cast(const_cast(data)); + strm->avail_in = static_cast(size); + + std::array out{}; + do { + strm->next_out = out.data(); + strm->avail_out = static_cast(out.size()); + const int ret = deflate(strm, flush); + if (ret == Z_STREAM_ERROR) + throw ArchiveError("gzip compression failed"); + const size_t produced = out.size() - strm->avail_out; + if (produced > 0 && std::fwrite(out.data(), 1, produced, file_) != produced) + throw ArchiveError("failed to write archive data"); + } while (strm->avail_out == 0); +} + +void TarGzWriter::write_padding(uint64_t size) { + const size_t remainder = static_cast(size % BLOCK_SIZE); + if (remainder == 0) + return; + std::array zeros{}; + deflate_chunk(zeros.data(), BLOCK_SIZE - remainder, Z_NO_FLUSH); +} + +void TarGzWriter::write_gnu_long(const std::string &value, char typeflag) { + TarHeader h{}; + set_field(h.name, sizeof(h.name), GNU_LONG_MAGIC_NAME); + write_octal(h.mode, sizeof(h.mode), 0); + write_octal(h.uid, sizeof(h.uid), 0); + write_octal(h.gid, sizeof(h.gid), 0); + write_octal(h.size, sizeof(h.size), value.size() + 1); + write_octal(h.mtime, sizeof(h.mtime), 0); + h.typeflag = typeflag; + // Old GNU magic: "ustar \0" spanning the magic and version fields. + std::memcpy(h.magic, "ustar ", sizeof(h.magic)); + h.version[0] = ' '; + h.version[1] = '\0'; + std::memset(h.chksum, ' ', sizeof(h.chksum)); + write_octal(h.chksum, 7, header_checksum(h)); + h.chksum[7] = ' '; + deflate_chunk(&h, BLOCK_SIZE, Z_NO_FLUSH); + + deflate_chunk(value.c_str(), value.size() + 1, Z_NO_FLUSH); + write_padding(value.size() + 1); +} + +void TarGzWriter::write_header(const std::string &path, uint32_t mode, + std::time_t mtime, uint64_t size, char typeflag, + const std::string &linkname) { + std::string prefix; + std::string name; + const bool fits = split_ustar_path(path, prefix, name); + if (!fits) { + write_gnu_long(path, GNU_LONGNAME); + prefix.clear(); + name = path.substr(0, NAME_FIELD); + } + if (linkname.size() > sizeof(TarHeader::linkname)) + write_gnu_long(linkname, GNU_LONGLINK); + + TarHeader h{}; + set_field(h.name, sizeof(h.name), name); + write_octal(h.mode, sizeof(h.mode), mode & 07777); + write_octal(h.uid, sizeof(h.uid), 0); + write_octal(h.gid, sizeof(h.gid), 0); + write_octal(h.size, sizeof(h.size), size); + write_octal(h.mtime, sizeof(h.mtime), static_cast(mtime)); + h.typeflag = typeflag; + set_field(h.linkname, sizeof(h.linkname), linkname); + std::memcpy(h.magic, "ustar", 5); // trailing NUL stays from zero-init + h.version[0] = '0'; + h.version[1] = '0'; + set_field(h.prefix, sizeof(h.prefix), prefix); + + std::memset(h.chksum, ' ', sizeof(h.chksum)); + write_octal(h.chksum, 7, header_checksum(h)); + h.chksum[7] = ' '; + deflate_chunk(&h, BLOCK_SIZE, Z_NO_FLUSH); +} + +void TarGzWriter::add_file(const std::string &path, uint32_t mode, + std::time_t mtime, const char *data, uint64_t size) { + write_header(path, mode, mtime, size, '0', ""); + if (size > 0) + deflate_chunk(data, static_cast(size), Z_NO_FLUSH); + write_padding(size); +} + +void TarGzWriter::add_symlink(const std::string &path, const std::string &target, + std::time_t mtime) { + write_header(path, 0777, mtime, 0, '2', target); +} + +void TarGzWriter::add_dir(const std::string &path, std::time_t mtime) { + const std::string with_slash = path.empty() || path.back() == '/' ? path : path + "/"; + write_header(with_slash, 0755, mtime, 0, '5', ""); +} + +void TarGzWriter::finish() { + if (finished_) + return; + std::array trailer{}; // two zero blocks end the archive + deflate_chunk(trailer.data(), trailer.size(), Z_NO_FLUSH); + deflate_chunk(nullptr, 0, Z_FINISH); + if (std::fflush(file_) != 0) + throw ArchiveError("failed to flush archive"); + finished_ = true; +} diff --git a/src/archive.h b/src/archive.h new file mode 100644 index 0000000..ca77e48 --- /dev/null +++ b/src/archive.h @@ -0,0 +1,46 @@ +#pragma once + +#include +#include +#include +#include +#include + +// Thrown for any tar/gzip writing failure. +class ArchiveError : public std::runtime_error { +public: + explicit ArchiveError(const std::string &what) : std::runtime_error(what) {} +}; + +// Streams a gzip-compressed tar archive to a file. Entry paths longer than the +// ustar limit are emitted via the GNU long-name extension, which modern tar +// implementations understand. Not thread safe: use one writer per archive. +class TarGzWriter { +public: + explicit TarGzWriter(const std::string &path); + ~TarGzWriter(); + TarGzWriter(const TarGzWriter &) = delete; + TarGzWriter &operator=(const TarGzWriter &) = delete; + + void add_file(const std::string &path, uint32_t mode, std::time_t mtime, + const char *data, uint64_t size); + void add_symlink(const std::string &path, const std::string &target, + std::time_t mtime); + void add_dir(const std::string &path, std::time_t mtime); + + // Writes the terminating zero blocks and flushes the gzip stream. Must be + // called for the archive to be valid; the destructor does not call it so a + // half-built archive is never silently completed. + void finish(); + +private: + void write_header(const std::string &path, uint32_t mode, std::time_t mtime, + uint64_t size, char typeflag, const std::string &linkname); + void write_gnu_long(const std::string &value, char typeflag); + void write_padding(uint64_t size); // pads body to a 512-byte boundary + void deflate_chunk(const void *data, size_t size, int flush); + + FILE *file_ = nullptr; + void *stream_ = nullptr; // z_stream*, type-erased to keep zlib out of the header + bool finished_ = false; +}; diff --git a/src/content.h b/src/content.h index de68461..aa33231 100644 --- a/src/content.h +++ b/src/content.h @@ -40,6 +40,8 @@ struct tree_view : public master { bool is_root = true; std::vector entries; std::vector tags; // tags pointing at commit_oid + std::vector branches; // local branches whose history contains commit_oid + bool on_default_branch = false; // when true, branch annotation is suppressed }; struct file_view : public master { @@ -52,6 +54,8 @@ struct file_view : public master { bool is_image = false; uint64_t size = 0; std::vector tags; // tags pointing at commit_oid + std::vector branches; // local branches whose history contains commit_oid + bool on_default_branch = false; // when true, branch annotation is suppressed }; struct blame_view : public master { diff --git a/src/git_repo.cpp b/src/git_repo.cpp index d4fca83..e1c3a6a 100644 --- a/src/git_repo.cpp +++ b/src/git_repo.cpp @@ -1,5 +1,6 @@ #include "git_repo.h" +#include "archive.h" #include "util.h" #include @@ -183,6 +184,93 @@ git_diff *make_commit_diff(git_repository *repo, git_commit *commit) { return diff; } +void archive_tree(git_repository *repo, git_tree *tree, + const std::string &archive_prefix, const std::string &repo_prefix, + std::time_t mtime, TarGzWriter &tar); + +// Resolves a submodule to its repository, reads the recorded commit tree and +// archives it under `archive_path`. Throws if the submodule is not registered, +// not initialized, or missing the recorded commit, so a partial archive is +// never produced. +void archive_submodule(git_repository *parent, const std::string &repo_path, + const std::string &archive_path, const git_oid *sub_commit, + std::time_t mtime, TarGzWriter &tar) { + git_submodule *sm = nullptr; + if (git_submodule_lookup(&sm, parent, repo_path.c_str()) != 0) + throw GitError("submodule not registered in .gitmodules: " + repo_path); + + git_repository *sub_repo = nullptr; + const int open_err = git_submodule_open(&sub_repo, sm); + git_submodule_free(sm); + if (open_err != 0) + throw GitError("submodule not initialized (cannot open): " + repo_path); + + git_commit *commit = nullptr; + const int commit_err = git_commit_lookup(&commit, sub_repo, sub_commit); + if (commit_err != 0) { + git_repository_free(sub_repo); + throw GitError("submodule commit " + oid_to_string(sub_commit) + + " not present: " + repo_path); + } + + git_tree *tree = nullptr; + const int tree_err = git_commit_tree(&tree, commit); + git_commit_free(commit); + if (tree_err != 0) { + git_repository_free(sub_repo); + check(tree_err, "read submodule tree"); + } + + tar.add_dir(archive_path, mtime); + archive_tree(sub_repo, tree, archive_path, "", mtime, tar); + git_tree_free(tree); + git_repository_free(sub_repo); +} + +// Recursively writes the entries of `tree` into the archive. `archive_prefix` +// is the path shown inside the tarball; `repo_prefix` is the path relative to +// `repo` (used for submodule lookup) and resets when descending into a submodule. +void archive_tree(git_repository *repo, git_tree *tree, + const std::string &archive_prefix, const std::string &repo_prefix, + std::time_t mtime, TarGzWriter &tar) { + const size_t count = git_tree_entrycount(tree); + for (size_t i = 0; i < count; ++i) { + const git_tree_entry *entry = git_tree_entry_byindex(tree, i); + const std::string name = git_tree_entry_name(entry); + const std::string archive_path = archive_prefix + "/" + name; + const std::string repo_path = repo_prefix.empty() ? name : repo_prefix + "/" + name; + const git_oid *id = git_tree_entry_id(entry); + + switch (git_tree_entry_type(entry)) { + case GIT_OBJECT_TREE: { + git_tree *child = nullptr; + check(git_tree_lookup(&child, repo, id), "lookup tree"); + tar.add_dir(archive_path, mtime); + archive_tree(repo, child, archive_path, repo_path, mtime, tar); + git_tree_free(child); + break; + } + case GIT_OBJECT_BLOB: { + git_blob *blob = nullptr; + check(git_blob_lookup(&blob, repo, id), "lookup blob"); + const char *data = static_cast(git_blob_rawcontent(blob)); + const auto size = static_cast(git_blob_rawsize(blob)); + if (git_tree_entry_filemode(entry) == GIT_FILEMODE_LINK) + tar.add_symlink(archive_path, std::string(data, size), mtime); + else + tar.add_file(archive_path, git_tree_entry_filemode(entry), mtime, data, size); + git_blob_free(blob); + break; + } + case GIT_OBJECT_COMMIT: // gitlink: a submodule + archive_submodule(repo, repo_path, archive_path, id, mtime, tar); + break; + default: + break; + } + } +} + } // namespace GitLibrary::GitLibrary() { @@ -449,8 +537,12 @@ std::vector GitRepo::tags() const { }; check(git_tag_foreach(repo_, callback, &ctx), "iterate tags"); - std::sort(result.begin(), result.end(), - [](const TagInfo &a, const TagInfo &b) { return a.name < b.name; }); + // Newest first; fall back to name for tags sharing a timestamp. + std::sort(result.begin(), result.end(), [](const TagInfo &a, const TagInfo &b) { + if (a.time != b.time) + return a.time > b.time; + return a.name < b.name; + }); return result; } @@ -708,3 +800,28 @@ ObjectView GitRepo::inspect(const std::string &oid_hex) const { git_object_free(obj); return view; } + +void GitRepo::write_archive(const std::string &commit_oid, const std::string &top_dir, + const std::string &out_path) const { + git_commit *commit = lookup_commit_by_oid(repo_, commit_oid); + + std::time_t mtime = 0; + if (const git_signature *committer = git_commit_committer(commit)) + mtime = committer->when.time; + + git_tree *tree = nullptr; + const int err = git_commit_tree(&tree, commit); + git_commit_free(commit); + check(err, "read commit tree"); + + try { + TarGzWriter tar(out_path); + tar.add_dir(top_dir, mtime); + archive_tree(repo_, tree, top_dir, "", mtime, tar); + tar.finish(); + } catch (...) { + git_tree_free(tree); + throw; + } + git_tree_free(tree); +} diff --git a/src/git_repo.h b/src/git_repo.h index 0288d86..f1c5ee3 100644 --- a/src/git_repo.h +++ b/src/git_repo.h @@ -166,6 +166,14 @@ public: // Inspect an arbitrary object by (possibly abbreviated) oid. ObjectView inspect(const std::string &oid) const; + // Writes a gzip-compressed tar archive of the commit's tree to `out_path`, + // recursing into initialized submodules. `top_dir` is the directory placed + // at the archive root (e.g. "repo_v1.0"). Throws GitError when a submodule + // cannot be resolved or any other libgit2 lookup fails; the archive writer + // throws ArchiveError on I/O failure. + void write_archive(const std::string &commit_oid, const std::string &top_dir, + const std::string &out_path) const; + private: git_repository *repo_ = nullptr; }; diff --git a/src/util.cpp b/src/util.cpp index 273b6a3..6052997 100644 --- a/src/util.cpp +++ b/src/util.cpp @@ -73,6 +73,19 @@ std::string trim_slashes(const std::string &path) { return path.substr(start, end - start); } +std::string sanitize_filename(const std::string &name) { + std::string out = name; + for (char &c : out) { + const bool is_digit = c >= '0' && c <= '9'; + const bool is_upper = c >= 'A' && c <= 'Z'; + const bool is_lower = c >= 'a' && c <= 'z'; + const bool is_safe_punct = c == '.' || c == '_' || c == '-'; + if (!is_digit && !is_upper && !is_lower && !is_safe_punct) + c = '_'; + } + return out; +} + bool is_markdown_path(const std::string &path) { const size_t dot = path.find_last_of('.'); if (dot == std::string::npos) diff --git a/src/util.h b/src/util.h index d395040..896f359 100644 --- a/src/util.h +++ b/src/util.h @@ -25,6 +25,10 @@ std::string parent_path(const std::string &path); // Remove leading and trailing '/' characters. std::string trim_slashes(const std::string &path); +// Replaces every character outside [A-Za-z0-9._-] with '_', so the result is +// safe to use as a single filesystem path component. +std::string sanitize_filename(const std::string &name); + // Whether author/committer/tagger email addresses may be shown. False unless // the SHOW_EMAILS environment variable is "1". Evaluated once. bool show_emails(); diff --git a/templates/skin.tmpl b/templates/skin.tmpl index b590bfa..e4eb4d1 100644 --- a/templates/skin.tmpl +++ b/templates/skin.tmpl @@ -78,6 +78,13 @@ <% view tree_view uses content::tree_view extends master %> <% template content_body() %>

<%= repo %><% if (content.path.length() > 0) %>/<%= path %><% end %>

+<% if (not content.on_default_branch) %> +<% if (content.branches.size() > 0) %> +

branches: <% foreach b in branches %><% item %><%= b.name %> <% end item %><% end foreach %>

+<% else %> +

not on any branch

+<% end %> +<% end %> <% if (content.tags.size() > 0) %>

tags: <% foreach t in tags %><% item %><%= t %> <% end item %><% end foreach %>

<% end %> @@ -110,6 +117,13 @@ <% view file_view uses content::file_view extends master %> <% template content_body() %>

<%= path %>

+<% if (not content.on_default_branch) %> +<% if (content.branches.size() > 0) %> +

branches: <% foreach b in branches %><% item %><%= b.name %> <% end item %><% end foreach %>

+<% else %> +

not on any branch

+<% end %> +<% end %> <% if (content.tags.size() > 0) %>

tags: <% foreach t in tags %><% item %><%= t %> <% end item %><% end foreach %>

<% end %> @@ -205,6 +219,7 @@ <%= t.short_oid %> <%= t.name %> browse files +download .tar.gz <% c++ out() << util::format_time(t.time); %> <% end item %><% empty %> @@ -222,6 +237,7 @@ points to <%= tag.short_oid %> · <% c++ out() << util::format_time(content.tag.time); %> · browse filesdownload .tar.gz <% if (content.tag.tagger_name.length() > 0) %>
tagger: <%= tag.tagger_name %><% end %>

<% if (content.tag.message.length() > 0) %>