[
  {
    "path": ".dockerignore",
    "content": "venv\n*.db\n"
  },
  {
    "path": ".github/workflows/fly-deploy.yml",
    "content": "# From https://fly.io/docs/app-guides/continuous-deployment-with-github-actions/\nname: Fly Deploy\non:\n  push:\n    branches:\n      - main\njobs:\n  deploy:\n    name: Deploy app\n    runs-on: ubuntu-latest\n    concurrency: deploy-group\n    steps:\n      - uses: actions/checkout@v4\n      - uses: superfly/flyctl-actions/setup-flyctl@master\n      - run: flyctl deploy --remote-only\n        env:\n          FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}\n"
  },
  {
    "path": ".gitignore",
    "content": "*.pyc\ngit_workflow.db\n"
  },
  {
    "path": "Dockerfile",
    "content": "# syntax=docker/dockerfile:1\n\nARG PYTHON_VERSION=3.13.6\n\nFROM python:${PYTHON_VERSION}-slim\n\nLABEL fly_launch_runtime=\"flask\"\n\nRUN apt-get update && apt-get install -y graphviz && apt-get install -y fonts-inconsolata\n\nWORKDIR /code\n\nCOPY requirements.txt requirements.txt\nRUN pip3 install -r requirements.txt\n\nCOPY . .\n\nEXPOSE 8080\n\nCMD [\"gunicorn\", \"--bind\", \"0.0.0.0:8080\", \"app:app\"]\n"
  },
  {
    "path": "LICENSE",
    "content": "Copyright (c) 2013 Julia Evans\n\nPermission is hereby granted, free of charge, to any person obtaining a copy of\nthis software and associated documentation files (the \"Software\"), to deal in\nthe Software without restriction, including without limitation the rights to\nuse, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of\nthe Software, and to permit persons to whom the Software is furnished to do so,\nsubject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS\nFOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR\nCOPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER\nIN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN\nCONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.\n"
  },
  {
    "path": "README.md",
    "content": "Visualize your Git!\n===================\n\nSource for http://visualize-your-git.herokuapp.com\n\nIt makes pictures that look like this:\n\n![](http://i.imgur.com/ezMmOXv.png)\n\n\nStand-alone version\n-------------------\n\n```\npip install -r requirements.local.txt\nhistory | python git-workflow.py > graph.svg\n```\n\nSee ``templates/index.html`` or the Website for an explanation of how it works.\n"
  },
  {
    "path": "app.py",
    "content": "from flask import (\n    Flask,\n    render_template,\n    jsonify,\n    request,\n    g,\n    redirect,\n    url_for,\n    Response,\n)\nimport pandas as pd\nimport graphviz\nimport numpy as np\nfrom io import StringIO\nimport sqlite3\nimport os\nfrom nanoid import generate\n\n\napp = Flask(__name__)\napp.config[\"DEBUG\"] = True\n\n\ndef load_valid_commands():\n    with open(\"commands.txt\", \"r\") as f:\n        return set(line.strip() for line in f)\n\n\nVALID_GIT_COMMANDS = load_valid_commands()\n\n\n@app.route(\"/\")\ndef hello():\n    return render_template(\"index.html\")\n\n\n@app.route(\"/review/<log_id>\")\ndef review_commands(log_id):\n    cursor = g.conn.cursor()\n    cursor.execute(\n        \"SELECT DISTINCT command FROM entries WHERE log_id = ? AND valid = 0\", (log_id,)\n    )\n    invalid_commands = [row[0] for row in cursor.fetchall()]\n    return render_template(\n        \"review_commands.html\", log_id=log_id, invalid_commands=invalid_commands\n    )\n\n\n@app.route(\"/review/<log_id>/clean\", methods=[\"POST\"])\ndef clean_commands(log_id):\n    cursor = g.conn.cursor()\n    cursor.execute(\"DELETE FROM entries WHERE log_id = ? AND valid = 0\", (log_id,))\n    g.conn.commit()\n    return redirect(url_for(\"display_graph\", num=log_id))\n\n\n@app.route(\"/review/<log_id>/keep\", methods=[\"POST\"])\ndef keep_commands(log_id):\n    return redirect(url_for(\"display_graph\", num=log_id))\n\n\n@app.route(\"/display/<num>\")\ndef display_graph(num=None):\n    return render_template(\"display_graph.html\", num=num)\n\n\n@app.route(\"/image/<num>/git-workflow.png\")\ndef serve_image(num=None):\n    return serve_image_inner(num)\n\n@app.route(\"/image/sparse/<num>/git-workflow.png\")\ndef serve_image_sparse(num=None):\n    return serve_image_inner(num, sparse=True)\n\ndef serve_image_inner(num, sparse=False):\n    cursor = g.conn.cursor()\n    cursor.execute(\n        \"SELECT row_number, command FROM entries WHERE log_id = ? ORDER BY row_number\",\n        (num,),\n    )\n    entries = cursor.fetchall()\n    if not entries:\n        return \"Graph is empty!\", 404\n    image = create_image(entries, format='png', sparse=sparse)\n    return Response(image, mimetype=\"image/png\")\n\n@app.route(\"/graph\", methods=[\"POST\"])\ndef get_image():\n    history = request.form[\"history\"]\n    log_id = save_history(history)\n\n    # Delete invalid commands that only appear once or start with dash\n    # (probably typos or flags)\n    cursor = g.conn.cursor()\n    cursor.execute(\n        \"\"\"\n        DELETE FROM entries\n        WHERE log_id = ? AND valid = 0\n        AND (\n            command = 'git'\n            OR command LIKE '-%'\n            OR command IN (\n                SELECT command\n                FROM entries\n                WHERE log_id = ? AND valid = 0\n                GROUP BY command\n                HAVING COUNT(*) = 1\n            )\n        )\n    \"\"\",\n        (log_id, log_id),\n    )\n    g.conn.commit()\n\n    # Check if there are still invalid commands\n    cursor.execute(\n        \"SELECT COUNT(*) FROM entries WHERE log_id = ? AND valid = 0\", (log_id,)\n    )\n    invalid_count = cursor.fetchone()[0]\n\n    if invalid_count > 0:\n        return redirect(url_for(\"review_commands\", log_id=log_id))\n    else:\n        return redirect(url_for(\"display_graph\", num=log_id))\n\n\ndef save_history(history):\n    cursor = g.conn.cursor()\n    log_id = generate(\n        alphabet=\"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz\",\n        size=10,\n    )\n    cursor.execute(\"INSERT INTO logs (id) VALUES (?);\", (log_id,))\n\n    lines = [l for l in history.split(\"\\n\") if len(l.strip()) > 0]\n    for line in lines:\n        parts = line.strip().split(\" \", 1)\n        if len(parts) != 2:\n            continue\n        row_number, command = parts\n        cursor.execute(\n            \"INSERT INTO entries (log_id, row_number, command, valid) VALUES (?, ?, ?, ?);\",\n            (log_id, int(row_number), command, command in VALID_GIT_COMMANDS),\n        )\n\n    g.conn.commit()\n    return log_id\n\n\ndef build_colorscheme(nodes):\n    # colorbrewer Dark2 color scheme + lighter fill colours\n    color_palette = [\n        {\"color\": \"#3b82f6\", \"fill\": \"#eff6ff\"},\n        {\"color\": \"#f59e0b\", \"fill\": \"#fffbeb\"},\n        {\"color\": \"#ec4899\", \"fill\": \"#fdf2f8\"},\n        {\"color\": \"#8b5cf6\", \"fill\": \"#f5f3ff\"},\n        {\"color\": \"#10b981\", \"fill\": \"#f0fdf4\"},\n        {\"color\": \"#f97316\", \"fill\": \"#fff7ed\"},\n        {\"color\": \"#64748b\", \"fill\": \"#f8fafc\"},\n        {\"color\": \"#ef4444\", \"fill\": \"#fef2f2\"},\n    ]\n    return {node: color_palette[i % len(color_palette)] for i, node in enumerate(nodes)}\n\ndef create_image(entries, format, sparse):\n    pair_counts, node_totals = get_statistics(entries, sparse)\n    return create_image_inner(pair_counts, node_totals, format)\n\ndef create_image_inner(pair_counts, node_totals, format=\"svg\"):\n    dot = graphviz.Digraph()\n    dot.attr(\n        rankdir=\"TB\",\n        bgcolor=\"#fef5e7\",\n        pad=\"0.2\",\n        fontname=\"Arial,Helvetica,system-ui,sans-serif\",\n        fontsize=\"12\",\n        fontcolor=\"#656d76\",\n        label=\"Visualize Your Git: gitviz.jvns.ca\",\n        labelloc=\"b\",\n        labeljust=\"r\",\n        dpi=\"200\"\n    )\n\n    # Make a subgraph for aesthetics\n    graph = graphviz.Digraph(name='cluster_main')\n    graph.attr(\n        style=\"filled,rounded\",\n        fillcolor=\"white\",\n        pencolor=\"#f6ad55\",\n        penwidth=\"1.5\",\n        margin=\"15\",\n        label=\"\"\n    )\n\n    # Extract nodes\n    nodes = set()\n    for (frm, to), row in pair_counts.iterrows():\n        nodes.add(frm)\n        nodes.add(to)\n    # Sort to make colorscheme deterministic\n    nodes = list(sorted(nodes))\n\n    node_colors = build_colorscheme(nodes)\n\n    # Add nodes\n    for node in nodes:\n        size = np.sqrt(node_totals[node])\n        size /= float(sum(np.sqrt(node_totals)))\n        size *= 6\n        size = max(size, 0.1)\n        size = min(size, 4)\n        width = max(size, 0.7)\n\n        percentage = int(node_totals[node] / float(sum(node_totals)) * 100)\n\n        graph.node(\n            node,\n            shape=\"box\",\n            style=\"filled,rounded\",\n            fontname=\"Inconsolata, monospace\",\n            fillcolor=node_colors[node][\"fill\"],\n            color=node_colors[node][\"color\"],\n            label=f\"{node}\\\\n{percentage}%\",\n            width=str(width),\n            height=str(size),\n            fontsize=\"12\",\n            penwidth=\"1.5\",\n            margin=\"0.15\"\n        )\n\n    # Add edges\n    total_count = np.sum(pair_counts[\"count\"])\n    for (frm, to), row in pair_counts.iterrows():\n        size = row[\"count\"]\n        penwidth = min(float(size) / total_count * 60, 10)\n        arrowsize = 1.0\n        if penwidth  > 5:\n            arrowsize = 0.1\n        graph.edge(\n            frm,\n            to,\n            penwidth=str(penwidth),\n            color=node_colors[frm][\"color\"],\n            arrowsize=str(arrowsize),\n        )\n\n    # Add the cluster to the main graph\n    dot.subgraph(graph)\n\n    return dot.pipe(format=format, encoding=\"utf-8\" if format == \"svg\" else None)\n\n\ndef get_statistics(entries, sparse=False):\n    df = pd.DataFrame(entries, columns=[\"row\", \"command\"]).set_index(\"row\")\n    pairs = pd.DataFrame(index=range(len(df) - 1))\n    pairs[\"dist\"] = df.index[1:].values - df.index[:-1].values\n    pairs[\"from\"] = df[\"command\"][:-1].values\n    pairs[\"to\"] = df[\"command\"][1:].values\n    node_totals = df[\"command\"].value_counts()\n    close_pairs = pairs[pairs.dist == 1]\n    pair_counts = (\n        close_pairs.groupby([\"from\", \"to\"])\n        .aggregate(len)\n        .rename(columns={\"dist\": \"count\"})\n    )\n\n    pair_counts = pair_counts.sort_values(\"count\", ascending=False)\n    total_count = float(np.sum(pair_counts[\"count\"]))\n\n    # In sparse mode, only include transitions that happen at\n    # least 1% of the time\n    min_count = 1\n    if sparse:\n        min_count = total_count / 100\n    elif total_count >= 1000:\n        min_count = 5\n    pair_counts = pair_counts[pair_counts[\"count\"] >= min_count]\n\n    return pair_counts, node_totals\n\n\n@app.before_request\ndef before():\n    g.conn = db_connect()\n\n\n@app.teardown_request\ndef teardown_request(exception):\n    db = getattr(g, \"db\", None)\n    if db is not None:\n        db.close()\n\n\ndef db_connect():\n    db_path = os.environ.get(\"DATABASE_PATH\", \"git_workflow.db\")\n    conn = sqlite3.connect(db_path)\n    conn.row_factory = sqlite3.Row\n\n    # Create tables if they don't exist\n    with open(\"schema.sql\", \"r\") as f:\n        schema = f.read()\n    conn.executescript(schema)\n    conn.commit()\n\n    return conn\n\n\nif __name__ == \"__main__\":\n    app.run(port=5001)\n"
  },
  {
    "path": "commands.txt",
    "content": "add\nam\narchive\nbackfill\nbisect\nbranch\nbundle\ncheckout\ncherry-pick\ncitool\nclean\nclone\ncommit\ndescribe\ndiff\nfetch\nformat-patch\ngc\ngitk\ngrep\ngui\ninit\nlog\nmaintenance\nmerge\nmv\nnotes\npull\npush\nrange-diff\nrebase\nreset\nrestore\nrevert\nrm\nscalar\nshortlog\nshow\nsparse-checkout\nstash\nstatus\nsubmodule\nswitch\ntag\nworktree\nconfig\nfast-export\nfast-import\nfilter-branch\nmergetool\npack-refs\nprune\nreflog\nrefs\nremote\nrepack\nreplace\nannotate\nblame\nbugreport\ncount-objects\ndiagnose\ndifftool\nfsck\ngitweb\nhelp\ninstaweb\nmerge-tree\nrerere\nshow-branch\nverify-commit\nverify-tag\nversion\nwhatchanged\narchimport\ncvsexportcommit\ncvsimport\ncvsserver\nimap-send\np4\nquiltimport\nrequest-pull\nsend-email\nsvn\napply\ncheckout-index\ncommit-graph\ncommit-tree\nhash-object\nindex-pack\nmerge-file\nmerge-index\nmktag\nmktree\nmulti-pack-index\npack-objects\nprune-packed\nread-tree\nreplay\nsymbolic-ref\nunpack-objects\nupdate-index\nupdate-ref\nwrite-tree\n\nLow-level\ncat-file\ncherry\ndiff-files\ndiff-index\ndiff-pairs\ndiff-tree\nfor-each-ref\nfor-each-repo\nget-tar-commit-id\nls-files\nls-remote\nls-tree\nmerge-base\nname-rev\npack-redundant\nrev-list\nrev-parse\nshow-index\nshow-ref\nunpack-file\nvar\nverify-pack\n\nLow-level\ndaemon\nfetch-pack\nhttp-backend\nsend-pack\nupdate-server-info\n\nLow-level\ncheck-attr\ncheck-ignore\ncheck-mailmap\ncheck-ref-format\ncolumn\ncredential\ncredential-cache\ncredential-store\nfmt-merge-msg\nhook\ninterpret-trailers\nmailinfo\nmailsplit\nmerge-one-file\npatch-id\nsh-i18n\nsh-setup\nstripspace\n\nUser-facing\nattributes\ncli\nhooks\nignore\nmailmap\nmodules\nrepository-layout\nrevisions\n\nDeveloper-facing\nformat-bundle\nformat-chunk\nformat-commit-graph\nformat-index\nformat-pack\nformat-signature\nprotocol-capabilities\nprotocol-common\nprotocol-http\nprotocol-pack\nprotocol-v2\nabsorb\ncommit-folders\nlb\noops\npreview-pr\nremote-update-time\nreview\nsw\nvee\nci\nco\nlg\n"
  },
  {
    "path": "fly.toml",
    "content": "# fly.toml app configuration file generated for git-workflow on 2025-08-29T16:14:36-04:00\n#\n# See https://fly.io/docs/reference/configuration/ for information about how to use this file.\n#\n\napp = 'git-workflow'\nprimary_region = 'ewr'\n\n[build]\n\n[http_service]\n  internal_port = 8080\n  force_https = true\n  auto_stop_machines = 'stop'\n  auto_start_machines = true\n  min_machines_running = 0\n  processes = ['app']\n\n[[vm]]\n  memory = '1gb'\n  cpu_kind = 'shared'\n  cpus = 1\n\n[mounts]\n  source = 'visualize_git_db'\n  destination = '/data'\n\n[env]\n  DATABASE_PATH = '/data/git_workflow.db'\n"
  },
  {
    "path": "git-workflow.py",
    "content": "#!/usr/bin/env python3\n\nimport sys\nfrom app import create_image\n\nif __name__ == \"__main__\":\n    entries = []\n    for line in sys.stdin:\n        if 'git ' in line:\n            parts = line.strip().split()\n            if len(parts) >= 3 and parts[1] == 'git':\n                entries.append((int(parts[0]), parts[2]))\n\n    if entries:\n        svg = create_image(entries, format='svg', sparse=False)\n        print(svg if svg else \"<!-- No data to visualize -->\")\n"
  },
  {
    "path": "requirements.txt",
    "content": "Flask==3.1.2\ngraphviz==0.21\ngunicorn==23.0.0\nnumpy==2.3.2\npandas==2.3.2\npydot==4.0.1\nnanoid==2.0.0\n"
  },
  {
    "path": "schema.sql",
    "content": "CREATE TABLE IF NOT EXISTS logs (\n    id TEXT PRIMARY KEY,\n    created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP\n);\n\nCREATE TABLE IF NOT EXISTS entries (\n    id INTEGER PRIMARY KEY AUTOINCREMENT,\n    log_id TEXT NOT NULL,\n    row_number INTEGER NOT NULL,\n    command TEXT NOT NULL,\n    valid INTEGER NOT NULL,\n    FOREIGN KEY (log_id) REFERENCES logs (id)\n);\n"
  },
  {
    "path": "static/style.css",
    "content": "/*! minireset.css v0.0.6 | MIT License | github.com/jgthms/minireset.css */\nhtml,\nbody,\np,\nol,\nul,\nli,\ndl,\ndt,\ndd,\nblockquote,\nfigure,\nfieldset,\nlegend,\ntextarea,\npre,\niframe,\nhr,\nh1,\nh2,\nh3,\nh4,\nh5,\nh6 {\n  margin: 0;\n  padding: 0\n}\n\nh1,\nh2,\nh3,\nh4,\nh5,\nh6 {\n  font-size: 100%;\n  font-weight: normal\n}\n\nul {\n  list-style: none\n}\n\nbutton,\ninput,\nselect {\n  margin: 0\n}\n\nhtml {\n  box-sizing: border-box\n}\n\n*,\n*::before,\n*::after {\n  box-sizing: inherit\n}\n\nimg,\nvideo {\n  height: auto;\n  max-width: 100%\n}\n\niframe {\n  border: 0\n}\n\ntable {\n  border-collapse: collapse;\n  border-spacing: 0\n}\n\ntd,\nth {\n  padding: 0\n}\n\n/* done with reset */\n\n:root {\n  --bg-color: #ed8936;\n  --link-color: #bd7335;\n  /* better contrast */\n}\n\nhtml {\n  font-family: sans-serif;\n  font-size: 18px;\n}\n\nnav {\n  padding: 1.5rem;\n  background-color: var(--bg-color);\n  display: flex;\n  justify-content: space-between;\n  color: white;\n  box-shadow: 2px 2px black;\n}\n\nnav a {\n  color: white;\n  text-decoration: none;\n}\n\nnav h1 {\n  font-size: 1.2rem;\n  font-weight: bold;\n}\n\nnav ul {\n  display: flex;\n  align-items: center;\n}\n\nnav img {\n  width: 1rem;\n}\n\nnav li {\n  margin: 0 .5rem;\n}\n\nmain {\n  max-width: 800px;\n  margin: 2rem auto;\n}\n\np, .tabs, form, div, button {\n  margin: 1rem 0;\n}\n\nbody {\n  margin: .5rem;\n}\n\npre {\n  background: #eee;\n  padding: .75rem;\n  border: 2px solid black;\n  white-space: pre-wrap;\n  word-break: keep-all;\n}\n\nbutton {\n  background-color: var(--bg-color);\n  color: white;\n  padding: .5rem 1rem;\n  box-shadow: 2px 2px black;\n  border: none;\n  outline: none;\n  font-size: 1.2rem;\n  margin-right: 1rem;\n  cursor: pointer;\n}\n\nbutton:focus {\n  box-shadow: none;\n}\n\ntextarea {\n  width: 100%;\n  height: 200px;\n  padding: .5rem;\n  font-family: sans-serif;\n}\n\nh2 {\n  margin-top: 2rem;\n  font-size: 1.5rem;\n}\n\n.image {\n  text-align: center;\n}\n\n.sparse img {\n  max-width: 400px;\n}\n\na {\n  color: var(--link-color);\n}\n\nul.invalid {\n  display: flex;\n  flex-wrap: wrap;\n  gap: .25rem;\n}\n\nul.invalid li {\n  margin: .25rem;\n  padding: .25rem;\n  border-radius: 5px;\n  background-color: #eee;\n}\n\n/* tabs */\n/* https://dev.to/joxx/building-a-tab-component-with-pure-css-using-radio-and-label-tags-200b */\n\n.tabs {\n  display: flex;\n  flex-wrap: wrap;\n}\n\n.tabs label {\n    background: #eee;\n\n  cursor: pointer;\n  display: block;\n  font-weight: 600;\n  order: initial;\n  padding: .5rem 1rem;\n  transition: background ease 0.3s;\n  width: 100%;\n  border: 2px solid black;\n  border-bottom: 0;\n}\n\n.tabs .tab-content {\n  display: none;\n  flex-grow: 1;\n  padding: 1rem;\n  width: 100%;\n}\n\n.tabs input[type=\"radio\"] {\n  display: none;\n}\n\n.tabs input[type=\"radio\"]:checked+label {\n  background: var(--bg-color);\n  color: white;\n}\n\n.tabs input[type=\"radio\"]:checked+label+.tab-content {\n  display: block;\n}\n\n.tabs .tab-content {\n  order: 99\n}\n\n.tabs label {\n  order: 1;\n}\n\n.tabs label {\n  margin-right: 0.3rem;\n  margin-top: 0;\n  width: auto;\n}\n"
  },
  {
    "path": "templates/base.html",
    "content": "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n    <meta charset=\"utf-8\">\n    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n    <title>Visualize Your Git!</title>\n    <link rel=\"stylesheet\" href=\"/static/style.css\">\n    {% block meta %}{% endblock %}\n</head>\n<body>\n    <header>\n        <nav>\n            <h1><a href=\"/\">Visualize Your Git!</a></h1>\n            <ul>\n                <li><a href=\"http://jvns.ca\">By Julia Evans</a></li>\n                <li><a href=\"http://github.com/jvns/git-workflow\"><img src=\"/static/images/GitHub-Mark-32px.png\" alt=\"GitHub\"></a></li>\n            </ul>\n        </nav>\n    </header>\n\n    <main>\n        {% block content %}{% endblock %}\n    </main>\n</body>\n</html>\n"
  },
  {
    "path": "templates/display_graph.html",
    "content": "{% extends \"base.html\" %}\n\n{% block meta %}\n    <meta property=\"og:image\" content=\"https://gitviz.jvns.ca{{ url_for('serve_image_sparse', num=num) }}\">\n    <meta property=\"og:type\" content=\"website\">\n    <meta property=\"og:description\" content=\"A visualization of you use Git\">\n{% endblock %}\n\n{% block content %}\n<p>\n  An arrow from 'commit' to 'push' means that you did 'git commit' right before\n  'git push'. Thicker arrows happened more times.\n</p>\n\n<h2>\n  Sparse version <small>(only has the most used arrows)</small>\n</h2>\n\n<div class=\"image sparse\">\n  <img src=\"/image/sparse/{{ num }}/git-workflow.png\">\n</div>\n\n<h2> Full version </h2>\n<div class=\"image\">\n  <img src=\"/image/{{ num }}/git-workflow.png\">\n</div>\n{% endblock %}\n"
  },
  {
    "path": "templates/index.html",
    "content": "{% extends \"base.html\" %}\n\n{% block content %}\n<p>\n  Hello! You can use this tool to visualize how you use Git!\n</p>\n\n<p>\n  First, run this command. If you have\n  bash aliases, try replacing <code>history</code> with <a href=\"https://gist.github.com/mwhite/7509467\">this\n    script</a>.\n</p>\n\n<div class=\"tabs\">\n    <input type=\"radio\" name=\"tab\" id=\"bash\" checked>\n    <label for=\"bash\">Bash</label>\n    <pre class=\"tab-content\">history | awk '$2 == \"git\" {print $1 \" \" $3}' > history.txt</pre>\n\n    <input type=\"radio\" name=\"tab\" id=\"zsh\">\n    <label for=\"zsh\">ZSH</label>\n    <pre class=\"tab-content\">history 0 | awk '$2 == \"git\" {print $1 \" \" $3}' > history.txt</pre>\n\n    <input type=\"radio\" name=\"tab\" id=\"fish\">\n    <label for=\"fish\">Fish</label>\n    <pre class=\"tab-content\">history | nl -w1 -s' ' | awk '$2 == \"git\" {print $1 \" \" $3}' > history.txt</pre>\n</div>\n\n\n\n<p>\n  Next, paste the results! You'll get a graph of which commands you use\n  after other commands. For example, if you always push after committing,\n  there will be a big arrow from 'commit' to 'push'.\n</p>\n\n<form action=\"/graph\" method=\"POST\">\n  <textarea name=\"history\" placeholder=\"Paste your git history here...\"></textarea>\n  <button type=\"submit\">Submit</button>\n</form>\n{% endblock %}\n"
  },
  {
    "path": "templates/review_commands.html",
    "content": "{% extends \"base.html\" %}\n\n{% block content %}\n<h2>Are these typos or aliases?</h2>\n\n<p>\n  There are a few Git subcommands that don't appear to be valid:\n</p>\n\n<ul class=\"invalid\">\n  {% for command in invalid_commands %}\n  <li><code>{{ command }}</code></li>\n  {% endfor %}\n</ul>\n\n<p>\n  Do you want to remove them from your visualization, or keep them?\n</p>\n\n<form action=\"/review/{{ log_id }}/clean\" method=\"POST\" style=\"display: inline;\">\n  <button type=\"submit\">Remove them</button>\n</form>\n\n<form action=\"/review/{{ log_id }}/keep\" method=\"POST\" style=\"display: inline;\">\n  <button type=\"submit\">Keep them</button>\n</form>\n{% endblock %}\n"
  }
]