git-trace

romele / git-trace public

62 commits · 1 branch · 1 tag · 1 contributor (top: Romele) · first commit 3d ago · last push 10m ago

src/export.c

#include "export.h"

#include "auth.h"
#include "gitx.h"
#include "highlight.h"
#include "markdown.h"
#include "repo.h"
#include "util.h"
#include "web.h"

#include <ctype.h>
#include <dirent.h>
#include <errno.h>
#include <limits.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>

/* ---- small helpers ---------------------------------------------------- */

static int xp_write(const char *path, const struct buf *b)
{
    FILE *f = fopen(path, "wb");
    if (!f)
        return -1;
    size_t w = b->data ? fwrite(b->data, 1, b->len, f) : 0;
    fclose(f);
    return (int)w == (int)b->len ? 0 : -1;
}

/* every exported page is self-contained: inline CSS, no path math */
static void xp_head(struct buf *b, const char *title, int depth)
{
    buf_puts(b, "<!doctype html><html><head><meta charset=utf-8>"
                "<meta name=viewport content='width=device-width,"
                "initial-scale=1'><title>");
    buf_put_escaped(b, title, strlen(title));
    buf_puts(b, "</title><style>");
    buf_puts(b, gt_web_css());
    buf_puts(b, "nav a{margin-right:.9em}</style></head><body>"
                "<header><a href='");
    for (int i = 0; i < depth; i++)
        buf_puts(b, "../");
    buf_puts(b, "index.html'>git-trace export</a></header><main>");
}

static void xp_foot(struct buf *b)
{
    buf_puts(b, "</main></body></html>");
}

/* join helper — output paths live in struct buf, so -Wformat-truncation
 * has nothing to warn about and no path is ever silently cut */
static void xp_path(struct buf *b, const char *dir, const char *suf)
{
    buf_init(b);
    buf_printf(b, "%s%s", dir, suf);
}

/* export filename for a git path: '/' is kept as a real separator —
 * the output mirrors the repo tree so file:// links survive the
 * browser's percent-decoding.  Within each component keep
 * [A-Za-z0-9._-] and %-encode the rest; components equal to "." or
 * ".." are fully encoded so a hostile tree cannot escape file/ */
static void fname_enc(struct buf *b, const char *path)
{
    const char *p = path;
    while (*p) {
        const char *sl = strchr(p, '/');
        size_t len = sl ? (size_t)(sl - p) : strlen(p);
        int dots = (len == 1 && p[0] == '.') ||
                   (len == 2 && p[0] == '.' && p[1] == '.');
        for (size_t i = 0; i < len; i++) {
            unsigned char ch = (unsigned char)p[i];
            if (!dots && (isalnum(ch) || ch == '.' || ch == '_' ||
                          ch == '-'))
                buf_put(b, &ch, 1);
            else
                buf_printf(b, "%%%02X", ch);
        }
        if (sl)
            buf_put(b, "/", 1);
        p = sl ? sl + 1 : p + len;
    }
}

/* create the parent directory chain of an output path */
static int xp_mkparent(const char *path)
{
    struct buf d;
    buf_init(&d);
    buf_printf(&d, "%s", path);
    int r = 0;
    char *sl = d.data ? strrchr(d.data, '/') : NULL;
    if (sl) {
        *sl = '\0';
        r = mkdir_p(d.data);
    }
    buf_free(&d);
    return r;
}

/* ---- per-repo export --------------------------------------------------- */

struct repo_job {
    char owner[64], name[64], dir[PATH_MAX], ref[256];
};

static int is_binary(const char *p, size_t n)
{
    return memchr(p, 0, n) != NULL;
}

static int export_commit(struct repo_job *j, const char *rdir,
                         const char *sha)
{
    struct buf out;
    buf_init(&out);
    const char *argv[] = {
        "git", "show", "--format=fuller", "--stat", "--patch",
        "--find-renames", sha, NULL
    };
    int rc = git_capture(rdir, argv, &out);
    if (rc != 0 || out.len > 524288) {          /* cap huge diffs */
        buf_free(&out);
        return rc == 0 ? 0 : -1;                /* skip, keep going */
    }
    struct buf pp;
    buf_init(&pp);
    buf_printf(&pp, "%s/commit/%s.html", j->dir, sha);
    struct buf b;
    buf_init(&b);
    char title[160];
    snprintf(title, sizeof title, "%s/%s commit %.12s",
             j->owner, j->name, sha);
    xp_head(&b, title, 3);                      /* commit/ dir depth */
    buf_puts(&b, "<nav><a href='../index.html'>files</a>"
                 "<a href='../log.html'>commits</a></nav>");
    buf_puts(&b, "<pre>");
    buf_put_escaped(&b, out.data ? out.data : "", out.len);
    buf_puts(&b, "</pre>");
    xp_foot(&b);
    int w = xp_write(pp.data, &b);
    buf_free(&b);
    buf_free(&pp);
    buf_free(&out);
    return w;
}

