git repos / sago_web_git

src/git_repo.cpp

browsing at commit = 4fe276c400f51581a3858e55f7b9a48a1f90f419

raw · blame · history

#include "git_repo.h"

#include "util.h"

#include <git2.h>

#include <algorithm>
#include <array>

namespace {

constexpr int OID_HEX_LEN = GIT_OID_SHA1_HEXSIZE;
constexpr int SHORT_OID_LEN = 8;
constexpr int README_PREVIEW_MAX = 65536;

void check(int error, const char *what) {
    if (error == 0)
        return;
    const git_error *e = git_error_last();
    const std::string detail = e && e->message ? e->message : "unknown error";
    throw GitError(std::string(what) + ": " + detail);
}

std::string oid_to_string(const git_oid *oid) {
    std::array<char, OID_HEX_LEN + 1> buf{};
    git_oid_tostr(buf.data(), buf.size(), oid);
    return std::string(buf.data());
}

// Resolves a revision spec (or HEAD when empty) to a commit. Caller owns the
// returned commit and must free it.
git_commit *lookup_commit(git_repository *repo, const std::string &id) {
    git_object *obj = nullptr;
    if (id.empty()) {
        git_reference *head = nullptr;
        check(git_repository_head(&head, repo), "resolve HEAD");
        const int err = git_reference_peel(&obj, head, GIT_OBJECT_COMMIT);
        git_reference_free(head);
        check(err, "peel HEAD to commit");
    } else {
        git_object *parsed = nullptr;
        check(git_revparse_single(&parsed, repo, id.c_str()), "parse revision");
        const int err = git_object_peel(&obj, parsed, GIT_OBJECT_COMMIT);
        git_object_free(parsed);
        check(err, "peel to commit");
    }
    return reinterpret_cast<git_commit *>(obj);
}

git_commit *lookup_commit_by_oid(git_repository *repo, const std::string &oid_hex) {
    git_oid oid;
    check(git_oid_fromstr(&oid, oid_hex.c_str()), "parse oid");
    git_commit *commit = nullptr;
    check(git_commit_lookup(&commit, repo, &oid), "lookup commit");
    return commit;
}

TreeEntry make_entry(const git_tree_entry *entry, const std::string &dir,
                     git_repository *repo) {
    TreeEntry out;
    out.name = git_tree_entry_name(entry);
    out.path = dir.empty() ? out.name : dir + "/" + out.name;
    out.oid = oid_to_string(git_tree_entry_id(entry));

    const git_object_t type = git_tree_entry_type(entry);
    if (type == GIT_OBJECT_TREE) {
        out.is_dir = true;
    } else if (type == GIT_OBJECT_COMMIT) {
        out.is_submodule = true; // gitlink
    } else if (type == GIT_OBJECT_BLOB) {
        git_blob *blob = nullptr;
        if (git_blob_lookup(&blob, repo, git_tree_entry_id(entry)) == 0) {
            out.size = static_cast<uint64_t>(git_blob_rawsize(blob));
            git_blob_free(blob);
        }
    }
    return out;
}

std::string first_line(const char *text) {
    if (!text)
        return "";
    const std::string s = text;
    const size_t nl = s.find('\n');
    return nl == std::string::npos ? s : s.substr(0, nl);
}

char status_char(git_delta_t status) {
    switch (status) {
    case GIT_DELTA_ADDED: return 'A';
    case GIT_DELTA_DELETED: return 'D';
    case GIT_DELTA_MODIFIED: return 'M';
    case GIT_DELTA_RENAMED: return 'R';
    case GIT_DELTA_COPIED: return 'C';
    case GIT_DELTA_TYPECHANGE: return 'T';
    default: return '?';
    }
}

std::string diff_css(char origin) {
    switch (origin) {
    case GIT_DIFF_LINE_ADDITION: return "d-add";
    case GIT_DIFF_LINE_DELETION: return "d-del";
    case GIT_DIFF_LINE_FILE_HDR: return "d-file";
    case GIT_DIFF_LINE_HUNK_HDR: return "d-hunk";
    default: return "d-ctx";
    }
}

bool is_code_line(char origin) {
    return origin == GIT_DIFF_LINE_CONTEXT || origin == GIT_DIFF_LINE_ADDITION ||
           origin == GIT_DIFF_LINE_DELETION;
}

std::string strip_newline(const std::string &s) {
    if (!s.empty() && s.back() == '\n')
        return s.substr(0, s.size() - 1);
    return s;
}

// Diff of a commit against its first parent (or the empty tree for a root
// commit). Returns an owned diff; caller frees. `commit` is borrowed.
git_diff *make_commit_diff(git_repository *repo, git_commit *commit) {
    git_tree *new_tree = nullptr;
    check(git_commit_tree(&new_tree, commit), "read commit tree");

    git_tree *old_tree = nullptr;
    if (git_commit_parentcount(commit) > 0) {
        git_commit *parent = nullptr;
        if (git_commit_parent(&parent, commit, 0) == 0) {
            git_commit_tree(&old_tree, parent);
            git_commit_free(parent);
        }
    }

    git_diff *diff = nullptr;
    git_diff_options opts = GIT_DIFF_OPTIONS_INIT;
    const int err = git_diff_tree_to_tree(&diff, repo, old_tree, new_tree, &opts);
    if (old_tree)
        git_tree_free(old_tree);
    git_tree_free(new_tree);
    check(err, "compute diff");
    return diff;
}

} // namespace

