Git archive viewer

A web interface to view git archives (Example). Instead of hosting single-user code projects in Github (or somewhere more ethical) a single PHP file (plus highlight.js for syntax highlighting) in the public web root renders a Git archive .zip or .tar.gz as a browseable project. You can upload as many archives as you like, with tags/versions or just the git commit hash. The interface defaults to the latest.

archive.sh

Bash script to generate Git archives, just uses the standard git archive syntax. Save as archive.sh and chmod +x archive.sh to make it executable.

#!/usr/bin/env bash
#
# Usage:
#   ./archive.sh            # archive HEAD, version from `git describe`
#   ./archive.sh v1.2.0     # archive a specific tag/branch/commit
#
# Output: dist/archive-<version>.tar.gz, .zip, and a SHA256SUMS file.

set -euo pipefail

cd "$(dirname "$0")"

# Change to project name:
NAME="archive"
REF="${1:-HEAD}"
OUT_DIR="dist"

# Version label: the given ref if it's a tag/branch, else `git describe`
# default to the short commit hash when there are no tags
if [ "$REF" != "HEAD" ]; then
    VERSION="$REF"
else
    VERSION="$(git describe --tags --always --dirty 2>/dev/null || git rev-parse --short HEAD)"
fi

PREFIX="${NAME}-${VERSION}"
TARBALL="${OUT_DIR}/${PREFIX}.tar.gz"
ZIPFILE="${OUT_DIR}/${PREFIX}.zip"

if ! git diff-index --quiet HEAD -- 2>/dev/null; then
    echo "Warning: found uncommitted changes, will NOT be in the archive." >&2
fi

mkdir -p "$OUT_DIR"

git archive --format=tar.gz --prefix="${PREFIX}/" -o "$TARBALL" "$REF"
git archive --format=zip    --prefix="${PREFIX}/" -o "$ZIPFILE" "$REF"

# Checksums for integrity (Linux and MacOS should work).
if command -v sha256sum >/dev/null 2>&1; then
    SHA="sha256sum"
else
    SHA="shasum -a 256"
fi
( cd "$OUT_DIR" && $SHA "${PREFIX}.tar.gz" "${PREFIX}.zip" > "${PREFIX}.SHA256SUMS" )

echo "Ouput dir: ${OUT_DIR}/:"
ls -lh "$TARBALL" "$ZIPFILE" "${OUT_DIR}/${PREFIX}.SHA256SUMS" | awk '{printf "  %-40s %s\n", $9, $5}'

.htaccess

Put the .htaccess in any directory that contains git archives.

RewriteEngine On

RewriteCond %{REQUEST_URI} !^/archive_viewer_assets/
RewriteCond %{REQUEST_FILENAME} -d
RewriteCond %{REQUEST_FILENAME}/index.html !-f
RewriteCond %{REQUEST_FILENAME}/index.php  !-f
RewriteCond %{REQUEST_FILENAME}/index.md   !-f
RewriteRule ^ /archive_renderer.php [L,QSA]

archive_renderer.php

<?php
error_reporting(E_ALL & ~E_DEPRECATED);

// Routing —-----

$docroot    = realpath($_SERVER['DOCUMENT_ROOT'] ?? __DIR__) ?: __DIR__;
$reqPathRaw = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH) ?: '/';
$reqPath    = rawurldecode($reqPathRaw);

if (php_sapi_name() === 'cli-server' && $reqPath !== '/' && is_file($docroot . $reqPath)) {
    return false;
}

$archiveDir = realpath($docroot . $reqPath);
if ($archiveDir === false || !is_dir($archiveDir)
    || ($archiveDir !== $docroot && strpos($archiveDir, $docroot . DIRECTORY_SEPARATOR) !== 0)) {
    http_response_code(404);
    echo '<!doctype html><meta charset="utf-8"><title>Not found</title><p>Not found.</p>';
    exit;
}

// Ensure a trailing slash so relative links (?archive=, downloads) resolve
if (substr($reqPath, -1) !== '/') {
    $qs = empty($_SERVER['QUERY_STRING']) ? '' : '?' . $_SERVER['QUERY_STRING'];
    header('Location: ' . $reqPathRaw . '/' . $qs, true, 301);
    exit;
}

const ASSET_BASE = '/archive_viewer_assets';
$scriptName = basename(__FILE__);

// Archive discovery ------

function archive_basename(string $name): string {
    return preg_replace('/\.(zip|tar\.gz|tgz|tar)$/i', '', $name);
}