/* one highlighted file page per blob; returns 0 ok */
static int export_files(struct repo_job *j, const char *rdir)
{
    struct buf fd;
    xp_path(&fd, j->dir, "/file");
    if (mkdir_p(fd.data) != 0) {
        buf_free(&fd);
        return -1;
    }
    buf_free(&fd);

    struct buf out;
    buf_init(&out);
    const char *argv[] = {
        "git", "ls-tree", "-r", "-z", j->ref, NULL
    };
    if (git_capture(rdir, argv, &out) != 0 || !out.data) {
        buf_free(&out);
        return -1;
    }

    /* records: "<mode> blob <sha>\t<path>" NUL-separated; -r lists
     * leaves, so tree/submodule entries are filtered on type */
    for (char *p = out.data; p < out.data + out.len; ) {
        char *tab = memchr(p, '\t', out.data + out.len - p);
        char *nul = memchr(p, '\0', out.data + out.len - p);
        if (!tab || !nul || nul < tab)
            break;
        *tab = '\0';
        if (strstr(p, " blob ") == NULL) {      /* keep blobs only */
            p = nul + 1;
            continue;
        }
        const char *path = tab + 1;

        struct buf fb;                           /* encoded filename */
        buf_init(&fb);
        fname_enc(&fb, path);
        buf_puts(&fb, ".html");
        struct buf fp;
        buf_init(&fp);
        buf_printf(&fp, "%s/file/%s", j->dir,
                   fb.data ? fb.data : "x.html");
        xp_mkparent(fp.data);

        char spec[1400];
        snprintf(spec, sizeof spec, "%s:%s", j->ref, path);
        struct buf content;
        buf_init(&content);
        const char *sargv[] = { "git", "show", spec, NULL };

        /* depth varies: file/<enc> sits under repo/, and each '/'
         * kept in the encoded name adds one real level */
        int sub = 0;
        for (const char *q = fb.data; q && *q; q++)
            sub += (*q == '/');
        struct buf up;
        buf_init(&up);
        for (int k = 0; k <= sub; k++)
            buf_puts(&up, "../");

        struct buf b;
        buf_init(&b);
        char title[1200];
        snprintf(title, sizeof title, "%s/%s: %s", j->owner, j->name,
                 path);
        xp_head(&b, title, 3 + sub);            /* file/ + nesting */
        buf_printf(&b, "<nav><a href='%sindex.html'>files</a>"
                       "<a href='%slog.html'>commits</a></nav>",
                   up.data, up.data);
        buf_puts(&b, "<p class=crumbs>");
        buf_put_escaped(&b, path, strlen(path));
        buf_puts(&b, "</p>");

        if (git_capture(rdir, sargv, &content) != 0) {
            buf_puts(&b, "<p class=muted>unavailable</p>");
        } else if (is_binary(content.data ? content.data : "",
                             content.len)) {
            buf_puts(&b, "<p class=muted>binary file (");
            buf_printf(&b, "%zu bytes)</p>", content.len);
        } else {
            buf_puts(&b, "<pre>");
            if (content.len <= 262144)
                hl_emit(&b, content.data, content.len, path);
            else
                buf_put_escaped(&b, content.data, content.len);
            buf_puts(&b, "</pre>");
        }
        xp_foot(&b);
        xp_write(fp.data, &b);
        buf_free(&b);
        buf_free(&up);
        buf_free(&fb);
        buf_free(&fp);
        buf_free(&content);
        p = nul + 1;
    }
    buf_free(&out);
    return 0;
}