GitLibrary::GitLibrary() {
    // git_libgit2_init returns the initialization count (>= 1) on success.
    if (git_libgit2_init() < 0)
        check(-1, "init libgit2");

    // This is a read-only viewer for repositories that are mounted from
    // elsewhere and are typically owned by a different user than the server
    // process (e.g. root in a container). Disable libgit2's "dubious
    // ownership" check, which would otherwise reject every such repository.
    git_libgit2_opts(GIT_OPT_SET_OWNER_VALIDATION, 0);
}
GitLibrary::~GitLibrary() { git_libgit2_shutdown(); }

GitRepo::GitRepo(const std::string &path) {
    check(git_repository_open(&repo_, path.c_str()), "open repository");
}

GitRepo::~GitRepo() {
    if (repo_)
        git_repository_free(repo_);
}

std::string GitRepo::default_branch() const {
    git_reference *head = nullptr;
    if (git_repository_head(&head, repo_) != 0)
        return "";
    const char *name = git_reference_shorthand(head);
    const std::string result = name ? name : "";
    git_reference_free(head);
    return result;
}

std::string GitRepo::resolve_commit(const std::string &id) const {
    git_commit *commit = lookup_commit(repo_, id);
    const std::string oid = oid_to_string(git_commit_id(commit));
    git_commit_free(commit);
    return oid;
}

std::vector<TreeEntry> GitRepo::list_tree(const std::string &commit_oid,
                                          const std::string &path) const {
    git_commit *commit = lookup_commit_by_oid(repo_, commit_oid);
    git_tree *root = nullptr;
    const int err = git_commit_tree(&root, commit);
    git_commit_free(commit);
    check(err, "read commit tree");

    const std::string clean = util::trim_slashes(path);
    git_tree *tree = root;
    git_tree *subtree = nullptr;
    if (!clean.empty()) {
        git_tree_entry *entry = nullptr;
        if (git_tree_entry_bypath(&entry, root, clean.c_str()) != 0) {
            git_tree_free(root);
            throw GitError("path not found: " + clean);
        }
        const bool is_tree = git_tree_entry_type(entry) == GIT_OBJECT_TREE;
        if (is_tree)
            git_tree_entry_to_object(reinterpret_cast<git_object **>(&subtree),
                                     repo_, entry);
        git_tree_entry_free(entry);
        if (!is_tree) {
            git_tree_free(root);
            throw GitError("not a directory: " + clean);
        }
        tree = subtree;
    }

    std::vector<TreeEntry> entries;
    const size_t count = git_tree_entrycount(tree);
    entries.reserve(count);
    for (size_t i = 0; i < count; ++i)
        entries.push_back(make_entry(git_tree_entry_byindex(tree, i), clean, repo_));

    if (subtree)
        git_tree_free(subtree);
    git_tree_free(root);

    // Directories first, then files; alphabetical within each group.
    std::sort(entries.begin(), entries.end(), [](const TreeEntry &a, const TreeEntry &b) {
        if (a.is_dir != b.is_dir)
            return a.is_dir;
        return a.name < b.name;
    });
    return entries;
}

