Skip to content

Commit 3a73eac

Browse files
Show git-sim live in Jupyter with %gitsim live
The magic runs git-sim live in the background and frames the page its local server serves, so the graph updates under the cell as the repo changes. %gitsim live stop stops it, as does restarting the kernel, and running it again reuses the server. A hosted notebook (Colab, JupyterHub, Binder) gets a note instead, since its browser can't reach the kernel's 127.0.0.1. The live server refuses to be framed, so a hidden --allow-local-frames option lets the magic's server be framed by localhost pages only. A plain git-sim live still sends X-Frame-Options: DENY. Signed-off-by: Jacob Stopak <jacob@initialcommit.io>
1 parent 20aa9e9 commit 3a73eac

5 files changed

Lines changed: 223 additions & 10 deletions

File tree

‎docs/integrations.md‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ git-sim works anywhere you use Git. The `git-sim` command line tool does the wor
1111
| Vim 8.1 and newer | the same commands as Neovim | [integrations/vim](../integrations/vim) |
1212
| Emacs 27.1 and newer | `git-sim-simulate`, `git-sim-preflight`, and `git-sim-live`, with Magit's branch or commit at point as the default | [integrations/emacs](../integrations/emacs) |
1313
| nano, and editors without plugins | `git live` in a second terminal pane, and nano's execute prompt (`^T`) to drop a pre-flight report into your file | [shell.md](shell.md) |
14-
| Jupyter | `%gitsim rebase main` shows the graph right under the cell | below |
14+
| Jupyter | `%gitsim rebase main` shows the graph right under the cell, and `%gitsim live` shows the live graph there | below |
1515
| GitHub CLI | `gh sim pr 42` shows what merging a pull request would do | [integrations/gh-sim](../integrations/gh-sim) |
1616
| GitHub Actions | a comment on each pull request with the pre-flight report, and the graph attached to the run | [integrations/github-action](../integrations/github-action) |
1717
| Blogs and docs | `git-sim-embed.js` wraps any git-sim graph in the interactive viewer | [embed.md](embed.md) |
@@ -34,6 +34,12 @@ Then, in a notebook:
3434
%gitsim rebase main
3535
%gitsim --height 700 -C ../other-repo merge feature
3636
%gitsim preflight reset --hard HEAD~1
37+
%gitsim live
38+
%gitsim live stop
3739
```
3840

3941
The command runs in the notebook's working directory (or the `-C` path), the same as it would in a terminal. The graph shows up in a frame under the cell that grows to fit it, up to 1200 pixels. To pick the height yourself, put `--height` and a number of pixels before the Git command. `preflight` prints the report as text. git-sim never changes your repo.
42+
43+
`%gitsim live` runs `git-sim live` in the background and shows the live page under the cell, 760 pixels tall unless you set `--height`. It updates as the repo changes, whether you run Git in the notebook or in a terminal, and you can add live mode options after it, like `%gitsim live --no-zones`. Running it again shows the same session. `%gitsim live stop` stops it, and so does restarting the kernel.
44+
45+
Live mode only works when Jupyter runs on your own machine, because the page comes from a small server on the kernel's `127.0.0.1`. In Colab, JupyterHub, or Binder, run `git-sim live` on the machine with the repo instead.

‎src/git_sim/jupyter.py‎

Lines changed: 137 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,19 +4,27 @@
44
%gitsim --height 700 merge feature a fixed height (it fits the graph otherwise)
55
%gitsim preflight reset --hard HEAD~1
66
the pre-flight report, as text
7+
%gitsim live the live graph, updating as the repo changes
8+
%gitsim live stop stops it (so does restarting the kernel)
79
810
The command runs in the notebook's working directory (or ``-C path``) exactly
911
as it would in a terminal, with the browser kept closed, and the page git-sim
1012
writes is shown in an iframe so its scripts and styles stay out of the
11-
notebook's own. Nothing in the repository is changed.
13+
notebook's own. Live mode runs git-sim live in the background and frames the
14+
page its local server serves, so it needs Jupyter on your own machine.
15+
Nothing in the repository is changed.
1216
"""
1317

18+
import atexit
1419
import html
1520
import os
21+
import queue
22+
import re
1623
import shlex
1724
import subprocess
1825
import sys
19-
from typing import List, Optional, Tuple
26+
import threading
27+
from typing import Dict, List, Optional, Tuple
2028

2129
DEFAULT_HEIGHT = 560
2230

@@ -95,6 +103,129 @@ def run(words: List[str], repo: Optional[str] = None) -> subprocess.CompletedPro
95103
)
96104

97105

106+
LIVE_HEIGHT = 760
107+
LIVE_URL = re.compile(r"http://127\.0\.0\.1:\d+/#k=[\w-]+")
108+
109+
# The git-sim live servers this kernel started: repository folder -> (process, url)
110+
_live: Dict[str, Tuple[subprocess.Popen, str]] = {}
111+
112+
113+
def remote_kernel() -> bool:
114+
"""Whether the notebook is likely open in a browser on another machine
115+
(Colab, JupyterHub, Binder), which can't reach this machine's 127.0.0.1."""
116+
return "google.colab" in sys.modules or any(
117+
name in os.environ
118+
for name in ("COLAB_RELEASE_TAG", "JUPYTERHUB_API_URL", "BINDER_SERVICE_HOST")
119+
)
120+
121+
122+
def live_frame(url: str, height: int = LIVE_HEIGHT) -> str:
123+
return (
124+
f'<div><iframe src="{html.escape(url, quote=True)}" '
125+
f'style="width:100%;height:{int(height)}px;border:0;border-radius:10px" '
126+
'title="git-sim live"></iframe></div>'
127+
)
128+
129+
130+
def live_start(extra: List[str], repo: Optional[str]) -> Tuple[Optional[str], str]:
131+
"""Start git-sim live for the repository in the background, or reuse the
132+
one already running there. Returns the page's URL, or None and what went
133+
wrong."""
134+
where = os.path.abspath(repo or ".")
135+
running = _live.get(where)
136+
if running and running[0].poll() is None:
137+
return running[1], ""
138+
cmd = [sys.executable, "-m", "git_sim", "--open-in", "local", "live"]
139+
cmd += ["--allow-local-frames", *extra]
140+
proc = subprocess.Popen(
141+
cmd,
142+
cwd=where,
143+
stdin=subprocess.DEVNULL,
144+
stdout=subprocess.DEVNULL,
145+
stderr=subprocess.PIPE,
146+
text=True,
147+
encoding="utf-8",
148+
errors="replace",
149+
env=dict(os.environ, git_sim_auto_open="false"),
150+
)
151+
# It prints the URL once the first drawing is done. The rest of what it
152+
# prints is read and dropped so the pipe never fills up.
153+
found: "queue.Queue[str]" = queue.Queue()
154+
said: List[str] = []
155+
156+
def read():
157+
for text in proc.stderr:
158+
match = LIVE_URL.search(text)
159+
if match:
160+
found.put(match.group(0))
161+
elif found.empty():
162+
said.append(text)
163+
found.put("")
164+
165+
threading.Thread(target=read, daemon=True).start()
166+
try:
167+
url = found.get(timeout=60)
168+
except queue.Empty:
169+
url = ""
170+
if not url:
171+
proc.kill()
172+
return None, "".join(said).strip() or "git-sim live did not start in 60 seconds"
173+
_live[where] = (proc, url)
174+
return url, ""
175+
176+
177+
def live_stop() -> List[str]:
178+
"""Stop every git-sim live this kernel started. Returns their folders."""
179+
stopped = []
180+
for where, (proc, _) in list(_live.items()):
181+
if proc.poll() is None:
182+
proc.terminate()
183+
stopped.append(where)
184+
del _live[where]
185+
return stopped
186+
187+
188+
atexit.register(live_stop)
189+
190+
191+
def show_live(rest: List[str], height: Optional[int], repo: Optional[str]) -> None:
192+
from IPython.display import HTML, Markdown, display
193+
194+
if rest[:1] == ["stop"]:
195+
stopped = live_stop()
196+
display(
197+
Markdown(
198+
"Stopped git-sim live for " + ", ".join(f"`{s}`" for s in stopped) + "."
199+
if stopped
200+
else "git-sim live isn't running."
201+
)
202+
)
203+
return
204+
if remote_kernel():
205+
display(
206+
Markdown(
207+
"Live mode needs Jupyter running on your own machine, because the page "
208+
"comes from a server on the kernel's `127.0.0.1`, which your browser "
209+
"can't reach from here. Run `git-sim live` on the machine with the repo instead."
210+
)
211+
)
212+
return
213+
url, error = live_start(rest, repo)
214+
if not url:
215+
display(
216+
HTML(
217+
f"<pre style='color:#b91c1c;font:13px/1.45 monospace'>git-sim live could not start\n{html.escape(error)}</pre>"
218+
)
219+
)
220+
return
221+
display(HTML(live_frame(url, height or LIVE_HEIGHT)))
222+
display(
223+
Markdown(
224+
"Watching the repo in the background. `%gitsim live stop` stops it, and so does restarting the kernel."
225+
)
226+
)
227+
228+
98229
def gitsim(line: str):
99230
"""The %gitsim line magic."""
100231
from IPython.display import HTML, Markdown, display
@@ -103,10 +234,13 @@ def gitsim(line: str):
103234
if not words:
104235
display(
105236
Markdown(
106-
"`%gitsim <git command>`, e.g. `%gitsim rebase main`, or `%gitsim preflight reset --hard HEAD~1`."
237+
"`%gitsim <git command>`, e.g. `%gitsim rebase main`, `%gitsim preflight reset --hard HEAD~1`, or `%gitsim live`."
107238
)
108239
)
109240
return
241+
if words[0] == "live":
242+
show_live(words[1:], height, repo)
243+
return
110244
result = run(words, repo)
111245
if words[0] == "preflight":
112246
text = (result.stdout or "") + (result.stderr or "")

‎src/git_sim/live.py‎

Lines changed: 27 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -971,7 +971,10 @@ def _send(self, body: bytes, content_type: str, status: int = 200) -> None:
971971
self.send_header("Content-Type", content_type)
972972
self.send_header("Content-Length", str(len(body)))
973973
self.send_header("Cache-Control", "no-store")
974-
self.send_header("X-Frame-Options", "DENY")
974+
if self.server.local_frames:
975+
self.send_header("Content-Security-Policy", LOCAL_FRAME_ANCESTORS)
976+
else:
977+
self.send_header("X-Frame-Options", "DENY")
975978
self._cors()
976979
self.end_headers()
977980
self.wfile.write(body)
@@ -1093,9 +1096,10 @@ class LiveServer(http.server.ThreadingHTTPServer):
10931096
# git-sim live finds it busy (and picks another) rather than sharing it.
10941097
allow_reuse_address = os.name != "nt"
10951098

1096-
def __init__(self, address, session: LiveSession):
1099+
def __init__(self, address, session: LiveSession, local_frames: bool = False):
10971100
super().__init__(address, LiveHandler)
10981101
self.session = session
1102+
self.local_frames = local_frames
10991103

11001104
def server_bind(self):
11011105
if os.name == "nt" and hasattr(socket, "SO_EXCLUSIVEADDRUSE"):
@@ -1105,8 +1109,17 @@ def server_bind(self):
11051109

11061110
SERVER_FILE = "server.json"
11071111

1112+
# With --allow-local-frames (the Jupyter magic): only pages served from this
1113+
# machine may put the live page in a frame. Without it, nothing may.
1114+
LOCAL_FRAME_ANCESTORS = (
1115+
"frame-ancestors http://localhost:* http://127.0.0.1:* "
1116+
"https://localhost:* https://127.0.0.1:*"
1117+
)
1118+
11081119

1109-
def _serve(session: "LiveSession", port: int, sessions_dir: str) -> LiveServer:
1120+
def _serve(
1121+
session: "LiveSession", port: int, sessions_dir: str, local_frames: bool = False
1122+
) -> LiveServer:
11101123
"""The live server, on the port and with the key this repository's last
11111124
one had (kept in <sessions dir>/server.json), so a page still open from
11121125
before git-sim live was restarted reconnects by itself. With --port, that
@@ -1121,12 +1134,14 @@ def _serve(session: "LiveSession", port: int, sessions_dir: str) -> LiveServer:
11211134
server = None
11221135
if saved.get("port") and saved.get("key") and (not port or port == saved["port"]):
11231136
try:
1124-
server = LiveServer(("127.0.0.1", int(saved["port"])), session)
1137+
server = LiveServer(
1138+
("127.0.0.1", int(saved["port"])), session, local_frames
1139+
)
11251140
session.key = saved["key"]
11261141
except (OSError, ValueError):
11271142
server = None
11281143
if server is None:
1129-
server = LiveServer(("127.0.0.1", port), session)
1144+
server = LiveServer(("127.0.0.1", port), session, local_frames)
11301145
try:
11311146
os.makedirs(sessions_dir, exist_ok=True)
11321147
with open(os.path.join(sessions_dir, SERVER_FILE), "w", encoding="utf-8") as f:
@@ -1276,6 +1291,12 @@ def live(
12761291
POLL_SECONDS, "--interval", help="Seconds between checks of the repository."
12771292
),
12781293
repo: str = typer.Option(".", "--repo", "-C", help="The repository to watch."),
1294+
local_frames: bool = typer.Option(
1295+
False,
1296+
"--allow-local-frames",
1297+
hidden=True,
1298+
help="Let pages on this machine frame the live page (the Jupyter magic uses it).",
1299+
),
12791300
):
12801301
"""Follow the repository as it changes: an animated graph that plays every
12811302
commit, branch, checkout, reset, rebase or staged file as it happens, in
@@ -1386,7 +1407,7 @@ def emit(snap: Snapshot) -> None:
13861407
# Browser mode: draw the current state, serve, watch.
13871408
_say(f"git-sim live: drawing {root} ...")
13881409
session.start()
1389-
server = _serve(session, port, _sessions_dir(root))
1410+
server = _serve(session, port, _sessions_dir(root), local_frames)
13901411
base = f"http://127.0.0.1:{server.server_address[1]}"
13911412
local_url = f"{base}/#k={session.key}"
13921413
watcher = threading.Thread(

‎tests/unit_tests/test_jupyter.py‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
"""The Jupyter magic's pieces that need no notebook."""
22

3+
import subprocess
4+
import urllib.request
5+
6+
from git_sim import jupyter
37
from git_sim.jupyter import DEFAULT_HEIGHT, embed_page, parse_line
48

59

@@ -33,3 +37,21 @@ def test_with_no_height_the_frame_fits_the_graph():
3337
assert f"height:{DEFAULT_HEIGHT}px" in frame and '" onload="' in frame
3438
assert "getElementById(&#x27;scene&#x27;)" in frame
3539

40+
41+
def test_live_starts_once_per_repo_and_serves_a_frameable_page(tmp_path):
42+
def git(*args):
43+
subprocess.run(["git", *args], cwd=tmp_path, check=True, capture_output=True)
44+
45+
git("init", "-q")
46+
git("-c", "user.name=t", "-c", "user.email=t@t", "commit", "-q", "--allow-empty", "-m", "a")
47+
try:
48+
url, error = jupyter.live_start(["--no-zones"], str(tmp_path))
49+
assert url and not error and jupyter.LIVE_URL.fullmatch(url)
50+
# a second cell reuses the running server
51+
assert jupyter.live_start([], str(tmp_path)) == (url, "")
52+
with urllib.request.urlopen(url.split("#")[0]) as r:
53+
assert "frame-ancestors" in r.headers["Content-Security-Policy"]
54+
assert f'src="{url}"' in jupyter.live_frame(url)
55+
finally:
56+
assert jupyter.live_stop() == [str(tmp_path)]
57+
assert jupyter.live_stop() == []

‎tests/unit_tests/test_live.py‎

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -663,6 +663,36 @@ def test_the_local_server_requires_the_key_and_answers_the_site(repo, tmp_path):
663663
urllib.request.urlopen(base + "/info")
664664
with urllib.request.urlopen(base + f"/info?k={session.key}") as r:
665665
assert json.loads(r.read()) == {"repo": "repo", "where": session.where}
666+
# no page may frame it
667+
with urllib.request.urlopen(base + "/") as r:
668+
assert r.headers["X-Frame-Options"] == "DENY"
669+
assert r.headers.get("Content-Security-Policy") is None
670+
finally:
671+
session.stop.set()
672+
server.shutdown()
673+
server.server_close()
674+
675+
676+
def test_the_jupyter_magics_server_lets_only_local_pages_frame_it(repo, tmp_path):
677+
import urllib.request
678+
679+
from git_sim.live import LiveServer
680+
681+
session = LiveSession(str(repo), str(tmp_path / "out"), zones=False, poll=0.1)
682+
session.start()
683+
server = LiveServer(("127.0.0.1", 0), session, local_frames=True)
684+
thread = __import__("threading").Thread(
685+
target=server.serve_forever, kwargs={"poll_interval": 0.1}, daemon=True
686+
)
687+
thread.start()
688+
try:
689+
with urllib.request.urlopen(
690+
f"http://127.0.0.1:{server.server_address[1]}/"
691+
) as r:
692+
assert r.headers.get("X-Frame-Options") is None
693+
policy = r.headers["Content-Security-Policy"]
694+
assert policy.startswith("frame-ancestors ") and "http://localhost:*" in policy
695+
assert "initialcommit" not in policy
666696
finally:
667697
session.stop.set()
668698
server.shutdown()

0 commit comments

Comments
 (0)