// Return the list of archive filenames in $dir
function find_archives(string $dir, string $skip): array {
    $found = [];
    foreach (scandir($dir) as $name) {
        if ($name === $skip) continue;
        if (preg_match('/\.(zip|tar\.gz|tgz|tar)$/i', $name)) {
            $found[] = $name;
        }
    }

    // If a base name has a .zip, drop its non-zip variants.
    $zipBases = [];
    foreach ($found as $name) {
        if (is_zip($name)) $zipBases[archive_basename($name)] = true;
    }
    $found = array_filter($found, fn($n) => is_zip($n) || !isset($zipBases[archive_basename($n)]));

    sort($found, SORT_NATURAL | SORT_FLAG_CASE);
    return $found;
}

// Resolve & validate a requested archive name against the discovered list:
function resolve_archive(?string $requested, array $archives, ?string $default = null): ?string {
    if ($requested !== null && in_array($requested, $archives, true)) {
        return $requested;
    }
    return $default ?? ($archives[0] ?? null);
}

function is_zip(string $name): bool {
    return (bool) preg_match('/\.zip$/i', $name);
}

// Reading archives ------

// List the files in an archive
function list_entries(string $archivePath): array {
    return is_zip($archivePath)
        ? list_zip_entries($archivePath)
        : list_tar_entries($archivePath);
}

function list_zip_entries(string $archivePath): array {
    $zip = new ZipArchive();
    if ($zip->open($archivePath) !== true) {
        throw new RuntimeException('Could not open zip archive.');
    }
    $entries = [];
    for ($i = 0; $i < $zip->numFiles; $i++) {
        $stat = $zip->statIndex($i);
        $name = $stat['name'];
        if (str_ends_with($name, '/')) continue; // directory entry
        $entries[] = ['path' => $name, 'size' => $stat['size']];
    }
    $zip->close();
    return $entries;
}

function list_tar_entries(string $archivePath): array {
    $entries = [];
    try {
        $phar = new PharData($archivePath);
        $iter = new RecursiveIteratorIterator($phar, RecursiveIteratorIterator::LEAVES_ONLY);
        foreach ($iter as $file) {
            /** @var PharFileInfo $file */
            if ($file->isDir()) continue;
            // Strip the "phar://.../archive.tar.gz/" prefix to get the inner path.
            $path = str_replace('phar://' . str_replace('\\', '/', realpath($archivePath)) . '/', '', str_replace('\\', '/', $file->getPathname()));
            $entries[] = ['path' => $path, 'size' => $file->getSize()];
        }
    } catch (Throwable $e) {
        throw new RuntimeException('Could not open tar archive: ' . $e->getMessage());
    }
    return $entries;
}

// Read raw file contents:
function read_entry(string $archivePath, string $entryPath): string {
    if (is_zip($archivePath)) {
        $zip = new ZipArchive();
        if ($zip->open($archivePath) !== true) {
            throw new RuntimeException('Could not open zip archive.');
        }
        $data = $zip->getFromName($entryPath);
        $zip->close();
        if ($data === false) {
            throw new RuntimeException('File not found in archive.');
        }
        return $data;
    }

    $phar = new PharData($archivePath);
    $inner = 'phar://' . $archivePath . '/' . $entryPath;
    $data = @file_get_contents($inner);
    if ($data === false) {
        throw new RuntimeException('File not found in archive.');
    }
    return $data;
}

// Helpers ------