bool GitRepo::read_file(const std::string &commit_oid, const std::string &path,
                        std::string &out, bool &is_binary) const {
    git_commit *commit = lookup_commit_by_oid(repo_, commit_oid);
    git_tree *tree = nullptr;
    const int tree_err = git_commit_tree(&tree, commit);
    git_commit_free(commit);
    check(tree_err, "read commit tree");

    git_tree_entry *entry = nullptr;
    const std::string clean = util::trim_slashes(path);
    if (git_tree_entry_bypath(&entry, tree, clean.c_str()) != 0) {
        git_tree_free(tree);
        return false;
    }
    if (git_tree_entry_type(entry) != GIT_OBJECT_BLOB) {
        git_tree_entry_free(entry);
        git_tree_free(tree);
        return false;
    }

    git_blob *blob = nullptr;
    const int blob_err = git_blob_lookup(&blob, repo_, git_tree_entry_id(entry));
    git_tree_entry_free(entry);
    git_tree_free(tree);
    check(blob_err, "lookup blob");

    is_binary = git_blob_is_binary(blob) != 0;
    out.assign(static_cast<const char *>(git_blob_rawcontent(blob)),
               static_cast<size_t>(git_blob_rawsize(blob)));
    git_blob_free(blob);
    return true;
}

std::string GitRepo::find_readme(const std::string &commit_oid) const {
    const std::vector<TreeEntry> entries = list_tree(commit_oid, "");
    for (const TreeEntry &e : entries) {
        if (e.is_dir)
            continue;
        std::string lower = e.name;
        std::transform(lower.begin(), lower.end(), lower.begin(), ::tolower);
        if (lower.rfind("readme", 0) == 0)
            return e.name;
    }
    return "";
}

std::vector<BlameLine> GitRepo::blame(const std::string &commit_oid,
                                      const std::string &path) const {
    const std::string clean = util::trim_slashes(path);

    std::string content;
    bool is_binary = false;
    if (!read_file(commit_oid, clean, content, is_binary))
        throw GitError("path not found: " + clean);
    if (is_binary)
        throw GitError("cannot blame binary file: " + clean);

    git_blame_options opts = GIT_BLAME_OPTIONS_INIT;
    check(git_oid_fromstr(&opts.newest_commit, commit_oid.c_str()), "parse oid");

    git_blame *blame = nullptr;
    check(git_blame_file(&blame, repo_, clean.c_str(), &opts), "compute blame");

    std::vector<BlameLine> lines;
    int number = 1;
    size_t start = 0;
    while (start <= content.size()) {
        const size_t nl = content.find('\n', start);
        const size_t end = nl == std::string::npos ? content.size() : nl;

        BlameLine line;
        line.number = number;
        line.content = content.substr(start, end - start);

        const git_blame_hunk *hunk = git_blame_get_hunk_byline(blame, number);
        if (hunk) {
            line.short_oid = oid_to_string(&hunk->final_commit_id).substr(0, SHORT_OID_LEN);
            if (hunk->final_signature) {
                line.author = hunk->final_signature->name;
                line.time = hunk->final_signature->when.time;
            }
        }
        lines.push_back(std::move(line));

        if (nl == std::string::npos)
            break;
        start = nl + 1;
        ++number;
    }

    git_blame_free(blame);
    return lines;
}

std::vector<CommitInfo> GitRepo::log(const std::string &start_oid, int limit) const {
    git_oid oid;
    check(git_oid_fromstr(&oid, start_oid.c_str()), "parse oid");

    git_revwalk *walk = nullptr;
    check(git_revwalk_new(&walk, repo_), "create revwalk");
    git_revwalk_sorting(walk, GIT_SORT_TIME);
    check(git_revwalk_push(walk, &oid), "seed revwalk");

    std::vector<CommitInfo> commits;
    git_oid current;
    while (static_cast<int>(commits.size()) < limit &&
           git_revwalk_next(&current, walk) == 0) {
        git_commit *commit = nullptr;
        if (git_commit_lookup(&commit, repo_, &current) != 0)
            continue;

        CommitInfo info;
        info.oid = oid_to_string(&current);
        info.short_oid = info.oid.substr(0, SHORT_OID_LEN);
        const char *summary = git_commit_summary(commit);
        info.summary = summary ? summary : "";
        const git_signature *author = git_commit_author(commit);
        if (author) {
            info.author_name = author->name;
            info.author_email = author->email;
            info.time = author->when.time;
        }
        commits.push_back(std::move(info));
        git_commit_free(commit);
    }

    git_revwalk_free(walk);
    return commits;
}

