git-visualizer / script.js
Ben-S's picture
A visual and interactive representation of the core features of git. Branch, merge, rebase, detached head, blame, commits, issues, pull requests, etc. Should be beautiful and easy to understand
ec7b4bb verified
(() => {
// Utility short-hands
const $ = (sel) => document.querySelector(sel);
const $$ = (sel) => document.querySelectorAll(sel);
// DOM refs
const svg = $('#graphSvg');
const branchListEl = $('#branchList');
const stashListEl = $('#stashList');
const currentHeadEl = $('#currentHead');
const detachedStateEl = $('#detachedState');
const commitMsgEl = $('#commitMsg');
const btnCommit = $('#btnCommit');
const btnCommitAmend = $('#btnCommitAmend');
const btnUndoCommit = $('#btnUndoCommit');
const branchNameEl = $('#branchName');
const btnCreateBranch = $('#btnCreateBranch');
const switchBranchEl = $('#switchBranch');
const checkoutCommitEl = $('#checkoutCommit');
const btnCheckout = $('#btnCheckout');
const btnDetach = $('#btnDetach');
const btnMerge = $('#btnMerge');
const btnRebase = $('#btnRebase');
const mergeTargetEl = $('#mergeTarget');
const rebaseTargetEl = $('#rebaseTarget');
const btnStash = $('#btnStash');
const btnStashPop = $('#btnStashPop');
const btnResetHard = $('#btnResetHard');
const resetTargetEl = $('#resetTarget');
const btnNewIssue = $('#btnNewIssue');
const btnNewPR = $('#btnNewPR');
const issuesListEl = $('#issuesList');
const prsListEl = $('#prsList');
const btnFit = $('#btnFit');
const btnCenterMain = $('#btnCenterMain');
const btnCenterHead = $('#btnCenterHead');
const btnDemoBasic = $('#btnDemoBasic');
const btnDemoPR = $('#btnDemoPR');
const blameCommitEl = $('#blameCommit');
const btnBlame = $('#btnBlame');
const blamePanel = $('#blamePanel');
// Graph rendering config
const Lane = {
width: 140,
marginX: 90,
marginY: 36,
commitRadius: 6
};
// Colors
const cssVar = (name, fallback) => getComputedStyle(document.documentElement).getPropertyValue(name).trim() || fallback;
// State
const state = {
repo: null,
layout: null,
nextCommitIndex: 0,
selectedNodeId: null,
cardEl: null,
};
// Init state
const initRepo = () => {
const main = newBranch('main', null);
const root = newCommit('Initial commit', 'System', { isRoot: true });
main.head = root.id;
return {
commits: new Map([[root.id, root]]),
branches: new Map([['main', main]]),
head: { type: 'branch', name: 'main' },
detachedHeadCommit: null,
stash: [],
issues: [],
prs: [],
nextId: 1
};
};
// Helpers
function newId(prefix = 'c') {
return `${prefix}${String(state.repo.nextId++).padStart(3, '0')}`;
}
function newBranch(name, fromCommitId) {
return {
name,
head: fromCommitId || null,
color: pickBranchColor()
};
}
function pickBranchColor() {
// Generates an HSL color based on repo state
const n = state.repo.branches.size + 1;
const hue = (n * 67) % 360;
return `hsl(${hue} 72% 60%)`;
}
function newCommit(message, author, extra = {}) {
const id = newId('c');
const idx = state.nextCommitIndex++;
return {
id,
idx,
message,
author,
parents: extra.parents || [],
branch: extra.branch || null,
isRoot: !!extra.isRoot,
timestamp: Date.now()
};
}
function getCommit(id) { return state.repo.commits.get(id); }
function getHeadCommit() {
const head = state.repo.head;
if (head.type === 'branch') {
const br = state.repo.branches.get(head.name);
return br && br.head ? getCommit(br.head) : null;
} else if (head.type === 'detached') {
return getCommit(head.commitId);
}
return null;
}
function getBranchByName(name) { return state.repo.branches.get(name); }
function isMergedInto(commitId, targetBranchName) {
// Check if commitId is ancestor of targetBranch head
const target = getBranchByName(targetBranchName)?.head;
if (!target) return false;
let cur = target;
const visited = new Set();
while (cur) {
if (cur === commitId) return true;
const node = getCommit(cur);
if (!node) break;
if (visited.has(cur)) break;
visited.add(cur);
cur = node.parents[0] || null;
}
return false;
}
// History navigation
function walk(commitId, fn) {
const visited = new Set();
let cur = commitId;
while (cur) {
if (visited.has(cur)) break;
visited.add(cur);
const node = getCommit(cur);
if (!node) break;
if (fn(node) === false) break;
cur = node.parents[0] || null;
}
}
// Index commit for linear layout per first-parent chain
function buildLayout() {
let idx = -1;
const visited = new Set();
let cur = null;
// find a head
const heads = [];
for (const br of state.repo.branches.values()) {
if (br.head) heads.push(br.head);
}
if (state.repo.head.type === 'detached') heads.push(state.repo.head.commitId);
// sort heads by existing idx
heads.sort((a, b) => (getCommit(a)?.idx ?? 0) - (getCommit(b)?.idx ?? 0));
// Walk each chain to assign indices topologically
for (const h of heads) {
cur = h;
const chain = [];
while (cur && !visited.has(cur)) {
visited.add(cur);
chain.push(cur);
const c = getCommit(cur);
cur = c?.parents?.[0] || null;
}
// Assign idx descending from 0 upwards
for (let i = chain.length - 1; i >= 0; i--) {
const id = chain[i];
const node = getCommit(id);
if (node && node.idx === -1) {
idx += 1;
node.idx = idx;
}
}
}
// Keep nextCommitIndex aligned with max idx
state.nextCommitIndex = idx + 1;
// Assign lanes (first-come-first-serve)
const lanes = new Map(); // branchName -> laneIndex
let laneCursor = 0;
for (const [name, br] of state.repo.branches) {
if (!br.head) continue;
if (!lanes.has(name)) {
lanes.set(name, laneCursor++);
}
}
// Compute x,y positions
const nodes = Array.from(state.repo.commits.values());
nodes.sort((a,b) => a.idx - b.idx);
const coords = new Map(); // id -> {x,y}
const xForLane = (i) => Lane.marginX + i * Lane.width;
const yForIdx = (i) => Lane.marginY + i * 36;
// First pass: assign rough positions
for (const c of nodes) {
const lane = lanes.get(c.branch || '') ?? 0;
coords.set(c.id, { x: xForLane(lane), y: yForIdx(c.idx) });
}
// Second pass: route merges with intermediate waypoints to avoid crossings
const edges = []; // {from,to,type}
for (const c of nodes) {
const a = coords.get(c.id);
for (const p of (c.parents || [])) {
const b = coords.get(p);
const type = c.parents.length > 1 ? 'merge' : 'link';
edges.push({ from: a, to: b, type });
}
}
// Compute bounding box
let maxY = 0;
nodes.forEach(n => {
const { y } = coords.get(n.id);
if (y > maxY) maxY = y;
});
state.layout = { coords, edges, maxY, lanes: lanes.size, nodes };
}
// SVG utilities
function clearSvg() {
while (svg.firstChild) svg.removeChild(svg.firstChild);
}
function make(tag, attrs = {}, children = []) {
const el = document.createElementNS('http://www.w3.org/2000/svg', tag);
for (const [k, v] of Object.entries(attrs)) {
if (v !== null && v !== undefined) el.setAttribute(k, v);
}
if (!Array.isArray(children)) children = [children];
children.forEach(ch => el.appendChild(ch));
return el;
}
// Draw graph
function renderGraph() {
clearSvg();
buildLayout();
// Size SVG
const width = Math.max(900, 2 * Lane.marginX + (state.layout.lanes * Lane.width));
const height = Math.max(420, state.layout.maxY + Lane.marginY + 40);
svg.setAttribute('viewBox', `0 0 ${width} ${height}`);
svg.setAttribute('width', '100%');
svg.setAttribute('height', '100%');
// Draw links
for (const [id, c] of state.repo.commits) {
for (const p of (c.parents || [])) {
const a = state.layout.coords.get(id);
const b = state.layout.coords.get(p);
if (!a || !b) continue;
const d = curvePath(a, b);
const cl = make('path', { d, class: `link ${c.parents.length > 1 ? 'merge' : ''}` });
svg.appendChild(cl);
}
}
// Draw nodes
const nodes = state.layout.nodes;
for (const c of nodes) {
const { x, y } = state.layout.coords.get(c.id);
const g = make('g', { class: 'node', transform: `translate(${x},${y})`, 'data-id': c.id });
const isHeadOfBranch = [...state.repo.branches.values()].some(b => b.head === c.id);
const isHead = (state.repo.head.type === 'branch' && getBranchByName(state.repo.head.name)?.head === c.id)
|| (state.repo.head.type === 'detached' && state.repo.head.commitId === c.id);
// circle
const circle = make('circle', { r: Lane.commitRadius });
g.appendChild(circle);
// label (commit short id and idx)
const short = c.id.slice(-6);
const label = make('text', { class: 'id', x: 12, y: -10 }, document.createTextNode(`${short} · idx:${c.idx}`));
g.appendChild(label);
// branch head marker
const branchNames = [];
for (const [name, br] of state.repo.branches) {
if (br.head === c.id) branchNames.push(name);
}
const branchLabel = make('text', { class: 'branch', x: 12, y: -22 }, document.createTextNode(branchNames.join(' · ')));
g.appendChild(branchLabel);
// HEAD marker
if (isHead) {
g.appendChild(make('circle', { r: Lane.commitRadius + 4, class: 'head' }));
}
// Merge node indicator: diamond
if (c.parents.length > 1) {
g.appendChild(make('rect', { x: -4, y: -4, width: 8, height: 8, class: 'diamond', transform: 'rotate(45)' }));
}
// events
g.addEventListener('click', () => showCommitCard(c.id, x, y));
svg.appendChild(g);
}
}
function curvePath(a, b) {
// Cubic Bezier curve between points
const dx = Math.max(30, Math.abs(a.x - b.x) * 0.35);
return `M ${a.x} ${a.y} C ${a.x + dx} ${a.y}, ${b.x - dx} ${b.y}, ${b.x} ${b.y}`;
}
// Commit card UI
function showCommitCard(commitId, x, y) {
if (state.cardEl && state.cardEl.parentNode) state.cardEl.parentNode.removeChild(state.cardEl);
const c = getCommit(commitId);
const card = document.createElement('div');
card.className = 'commit-card';
card.style.left = Math.min(Math.max(10, x + 14), svg.clientWidth - 280) + 'px';
card.style.top = Math.min(Math.max(10, y - 20), svg.clientHeight - 160) + 'px';
const parents = c.parents.length ? c.parents.map(id => getCommit(id)?.id.slice(-6)).join(', ') : 'none';
const branches = [...state.repo.branches.entries()]
.filter(([_, br]) => br.head === c.id)
.map(([name, _]) => name)
.join(', ') || '-';
card.innerHTML = `
<h3>${escapeHtml(c.message)}</h3>
<div class="meta">
<span class="tag">${escapeHtml(c.author)}</span>
<span class="tag">${c.id.slice(-6)}</span>
<span class="tag">parents: ${parents}</span>
<span class="tag">branches: ${escapeHtml(branches)}</span>
</div>
<div class="actions">
<button class="btn" data-action="checkout">Checkout</button>
<button class="btn" data-action="branch">New branch here</button>
<button class="btn warn" data-action="reset">Reset --hard here</button>
<button class="btn ghost" data-action="blame">Blame</button>
</div>
`;
card.addEventListener('click', (e) => {
const action = e.target?.dataset?.action;
if (!action) return;
if (action === 'checkout') checkoutCommit(c.id);
if (action === 'branch') {
branchNameEl.value = suggestBranchName();
branchNameEl.dataset.from = c.id;
}
if (action === 'reset') resetHardTo(c.id);
if (action === 'blame') {
blameCommitEl.value = c.id;
showBlame(c.id);
card.remove();
}
});
svg.parentElement.appendChild(card);
state.cardEl = card;
}
function escapeHtml(s) {
return String(s).replace(/[&<>"']/g, m => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#039;'}[m]));
}
// Actions
function commit(message, { amend = false } = {}) {
const head = getHeadCommit();
const parent = head ? head.id : null;
const branchName = state.repo.head.type === 'branch' ? state.repo.head.name : null;
if (amend && parent) {
// Modify last commit (keep same id)
const last = getCommit(parent);
if (last) {
last.message = message || last.message;
last.author = 'You';
last.timestamp = Date.now();
touchRefresh();
return;
}
}
const c = newCommit(message || defaultCommitMsg(), 'You');
c.parents = parent ? [parent] : [];
if (branchName) c.branch = branchName;
if (state.repo.head.type === 'branch') {
const br = getBranchByName(branchName);
br.head = c.id;
} else if (state.repo.head.type === 'detached') {
state.repo.head.commitId = c.id;
}
state.repo.commits.set(c.id, c);
touchRefresh();
}
function defaultCommitMsg() {
const n = (state.nextCommitIndex % 8);
const msgs = [
'Make it work',
'Improve naming',
'Add tests',
'Refactor utils',
'Optimize performance',
'Fix edge cases',
'Polish UI',
'Document code'
];
return msgs[n];
}
function createBranch(name, fromCommitId) {
if (!name) return alert('Enter a branch name.');
if (state.repo.branches.has(name)) return alert('Branch already exists.');
const from = fromCommitId || getHeadCommit()?.id || null;
const br = newBranch(name, from);
state.repo.branches.set(name, br);
touchRefresh();
}
function checkout(branchName) {
if (!state.repo.branches.has(branchName)) return alert('Branch not found.');
state.repo.head = { type: 'branch', name: branchName };
touchRefresh();
}
function checkoutCommit(commitId) {
if (!getCommit(commitId)) return alert('Commit not found.');
state.repo.head = { type: 'detached', commitId };
touchRefresh();
}
function mergeIntoCurrent(targetBranchName) {
const curName = state.repo.head.type === 'branch' ? state.repo.head.name : null;
if (!curName) return alert('You can only merge while on a branch.');
const target = getBranchByName(targetBranchName);
if (!target || !target.head) return alert('Target branch has no commits.');
const curHead = getBranchByName(curName).head;
const targetHead = target.head;
if (curHead