function h(string $s): string {
    return htmlspecialchars($s, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}

// Debug code to bust server cache of style.css
function asset_url(string $name): string {
    $url = ASSET_BASE . '/' . $name;
    $fsPath = ($_SERVER['DOCUMENT_ROOT'] ?? '') . ASSET_BASE . '/' . $name;
    $mtime = @filemtime($fsPath);
    if ($mtime !== false) {
        $url .= '?v=' . $mtime;
    }
    return $url;
}

function human_size(int $bytes): string {
    if ($bytes < 1024) return $bytes . ' B';
    $units = ['KB', 'MB', 'GB'];
    $n = $bytes;
    foreach ($units as $u) {
        $n /= 1024;
        if ($n < 1024) return sprintf('%.1f %s', $n, $u);
    }
    return sprintf('%.1f GB', $n);
}

// Build nested tree:
function build_tree(array $entries): array {
    $tree = [];
    foreach ($entries as $entry) {
        $parts = explode('/', $entry['path']);
        $node = &$tree;
        $last = count($parts) - 1;
        foreach ($parts as $i => $part) {
            if ($part === '') continue;
            if ($i === $last) {
                $node['__files'][] = ['name' => $part, 'path' => $entry['path'], 'size' => $entry['size']];
            } else {
                $node['__dirs'][$part] ??= [];
                $node = &$node['__dirs'][$part];
            }
        }
        unset($node);
    }
    return $tree;
}

function render_tree(array $node, string $archive, ?string $selected): string {
    $html = '<ul>';
    if (!empty($node['__dirs'])) {
        ksort($node['__dirs'], SORT_NATURAL | SORT_FLAG_CASE);
        foreach ($node['__dirs'] as $name => $child) {
            $html .= '<li class="dir"><details open><summary>' . h($name) . '</summary>'
                   . render_tree($child, $archive, $selected) . '</details></li>';
        }
    }
    if (!empty($node['__files'])) {
        usort($node['__files'], fn($a, $b) => strnatcasecmp($a['name'], $b['name']));
        foreach ($node['__files'] as $file) {
            $url = '?archive=' . urlencode($archive) . '&file=' . urlencode($file['path']);
            $isSel = $selected === $file['path'];
            $html .= '<li class="file' . ($isSel ? ' selected' : '') . '">'
                   . '<a href="' . h($url) . '">'
                   . '<span class="fname">' . h($file['name']) . '</span>'
                   . '<span class="fsize">' . h(human_size($file['size'])) . '</span>'
                   . '</a></li>';
        }
    }
    $html .= '</ul>';
    return $html;
}

// file path to a highlight.js language type. If null hljs will try and auto-detect:
function hljs_language(string $path): ?string {
    $base = strtolower(basename($path));
    $byName = [
        'makefile'       => 'makefile',
        'dockerfile'     => 'dockerfile',
        '.gitignore'     => 'plaintext',
        '.gitattributes' => 'plaintext',
        '.htaccess'      => 'apache',
    ];
    if (isset($byName[$base])) return $byName[$base];

    $ext = strtolower(pathinfo($path, PATHINFO_EXTENSION));
    $byExt = [
        'php' => 'php', 'phtml' => 'php',
        'js' => 'javascript', 'mjs' => 'javascript', 'cjs' => 'javascript',
        'ts' => 'typescript', 'jsx' => 'javascript', 'tsx' => 'typescript',
        'css' => 'css', 'scss' => 'scss', 'less' => 'less',
        'html' => 'xml', 'htm' => 'xml', 'xml' => 'xml', 'svg' => 'xml',
        'json' => 'json', 'yml' => 'yaml', 'yaml' => 'yaml',
        'md' => 'markdown', 'markdown' => 'markdown',
        'sh' => 'bash', 'bash' => 'bash', 'zsh' => 'bash',
        'py' => 'python', 'rb' => 'ruby', 'go' => 'go', 'rs' => 'rust',
        'java' => 'java', 'kt' => 'kotlin', 'kts' => 'kotlin', 'swift' => 'swift',
        'c' => 'c', 'h' => 'c', 'cpp' => 'cpp', 'cc' => 'cpp', 'cxx' => 'cpp', 'hpp' => 'cpp',
        'cs' => 'csharp', 'sql' => 'sql', 'pl' => 'perl', 'lua' => 'lua', 'r' => 'r',
        'ini' => 'ini', 'toml' => 'ini', 'conf' => 'apache',
        'diff' => 'diff', 'patch' => 'diff', 'txt' => 'plaintext',
    ];
    return $byExt[$ext] ?? null;
}

function looks_binary(string $data): bool {
    if ($data === '') return false;
    $sample = substr($data, 0, 8000);
    if (str_contains($sample, "\0")) return true;
    // Count non-printable, non-whitespace bytes.
    $nonPrintable = preg_match_all('/[^\P{C}\t\r\n]/u', $sample);
    if ($nonPrintable === false) return true; // invalid UTF-8 => treat as binary
    return $nonPrintable > strlen($sample) * 0.1;
}

// Request handling ------

$archives = find_archives($archiveDir, $scriptName);

// Identify the newest archive (by modification time):
$latestArchive = null;
$latestMtime = -1;
foreach ($archives as $a) {
    $m = filemtime($archiveDir . '/' . $a);
    if ($m !== false && $m > $latestMtime) {
        $latestMtime = $m;
        $latestArchive = $a;
    }
}

$archive  = resolve_archive($_GET['archive'] ?? null, $archives, $latestArchive);
$file     = $_GET['file'] ?? null;
$error    = null;
$entries  = [];

if ($archive !== null) {
    try {
        $entries = list_entries($archiveDir . '/' . $archive);
    } catch (Throwable $e) {
        $error = $e->getMessage();
    }
}

// Traversal guarding:
$validPaths = array_column($entries, 'path');
if ($file !== null && !in_array($file, $validPaths, true)) {
    $file = null;
}

$fileContents = null;
$fileIsBinary = false;
if ($file !== null && $archive !== null) {
    try {
        $fileContents = read_entry($archiveDir . '/' . $archive, $file);
        $fileIsBinary = looks_binary($fileContents);
    } catch (Throwable $e) {
        $error = $e->getMessage();
    }
}

if (count($archives) === 0) {
    http_response_code(404);
}

$tree = build_tree($entries);
?>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Git Archive Viewer<?= $archive ? ' — ' . h($archive) : '' ?></title>
<link rel="stylesheet" href="<?= h(asset_url('highlight-theme.css')) ?>">
<link rel="stylesheet" href="<?= h(asset_url('viewer.css')) ?>">
</head>
<body>
<header>
     <a href="/"><img class="rose" src="/images/yorkshire_rose.svg"
     alt="White Rose of Yorkshire" /></a>
    <span class="header__id">Yorkshire&nbsp;Systems</span>
    <?php if (count($archives) > 0): ?>
    <form class="picker" method="get">
        <select id="archive" name="archive" onchange="this.form.submit()">
            <?php foreach ($archives as $a): ?>
                <option value="<?= h($a) ?>"<?= $a === $archive ? ' selected' : '' ?>><?= h($a) . ($a === $latestArchive ? ' (latest)' : '') ?></option>
            <?php endforeach; ?>
        </select>
    </form>
    <?php endif; ?>
</header>

<?php if (count($archives) === 0): ?>
    <div class="empty">No archives (.zip / .tar.gz / .tgz / .tar) found in this directory.</div>
<?php elseif ($error && empty($entries)): ?>
    <div class="error"><?= h($error) ?></div>
<?php else: ?>
<button type="button" class="sidebar-toggle" aria-controls="file-tree" aria-expanded="true" title="Show/hide file list">✕</button>
<div class="layout">
    <aside id="file-tree">
        <a class="download" href="<?= h(rawurlencode($archive)) ?>" download>↓ Download <?= h($archive) ?></a>
        <div class="sidebar-meta"><?= count($entries) ?> files in <?= h($archive) ?></div>
        <?= render_tree($tree, $archive, $file) ?>
    </aside>
    <main>
        <?php if ($error): ?>
            <div class="error"><?= h($error) ?></div>
        <?php elseif ($file === null): ?>
            <div class="empty">Select a file from the tree to view its source.</div>
        <?php else: ?>
            <div class="file-header">
                <span class="path"><?= h($file) ?></span>
                <span class="meta"><?= h(human_size(strlen((string) $fileContents))) ?></span>
            </div>
            <?php if ($fileIsBinary): ?>
                <div class="binary-note">Binary file — not shown.</div>
            <?php else:
                $lineCount = max(1, substr_count($fileContents, "\n") + 1);
                $gutter = implode("\n", range(1, $lineCount));
                $lang = hljs_language($file);
                $codeClass = $lang !== null ? 'language-' . $lang : '';
            ?>
            <div class="code-wrap">
                <div class="gutter"><?= $gutter ?></div>
                <div class="code">
                    <pre><code class="<?= h($codeClass) ?>"><?= h($fileContents) ?></code></pre>
                </div>
            </div>
            <?php endif; ?>
        <?php endif; ?>
    </main>
</div>
<?php endif; ?>
<script src="<?= h(asset_url('highlight.min.js')) ?>"></script>
<script>
    document.querySelectorAll('.code pre code').forEach(function (el) {
        hljs.highlightElement(el);
    });

    // Mobile-only: list toggle button:
    var toggle = document.querySelector('.sidebar-toggle');
    if (toggle) {
        toggle.addEventListener('click', function () {
            var collapsed = document.body.classList.toggle('sidebar-collapsed');
            toggle.setAttribute('aria-expanded', String(!collapsed));
            toggle.textContent = collapsed ? '☰' : '✕';
        });
    }
</script>
</body>
</html>