std::vector<TagInfo> GitRepo::tags() const {
    struct Context {
        std::vector<TagInfo> *out;
        git_repository *repo;
    };
    std::vector<TagInfo> result;
    Context ctx{&result, repo_};

    auto callback = [](const char *name, git_oid *oid, void *payload) -> int {
        auto *c = static_cast<Context *>(payload);
        TagInfo tag;
        const std::string prefix = "refs/tags/";
        const std::string ref = name;
        tag.name = ref.rfind(prefix, 0) == 0 ? ref.substr(prefix.size()) : ref;

        git_object *obj = nullptr;
        if (git_object_lookup(&obj, c->repo, oid, GIT_OBJECT_ANY) != 0)
            return 0;
        tag.annotated = git_object_type(obj) == GIT_OBJECT_TAG;
        if (tag.annotated) {
            const git_signature *tagger = git_tag_tagger(reinterpret_cast<git_tag *>(obj));
            if (tagger)
                tag.time = tagger->when.time;
        }

        git_object *commit = nullptr;
        if (git_object_peel(&commit, obj, GIT_OBJECT_COMMIT) == 0) {
            tag.target_oid = oid_to_string(git_object_id(commit));
            tag.short_oid = tag.target_oid.substr(0, SHORT_OID_LEN);
            if (!tag.annotated) {
                const git_signature *author =
                    git_commit_author(reinterpret_cast<git_commit *>(commit));
                if (author)
                    tag.time = author->when.time;
            }
            git_object_free(commit);
        }
        git_object_free(obj);
        c->out->push_back(std::move(tag));
        return 0;
    };

    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; });
    return result;
}

TagDetail GitRepo::tag_detail(const std::string &name) const {
    TagDetail detail;
    detail.name = name;

    const std::string refname = "refs/tags/" + name;
    git_reference *ref = nullptr;
    if (git_reference_lookup(&ref, repo_, refname.c_str()) != 0)
        throw GitError("no such tag: " + name);

    git_object *obj = nullptr;
    const int err = git_reference_peel(&obj, ref, GIT_OBJECT_ANY);
    git_reference_free(ref);
    check(err, "resolve tag");

    detail.annotated = git_object_type(obj) == GIT_OBJECT_TAG;
    if (detail.annotated) {
        git_tag *tag = reinterpret_cast<git_tag *>(obj);
        const git_signature *tagger = git_tag_tagger(tag);
        if (tagger) {
            detail.tagger_name = tagger->name;
            detail.time = tagger->when.time;
        }
        const char *message = git_tag_message(tag);
        detail.message = message ? message : "";
    }

    git_object *commit = nullptr;
    if (git_object_peel(&commit, obj, GIT_OBJECT_COMMIT) == 0) {
        detail.target_oid = oid_to_string(git_object_id(commit));
        detail.short_oid = detail.target_oid.substr(0, SHORT_OID_LEN);
        if (!detail.annotated) {
            const git_signature *author =
                git_commit_author(reinterpret_cast<git_commit *>(commit));
            if (author)
                detail.time = author->when.time;
        }
        git_object_free(commit);
    }
    git_object_free(obj);
    return detail;
}

CommitDetail GitRepo::commit_detail(const std::string &oid) const {
    git_commit *commit = lookup_commit(repo_, oid);

    CommitDetail detail;
    detail.oid = oid_to_string(git_commit_id(commit));
    detail.short_oid = detail.oid.substr(0, SHORT_OID_LEN);
    const git_signature *author = git_commit_author(commit);
    if (author) {
        detail.author_name = author->name;
        if (util::show_emails())
            detail.author_email = author->email;
        detail.time = author->when.time;
    }
    const char *message = git_commit_message(commit);
    detail.message = message ? message : "";
    const unsigned parents = git_commit_parentcount(commit);
    for (unsigned i = 0; i < parents; ++i)
        detail.parents.push_back(oid_to_string(git_commit_parent_id(commit, i)));
    if (!detail.parents.empty())
        detail.first_parent = detail.parents.front();

    git_diff *diff = make_commit_diff(repo_, commit);

    const size_t deltas = git_diff_num_deltas(diff);
    for (size_t i = 0; i < deltas; ++i) {
        const git_diff_delta *delta = git_diff_get_delta(diff, i);
        FileChange change;
        change.status = status_char(delta->status);
        const char *new_path = delta->new_file.path;
        const char *old_path = delta->old_file.path;
        change.path = new_path ? new_path : (old_path ? old_path : "");
        change.old_path = old_path ? old_path : "";
        detail.files.push_back(std::move(change));
    }

    auto line_cb = [](const git_diff_delta *, const git_diff_hunk *,
                      const git_diff_line *line, void *payload) -> int {
        auto *lines = static_cast<std::vector<DiffLine> *>(payload);
        DiffLine dl;
        dl.css = diff_css(line->origin);
        std::string text(line->content, line->content_len);
        if (is_code_line(line->origin))
            text = std::string(1, line->origin) + text;
        dl.content = strip_newline(text);
        lines->push_back(std::move(dl));
        return 0;
    };
    git_diff_print(diff, GIT_DIFF_FORMAT_PATCH, line_cb, &detail.diff);

    git_diff_free(diff);
    git_commit_free(commit);
    return detail;
}