static int export_repo(struct app_ctx *c, const char *destdir,
                       const char *owner, const char *name, int maxc)
{
    char rdir[PATH_MAX];
    if (repo_path(rdir, sizeof rdir, c->repos_root, owner, name) != 0)
        return -1;

    struct repo_job j;
    memset(&j, 0, sizeof j);
    snprintf(j.owner, sizeof j.owner, "%s", owner);
    snprintf(j.name, sizeof j.name, "%s", name);
    snprintf(j.dir, sizeof j.dir, "%s/%s/%s", destdir, owner, name);
    char *d = git_default_branch(rdir);
    snprintf(j.ref, sizeof j.ref, "%s", d ? d : "HEAD");
    free(d);

    struct buf dp;
    xp_path(&dp, j.dir, "/commit");
    int mk = mkdir_p(dp.data);
    buf_free(&dp);
    if (mk != 0)
        return -1;

    /* ---- log.html + per-commit pages ---- */
    char nbuf[16];
    snprintf(nbuf, sizeof nbuf, "-%d", maxc);
    struct buf log;
    buf_init(&log);
    const char *largv[] = {
        "git", "log", nbuf, "--date=short",
        "--format=%H%x1f%an%x1f%ad%x1f%s", j.ref, "--", NULL
    };
    struct buf b;
    buf_init(&b);
    char title[200];
    snprintf(title, sizeof title, "%s/%s", owner, name);
    if (git_capture(rdir, largv, &log) == 0 && log.data) {
        xp_head(&b, title, 2);
        buf_puts(&b, "<nav><a href='index.html'>files</a></nav>"
                     "<h2>commits</h2><table>");
        char *save = NULL;
        for (char *line = strtok_r(log.data, "\n", &save); line;
             line = strtok_r(NULL, "\n", &save)) {
            char *sha = strtok(line, "\x1f");
            char *an  = strtok(NULL, "\x1f");
            char *ad  = strtok(NULL, "\x1f");
            char *sub = strtok(NULL, "\x1f");
            if (!sha)
                continue;
            buf_puts(&b, "<tr><td class=sha><a href='commit/");
            buf_printf(&b, "%s.html'>%.10s</a></td><td>", sha, sha);
            buf_put_escaped(&b, sub ? sub : "", sub ? strlen(sub) : 0);
            buf_puts(&b, "</td><td class=muted>");
            buf_put_escaped(&b, an ? an : "", an ? strlen(an) : 0);
            buf_puts(&b, "</td><td class=muted>");
            buf_put_escaped(&b, ad ? ad : "", ad ? strlen(ad) : 0);
            buf_puts(&b, "</td></tr>");
            export_commit(&j, rdir, sha);
        }
        buf_puts(&b, "</table>");
    } else {
        xp_head(&b, title, 2);
        buf_puts(&b, "<p class=muted>no commits</p>");
    }
    buf_free(&log);
    xp_foot(&b);
    xp_path(&dp, j.dir, "/log.html");
    xp_write(dp.data, &b);
    buf_free(&dp);
    buf_free(&b);

    /* ---- refs.html ---- */
    struct buf refs;
    buf_init(&refs);
    const char *rargv[] = {
        "git", "for-each-ref",
        "--format=%(refname:short)%09%(objecttype)%09%(*objectname)",
        "refs/heads", "refs/tags", NULL
    };
    buf_init(&b);
    xp_head(&b, title, 2);
    buf_puts(&b, "<nav><a href='index.html'>files</a>"
                 "<a href='log.html'>commits</a></nav><h2>refs</h2>");
    if (git_capture(rdir, rargv, &refs) == 0 && refs.data) {
        buf_puts(&b, "<pre>");
        buf_put_escaped(&b, refs.data, refs.len);
        buf_puts(&b, "</pre>");
    }
    buf_free(&refs);
    xp_foot(&b);
    xp_path(&dp, j.dir, "/refs.html");
    xp_write(dp.data, &b);
    buf_free(&dp);
    buf_free(&b);

    /* ---- file pages ---- */
    export_files(&j, rdir);

    /* ---- index.html: summary + flat file list + README ---- */
    buf_init(&b);
    xp_head(&b, title, 2);
    buf_printf(&b, "<h2>%s / %s</h2>", owner, name);
    buf_puts(&b, "<nav><a href='log.html'>commits</a>"
                 "<a href='refs.html'>refs</a></nav>");
    buf_puts(&b, "<h3>files</h3><table>");
    struct buf ls;
    buf_init(&ls);
    const char *targv[] = {
        "git", "ls-tree", "-r", "--name-only", "-z", j.ref, NULL
    };
    if (git_capture(rdir, targv, &ls) == 0 && ls.data) {
        for (char *p = ls.data; p < ls.data + ls.len; ) {
            char *nul = memchr(p, '\0', ls.data + ls.len - p);
            if (!nul)
                break;
            buf_puts(&b, "<tr><td><a href='file/");
            fname_enc(&b, p);
            buf_puts(&b, ".html'>");
            buf_put_escaped(&b, p, strlen(p));
            buf_puts(&b, "</a></td></tr>");
            p = nul + 1;
        }
    }
    buf_free(&ls);
    buf_puts(&b, "</table>");

    /* README inline, if present */
    const char *readmes[] = { "README.md", "README", "README.txt" };
    for (size_t i = 0; i < sizeof readmes / sizeof readmes[0]; i++) {
        char spec[320];
        snprintf(spec, sizeof spec, "%s:%s", j.ref, readmes[i]);
        struct buf rm;
        buf_init(&rm);
        const char *rargv2[] = { "git", "show", spec, NULL };
        if (git_capture(rdir, rargv2, &rm) == 0 && rm.data &&
            rm.len < 262144 && !is_binary(rm.data, rm.len)) {
            buf_puts(&b, "<h3>");
            buf_put_escaped(&b, readmes[i], strlen(readmes[i]));
            buf_puts(&b, "</h3>");
            if (ends_with(readmes[i], ".md")) {
                buf_puts(&b, "<div class=rdb>");
                md_emit(&b, rm.data, rm.len);
                buf_puts(&b, "</div>");
            } else {
                buf_puts(&b, "<pre>");
                buf_put_escaped(&b, rm.data, rm.len);
                buf_puts(&b, "</pre>");
            }
            buf_free(&rm);
            break;
        }
        buf_free(&rm);
    }
    xp_foot(&b);
    xp_path(&dp, j.dir, "/index.html");
    int w = xp_write(dp.data, &b);
    buf_free(&dp);
    buf_free(&b);
    printf("%s/%s -> %s\n", owner, name, j.dir);
    return w;
}

