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 $
LEVEL CLEAR
Developer Reference

Core Algorithm & Standalone Script

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 &lt;hash&gt;</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 &lt;hash&gt;</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 $
LEVEL CLEAR
; } 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();