Learn Git by actually using it. Type real commands in a live terminal, watch the commit graph update, and level up from git init to git rebase.
Standalone, zero-dependency JavaScript implementation powering this tool. Free to inspect, copy, and build upon.
// ═══════════════════════════════════════════
// GIT SIMULATOR
// ═══════════════════════════════════════════
class GitRepo {
constructor() { this._reset(); }
_reset() {
this._seq = 0;
this.commits = {};
this.branches = {};
this.tags = {};
this.HEAD = 'main';
this.headDetached = false;
this.index = {}; // staged: path -> content
this.workDir = {}; // working dir: path -> content
this.initialized = false;
this.stash = [];
this._mergeConflict = null;
}
_hash() {
this._seq++;
const n = this._seq * 0x9e3779b9 ^ (Math.random() * 0xffffffff | 0);
return (n >>> 0).toString(16).padStart(7,'0').slice(0,7);
}
currentBranch() { return this.headDetached ? null : this.HEAD; }
currentHash() {
if (this.headDetached) return this.HEAD;
return this.branches[this.HEAD] || null;
}
currentTree() {
const h = this.currentHash();
return h ? { ...this.commits[h].tree } : {};
}
allRefs() {
const refs = {};
Object.entries(this.branches).forEach(([b,h]) => {
if (!refs[h]) refs[h] = [];
refs[h].push(b);
});
Object.entries(this.tags).forEach(([t,h]) => {
if (!refs[h]) refs[h] = [];
refs[h].push('tag:' + t);
});
return refs;
}
// ── init ──
init(defaultBranch = 'main') {
if (this.initialized) return { ok: false, msg: 'Already initialized.' };
this.initialized = true;
this.HEAD = defaultBranch;
return { ok: true, msg: `Initialized empty Git repository.` };
}
// ── file ops ──
touch(path, content = '') {
if (!this.initialized) return { ok: false, msg: 'Not a git repository.' };
this.workDir[path] = content;
return { ok: true };
}
writeFile(path, content) {
if (!this.initialized) return { ok: false, msg: 'Not a git repository.' };
this.workDir[path] = content;
return { ok: true };
}
readFile(path) {
if (path in this.workDir) return { ok: true, content: this.workDir[path] };
const tree = this.currentTree();
if (path in tree) return { ok: true, content: tree[path] };
return { ok: false, msg: `${path}: No such file.` };
}
ls() {
const tree = this.currentTree();
const all = new Set([...Object.keys(this.workDir), ...Object.keys(tree)]);
return [...all].sort();
}
// ── status ──
status() {
if (!this.initialized) return { ok: false, msg: 'Not a git repository.' };
const tree = this.currentTree();
const staged = [];
const unstaged = [];
const untracked = [];
// staged = diff between index and tree
const allIndexed = new Set([...Object.keys(this.index), ...Object.keys(tree)]);
allIndexed.forEach(f => {
const inTree = f in tree;
const inIndex = f in this.index;
if (inIndex && !inTree) staged.push({ f, s: 'new file' });
else if (!inIndex && inTree) staged.push({ f, s: 'deleted' });
else if (inIndex && inTree && this.index[f] !== tree[f]) staged.push({ f, s: 'modified' });
});
// unstaged = diff between workDir and index (or tree if not in index)
const allWork = new Set([...Object.keys(this.workDir), ...Object.keys(this.index), ...Object.keys(tree)]);
allWork.forEach(f => {
if (f in this.index) return; // already staged
const inWork = f in this.workDir;
const inTree = f in tree;
if (inWork && !inTree) untracked.push(f);
else if (!inWork && inTree) unstaged.push({ f, s: 'deleted' });
else if (inWork && inTree && this.workDir[f] !== tree[f]) unstaged.push({ f, s: 'modified' });
});
return { ok: true, staged, unstaged, untracked, branch: this.currentBranch(), hash: this.currentHash(), headDetached: this.headDetached };
}
// ── add ──
add(paths) {
if (!this.initialized) return { ok: false, msg: 'Not a git repository.' };
const tree = this.currentTree();
const added = [];
for (const p of paths) {
if (p === '.') {
// add all modifications
const allWork = new Set([...Object.keys(this.workDir), ...Object.keys(tree)]);
allWork.forEach(f => {
const inWork = f in this.workDir;
const inTree = f in tree;
if (inWork) { this.index[f] = this.workDir[f]; added.push(f); }
else if (inTree && !(f in this.workDir)) { /* deleted - mark as removed */ delete this.index[f]; added.push(f); }
});
} else if (p in this.workDir) {
this.index[p] = this.workDir[p];
added.push(p);
} else if (p in tree) {
this.index[p] = tree[p];
added.push(p);
} else {
return { ok: false, msg: `pathspec '${p}' did not match any files.` };
}
}
return { ok: true, added };
}
// ── commit ──
commit(message, opts = {}) {
if (!this.initialized) return { ok: false, msg: 'Not a git repository.' };
if (this._mergeConflict) return { ok: false, msg: 'You have unresolved conflicts. Fix them first.' };
const tree = this.currentTree();
// merge index into tree
const newTree = { ...tree, ...this.index };
// handle deletions: if a file was in tree but not in workDir and was staged as deleted
// (simplified: we just use index as additive here; for deletions use git rm)
const staged = Object.keys(this.index);
if (staged.length === 0 && !opts.allowEmpty) {
return { ok: false, msg: 'Nothing to commit, working tree clean.\nUse git add <file> first.' };
}
const hash = this._hash();
const parents = [];
const cur = this.currentHash();
if (cur) parents.push(cur);
if (opts.mergeParent) parents.push(opts.mergeParent);
this.commits[hash] = {
hash,
message,
parents,
tree: newTree,
author: 'you',
timestamp: Date.now(),
};
if (!this.headDetached) {
this.branches[this.HEAD] = hash;
} else {
// detached HEAD commit - update HEAD ref
this.HEAD = hash;
}
this.index = {};
return { ok: true, hash, shortHash: hash.slice(0,7), message, branch: this.currentBranch() };
}
// ── log ──
log(opts = {}) {
if (!this.initialized) return { ok: false, msg: 'Not a git repository.' };
const h = this.currentHash();
if (!h) return { ok: true, entries: [] };
const entries = [];
const visited = new Set();
const queue = [h];
while (queue.length) {
const cur = queue.shift();
if (visited.has(cur)) continue;
visited.add(cur);
const c = this.commits[cur];
if (!c) continue;
entries.push(c);
c.parents.forEach(p => queue.push(p));
}
entries.sort((a, b) => b.timestamp - a.timestamp);
return { ok: true, entries };
}
// ── branch ──
branch(name, opts = {}) {
if (!this.initialized) return { ok: false, msg: 'Not a git repository.' };
if (opts.delete) {
if (!(name in this.branches)) return { ok: false, msg: `Branch '${name}' not found.` };
if (name === this.HEAD && !this.headDetached) return { ok: false, msg: `Cannot delete currently checked-out branch.` };
delete this.branches[name];
return { ok: true, msg: `Deleted branch ${name}.` };
}
if (opts.list || !name) {
return { ok: true, branches: Object.keys(this.branches), current: this.currentBranch() };
}
if (name in this.branches) return { ok: false, msg: `A branch named '${name}' already exists.` };
const h = this.currentHash();
if (!h) return { ok: false, msg: 'Cannot create branch: no commits yet.' };
this.branches[name] = h;
return { ok: true, msg: `Created branch '${name}'.` };
}
// ── checkout / switch ──
checkout(target, opts = {}) {
if (!this.initialized) return { ok: false, msg: 'Not a git repository.' };
if (opts.newBranch) {
if (target in this.branches) return { ok: false, msg: `Branch '${target}' already exists.` };
const base = opts.from || this.currentHash();
if (!base) return { ok: false, msg: 'No commits yet.' };
this.branches[target] = base;
}
if (target in this.branches) {
this.HEAD = target;
this.headDetached = false;
// update workDir to branch tree
const tree = { ...this.commits[this.branches[target]].tree };
this.workDir = { ...tree };
this.index = {};
return { ok: true, msg: `Switched to branch '${target}'.`, branch: target };
}
// try as hash (detached HEAD)
const fullHash = this._resolveHash(target);
if (fullHash) {
this.HEAD = fullHash;
this.headDetached = true;
this.workDir = { ...this.commits[fullHash].tree };
this.index = {};
return { ok: true, msg: `HEAD is now at ${fullHash.slice(0,7)} ${this.commits[fullHash].message}`, detached: true };
}
return { ok: false, msg: `pathspec '${target}' did not match any branch or commit.` };
}
_resolveHash(ref) {
// exact match
if (ref in this.commits) return ref;
// short hash prefix
const match = Object.keys(this.commits).find(h => h.startsWith(ref));
return match || null;
}
// ── merge ──
merge(branchName) {
if (!this.initialized) return { ok: false, msg: 'Not a git repository.' };
if (!(branchName in this.branches)) return { ok: false, msg: `Branch '${branchName}' not found.` };
const curHash = this.currentHash();
const tgtHash = this.branches[branchName];
if (curHash === tgtHash) return { ok: false, msg: 'Already up to date.' };
// check if curHash is ancestor of tgtHash → fast-forward
if (this._isAncestor(curHash, tgtHash)) {
this.branches[this.HEAD] = tgtHash;
const tree = this.commits[tgtHash].tree;
this.workDir = { ...tree };
return { ok: true, msg: `Fast-forward. Updated ${this.HEAD} to ${tgtHash.slice(0,7)}.`, ff: true, hash: tgtHash };
}
// check if tgtHash is ancestor of curHash → already up to date
if (this._isAncestor(tgtHash, curHash)) {
return { ok: true, msg: 'Already up to date.' };
}
// find LCA
const base = this._lca(curHash, tgtHash);
const baseTree = base ? this.commits[base].tree : {};
const curTree = this.commits[curHash].tree;
const tgtTree = this.commits[tgtHash].tree;
// 3-way merge
const merged = {};
const conflicts = [];
const allFiles = new Set([...Object.keys(curTree), ...Object.keys(tgtTree), ...Object.keys(baseTree)]);
allFiles.forEach(f => {
const b = baseTree[f];
const c = curTree[f];
const t = tgtTree[f];
if (c === t) { if (c !== undefined) merged[f] = c; }
else if (c === b) { if (t !== undefined) merged[f] = t; }
else if (t === b) { if (c !== undefined) merged[f] = c; }
else {
// real conflict
conflicts.push(f);
merged[f] = `<<<<<<< HEAD\n${c || ''}\n=======\n${t || ''}\n>>>>>>> ${branchName}`;
}
});
if (conflicts.length > 0) {
this.workDir = merged;
this._mergeConflict = { branch: branchName, parent2: tgtHash };
return { ok: false, conflict: true, conflicts, msg: `Merge conflict in: ${conflicts.join(', ')}.\nFix conflicts then: git add . && git commit` };
}
// auto-commit merge
this.workDir = merged;
this.index = merged;
const res = this.commit(`Merge branch '${branchName}'`, { mergeParent: tgtHash });
this.index = {};
this._mergeConflict = null;
return { ok: true, msg: `Merge made by 'ort' strategy.\nNew commit ${res.shortHash}: Merge branch '${branchName}'`, hash: res.hash, ff: false };
}
_isAncestor(ancestor, descendant) {
if (!ancestor || !descendant) return false;
const visited = new Set();
const queue = [descendant];
while (queue.length) {
const cur = queue.shift();
if (cur === ancestor) return true;
if (visited.has(cur)) continue;
visited.add(cur);
const c = this.commits[cur];
if (c) c.parents.forEach(p => queue.push(p));
}
return false;
}
_lca(a, b) {
const ancestorsA = new Set();
const queueA = [a];
while (queueA.length) {
const cur = queueA.shift();
if (!cur || ancestorsA.has(cur)) continue;
ancestorsA.add(cur);
const c = this.commits[cur];
if (c) c.parents.forEach(p => queueA.push(p));
}
const queueB = [b];
const visited = new Set();
while (queueB.length) {
const cur = queueB.shift();
if (visited.has(cur)) continue;
visited.add(cur);
if (ancestorsA.has(cur)) return cur;
const c = this.commits[cur];
if (c) c.parents.forEach(p => queueB.push(p));
}
return null;
}
// ── rebase ──
rebase(baseBranch) {
if (!this.initialized) return { ok: false, msg: 'Not a git repository.' };
if (!(baseBranch in this.branches)) return { ok: false, msg: `Branch '${baseBranch}' not found.` };
const curHash = this.currentHash();
const baseHash = this.branches[baseBranch];
if (curHash === baseHash) return { ok: true, msg: 'Already up to date.' };
if (this._isAncestor(curHash, baseHash)) {
// current is behind, fast-forward
this.branches[this.HEAD] = baseHash;
this.workDir = { ...this.commits[baseHash].tree };
return { ok: true, msg: `Fast-forward. Already up to date.` };
}
const lca = this._lca(curHash, baseHash);
// collect commits to replay (from lca to curHash)
const toReplay = [];
let cur = curHash;
while (cur && cur !== lca) {
toReplay.unshift(this.commits[cur]);
cur = this.commits[cur]?.parents[0];
}
if (toReplay.length === 0) return { ok: true, msg: 'Nothing to rebase.' };
// replay commits onto baseHash
let newParent = baseHash;
const newHashes = [];
for (const commit of toReplay) {
const newHash = this._hash();
const parentTree = this.commits[newParent].tree;
// apply the diff of this commit onto newParent
const oldParentTree = commit.parents.length > 0 ? (this.commits[commit.parents[0]]?.tree || {}) : {};
const newTree = { ...parentTree };
// apply additions/modifications
Object.entries(commit.tree).forEach(([f, content]) => {
if (!(f in oldParentTree) || oldParentTree[f] !== content) {
newTree[f] = content;
}
});
// apply deletions
Object.keys(oldParentTree).forEach(f => {
if (!(f in commit.tree)) delete newTree[f];
});
this.commits[newHash] = {
hash: newHash,
message: commit.message,
parents: [newParent],
tree: newTree,
author: commit.author,
timestamp: Date.now(),
};
newHashes.push(newHash);
newParent = newHash;
}
this.branches[this.HEAD] = newParent;
this.workDir = { ...this.commits[newParent].tree };
this.index = {};
return { ok: true, msg: `Successfully rebased. Applied ${toReplay.length} commit(s).`, count: toReplay.length };
}
// ── cherry-pick ──
cherryPick(ref) {
if (!this.initialized) return { ok: false, msg: 'Not a git repository.' };
const hash = this._resolveHash(ref) || (ref in this.branches ? this.branches[ref] : null);
if (!hash) return { ok: false, msg: `Bad object: ${ref}` };
const commit = this.commits[hash];
if (!commit) return { ok: false, msg: `commit '${ref}' not found.` };
const parentTree = commit.parents.length > 0 ? (this.commits[commit.parents[0]]?.tree || {}) : {};
const curTree = this.currentTree();
const newTree = { ...curTree };
// apply changes from commit
Object.entries(commit.tree).forEach(([f, content]) => {
if (!(f in parentTree) || parentTree[f] !== content) newTree[f] = content;
});
Object.keys(parentTree).forEach(f => {
if (!(f in commit.tree)) delete newTree[f];
});
const newHash = this._hash();
this.commits[newHash] = {
hash: newHash,
message: commit.message,
parents: [this.currentHash()],
tree: newTree,
author: commit.author,
timestamp: Date.now(),
};
if (!this.headDetached) this.branches[this.HEAD] = newHash;
else this.HEAD = newHash;
this.workDir = { ...newTree };
return { ok: true, hash: newHash, shortHash: newHash.slice(0,7), msg: `Cherry-picked ${hash.slice(0,7)}: ${commit.message}` };
}
// ── reset ──
reset(target, mode = 'mixed') {
if (!this.initialized) return { ok: false, msg: 'Not a git repository.' };
let hash = this._resolveHash(target);
if (!hash && target in this.branches) hash = this.branches[target];
if (!hash) {
// handle HEAD~n notation
const tilde = target.match(/^HEAD~(\d+)$/i);
if (tilde) {
let h = this.currentHash();
let n = parseInt(tilde[1]);
while (n-- > 0 && h) h = this.commits[h]?.parents[0];
hash = h;
}
}
if (!hash) return { ok: false, msg: `ambiguous argument '${target}'.` };
if (!this.headDetached) this.branches[this.HEAD] = hash;
else this.HEAD = hash;
const tree = this.commits[hash].tree;
if (mode === 'hard') {
this.index = {};
this.workDir = { ...tree };
} else if (mode === 'mixed') {
this.index = {};
}
// soft: only moves HEAD, keeps index + workDir
return { ok: true, msg: `HEAD is now at ${hash.slice(0,7)}`, hash };
}
// ── stash ──
stash(opts = {}) {
if (!this.initialized) return { ok: false, msg: 'Not a git repository.' };
if (opts.pop) {
if (!this.stash.length) return { ok: false, msg: 'No stash entries found.' };
const entry = this.stash.pop();
this.workDir = { ...this.currentTree(), ...entry.workDir };
this.index = { ...entry.index };
return { ok: true, msg: `Dropped stash@{0}. Restored working directory.` };
}
if (opts.list) {
if (!this.stash.length) return { ok: true, entries: [] };
return { ok: true, entries: this.stash.map((e,i) => `stash@{${i}}: WIP on ${e.branch || 'HEAD'}: ${e.msg}`) };
}
// default: save
const tree = this.currentTree();
let hasDiff = false;
Object.keys(this.workDir).forEach(f => { if (this.workDir[f] !== tree[f]) hasDiff = true; });
Object.keys(this.index).forEach(() => { hasDiff = true; });
if (!hasDiff) return { ok: false, msg: 'No local changes to save.' };
const entry = { workDir: { ...this.workDir }, index: { ...this.index }, branch: this.currentBranch(), msg: 'WIP' };
this.stash.push(entry);
this.workDir = { ...tree };
this.index = {};
return { ok: true, msg: `Saved working directory state. stash@{0}` };
}
// ── tag ──
tag(name, ref) {
if (!this.initialized) return { ok: false, msg: 'Not a git repository.' };
if (!name) {
return { ok: true, tags: Object.keys(this.tags) };
}
const hash = ref ? (this._resolveHash(ref) || this.branches[ref]) : this.currentHash();
if (!hash) return { ok: false, msg: 'No commit to tag.' };
this.tags[name] = hash;
return { ok: true, msg: `Tagged ${hash.slice(0,7)} as '${name}'.` };
}
// ── diff ──
diff(staged = false) {
const tree = this.currentTree();
const compare = staged ? this.index : this.workDir;
const lines = [];
const allFiles = new Set([...Object.keys(tree), ...Object.keys(compare)]);
allFiles.forEach(f => {
const old = tree[f] || '';
const cur = compare[f];
if (cur === undefined) {
lines.push(`--- a/${f}`, `+++ /dev/null`, `-${old}`);
} else if (old !== cur) {
lines.push(`--- a/${f}`, `+++ b/${f}`, `-${old}`, `+${cur}`);
}
});
return { ok: true, lines };
}
// ── inject a pre-made commit (for level setup) ──
_injectCommit(hash, message, parents, tree, branch = null) {
this.commits[hash] = { hash, message, parents, tree, author: 'you', timestamp: Date.now() };
if (branch) this.branches[branch] = hash;
return hash;
}
}
// ═══════════════════════════════════════════
// GRAPH RENDERER
// ═══════════════════════════════════════════
const BRANCH_COLORS = ['#ff2200','#4fc3f7','#81c784','#ffb74d','#ce93d8','#f06292','#4db6ac','#fff176'];
let branchColorMap = {};
let branchColorIdx = 0;
function branchColor(name) {
if (!branchColorMap[name]) {
branchColorMap[name] = BRANCH_COLORS[branchColorIdx % BRANCH_COLORS.length];
branchColorIdx++;
}
return branchColorMap[name];
}
function renderGraph(repo) {
const svg = document.getElementById('graphSvg');
const empty = document.getElementById('graphEmpty');
svg.innerHTML = '';
const allCommits = Object.values(repo.commits);
if (!allCommits.length) { empty.style.display = ''; return; }
empty.style.display = 'none';
// collect all reachable commits from all refs
const reachable = new Set();
const queue = [...Object.values(repo.branches), ...Object.values(repo.tags)];
if (repo.headDetached && repo.HEAD in repo.commits) queue.push(repo.HEAD);
while (queue.length) {
const h = queue.shift();
if (!h || reachable.has(h) || !repo.commits[h]) continue;
reachable.add(h);
repo.commits[h].parents.forEach(p => queue.push(p));
}
// sort topologically: oldest first
const sorted = [...reachable].map(h => repo.commits[h])
.sort((a,b) => a.timestamp - b.timestamp);
// assign lanes (x) per commit
// strategy: track which lanes are "active" (have a branch still being built)
const laneOf = {};
const activeLanes = []; // activeLanes[i] = hash of tip of lane i
const maxLanes = 6;
// process newest to oldest for lane assignment
const sorted_new_first = [...sorted].reverse();
sorted_new_first.forEach(commit => {
// find if any child already assigned this commit to a lane
let lane = -1;
for (let i = 0; i < activeLanes.length; i++) {
if (activeLanes[i] === commit.hash) { lane = i; break; }
}
if (lane === -1) {
// find first free lane
lane = activeLanes.findIndex(l => l === null);
if (lane === -1) lane = Math.min(activeLanes.length, maxLanes - 1);
if (activeLanes.length <= lane) activeLanes.push(commit.hash);
else activeLanes[lane] = commit.hash;
}
laneOf[commit.hash] = lane;
// update active lanes for parents
if (commit.parents.length === 0) {
activeLanes[lane] = null;
} else {
activeLanes[lane] = commit.parents[0];
// second parent (merge) gets a new lane
if (commit.parents.length > 1) {
let ml = activeLanes.findIndex(l => l === null);
if (ml === -1) { if (activeLanes.length < maxLanes) ml = activeLanes.length; else ml = maxLanes - 1; }
if (activeLanes.length <= ml) activeLanes.push(commit.parents[1]);
else activeLanes[ml] = commit.parents[1];
}
}
});
// layout constants
const containerW = document.getElementById('graphArea').clientWidth || 600;
const containerH = document.getElementById('graphArea').clientHeight || 280;
const xPad = 80;
const yPad = 40;
const laneGap = 60;
const rowGap = 60;
const r = 10;
const maxVisible = Math.floor((containerH - yPad * 2) / rowGap) + 1;
const visible = sorted.slice(-maxVisible);
const posOf = {};
visible.forEach((commit, idx) => {
const lane = laneOf[commit.hash] || 0;
const x = xPad + lane * laneGap;
const y = containerH - yPad - idx * rowGap;
posOf[commit.hash] = { x, y };
});
// SVG namespace helper
function el(tag, attrs = {}) {
const e = document.createElementNS('http://www.w3.org/2000/svg', tag);
Object.entries(attrs).forEach(([k,v]) => e.setAttribute(k, v));
return e;
}
function text(content, attrs = {}) {
const t = el('text', attrs);
t.textContent = content;
return t;
}
svg.setAttribute('viewBox', `0 0 ${containerW} ${containerH}`);
const refs = repo.allRefs();
// determine commit colors by branch ownership
function commitColor(commit) {
// which branch does this commit belong to?
const branches = Object.entries(repo.branches);
// walk from each branch tip until we find this commit
for (const [bname, tip] of branches) {
let h = tip;
const visited = new Set();
while (h && !visited.has(h)) {
if (h === commit.hash) return branchColor(bname);
visited.add(h);
h = repo.commits[h]?.parents[0];
}
}
return '#888';
}
// draw edges first
visible.forEach(commit => {
const pos = posOf[commit.hash];
if (!pos) return;
commit.parents.forEach((parentHash, pi) => {
const ppos = posOf[parentHash];
if (!ppos) return;
const color = commitColor(commit);
if (pos.x === ppos.x) {
// straight line
svg.appendChild(el('line', { x1: pos.x, y1: pos.y + r, x2: ppos.x, y2: ppos.y - r, stroke: color, 'stroke-width': 2, opacity: 0.6 }));
} else {
// curved line
const mx = (pos.x + ppos.x) / 2;
const d = `M ${pos.x} ${pos.y + r} C ${pos.x} ${pos.y + rowGap / 2}, ${ppos.x} ${ppos.y - rowGap / 2}, ${ppos.x} ${ppos.y - r}`;
svg.appendChild(el('path', { d, stroke: color, 'stroke-width': 2, fill: 'none', opacity: 0.6 }));
}
});
});
// draw commits
visible.forEach(commit => {
const pos = posOf[commit.hash];
if (!pos) return;
const color = commitColor(commit);
const g = el('g', { class: 'g-commit', transform: `translate(${pos.x},${pos.y})` });
// circle
const isHead = (repo.headDetached ? repo.HEAD : repo.branches[repo.HEAD]) === commit.hash;
g.appendChild(el('circle', { r: isHead ? r + 3 : r, fill: color, stroke: isHead ? '#fff' : 'none', 'stroke-width': isHead ? 2 : 0 }));
// hash label
g.appendChild(text(commit.hash.slice(0,7), { x: r + 6, y: 4, class: 'g-hash', fill: '#666', 'font-size': 9 }));
// message (truncated)
const msg = commit.message.length > 28 ? commit.message.slice(0,25) + '…' : commit.message;
g.appendChild(text(msg, { x: r + 56, y: 4, class: 'g-msg', fill: '#888', 'font-size': 10 }));
// branch/HEAD labels
const labels = refs[commit.hash] || [];
// also add HEAD if detached
if (repo.headDetached && repo.HEAD === commit.hash) labels.unshift('HEAD');
if (!repo.headDetached) {
const headBranch = repo.HEAD;
if (repo.branches[headBranch] === commit.hash) {
labels.unshift(headBranch + ' (HEAD)');
}
}
let lx = -r - 6;
[...labels].reverse().forEach(lbl => {
const isHEAD = lbl.includes('HEAD');
const isTag = lbl.startsWith('tag:');
const dispLbl = lbl.replace('tag:', '').replace(' (HEAD)', '');
const dispText = isHEAD && lbl === 'HEAD' ? 'HEAD' : (lbl.includes('(HEAD)') ? `${dispLbl} ← HEAD` : dispLbl);
const bgColor = isTag ? '#f5c518' : (isHEAD && lbl === 'HEAD' ? '#fff' : branchColor(dispLbl));
const tw = dispText.length * 7 + 10;
const labelG = el('g', { transform: `translate(${lx - tw}, -18)` });
labelG.appendChild(el('rect', { width: tw, height: 16, fill: bgColor, rx: 2, opacity: 0.9 }));
labelG.appendChild(text(dispText, { x: 5, y: 11, fill: '#000', 'font-size': 9, 'font-weight': 'bold', 'font-family': 'monospace' }));
g.appendChild(labelG);
lx -= tw + 4;
});
svg.appendChild(g);
});
}
// ═══════════════════════════════════════════
// COMMAND PARSER & EXECUTOR
// ═══════════════════════════════════════════
function executeCommand(repo, rawCmd) {
const cmd = rawCmd.trim();
if (!cmd) return [];
const parts = tokenize(cmd);
const prog = parts[0];
const args = parts.slice(1);
if (prog === 'git') return execGit(repo, args);
if (prog === 'touch') return execTouch(repo, args);
if (prog === 'echo') return execEcho(repo, parts);
if (prog === 'cat') return execCat(repo, args);
if (prog === 'ls') return execLs(repo);
if (prog === 'clear') return [{ type: 'clear' }];
if (prog === 'help') return [{ type: 'info', text: 'Available commands:\n git <command> — git operations\n touch <file> — create file\n echo "text" > file — write file\n cat <file> — read file\n ls — list files\n clear — clear terminal' }];
return [{ type: 'error', text: `command not found: ${prog}` }];
}
function tokenize(str) {
const tokens = [];
let i = 0, tok = '';
while (i <= str.length) {
const c = str[i];
if (c === '"' || c === "'") {
const q = c; i++;
while (i < str.length && str[i] !== q) tok += str[i++];
i++;
} else if (c === ' ' || c === '\t' || c === undefined) {
if (tok) { tokens.push(tok); tok = ''; }
} else {
tok += c;
}
i++;
}
return tokens;
}
function execGit(repo, args) {
if (!args.length) return [{ type: 'output', text: 'usage: git <command> [args]\nTry: git help' }];
const sub = args[0];
if (sub === 'help' || sub === '--help') {
return [{ type: 'info', text: 'Common commands:\n git init\n git status\n git add <file|.>\n git commit -m "msg"\n git log [--oneline]\n git branch [name]\n git checkout <branch> / git checkout -b <name>\n git switch <branch> / git switch -c <name>\n git merge <branch>\n git rebase <branch>\n git cherry-pick <hash>\n git reset [--soft|--mixed|--hard] <ref>\n git stash / git stash pop / git stash list\n git tag <name>\n git diff' }];
}
if (sub === 'init') {
const r = repo.init();
return [r.ok ? { type: 'success', text: r.msg } : { type: 'error', text: r.msg }];
}
if (sub === 'status') {
const s = repo.status();
if (!s.ok) return [{ type: 'error', text: s.msg }];
const lines = [];
if (s.headDetached) lines.push(`HEAD detached at ${repo.HEAD.slice(0,7)}`);
else {
lines.push(`On branch ${s.branch}`);
if (!s.hash) lines.push('No commits yet');
}
if (s.staged.length) {
lines.push('Changes to be committed:');
s.staged.forEach(({ f, s: st }) => lines.push(` \x1b[32m${st}: ${f}\x1b[0m`));
}
if (s.unstaged.length) {
lines.push('Changes not staged for commit:');
s.unstaged.forEach(({ f, s: st }) => lines.push(` modified: ${f}`));
}
if (s.untracked.length) {
lines.push('Untracked files:');
s.untracked.forEach(f => lines.push(` ${f}`));
}
if (!s.staged.length && !s.unstaged.length && !s.untracked.length) lines.push('nothing to commit, working tree clean');
return lines.map(l => ({ type: 'output', text: l }));
}
if (sub === 'add') {
const paths = args.slice(1);
if (!paths.length) return [{ type: 'error', text: 'Nothing specified, nothing added.' }];
const r = repo.add(paths);
if (!r.ok) return [{ type: 'error', text: r.msg }];
return r.added.map(f => ({ type: 'success', text: `Staged: ${f}` }));
}
if (sub === 'commit') {
let msg = '';
for (let i = 1; i < args.length; i++) {
if (args[i] === '-m' && args[i+1]) { msg = args[i+1]; break; }
if (args[i].startsWith('-m')) { msg = args[i].slice(2); break; }
}
if (!msg) return [{ type: 'error', text: 'Aborting commit due to empty message.\nUse: git commit -m "your message"' }];
const r = repo.commit(msg);
if (!r.ok) return [{ type: 'error', text: r.msg }];
return [{ type: 'success', text: `[${r.branch || 'HEAD'} ${r.shortHash}] ${r.message}` }];
}
if (sub === 'log') {
const oneline = args.includes('--oneline');
const r = repo.log();
if (!r.ok) return [{ type: 'error', text: r.msg }];
if (!r.entries.length) return [{ type: 'muted', text: '(no commits)' }];
const refs = repo.allRefs();
return r.entries.map(c => {
const labels = refs[c.hash] || [];
if (!repo.headDetached && repo.HEAD && repo.branches[repo.HEAD] === c.hash) labels.unshift('HEAD → ' + repo.HEAD);
const labelStr = labels.length ? ` (${labels.join(', ')})` : '';
if (oneline) return { type: 'output', text: `${c.hash.slice(0,7)} ${c.message}${labelStr}` };
return { type: 'output', text: `commit ${c.hash}\nAuthor: ${c.author}\n\n ${c.message}${labelStr}\n` };
});
}
if (sub === 'branch') {
const delFlag = args.includes('-d') || args.includes('-D');
const name = args.find(a => !a.startsWith('-'));
if (delFlag && name) {
const r = repo.branch(name, { delete: true });
return [r.ok ? { type: 'success', text: r.msg } : { type: 'error', text: r.msg }];
}
if (name) {
const r = repo.branch(name);
return [r.ok ? { type: 'success', text: r.msg } : { type: 'error', text: r.msg }];
}
const r = repo.branch(null, { list: true });
if (!r.ok) return [{ type: 'error', text: r.msg }];
return r.branches.map(b => ({ type: 'output', text: (b === r.current ? '* ' : ' ') + b }));
}
if (sub === 'checkout' || sub === 'switch') {
const newBranch = args.includes('-b') || args.includes('-c') || args.includes('--create');
const target = args.find(a => !a.startsWith('-'));
if (!target) return [{ type: 'error', text: `No branch or commit specified.` }];
const r = repo.checkout(target, { newBranch });
return [r.ok ? { type: 'success', text: r.msg } : { type: 'error', text: r.msg }];
}
if (sub === 'merge') {
const branch = args.find(a => !a.startsWith('-'));
if (!branch) return [{ type: 'error', text: 'No branch specified.' }];
const r = repo.merge(branch);
if (r.conflict) return [{ type: 'warning', text: r.msg }];
return [r.ok ? { type: 'success', text: r.msg } : { type: 'error', text: r.msg }];
}
if (sub === 'rebase') {
const branch = args.find(a => !a.startsWith('-'));
if (!branch) return [{ type: 'error', text: 'No branch specified.' }];
const r = repo.rebase(branch);
return [r.ok ? { type: 'success', text: r.msg } : { type: 'error', text: r.msg }];
}
if (sub === 'cherry-pick') {
const ref = args[1];
if (!ref) return [{ type: 'error', text: 'No commit specified.' }];
const r = repo.cherryPick(ref);
return [r.ok ? { type: 'success', text: r.msg } : { type: 'error', text: r.msg }];
}
if (sub === 'reset') {
let mode = 'mixed';
const modeFlag = args.find(a => a.startsWith('--'));
if (modeFlag) mode = modeFlag.slice(2);
const target = args.find(a => !a.startsWith('-')) || 'HEAD';
const r = repo.reset(target, mode);
return [r.ok ? { type: 'success', text: r.msg } : { type: 'error', text: r.msg }];
}
if (sub === 'stash') {
const action = args[1];
if (!action || action === 'push') { const r = repo.stash(); return [r.ok ? { type: 'success', text: r.msg } : { type: 'error', text: r.msg }]; }
if (action === 'pop') { const r = repo.stash({ pop: true }); return [r.ok ? { type: 'success', text: r.msg } : { type: 'error', text: r.msg }]; }
if (action === 'list') { const r = repo.stash({ list: true }); return r.entries?.map(e => ({ type: 'output', text: e })) || [{ type: 'muted', text: '(empty stash)' }]; }
return [{ type: 'error', text: `Unknown stash command: ${action}` }];
}
if (sub === 'tag') {
const name = args[1];
const r = repo.tag(name);
if (!r.ok) return [{ type: 'error', text: r.msg }];
if (r.tags) return r.tags.length ? r.tags.map(t => ({ type: 'output', text: t })) : [{ type: 'muted', text: '(no tags)' }];
return [{ type: 'success', text: r.msg }];
}
if (sub === 'diff') {
const staged = args.includes('--staged') || args.includes('--cached');
const r = repo.diff(staged);
if (!r.lines.length) return [{ type: 'muted', text: '(no changes)' }];
return r.lines.map(l => ({
type: l.startsWith('+') ? 'success' : l.startsWith('-') ? 'error' : 'output',
text: l
}));
}
if (sub === 'show') {
const ref = args[1] || repo.currentHash();
const hash = repo._resolveHash(ref) || (ref in repo.branches ? repo.branches[ref] : null);
if (!hash) return [{ type: 'error', text: `bad object ${ref}` }];
const c = repo.commits[hash];
const lines = [`commit ${c.hash}\nAuthor: ${c.author}\n\n ${c.message}\n`];
const parentTree = c.parents.length ? (repo.commits[c.parents[0]]?.tree || {}) : {};
Object.entries(c.tree).forEach(([f, content]) => {
if (!(f in parentTree)) lines.push(`+++ b/${f}\n+${content}`);
else if (parentTree[f] !== content) lines.push(`--- a/${f}\n+++ b/${f}\n-${parentTree[f]}\n+${content}`);
});
return lines.map(l => ({ type: 'output', text: l }));
}
return [{ type: 'error', text: `git: '${sub}' is not a git command.\nSee 'git help'` }];
}
function execTouch(repo, args) {
if (!args.length) return [{ type: 'error', text: 'touch: missing file operand' }];
if (!repo.initialized) return [{ type: 'error', text: 'fatal: not a git repository' }];
args.forEach(f => repo.touch(f, ''));
return args.map(f => ({ type: 'output', text: `Created: ${f}` }));
}
function execEcho(repo, parts) {
// echo "text" > file or echo "text" >> file or just echo text
const raw = parts.slice(1).join(' ');
const appendIdx = raw.lastIndexOf('>>');
const writeIdx = raw.lastIndexOf('>');
if (appendIdx > -1) {
const text = raw.slice(0, appendIdx).trim().replace(/^["']|["']$/g, '');
const file = raw.slice(appendIdx + 2).trim();
if (!repo.initialized) return [{ type: 'error', text: 'fatal: not a git repository' }];
const existing = repo.readFile(file);
repo.writeFile(file, (existing.ok ? existing.content + '\n' : '') + text);
return [{ type: 'output', text: `Appended to ${file}` }];
}
if (writeIdx > -1) {
const text = raw.slice(0, writeIdx).trim().replace(/^["']|["']$/g, '');
const file = raw.slice(writeIdx + 1).trim();
if (!repo.initialized) return [{ type: 'error', text: 'fatal: not a git repository' }];
repo.writeFile(file, text);
return [{ type: 'output', text: `Wrote to ${file}` }];
}
// plain echo
const text = raw.replace(/^["']|["']$/g, '');
return [{ type: 'output', text }];
}
function execCat(repo, args) {
if (!args.length) return [{ type: 'error', text: 'cat: missing file operand' }];
if (!repo.initialized) return [{ type: 'error', text: 'fatal: not a git repository' }];
return args.flatMap(f => {
const r = repo.readFile(f);
return r.ok ? [{ type: 'output', text: r.content || '(empty file)' }] : [{ type: 'error', text: r.msg }];
});
}
function execLs(repo) {
if (!repo.initialized) return [{ type: 'error', text: 'fatal: not a git repository' }];
const files = repo.ls();
return files.length ? files.map(f => ({ type: 'output', text: f })) : [{ type: 'muted', text: '(empty)' }];
}
// ═══════════════════════════════════════════
// LEVELS
// ═══════════════════════════════════════════
const LEVELS = [
{
id: 1, title: 'First Repository',
commands: ['git init', 'touch', 'git add', 'git commit'],
desc: 'Initialize a repo, create a file, and make your first commit.',
objective: 'Initialize a Git repository and make at least one commit.',
story: 'Every project starts here. Initialize a repo, add a file, and capture your first snapshot.',
hints: [
{ reveal: 'Run <code>git init</code> to initialize the repository.' },
{ reveal: 'Create a file: <code>touch readme.txt</code>' },
{ reveal: 'Stage it: <code>git add readme.txt</code>' },
{ reveal: 'Commit: <code>git commit -m "initial commit"</code>' },
],
setup: (repo) => { /* empty repo */ },
check: (repo) => Object.keys(repo.commits).length >= 1,
win: 'You made your first commit! Every great codebase starts with this single step.',
},
{
id: 2, title: 'Build History',
commands: ['git commit', 'git log', 'git status'],
desc: 'Make multiple commits and inspect history with git log.',
objective: 'Make at least 3 commits and run git log to see the history.',
story: 'A single commit is a snapshot. Multiple commits tell a story. Build a history and learn to read it.',
hints: [
{ reveal: 'Edit a file: <code>echo "v2" > readme.txt</code> then commit again.' },
{ reveal: '<code>git add .</code> stages all changes at once.' },
{ reveal: '<code>git log --oneline</code> shows a compact history.' },
{ reveal: '<code>git status</code> always tells you what needs to be done next.' },
],
setup: (repo) => {
repo.init();
repo.touch('readme.txt', 'Hello World');
repo.add(['.']);
repo.commit('initial commit');
},
check: (repo) => {
const { entries } = repo.log();
return entries && entries.length >= 3;
},
win: 'You\'re building history. git log is your time machine — every commit is a point you can return to.',
},
{
id: 3, title: 'Create a Branch',
commands: ['git branch', 'git checkout -b', 'git switch'],
desc: 'Create a new branch and make a commit on it.',
objective: 'Create a branch called "feature" and make a commit on it.',
story: 'Branches let you work on new features without breaking main. Create a feature branch and commit to it.',
hints: [
{ reveal: 'Create and switch: <code>git checkout -b feature</code>' },
{ reveal: 'Or: <code>git switch -c feature</code>' },
{ reveal: 'Make a change: <code>echo "feature work" > feature.txt</code>' },
{ reveal: '<code>git add . && git commit -m "add feature"</code>' },
],
setup: (repo) => {
repo.init();
repo.touch('readme.txt', 'Hello');
repo.add(['.']);
repo.commit('initial commit');
},
check: (repo) => {
return 'feature' in repo.branches && repo.branches['feature'] !== repo.branches['main'];
},
win: 'Branches are free in Git. Create them liberally — they\'re the foundation of every workflow.',
},
{
id: 4, title: 'Fast-Forward Merge',
commands: ['git merge'],
desc: 'Merge a branch when no diverging changes exist.',
objective: 'Merge the "feature" branch into "main" (fast-forward).',
story: 'You finished a feature on its own branch. Now it\'s time to merge it back. When main hasn\'t moved, Git does a fast-forward — no merge commit needed.',
hints: [
{ reveal: 'First switch to main: <code>git checkout main</code>' },
{ reveal: 'Then merge: <code>git merge feature</code>' },
{ reveal: 'Since main hasn\'t changed, Git moves the pointer forward — no commit needed.' },
{ reveal: 'Run <code>git log --oneline</code> to see the result.' },
],
setup: (repo) => {
repo.init();
repo.touch('readme.txt', 'Hello');
repo.add(['.']); repo.commit('initial commit');
repo.branch('feature');
repo.checkout('feature');
repo.touch('feature.txt', 'new feature');
repo.add(['.']); repo.commit('add feature');
repo.checkout('main');
},
check: (repo) => repo.branches['main'] === repo.branches['feature'],
win: 'Fast-forward merge! When branches haven\'t diverged, Git just moves the pointer — clean and simple.',
},
{
id: 5, title: 'True Merge',
commands: ['git merge'],
desc: 'Merge two diverged branches creating a merge commit.',
objective: 'Merge "feature" into "main". Both branches have unique commits — Git will create a merge commit.',
story: 'Both main and feature have new work. Git can\'t fast-forward, so it creates a merge commit to join the histories.',
hints: [
{ reveal: 'Switch to main: <code>git checkout main</code>' },
{ reveal: 'Run <code>git merge feature</code> — a merge commit is created automatically.' },
{ reveal: 'Run <code>git log --oneline</code> to see the merge commit with two parents.' },
],
setup: (repo) => {
repo.init();
repo.touch('main.txt', 'main file');
repo.add(['.']); repo.commit('initial commit');
repo.branch('feature');
repo.checkout('feature');
repo.touch('feature.txt', 'feature work');
repo.add(['.']); repo.commit('feature commit');
repo.checkout('main');
repo.touch('hotfix.txt', 'hotfix');
repo.add(['.']); repo.commit('hotfix on main');
},
check: (repo) => {
const mainHash = repo.branches['main'];
if (!mainHash) return false;
const commit = repo.commits[mainHash];
return commit && commit.parents.length === 2; // merge commit
},
win: 'A merge commit! Notice in the graph how it has two parents. This is how Git records "these two histories were joined here."',
},
{
id: 6, title: 'Time Travel',
commands: ['git checkout <hash>', 'git log'],
desc: 'Visit an old commit with a detached HEAD.',
objective: 'Checkout an old commit by its hash to enter "detached HEAD" mode, then return to main.',
story: 'Git is a time machine. You can visit any point in history. When you checkout a commit hash (not a branch), you enter detached HEAD mode — exploring without modifying history.',
hints: [
{ reveal: 'Run <code>git log --oneline</code> to see hashes.' },
{ reveal: 'Checkout an old commit: <code>git checkout <hash></code>' },
{ reveal: 'You\'re in detached HEAD. You can look but shouldn\'t commit here.' },
{ reveal: 'Return to safety: <code>git checkout main</code>' },
],
setup: (repo) => {
repo.init();
repo.touch('v1.txt', 'version 1');
repo.add(['.']); repo.commit('version 1');
repo.touch('v2.txt', 'version 2');
repo.add(['.']); repo.commit('version 2');
repo.touch('v3.txt', 'version 3');
repo.add(['.']); repo.commit('version 3');
},
check: (repo) => !repo.headDetached && repo.HEAD === 'main',
win: 'You traveled through time and came back! Detached HEAD is Git\'s way of saying "you\'re looking at history, not writing it."',
},
{
id: 7, title: 'Undo with Reset',
commands: ['git reset --soft', 'git reset --hard'],
desc: 'Undo commits with different reset strategies.',
objective: 'Use git reset to undo the last commit (keep the "bad-commit" from entering history).',
story: 'You committed something you shouldn\'t have. git reset lets you undo commits. --soft keeps changes staged, --hard throws them away.',
hints: [
{ reveal: 'Run <code>git log --oneline</code> to see "bad commit" at the top.' },
{ reveal: 'Undo it (keep files): <code>git reset --soft HEAD~1</code>' },
{ reveal: 'Or undo it completely: <code>git reset --hard HEAD~1</code>' },
{ reveal: 'HEAD~1 means "one commit before HEAD". HEAD~2 goes back two.' },
],
setup: (repo) => {
repo.init();
repo.touch('app.txt', 'good code');
repo.add(['.']); repo.commit('good commit');
repo.touch('oops.txt', 'mistake');
repo.add(['.']); repo.commit('bad commit — undo me!');
},
check: (repo) => {
const entries = repo.log().entries;
return entries && entries.length === 1 && entries[0].message === 'good commit';
},
win: 'Reset mastered! Remember: reset rewrites history. Only use it on commits you haven\'t pushed. For shared history, use git revert instead.',
},
{
id: 8, title: 'Cherry Pick',
commands: ['git cherry-pick'],
desc: 'Pick a single commit from another branch.',
objective: 'Cherry-pick the "hotfix" commit from the "fix" branch onto main.',
story: 'The fix branch has one specific commit you need right now — but you\'re not ready to merge the whole branch. Cherry-pick copies just that one commit.',
hints: [
{ reveal: 'Find the commit: <code>git log --oneline fix</code> isn\'t real, but try <code>git log --oneline</code> while on fix branch first.' },
{ reveal: 'Switch to main: <code>git checkout main</code>' },
{ reveal: '<code>git log</code> on fix branch to find the hash, then: <code>git cherry-pick <hash></code>' },
{ reveal: 'Or use the branch name directly: <code>git cherry-pick fix</code> to pick the tip commit.' },
],
setup: (repo) => {
repo.init();
repo.touch('app.txt', 'v1');
repo.add(['.']); repo.commit('initial');
repo.branch('fix');
repo.checkout('fix');
repo.touch('app.txt', 'v1-fixed');
repo.add(['.']); repo.commit('critical hotfix');
repo.checkout('main');
repo.touch('feature.txt', 'wip feature');
repo.add(['.']); repo.commit('wip feature');
},
check: (repo) => {
const mainHash = repo.branches['main'];
if (!mainHash) return false;
let h = mainHash;
while (h) {
const c = repo.commits[h];
if (c && c.message === 'critical hotfix') return true;
h = c?.parents[0];
}
return false;
},
win: 'Cherry-pick! You transplanted a single commit. It gets a new hash but the same changes. Perfect for backporting fixes.',
},
{
id: 9, title: 'Rebase',
commands: ['git rebase'],
desc: 'Replay your branch commits onto another branch for a linear history.',
objective: 'Rebase the "feature" branch onto "main" for a clean, linear history.',
story: 'While you worked on feature, main got new commits. Instead of merging (which creates a merge commit), rebase replays your commits on top of the updated main. The result: a clean, linear history.',
hints: [
{ reveal: 'Switch to feature: <code>git checkout feature</code>' },
{ reveal: 'Then rebase: <code>git rebase main</code>' },
{ reveal: 'Your commits are replayed on top of main\'s latest commit.' },
{ reveal: 'Check the result: <code>git log --oneline</code> — perfectly linear!' },
],
setup: (repo) => {
repo.init();
repo.touch('base.txt', 'base');
repo.add(['.']); repo.commit('base commit');
repo.branch('feature');
repo.checkout('feature');
repo.touch('feat.txt', 'feature');
repo.add(['.']); repo.commit('feature work');
repo.checkout('main');
repo.touch('main2.txt', 'main update');
repo.add(['.']); repo.commit('main progress');
repo.checkout('feature');
},
check: (repo) => {
const featureHash = repo.branches['feature'];
const mainHash = repo.branches['main'];
return featureHash && mainHash && repo._isAncestor(mainHash, featureHash);
},
win: 'Rebased! Your commits now sit on top of main\'s latest. The history looks as if you started the feature after main\'s update. Clean and professional.',
},
{
id: 10, title: 'Stash Work',
commands: ['git stash', 'git stash pop'],
desc: 'Save work-in-progress without committing, then restore it.',
objective: 'Stash your current changes, switch branches, then pop the stash back.',
story: 'You\'re mid-feature when an urgent bug report comes in. You can\'t commit half-done work. Stash it — Git saves your changes as a stack entry, then you can restore them later.',
hints: [
{ reveal: 'You have uncommitted changes in the working dir. Run <code>git stash</code>.' },
{ reveal: 'Now switch to fix: <code>git checkout fix</code>. Clean working directory!' },
{ reveal: 'Do some work on fix, then switch back: <code>git checkout main</code>' },
{ reveal: 'Restore your stashed work: <code>git stash pop</code>' },
],
setup: (repo) => {
repo.init();
repo.touch('app.txt', 'v1');
repo.add(['.']); repo.commit('initial');
repo.branch('fix');
// put uncommitted changes in workDir
repo.workDir['wip.txt'] = 'half-finished feature';
},
check: (repo) => {
return repo.stash.length === 0 && 'wip.txt' in repo.workDir;
},
win: 'Stash mastered! Think of stash as a clipboard for your work-in-progress. It\'s perfect for context-switching without polluting your commit history.',
},
];
// ═══════════════════════════════════════════
// GAME CONTROLLER
// ═══════════════════════════════════════════
let repo = null;
let currentLevel = null;
let cmdHistory = [];
let historyIdx = -1;
let levelComplete = false;
const completedLevels = new Set(JSON.parse(localStorage.getItem('gitquest_completed') || '[]'));
function buildLanding() {
const grid = document.getElementById('levelGrid');
grid.innerHTML = '';
LEVELS.forEach((lvl, i) => {
const locked = i > 0 && !completedLevels.has(i - 1);
const done = completedLevels.has(lvl.id - 1);
const card = document.createElement('div');
card.className = 'level-card' + (locked ? ' locked' : '') + (done ? ' completed' : '');
card.innerHTML = `
<div class="lc-num">${String(lvl.id).padStart(2,'0')}</div>
<div class="lc-title">${lvl.title}</div>
<div class="lc-desc">${lvl.desc}</div>
<div class="lc-commands">${lvl.commands.map(c => `<span class="lc-cmd">${c}</span>`).join('')}</div>
`;
if (!locked) card.onclick = () => startLevel(lvl.id - 1);
grid.appendChild(card);
});
}
function startLevel(idx) {
const lvl = LEVELS[idx];
currentLevel = lvl;
levelComplete = false;
branchColorMap = {};
branchColorIdx = 0;
repo = new GitRepo();
lvl.setup(repo);
if (Object.keys(repo.branches).length > 0) branchColor('main');
document.getElementById('landing').style.display = 'none';
const gameEl = document.getElementById('game');
gameEl.classList.add('visible');
// progress dots
const prog = document.getElementById('lpProgress');
prog.innerHTML = LEVELS.map((l, i) => {
const cl = completedLevels.has(l.id - 1) ? 'done' : (i === idx ? 'current' : '');
return `<div class="lp-dot ${cl}"></div>`;
}).join('');
document.getElementById('lpNum').textContent = `LEVEL ${lvl.id} / ${LEVELS.length}`;
document.getElementById('lpTitle').textContent = lvl.title;
document.getElementById('lpObjective').textContent = lvl.objective;
document.getElementById('lpStory').textContent = lvl.story;
document.getElementById('lpComplete').classList.remove('show');
// hints
const hintEl = document.getElementById('lpHints');
hintEl.innerHTML = lvl.hints.map((h, i) => `
<div class="hint-item" onclick="this.classList.toggle('revealed')">
<div class="hint-toggle">💡 Hint ${i + 1} — click to reveal</div>
<div class="hint-revealed-text">${h.reveal}</div>
</div>
`).join('');
// terminal
const output = document.getElementById('termOutput');
output.innerHTML = '';
termPrint({ type: 'info', text: `=== LEVEL ${lvl.id}: ${lvl.title} ===` });
termPrint({ type: 'muted', text: lvl.objective });
termPrint({ type: 'muted', text: '─'.repeat(40) });
if (Object.keys(repo.commits).length > 0) {
termPrint({ type: 'muted', text: 'Repo pre-loaded. Run: git log --oneline' });
}
termPrint({ type: 'muted', text: 'Type "help" for available commands.' });
updatePrompt();
renderGraph(repo);
updateFileList();
const input = document.getElementById('termInput');
input.value = '';
cmdHistory = [];
historyIdx = -1;
setTimeout(() => input.focus(), 100);
}
function exitGame() {
document.getElementById('game').classList.remove('visible');
document.getElementById('landing').style.display = 'block';
buildLanding();
}
function nextLevel() {
document.getElementById('winOverlay').classList.remove('show');
const nextIdx = LEVELS.indexOf(currentLevel) + 1;
if (nextIdx < LEVELS.length) {
startLevel(nextIdx);
} else {
exitGame();
setTimeout(() => alert('🎉 You completed all levels! You\'re a Git master.'), 200);
}
}
function replayLevel() {
document.getElementById('winOverlay').classList.remove('show');
startLevel(LEVELS.indexOf(currentLevel));
}
function termPrint(line) {
const output = document.getElementById('termOutput');
if (line.type === 'clear') { output.innerHTML = ''; return; }
const div = document.createElement('div');
div.className = 'term-line ' + (line.type || 'output');
div.textContent = line.text;
output.appendChild(div);
output.scrollTop = output.scrollHeight;
}
function updatePrompt() {
const branch = repo.headDetached ? `(${repo.HEAD.slice(0,7)})` : repo.HEAD;
document.getElementById('termPrompt').textContent = `~/repo (${branch})
Git Quest — Learn Git by Playing — toolpad.cc
Game
GIT QUEST
Learn Git by actually using it. Type real commands in a live terminal, watch the commit graph update, and level up from git init to git rebase.
LEVEL 1
—
LEVEL CLEAR
Objective
Story
Hints (click to reveal)
No commits yet.
Initialize a repository to get started.
~/repo $
;
}
function updateFileList() {
const section = document.getElementById('lpFilesSection');
const list = document.getElementById('lpFiles');
const files = repo.ls();
if (!files.length) { section.style.display = 'none'; return; }
section.style.display = '';
const tree = repo.currentTree();
const status = repo.initialized ? repo.status() : { staged: [], unstaged: [], untracked: [] };
const stagedNames = new Set((status.staged || []).map(s => s.f));
const modNames = new Set((status.unstaged || []).map(s => s.f));
const untrackedNames = new Set(status.untracked || []);
list.innerHTML = files.map(f => {
let st = 'tracked', stClass = '';
if (stagedNames.has(f)) { st = 'staged'; stClass = 'staged'; }
else if (modNames.has(f)) { st = 'modified'; stClass = 'modified'; }
else if (untrackedNames.has(f)) { st = 'untracked'; stClass = 'untracked'; }
return `<div class="file-item"><span class="file-name">${f}</span><span class="file-status ${stClass}">${st}</span></div>`;
}).join('');
}
function handleInput(e) {
if (e.key === 'Enter') {
const input = document.getElementById('termInput');
const cmd = input.value.trim();
if (!cmd) return;
cmdHistory.unshift(cmd);
historyIdx = -1;
input.value = '';
termPrint({ type: 'cmd', text: `$ ${cmd}` });
const lines = executeCommand(repo, cmd);
lines.forEach(l => termPrint(l));
updatePrompt();
renderGraph(repo);
updateFileList();
// check level complete
if (!levelComplete && currentLevel && currentLevel.check(repo)) {
levelComplete = true;
completedLevels.add(currentLevel.id - 1);
localStorage.setItem('gitquest_completed', JSON.stringify([...completedLevels]));
setTimeout(() => {
document.getElementById('winMsg').textContent = currentLevel.win;
document.getElementById('winOverlay').classList.add('show');
document.getElementById('lpComplete').classList.add('show');
document.getElementById('lpCompleteMsg').textContent = currentLevel.win;
document.getElementById('lpScroll').scrollTop = 0;
}, 400);
}
} else if (e.key === 'ArrowUp') {
e.preventDefault();
if (historyIdx < cmdHistory.length - 1) {
historyIdx++;
document.getElementById('termInput').value = cmdHistory[historyIdx];
}
} else if (e.key === 'ArrowDown') {
e.preventDefault();
if (historyIdx > 0) { historyIdx--; document.getElementById('termInput').value = cmdHistory[historyIdx]; }
else { historyIdx = -1; document.getElementById('termInput').value = ''; }
} else if (e.key === 'Tab') {
e.preventDefault();
// basic autocomplete
const val = document.getElementById('termInput').value;
const suggestions = ['git init','git status','git add .','git commit -m ""','git log --oneline','git branch','git checkout -b','git merge','git rebase','git cherry-pick','git reset --hard HEAD~1','git stash','git stash pop','touch ','echo "" > ','cat ','ls'];
const match = suggestions.find(s => s.startsWith(val) && s !== val);
if (match) document.getElementById('termInput').value = match;
}
}
// ── init ──
document.getElementById('termInput').addEventListener('keydown', handleInput);
document.getElementById('graphArea').addEventListener('click', () => document.getElementById('termInput').focus());
// Handle resize for graph rerender
window.addEventListener('resize', () => { if (repo) renderGraph(repo); });
buildLanding();