/* ---- driver ------------------------------------------------------------ */

int gt_export(struct app_ctx *c, int argc, char **argv)
{
    const char *destdir = "export";
    int maxc = 100, priv = 0, i = 0;
    for (; i < argc; i++) {
        if (strcmp(argv[i], "-o") == 0 && i + 1 < argc)
            destdir = argv[++i];
        else if (strcmp(argv[i], "-n") == 0 && i + 1 < argc)
            maxc = atoi(argv[++i]);
        else if (strcmp(argv[i], "--private") == 0)
            priv = 1;
        else
            break;
    }
    if (strlen(destdir) > PATH_MAX - 160 ||
        mkdir_p(destdir) != 0) {
        fprintf(stderr, "git-trace: cannot create %s\n", destdir);
        return 1;
    }
    if (maxc < 1)
        maxc = 1;

    /* repo set: explicit owner/name list, else all (public) repos */
    struct buf idx;
    buf_init(&idx);
    xp_head(&idx, "repositories", 0);
    buf_puts(&idx, "<h1>git-trace export</h1><table>");
    int n = 0;

    if (i < argc) {
        for (; i < argc; i++) {
            char owner[64], name[64];
            const char *sl = strchr(argv[i], '/');
            if (!sl || sl == argv[i])
                continue;
            size_t ol = (size_t)(sl - argv[i]);
            if (ol >= sizeof owner)
                continue;
            memcpy(owner, argv[i], ol);
            owner[ol] = '\0';
            snprintf(name, sizeof name, "%s", sl + 1);
            if (!owner_ok(owner) || !name_ok(name))
                continue;
            if (export_repo(c, destdir, owner, name, maxc) == 0) {
                buf_printf(&idx, "<tr><td><a href='%s/%s/index.html'>"
                                 "%s/%s</a></td></tr>",
                           owner, name, owner, name);
                n++;
            }
        }
    } else {
        /* discovery mirrors the web index: the filesystem is the
         * source of truth for existence, repo_perm() decides what
         * anonymous may read (public or unregistered -> 'r').
         * --private lifts the filter entirely — explicit operator
         * consent to publish private code. */
        DIR *root = opendir(c->repos_root);
        struct dirent *oe;
        while (root && (oe = readdir(root))) {
            if (!owner_ok(oe->d_name))
                continue;
            struct buf od;
            buf_init(&od);
            buf_printf(&od, "%s/%s", c->repos_root, oe->d_name);
            DIR *rd = opendir(od.data ? od.data : "");
            buf_free(&od);
            if (!rd)
                continue;
            struct dirent *re_;
            while ((re_ = readdir(rd))) {
                if (!ends_with(re_->d_name, ".git"))
                    continue;
                char name[64];
                size_t nl = strlen(re_->d_name) - 4;
                if (nl == 0 || nl >= sizeof name)
                    continue;
                memcpy(name, re_->d_name, nl);
                name[nl] = '\0';
                if (!name_ok(name))
                    continue;
                if (!priv &&
                    repo_perm(c->db, oe->d_name, name, 0) != 'r')
                    continue;
                if (export_repo(c, destdir, oe->d_name, name,
                                maxc) != 0) {
                    fprintf(stderr, "git-trace: export %s/%s failed\n",
                            oe->d_name, name);
                    continue;
                }
                buf_printf(&idx,
                           "<tr><td><a href='%s/%s/index.html'>"
                           "%s/%s</a></td></tr>",
                           oe->d_name, name, oe->d_name, name);
                n++;
            }
            closedir(rd);
        }
        if (root)
            closedir(root);
    }

    buf_puts(&idx, "</table>");
    xp_foot(&idx);
    struct buf ip;
    xp_path(&ip, destdir, "/index.html");
    xp_write(ip.data, &idx);
    buf_free(&ip);
    buf_free(&idx);
    printf("%d repo%s exported to %s\n", n, n == 1 ? "" : "s", destdir);
    return n > 0 ? 0 : 1;
}