#!/usr/bin/env bash
# Real demos for P015 "The 7 Levels of Git". Runs on render01 (Docker).
# A small lab on this machine only: a Git server standing in for GitHub (git-server: SSH + an empty
# bare repository, shop.git) and two laptops, Ana's and Ben's. Ana builds her kettle shop's website
# from an empty folder; Ben joins at level 5.
# Commands are typed into real interactive shells (one pty per machine), as the viewer sees them;
# commands + real output go to demo_output.json. Terminal scenes show only that.
# File edits between commands happen off-screen (an editor would do them); every commit, diff and
# merge is on screen. Commit IDs change every run: build_script.py reads everything from the output.
set -euo pipefail
cd "$(dirname "$0")"
P=ce-p015
BOXES="git-server ana-laptop ben-laptop"
cleanup() {
  for b in $BOXES; do docker rm -f $P-$b >/dev/null 2>&1 || true; done
  docker network rm $P-net >/dev/null 2>&1 || true
}
cleanup
trap cleanup EXIT
W=/tmp/ce-p015
rm -rf $W && mkdir -p $W/img

cat > $W/img/Dockerfile <<'EOF'
FROM ubuntu:24.04
RUN apt-get update -qq && DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends \
      git openssh-client openssh-server ca-certificates >/dev/null \
 && rm -rf /var/lib/apt/lists/* && userdel -r ubuntu 2>/dev/null; \
    useradd -m -s /usr/bin/git-shell git && useradd -m -s /bin/bash ana && useradd -m -s /bin/bash ben
EOF
docker build -q -t ce-p015-git $W/img >/dev/null
docker network create $P-net >/dev/null
for b in $BOXES; do
  docker run -d --name $P-$b --hostname $b --network $P-net --network-alias $b ce-p015-git sleep infinity >/dev/null
done
ex() { docker exec "$P-$1" bash -c "$2"; }
as() { docker exec -u "$2" -w "/home/$2" -e HOME="/home/$2" "$P-$1" bash -c "$3"; }

# The server: SSH, user git (git-shell: it can only run git), one empty bare repository.
ex git-server "mkdir -p /run/sshd && /usr/sbin/sshd"; sleep 2
as git-server git "git init -q --bare -b main shop.git && mkdir -p .ssh && chmod 700 .ssh"
for u in ana ben; do
  as $u-laptop $u "mkdir -p -m 700 .ssh && ssh-keygen -q -t ed25519 -N '' -f .ssh/id_ed25519 && ssh-keyscan git-server >> .ssh/known_hosts 2>/dev/null \
    && git config --global user.name ${u^} && git config --global user.email $u@example.com && git config --global init.defaultBranch main"
  docker exec $P-$u-laptop cat /home/$u/.ssh/id_ed25519.pub | docker exec -i -u git $P-git-server sh -c 'cat >> ~/.ssh/authorized_keys'
done
ex git-server "chmod 600 /home/git/.ssh/authorized_keys"
as ana-laptop ana "mkdir shop"

python3 - "$P" <<'PY'
import fcntl, json, os, pty, re, select, struct, subprocess, sys, termios, time
P = sys.argv[1]
steps = {}
READY = re.compile(rb"__READY__:(.*?):__ ")

def sh(cmd, check=True):
    r = subprocess.run(["bash", "-c", cmd], capture_output=True, text=True)
    if check and r.returncode:
        raise SystemExit(f"FAILED: {cmd}\n{r.stdout}\n{r.stderr}")
    return r.stdout

def clean(text):
    text = re.sub(r"\x1b\[[0-9;?]*[A-Za-z]|\x1b[()][A-Z0-9]", "", text)   # colours, cursor codes
    # git's progress counters redraw their line after a bare \r: keep the last draw
    lines = [l.rstrip("\r").split("\r")[-1] for l in text.split("\n")]
    return "\n".join(l.rstrip() for l in lines).strip("\n")

class Shell:
    """One interactive bash in a real terminal (80 columns) on one machine, like a terminal window."""
    def __init__(self, box, user, cwd):
        self.host, self.user, self.where = box, user, "~"
        home = f"/home/{user}"
        pid, fd = pty.fork()
        if pid == 0:
            fcntl.ioctl(0, termios.TIOCSWINSZ, struct.pack("HHHH", 40, 80, 0, 0))
            os.execvp("docker", ["docker", "exec", "-it", "-u", user, "-w", cwd, "-e", f"HOME={home}",
                                 "-e", "TERM=xterm", "-e", "GIT_PAGER=cat", f"{P}-{box}", "bash", "-i"])
        self.fd = fd
        # Marker prompt: shows where each output ends and which folder bash is in (\w). Echo off:
        # the screen shows the command once, from our record, with the prompt the user would see.
        os.write(fd, b"stty -echo; PS1='__REA''DY__:\\w:__ '\n")
        self.read()

    def read(self, timeout=120):
        buf, end = b"", time.time() + timeout
        while time.time() < end:
            if select.select([self.fd], [], [], 0.3)[0]:
                buf += os.read(self.fd, 65536)
                m = None
                for m in READY.finditer(buf):
                    pass
                if m:
                    self.where = m.group(1).decode()
                    return clean(buf[:m.start()].decode("utf-8", "replace"))
        raise SystemExit(f"{self.host}: timed out; last output: {buf[-400:]!r}")

    def run(self, key, cmd):
        shown = f"{self.user}@{self.host}:{self.where}$ {cmd}"
        os.write(self.fd, cmd.encode() + b"\n")
        out = self.read()
        steps[key] = {"cmd": shown, "out": out, "host": self.host}
        print(f"== {key}\n{shown}\n{out}\n", flush=True)
        return out

def write(box, user, path, text):
    """Off-screen file edit on a laptop, as an editor would save it (the commands that follow are on screen)."""
    subprocess.run(["docker", "exec", "-i", "-u", user, f"{P}-{box}", "tee", f"/home/{user}/shop/{path}"],
                   input=text, text=True, capture_output=True, check=True)

def page(*rows):
    return "\n".join(rows) + "\n"

H1, BLUE, RED = "<h1>Kettle shop</h1>", "<p>Blue kettle: $25</p>", "<p>Red kettle: $30</p>"

ana = Shell("ana-laptop", "ana", "/home/ana/shop")

# ---------------------------------------------------------------- level 1: commits
write("ana-laptop", "ana", "index.html", page(H1, BLUE, RED))
ana.run("cat0", "cat index.html")
ana.run("init", "git init")
ana.run("ls_a", "ls -a")
ana.run("status0", "git status")
ana.run("add0", "git add index.html")
ana.run("status1", "git status")
ana.run("commit0", 'git commit -m "Create the shop homepage"')
ana.run("log0", "git log")

# ---------------------------------------------------------------- level 2: undo
# An accidental edit (a deleted digit): the page "worked yesterday".
write("ana-laptop", "ana", "index.html", page(H1, BLUE.replace("$25", "$2"), RED))
ana.run("status2", "git status --short")
ana.run("diff", "git diff")
ana.run("restore", "git restore index.html")
ana.run("cat1", "cat index.html")
# A mistake that got committed.
write("ana-laptop", "ana", "index.html", page(H1, "<p>Sale: every kettle is free!</p>", BLUE, RED))
ana.run("commit_bad", 'git commit -am "Add a sale banner"')
ana.run("revert", "git revert HEAD --no-edit")
ana.run("log1", "git log --oneline")
ana.run("cat2", "cat index.html")

# ---------------------------------------------------------------- level 3: branches
ana.run("branch_new", "git switch -c login-page")
write("ana-laptop", "ana", "login.html", page('<form id="login">', '  <input name="email">',
                                             '  <input name="password" type="password">', "</form>"))
ana.run("add_login", "git add login.html")
ana.run("commit_login", 'git commit -m "Add a login page"')
ana.run("branches", "git branch")
ana.run("to_main", "git switch main")
ana.run("ls_main", "ls")
ana.run("to_login", "git switch login-page")
ana.run("ls_login", "ls")

# ---------------------------------------------------------------- level 4: merging
ana.run("to_main2", "git switch main")
ana.run("merge_ff", "git merge login-page")
ana.run("sale_branch", "git switch -c summer-sale")
write("ana-laptop", "ana", "index.html", page(H1, BLUE.replace("$25", "$20"), RED))
ana.run("commit_sale", 'git commit -am "Summer sale price for the blue kettle"')
ana.run("to_main3", "git switch main")
write("ana-laptop", "ana", "index.html", page(H1, BLUE.replace("</p>", " (in stock)</p>"), RED))
ana.run("commit_stock", 'git commit -am "Show that the blue kettle is in stock"')
ana.run("merge_conflict", "git merge summer-sale")
ana.run("cat_conflict", "cat index.html")
write("ana-laptop", "ana", "index.html", page(H1, "<p>Blue kettle: $20 (in stock)</p>", RED))
ana.run("cat_fixed", "cat index.html")
ana.run("add_fixed", "git add index.html")
ana.run("commit_merge", 'git commit -m "Merge the summer sale"')
ana.run("graph", "git log --oneline --graph")

# ---------------------------------------------------------------- level 5: remotes
ana.run("remote_add", "git remote add origin git@git-server:shop.git")
ana.run("push_first", "git push -u origin main")
ben = Shell("ben-laptop", "ben", "/home/ben")
ben.run("clone", "git clone git@git-server:shop.git")
ben.run("ben_cd", "cd shop")
ben.run("ben_log", "git log --oneline")
# Ben's edit: one line at the end of the page.
sh(f"docker exec -u ben {P}-ben-laptop bash -c \"echo '<p>Questions? hello@kettleshop.example</p>' >> /home/ben/shop/index.html\"")
ben.run("ben_commit", 'git commit -am "Add a contact email"')
ben.run("ben_push", "git push")
ana.run("pull_ff", "git pull")
ana.run("ana_tail", "tail -n 1 index.html")

# ---------------------------------------------------------------- level 6: rejected pushes
write("ben-laptop", "ben", "faq.html", page("<h2>Questions</h2>", "<p>Do kettles come with a warranty? Yes, 2 years.</p>"))
ben.run("ben_add_faq", "git add faq.html")
ben.run("ben_commit_faq", 'git commit -m "Add an FAQ page"')
ben.run("ben_push_faq", "git push")
write("ana-laptop", "ana", "login.html", page('<form id="login">', '  <input name="email">',
                                             '  <input name="password" type="password">',
                                             '  <button>Log in</button>', "</form>"))
ana.run("commit_button", 'git commit -am "Add a log-in button"')
ana.run("push_rejected", "git push")
ana.run("pull_diverged", "git pull")
ana.run("pull_rebase", "git pull --rebase")
ana.run("push_ok", "git push")
ana.run("log_after", "git log --oneline -4")

# ---------------------------------------------------------------- level 7: rescue
ana.run("reset_oops", "git reset --hard HEAD~3")
ana.run("log_lost", "git log --oneline -4")
ana.run("reflog", "git reflog -5")
ana.run("reset_back", "git reset --hard HEAD@{1}")
ana.run("log_back", "git log --oneline -4")
ana.run("status_end", "git status")

steps["version"] = {"cmd": "versions", "out": sh(
    f"docker exec {P}-ana-laptop sh -c '. /etc/os-release; echo $PRETTY_NAME; git --version'; date -u +%Y-%m-%dT%H:%MZ").strip()}
json.dump(steps, open("demo_output.json", "w"), indent=1)
print("saved demo_output.json")
PY
