Repository: jvns/git-workflow Branch: main Commit: 935d073cd5a6 Files: 17 Total size: 19.9 KB Directory structure: gitextract_a3ambk43/ ├── .dockerignore ├── .github/ │ └── workflows/ │ └── fly-deploy.yml ├── .gitignore ├── Dockerfile ├── LICENSE ├── README.md ├── app.py ├── commands.txt ├── fly.toml ├── git-workflow.py ├── requirements.txt ├── schema.sql ├── static/ │ └── style.css └── templates/ ├── base.html ├── display_graph.html ├── index.html └── review_commands.html ================================================ FILE CONTENTS ================================================ ================================================ FILE: .dockerignore ================================================ venv *.db ================================================ FILE: .github/workflows/fly-deploy.yml ================================================ # From https://fly.io/docs/app-guides/continuous-deployment-with-github-actions/ name: Fly Deploy on: push: branches: - main jobs: deploy: name: Deploy app runs-on: ubuntu-latest concurrency: deploy-group steps: - uses: actions/checkout@v4 - uses: superfly/flyctl-actions/setup-flyctl@master - run: flyctl deploy --remote-only env: FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }} ================================================ FILE: .gitignore ================================================ *.pyc git_workflow.db ================================================ FILE: Dockerfile ================================================ # syntax=docker/dockerfile:1 ARG PYTHON_VERSION=3.13.6 FROM python:${PYTHON_VERSION}-slim LABEL fly_launch_runtime="flask" RUN apt-get update && apt-get install -y graphviz && apt-get install -y fonts-inconsolata WORKDIR /code COPY requirements.txt requirements.txt RUN pip3 install -r requirements.txt COPY . . EXPOSE 8080 CMD ["gunicorn", "--bind", "0.0.0.0:8080", "app:app"] ================================================ FILE: LICENSE ================================================ Copyright (c) 2013 Julia Evans Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README.md ================================================ Visualize your Git! =================== Source for http://visualize-your-git.herokuapp.com It makes pictures that look like this: ![](http://i.imgur.com/ezMmOXv.png) Stand-alone version ------------------- ``` pip install -r requirements.local.txt history | python git-workflow.py > graph.svg ``` See ``templates/index.html`` or the Website for an explanation of how it works. ================================================ FILE: app.py ================================================ from flask import ( Flask, render_template, jsonify, request, g, redirect, url_for, Response, ) import pandas as pd import graphviz import numpy as np from io import StringIO import sqlite3 import os from nanoid import generate app = Flask(__name__) app.config["DEBUG"] = True def load_valid_commands(): with open("commands.txt", "r") as f: return set(line.strip() for line in f) VALID_GIT_COMMANDS = load_valid_commands() @app.route("/") def hello(): return render_template("index.html") @app.route("/review/") def review_commands(log_id): cursor = g.conn.cursor() cursor.execute( "SELECT DISTINCT command FROM entries WHERE log_id = ? AND valid = 0", (log_id,) ) invalid_commands = [row[0] for row in cursor.fetchall()] return render_template( "review_commands.html", log_id=log_id, invalid_commands=invalid_commands ) @app.route("/review//clean", methods=["POST"]) def clean_commands(log_id): cursor = g.conn.cursor() cursor.execute("DELETE FROM entries WHERE log_id = ? AND valid = 0", (log_id,)) g.conn.commit() return redirect(url_for("display_graph", num=log_id)) @app.route("/review//keep", methods=["POST"]) def keep_commands(log_id): return redirect(url_for("display_graph", num=log_id)) @app.route("/display/") def display_graph(num=None): return render_template("display_graph.html", num=num) @app.route("/image//git-workflow.png") def serve_image(num=None): return serve_image_inner(num) @app.route("/image/sparse//git-workflow.png") def serve_image_sparse(num=None): return serve_image_inner(num, sparse=True) def serve_image_inner(num, sparse=False): cursor = g.conn.cursor() cursor.execute( "SELECT row_number, command FROM entries WHERE log_id = ? ORDER BY row_number", (num,), ) entries = cursor.fetchall() if not entries: return "Graph is empty!", 404 image = create_image(entries, format='png', sparse=sparse) return Response(image, mimetype="image/png") @app.route("/graph", methods=["POST"]) def get_image(): history = request.form["history"] log_id = save_history(history) # Delete invalid commands that only appear once or start with dash # (probably typos or flags) cursor = g.conn.cursor() cursor.execute( """ DELETE FROM entries WHERE log_id = ? AND valid = 0 AND ( command = 'git' OR command LIKE '-%' OR command IN ( SELECT command FROM entries WHERE log_id = ? AND valid = 0 GROUP BY command HAVING COUNT(*) = 1 ) ) """, (log_id, log_id), ) g.conn.commit() # Check if there are still invalid commands cursor.execute( "SELECT COUNT(*) FROM entries WHERE log_id = ? AND valid = 0", (log_id,) ) invalid_count = cursor.fetchone()[0] if invalid_count > 0: return redirect(url_for("review_commands", log_id=log_id)) else: return redirect(url_for("display_graph", num=log_id)) def save_history(history): cursor = g.conn.cursor() log_id = generate( alphabet="0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz", size=10, ) cursor.execute("INSERT INTO logs (id) VALUES (?);", (log_id,)) lines = [l for l in history.split("\n") if len(l.strip()) > 0] for line in lines: parts = line.strip().split(" ", 1) if len(parts) != 2: continue row_number, command = parts cursor.execute( "INSERT INTO entries (log_id, row_number, command, valid) VALUES (?, ?, ?, ?);", (log_id, int(row_number), command, command in VALID_GIT_COMMANDS), ) g.conn.commit() return log_id def build_colorscheme(nodes): # colorbrewer Dark2 color scheme + lighter fill colours color_palette = [ {"color": "#3b82f6", "fill": "#eff6ff"}, {"color": "#f59e0b", "fill": "#fffbeb"}, {"color": "#ec4899", "fill": "#fdf2f8"}, {"color": "#8b5cf6", "fill": "#f5f3ff"}, {"color": "#10b981", "fill": "#f0fdf4"}, {"color": "#f97316", "fill": "#fff7ed"}, {"color": "#64748b", "fill": "#f8fafc"}, {"color": "#ef4444", "fill": "#fef2f2"}, ] return {node: color_palette[i % len(color_palette)] for i, node in enumerate(nodes)} def create_image(entries, format, sparse): pair_counts, node_totals = get_statistics(entries, sparse) return create_image_inner(pair_counts, node_totals, format) def create_image_inner(pair_counts, node_totals, format="svg"): dot = graphviz.Digraph() dot.attr( rankdir="TB", bgcolor="#fef5e7", pad="0.2", fontname="Arial,Helvetica,system-ui,sans-serif", fontsize="12", fontcolor="#656d76", label="Visualize Your Git: gitviz.jvns.ca", labelloc="b", labeljust="r", dpi="200" ) # Make a subgraph for aesthetics graph = graphviz.Digraph(name='cluster_main') graph.attr( style="filled,rounded", fillcolor="white", pencolor="#f6ad55", penwidth="1.5", margin="15", label="" ) # Extract nodes nodes = set() for (frm, to), row in pair_counts.iterrows(): nodes.add(frm) nodes.add(to) # Sort to make colorscheme deterministic nodes = list(sorted(nodes)) node_colors = build_colorscheme(nodes) # Add nodes for node in nodes: size = np.sqrt(node_totals[node]) size /= float(sum(np.sqrt(node_totals))) size *= 6 size = max(size, 0.1) size = min(size, 4) width = max(size, 0.7) percentage = int(node_totals[node] / float(sum(node_totals)) * 100) graph.node( node, shape="box", style="filled,rounded", fontname="Inconsolata, monospace", fillcolor=node_colors[node]["fill"], color=node_colors[node]["color"], label=f"{node}\\n{percentage}%", width=str(width), height=str(size), fontsize="12", penwidth="1.5", margin="0.15" ) # Add edges total_count = np.sum(pair_counts["count"]) for (frm, to), row in pair_counts.iterrows(): size = row["count"] penwidth = min(float(size) / total_count * 60, 10) arrowsize = 1.0 if penwidth > 5: arrowsize = 0.1 graph.edge( frm, to, penwidth=str(penwidth), color=node_colors[frm]["color"], arrowsize=str(arrowsize), ) # Add the cluster to the main graph dot.subgraph(graph) return dot.pipe(format=format, encoding="utf-8" if format == "svg" else None) def get_statistics(entries, sparse=False): df = pd.DataFrame(entries, columns=["row", "command"]).set_index("row") pairs = pd.DataFrame(index=range(len(df) - 1)) pairs["dist"] = df.index[1:].values - df.index[:-1].values pairs["from"] = df["command"][:-1].values pairs["to"] = df["command"][1:].values node_totals = df["command"].value_counts() close_pairs = pairs[pairs.dist == 1] pair_counts = ( close_pairs.groupby(["from", "to"]) .aggregate(len) .rename(columns={"dist": "count"}) ) pair_counts = pair_counts.sort_values("count", ascending=False) total_count = float(np.sum(pair_counts["count"])) # In sparse mode, only include transitions that happen at # least 1% of the time min_count = 1 if sparse: min_count = total_count / 100 elif total_count >= 1000: min_count = 5 pair_counts = pair_counts[pair_counts["count"] >= min_count] return pair_counts, node_totals @app.before_request def before(): g.conn = db_connect() @app.teardown_request def teardown_request(exception): db = getattr(g, "db", None) if db is not None: db.close() def db_connect(): db_path = os.environ.get("DATABASE_PATH", "git_workflow.db") conn = sqlite3.connect(db_path) conn.row_factory = sqlite3.Row # Create tables if they don't exist with open("schema.sql", "r") as f: schema = f.read() conn.executescript(schema) conn.commit() return conn if __name__ == "__main__": app.run(port=5001) ================================================ FILE: commands.txt ================================================ add am archive backfill bisect branch bundle checkout cherry-pick citool clean clone commit describe diff fetch format-patch gc gitk grep gui init log maintenance merge mv notes pull push range-diff rebase reset restore revert rm scalar shortlog show sparse-checkout stash status submodule switch tag worktree config fast-export fast-import filter-branch mergetool pack-refs prune reflog refs remote repack replace annotate blame bugreport count-objects diagnose difftool fsck gitweb help instaweb merge-tree rerere show-branch verify-commit verify-tag version whatchanged archimport cvsexportcommit cvsimport cvsserver imap-send p4 quiltimport request-pull send-email svn apply checkout-index commit-graph commit-tree hash-object index-pack merge-file merge-index mktag mktree multi-pack-index pack-objects prune-packed read-tree replay symbolic-ref unpack-objects update-index update-ref write-tree Low-level cat-file cherry diff-files diff-index diff-pairs diff-tree for-each-ref for-each-repo get-tar-commit-id ls-files ls-remote ls-tree merge-base name-rev pack-redundant rev-list rev-parse show-index show-ref unpack-file var verify-pack Low-level daemon fetch-pack http-backend send-pack update-server-info Low-level check-attr check-ignore check-mailmap check-ref-format column credential credential-cache credential-store fmt-merge-msg hook interpret-trailers mailinfo mailsplit merge-one-file patch-id sh-i18n sh-setup stripspace User-facing attributes cli hooks ignore mailmap modules repository-layout revisions Developer-facing format-bundle format-chunk format-commit-graph format-index format-pack format-signature protocol-capabilities protocol-common protocol-http protocol-pack protocol-v2 absorb commit-folders lb oops preview-pr remote-update-time review sw vee ci co lg ================================================ FILE: fly.toml ================================================ # fly.toml app configuration file generated for git-workflow on 2025-08-29T16:14:36-04:00 # # See https://fly.io/docs/reference/configuration/ for information about how to use this file. # app = 'git-workflow' primary_region = 'ewr' [build] [http_service] internal_port = 8080 force_https = true auto_stop_machines = 'stop' auto_start_machines = true min_machines_running = 0 processes = ['app'] [[vm]] memory = '1gb' cpu_kind = 'shared' cpus = 1 [mounts] source = 'visualize_git_db' destination = '/data' [env] DATABASE_PATH = '/data/git_workflow.db' ================================================ FILE: git-workflow.py ================================================ #!/usr/bin/env python3 import sys from app import create_image if __name__ == "__main__": entries = [] for line in sys.stdin: if 'git ' in line: parts = line.strip().split() if len(parts) >= 3 and parts[1] == 'git': entries.append((int(parts[0]), parts[2])) if entries: svg = create_image(entries, format='svg', sparse=False) print(svg if svg else "") ================================================ FILE: requirements.txt ================================================ Flask==3.1.2 graphviz==0.21 gunicorn==23.0.0 numpy==2.3.2 pandas==2.3.2 pydot==4.0.1 nanoid==2.0.0 ================================================ FILE: schema.sql ================================================ CREATE TABLE IF NOT EXISTS logs ( id TEXT PRIMARY KEY, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE IF NOT EXISTS entries ( id INTEGER PRIMARY KEY AUTOINCREMENT, log_id TEXT NOT NULL, row_number INTEGER NOT NULL, command TEXT NOT NULL, valid INTEGER NOT NULL, FOREIGN KEY (log_id) REFERENCES logs (id) ); ================================================ FILE: static/style.css ================================================ /*! minireset.css v0.0.6 | MIT License | github.com/jgthms/minireset.css */ html, body, p, ol, ul, li, dl, dt, dd, blockquote, figure, fieldset, legend, textarea, pre, iframe, hr, h1, h2, h3, h4, h5, h6 { margin: 0; padding: 0 } h1, h2, h3, h4, h5, h6 { font-size: 100%; font-weight: normal } ul { list-style: none } button, input, select { margin: 0 } html { box-sizing: border-box } *, *::before, *::after { box-sizing: inherit } img, video { height: auto; max-width: 100% } iframe { border: 0 } table { border-collapse: collapse; border-spacing: 0 } td, th { padding: 0 } /* done with reset */ :root { --bg-color: #ed8936; --link-color: #bd7335; /* better contrast */ } html { font-family: sans-serif; font-size: 18px; } nav { padding: 1.5rem; background-color: var(--bg-color); display: flex; justify-content: space-between; color: white; box-shadow: 2px 2px black; } nav a { color: white; text-decoration: none; } nav h1 { font-size: 1.2rem; font-weight: bold; } nav ul { display: flex; align-items: center; } nav img { width: 1rem; } nav li { margin: 0 .5rem; } main { max-width: 800px; margin: 2rem auto; } p, .tabs, form, div, button { margin: 1rem 0; } body { margin: .5rem; } pre { background: #eee; padding: .75rem; border: 2px solid black; white-space: pre-wrap; word-break: keep-all; } button { background-color: var(--bg-color); color: white; padding: .5rem 1rem; box-shadow: 2px 2px black; border: none; outline: none; font-size: 1.2rem; margin-right: 1rem; cursor: pointer; } button:focus { box-shadow: none; } textarea { width: 100%; height: 200px; padding: .5rem; font-family: sans-serif; } h2 { margin-top: 2rem; font-size: 1.5rem; } .image { text-align: center; } .sparse img { max-width: 400px; } a { color: var(--link-color); } ul.invalid { display: flex; flex-wrap: wrap; gap: .25rem; } ul.invalid li { margin: .25rem; padding: .25rem; border-radius: 5px; background-color: #eee; } /* tabs */ /* https://dev.to/joxx/building-a-tab-component-with-pure-css-using-radio-and-label-tags-200b */ .tabs { display: flex; flex-wrap: wrap; } .tabs label { background: #eee; cursor: pointer; display: block; font-weight: 600; order: initial; padding: .5rem 1rem; transition: background ease 0.3s; width: 100%; border: 2px solid black; border-bottom: 0; } .tabs .tab-content { display: none; flex-grow: 1; padding: 1rem; width: 100%; } .tabs input[type="radio"] { display: none; } .tabs input[type="radio"]:checked+label { background: var(--bg-color); color: white; } .tabs input[type="radio"]:checked+label+.tab-content { display: block; } .tabs .tab-content { order: 99 } .tabs label { order: 1; } .tabs label { margin-right: 0.3rem; margin-top: 0; width: auto; } ================================================ FILE: templates/base.html ================================================ Visualize Your Git! {% block meta %}{% endblock %}
{% block content %}{% endblock %}
================================================ FILE: templates/display_graph.html ================================================ {% extends "base.html" %} {% block meta %} {% endblock %} {% block content %}

An arrow from 'commit' to 'push' means that you did 'git commit' right before 'git push'. Thicker arrows happened more times.

Sparse version (only has the most used arrows)

Full version

{% endblock %} ================================================ FILE: templates/index.html ================================================ {% extends "base.html" %} {% block content %}

Hello! You can use this tool to visualize how you use Git!

First, run this command. If you have bash aliases, try replacing history with this script.

history | awk '$2 == "git" {print $1 " " $3}' > history.txt
history 0 | awk '$2 == "git" {print $1 " " $3}' > history.txt
history | nl -w1 -s' ' | awk '$2 == "git" {print $1 " " $3}' > history.txt

Next, paste the results! You'll get a graph of which commands you use after other commands. For example, if you always push after committing, there will be a big arrow from 'commit' to 'push'.

{% endblock %} ================================================ FILE: templates/review_commands.html ================================================ {% extends "base.html" %} {% block content %}

Are these typos or aliases?

There are a few Git subcommands that don't appear to be valid:

    {% for command in invalid_commands %}
  • {{ command }}
  • {% endfor %}

Do you want to remove them from your visualization, or keep them?

{% endblock %}