#!/usr/bin/env bash
# Real demos for P005 "Stop Using git push --force (Use This Instead)". Runs on render01 (Docker).
# A small lab on this machine only: a Git server standing in for GitHub (git-server: SSH + a bare
# repository, shop.git) and two laptops, Ana's and Ben's, both working on the branch login-page.
# A third teammate, Chloe, adds commits to main off-screen, which gives Ana a reason to rebase.
# 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.
# Every number and hash in video.json is read from here. Re-run = new hashes = rebuild the script.
set -euo pipefail
cd "$(dirname "$0")"
P=ce-p005
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-p005
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-p005-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-p005-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 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"

# The project so far: Chloe made main; Ana started login-page and pushed her first commit.
as ana-laptop ana "git clone -q git@git-server:shop.git && cd shop \
  && printf '<h1>Kettle shop</h1>\n' > index.html && git add index.html \
  && GIT_AUTHOR_NAME=Chloe GIT_AUTHOR_EMAIL=chloe@example.com git commit -qm 'Create the shop homepage' && git push -q origin main \
  && git switch -qc login-page && printf '<form id=\"login\">\n  <input name=\"email\">\n  <input name=\"password\" type=\"password\">\n</form>\n' > login.html \
  && git add login.html && git commit -qm 'Add login form' && git push -q -u origin login-page 2>/dev/null \
  && git remote set-head origin -a >/dev/null"
as ben-laptop ben "git clone -q git@git-server:shop.git && cd shop && git switch -q login-page"

# Chloe, off-screen: a commit to main, pushed straight into the server's repository.
chloe() {   # chloe "message" file.html
  as git-server git "rm -rf /tmp/chloe && git clone -q ~/shop.git /tmp/chloe && cd /tmp/chloe \
    && printf '<h1>%s</h1>\\n' '$1' > $2 && git add $2 \
    && git -c user.name=Chloe -c user.email=chloe@example.com commit -qm '$1' && git push -q origin main"
}

python3 - "$P" "$(declare -f)" <<'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

FUNCS = sys.argv[2]
def bash(fn_call):
    """Off-screen setup, with demo.sh's helper functions (as, chloe)."""
    sh(f"P={P}\n{FUNCS}\n{fn_call}")

def write(box, user, path, text):
    """Off-screen file edit on a laptop (the commit that follows is 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)

LOGIN = '<form id="login">\n  <input name="email">\n  <input name="password" type="password">\n</form>\n'

def head(box, user):
    return sh(f"docker exec -u {user} -w /home/{user}/shop {P}-{box} git rev-parse --short HEAD").strip()

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

# ---------------------------------------------------------------- the setup on screen
ana.run("ana_log0", "git log --oneline")
ben.run("ben_log0", "git log --oneline")

# ---------------------------------------------------------------- round 1: --force
bash("chloe 'Add shopping cart' cart.html")
ana.run("fetch1", "git fetch")
ana.run("rebase1", "git rebase origin/main")
ana.run("ana_log1", "git log --oneline")
ana.run("push1", "git push")                     # refused: not a fast-forward
# While Ana reads that, Ben pushes his work to the same branch.
LOGIN = LOGIN.replace("</form>", '  <a href="/forgot">Forgot password?</a>\n</form>')
write("ben-laptop", "ben", "login.html", LOGIN)
ben.run("ben_commit1", 'git commit -am "Add forgot-password link"')
ben.run("ben_push1", "git push")
steps["_lost_commit"] = {"cmd": "", "out": head("ben-laptop", "ben")}
ana.run("force", "git push --force")
ben.run("ben_fetch1", "git fetch")
ben.run("ben_remote_log", "git log --oneline origin/login-page")
ben.run("ben_status", "git status")
# The commit still exists on Ben's laptop: put it back on top of the new branch.
ben.run("ben_rebase", "git rebase origin/login-page")
ben.run("ben_push2", "git push")
ben.run("ben_log2", "git log --oneline")
bash("as ana-laptop ana 'cd shop && git pull -q'")

# ---------------------------------------------------------------- round 2: --force-with-lease
bash("chloe 'Add search box' search.html")
ana.run("fetch2", "git fetch")
ana.run("rebase2", "git rebase origin/main")
LOGIN = LOGIN.replace("</form>", '  <p class="error" hidden>Wrong password</p>\n</form>')
write("ben-laptop", "ben", "login.html", LOGIN)
ben.run("ben_commit2", 'git commit -am "Show an error for a wrong password"')
ben.run("ben_push3", "git push")
ana.run("lease", "git push --force-with-lease")
ana.run("tracking", "git rev-parse --short origin/login-page")
steps["_server_tip"] = {"cmd": "", "out": sh(f"docker exec -u git {P}-git-server git -C /home/git/shop.git rev-parse --short login-page").strip()}

# The trap: something fetches in the background (an editor's auto-fetch). We run it by hand.
ana.run("bg_fetch", "git fetch")
ana.run("lease_dry", "git push --force-with-lease --dry-run")
ana.run("includes", "git push --force-with-lease --force-if-includes")

# The right way: bring Ben's work in, redo the tidy-up, then push with both checks.
ana.run("pull_rebase", "git pull --rebase")
ana.run("rebase3", "git rebase origin/main")
ana.run("ana_log3", "git log --oneline")
ana.run("push_ok", "git push --force-with-lease --force-if-includes")
ben.run("ben_pull", "git pull --rebase")
ben.run("ben_log3", "git log --oneline")

# ---------------------------------------------------------------- make it the default
ana.run("alias", 'git config --global alias.pushf "push --force-with-lease"')
ana.run("useifincl", "git config --global push.useForceIfIncludes true")
ana.run("config_show", "git config --global --list | grep -i -e pushf -e useforce")
# Same trap again: Ben pushes, Ana's editor fetches, Ana has a new commit and pushes with pushf.
bash("as ben-laptop ben \"cd shop && printf 'Remember me\\n' > remember.txt && git add remember.txt && git commit -qm 'Add remember-me box' && git push -q\"")
ana.run("bg_fetch2", "git fetch")
bash("as ana-laptop ana \"cd shop && printf 'button { color: navy; }\\n' > login.css && git add login.css && git commit -qm 'Style the login button'\"")
ana.run("pushf", "git pushf")

# ---------------------------------------------------------------- protect main on the server
srv = Shell("git-server", "git", "/home/git/shop.git")
srv.run("deny", "git config receive.denyNonFastForwards true")
bash("as ana-laptop ana \"cd shop && git switch -q main && git pull -q && git commit -q --amend -m 'Add a search box'\"")
ana.run("deny_push", "git push --force")

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