std::string GitRepo::commit_patch(const std::string &oid) const {
    git_commit *commit = lookup_commit(repo_, oid);
    git_diff *diff = make_commit_diff(repo_, commit);

    auto line_cb = [](const git_diff_delta *, const git_diff_hunk *,
                      const git_diff_line *line, void *payload) -> int {
        auto *out = static_cast<std::string *>(payload);
        if (is_code_line(line->origin))
            out->push_back(line->origin);
        out->append(line->content, line->content_len);
        return 0;
    };
    std::string patch;
    git_diff_print(diff, GIT_DIFF_FORMAT_PATCH, line_cb, &patch);

    git_diff_free(diff);
    git_commit_free(commit);
    return patch;
}

ObjectView GitRepo::inspect(const std::string &oid_hex) const {
    git_oid oid;
    const size_t len = oid_hex.length();
    check(git_oid_fromstrn(&oid, oid_hex.c_str(), len), "parse oid");

    git_object *obj = nullptr;
    if (len == static_cast<size_t>(OID_HEX_LEN))
        check(git_object_lookup(&obj, repo_, &oid, GIT_OBJECT_ANY), "lookup object");
    else
        check(git_object_lookup_prefix(&obj, repo_, &oid, len, GIT_OBJECT_ANY),
              "lookup object");

    ObjectView view;
    view.oid = oid_to_string(git_object_id(obj));

    switch (git_object_type(obj)) {
    case GIT_OBJECT_BLOB: {
        view.kind = ObjectKind::Blob;
        view.type_name = "blob";
        git_blob *blob = reinterpret_cast<git_blob *>(obj);
        view.is_binary = git_blob_is_binary(blob) != 0;
        if (!view.is_binary) {
            const auto size = static_cast<size_t>(git_blob_rawsize(blob));
            const size_t take = std::min<size_t>(size, README_PREVIEW_MAX);
            view.body.assign(static_cast<const char *>(git_blob_rawcontent(blob)), take);
        } else {
            view.body = "binary blob (" +
                        util::human_size(static_cast<uint64_t>(git_blob_rawsize(blob))) +
                        ")";
        }
        break;
    }
    case GIT_OBJECT_TREE: {
        view.kind = ObjectKind::Tree;
        view.type_name = "tree";
        git_tree *tree = reinterpret_cast<git_tree *>(obj);
        const size_t count = git_tree_entrycount(tree);
        for (size_t i = 0; i < count; ++i)
            view.entries.push_back(make_entry(git_tree_entry_byindex(tree, i), "", repo_));
        break;
    }
    case GIT_OBJECT_COMMIT: {
        view.kind = ObjectKind::Commit;
        view.type_name = "commit";
        git_commit *commit = reinterpret_cast<git_commit *>(obj);
        const git_signature *author = git_commit_author(commit);
        std::string text = "commit " + view.oid + "\n";
        if (author) {
            text += "Author: " + std::string(author->name);
            if (util::show_emails())
                text += " <" + std::string(author->email) + ">";
            text += "\nDate:   " + util::format_time(author->when.time) + "\n";
        }
        const char *message = git_commit_message(commit);
        text += "\n";
        text += message ? message : "";
        view.body = text;
        break;
    }
    case GIT_OBJECT_TAG: {
        view.kind = ObjectKind::Tag;
        view.type_name = "tag";
        git_tag *tag = reinterpret_cast<git_tag *>(obj);
        const char *name = git_tag_name(tag);
        const char *message = git_tag_message(tag);
        view.body = "tag " + std::string(name ? name : "") + "\n\n" +
                    std::string(message ? message : "");
        break;
    }
    default:
        view.kind = ObjectKind::Unknown;
        view.type_name = "unknown";
        break;
    }

    git_object_free(obj);
    return view;
}