commit 9881d02a
Add option to download the tags as tar archives
Changed files
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/<name>/commit/<hash>.patch` | Raw unified patch (`text/plain`) |
| `/repos/<name>/tags/` | List of tags |
| `/repos/<name>/tags/<tag>` | Tag details (target commit, message) |
+| `/repos/<name>/tags/<tag>.tar.gz` | Download the tag as a gzipped tarball, submodules included (built on first access, then cached) |
| `/repos/<name>/object/<hash>` | Inspect a blob, tree, commit or tag |
Every page accepts `?id=<hash>` 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 <cppcms/service.h>
#include <cppcms/url_dispatcher.h>
+#include <atomic>
+#include <cstdlib>
+#include <filesystem>
#include <fstream>
+#include <thread>
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<std::string>("git.static_root", "./static");
+ cache_root_ = resolve_cache_root(settings().get<std::string>("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<GitRepo> 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<uint64_t> counter{0};
+ const std::string tmp = cache_file + ".tmp." +
+ std::to_string(counter.fetch_add(1)) + "." +
+ std::to_string(std::hash<std::thread::id>{}(
+ 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<GitRepo> 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 <zlib.h>
+
+#include <array>
+#include <cstring>
+
+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<char>('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<const unsigned char *>(&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<z_stream *>(stream_));
+ delete static_cast<z_stream *>(stream_);
+ }
+ if (file_)
+ std::fclose(file_);
+}
+
+void TarGzWriter::deflate_chunk(const void *data, size_t size, int flush) {
+ z_stream *strm = static_cast<z_stream *>(stream_);
+ strm->next_in = reinterpret_cast<Bytef *>(const_cast<void *>(data));
+ strm->avail_in = static_cast<uInt>(size);
+
+ std::array<unsigned char, 1 << 16> out{};
+ do {
+ strm->next_out = out.data();
+ strm->avail_out = static_cast<uInt>(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_t>(size % BLOCK_SIZE);
+ if (remainder == 0)
+ return;
+ std::array<char, BLOCK_SIZE> 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<uint64_t>(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_t>(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<char, BLOCK_SIZE * 2> 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 <cstdint>
+#include <cstdio>
+#include <ctime>
+#include <stdexcept>
+#include <string>
+
+// 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<TreeEntry> entries;
std::vector<std::string> tags; // tags pointing at commit_oid
+ std::vector<BranchInfo> 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<std::string> tags; // tags pointing at commit_oid
+ std::vector<BranchInfo> 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 <git2.h>
@@ -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<const char *>(git_blob_rawcontent(blob));
+ const auto size = static_cast<uint64_t>(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<TagInfo> 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() %>
<h1><%= repo %><% if (content.path.length() > 0) %>/<%= path %><% end %></h1>
+<% if (not content.on_default_branch) %>
+<% if (content.branches.size() > 0) %>
+<p class="tags">branches: <% foreach b in branches %><% item %><a href="/repos/<%= repo %>/tree/?id=<%= b.target_oid %>"><%= b.name %></a> <% end item %><% end foreach %></p>
+<% else %>
+<p class="tags">not on any branch</p>
+<% end %>
+<% end %>
<% if (content.tags.size() > 0) %>
<p class="tags">tags: <% foreach t in tags %><% item %><a href="/repos/<%= repo %>/tags/<%= t %>"><%= t %></a> <% end item %><% end foreach %></p>
<% end %>
@@ -110,6 +117,13 @@
<% view file_view uses content::file_view extends master %>
<% template content_body() %>
<h1><%= path %></h1>
+<% if (not content.on_default_branch) %>
+<% if (content.branches.size() > 0) %>
+<p class="tags">branches: <% foreach b in branches %><% item %><a href="/repos/<%= repo %>/tree/?id=<%= b.target_oid %>"><%= b.name %></a> <% end item %><% end foreach %></p>
+<% else %>
+<p class="tags">not on any branch</p>
+<% end %>
+<% end %>
<% if (content.tags.size() > 0) %>
<p class="tags">tags: <% foreach t in tags %><% item %><a href="/repos/<%= repo %>/tags/<%= t %>"><%= t %></a> <% end item %><% end foreach %></p>
<% end %>
@@ -205,6 +219,7 @@
<td class="commit"><a href="/repos/<%= repo %>/commit/<%= t.target_oid %>"><%= t.short_oid %></a></td>
<td class="msg"><a href="/repos/<%= repo %>/tags/<%= t.name %>"><%= t.name %></a></td>
<td class="msg"><a href="/repos/<%= repo %>/tree/?id=<%= t.target_oid %>">browse files</a></td>
+<td class="msg"><a href="/repos/<%= repo %>/tags/<%= t.name %>.tar.gz">download .tar.gz</a></td>
<td class="date"><% c++ out() << util::format_time(t.time); %></td>
</tr>
<% end item %><% empty %>
@@ -222,6 +237,7 @@
points to <a href="/repos/<%= repo %>/commit/<%= tag.target_oid %>"><%= tag.short_oid %></a>
· <% c++ out() << util::format_time(content.tag.time); %>
· <a href="/repos/<%= repo %>/tree/?id=<%= tag.target_oid %>">browse files</a>
+· <a href="/repos/<%= repo %>/tags/<%= tag.name %>.tar.gz">download .tar.gz</a>
<% if (content.tag.tagger_name.length() > 0) %><br>tagger: <%= tag.tagger_name %><% end %>
</p>
<% if (content.tag.message.length() > 0) %>