git-sci 0.1.0__tar.gz → 0.1.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
|
-
"""git-
|
|
2
|
+
"""git-sci: an operation log for humans + agents on top of git.
|
|
3
3
|
|
|
4
4
|
Commits are attributed as <user>/<source>: <user>/local, or <user>/agent:<harness>/<session>.
|
|
5
5
|
PreToolUse commits dirty state as local; PostToolUse commits the pre->post diff as the agent's;
|
|
6
|
-
Stop squashes the turn (per-call commits stay under refs/git-
|
|
7
|
-
transcript chunk to refs/git-
|
|
8
|
-
Events: .git/git-
|
|
9
|
-
by timestamp at read; refs/git-
|
|
10
|
-
hook init installs. .git/git-
|
|
6
|
+
Stop squashes the turn (per-call commits stay under refs/git-sci/ops) and appends the turn's
|
|
7
|
+
transcript chunk to refs/git-sci/sessions/<user>/<id>; init imports existing sessions + backfills.
|
|
8
|
+
Events: .git/git-sci/events.jsonl (cache) mirrored to refs/git-sci/log/<user>, one writer each, merged
|
|
9
|
+
by timestamp at read; refs/git-sci/* are the source of truth and travel via the refspec + pre-push
|
|
10
|
+
hook init installs. .git/git-sci/lock (flock) serializes everything, like git's index.lock."""
|
|
11
11
|
import argparse, difflib, fcntl, glob, hashlib, json, os, re, subprocess, sys
|
|
12
12
|
import time, uuid, zlib
|
|
13
13
|
from datetime import datetime
|
|
@@ -15,17 +15,17 @@ EDIT_TOOLS = {"Edit", "Write", "MultiEdit", "NotebookEdit", "apply_patch"}
|
|
|
15
15
|
HOOK_EVENTS = ("SessionStart", "UserPromptSubmit", "PreToolUse", "PostToolUse", "Stop")
|
|
16
16
|
CODEX_HOOK_EVENTS = HOOK_EVENTS + ("SubagentStart", "SubagentStop")
|
|
17
17
|
CLAUDE_LINE = ("Past sessions for this repo, yours and other team members', are in "
|
|
18
|
-
".git/git-
|
|
18
|
+
".git/git-sci/sessions/ (same layout as ~/.claude/projects/, with a "
|
|
19
19
|
"sessions-index.json). Look there instead of ~/.claude/projects/.")
|
|
20
20
|
CODEX_LINE = ("Past sessions for this repo, yours and other team members', are in "
|
|
21
|
-
".git/git-
|
|
22
|
-
"~/.codex/sessions/, or use `git-
|
|
23
|
-
projects = lambda: os.environ.get("
|
|
21
|
+
".git/git-sci/sessions/ (with a sessions-index.json). Look there instead of "
|
|
22
|
+
"~/.codex/sessions/, or use `git-sci sessions` to search them.")
|
|
23
|
+
projects = lambda: os.environ.get("GIT_SCI_PROJECTS", os.path.expanduser("~/.claude/projects"))
|
|
24
24
|
codex_sessions = lambda: os.environ.get(
|
|
25
|
-
"
|
|
25
|
+
"GIT_SCI_CODEX_SESSIONS", os.path.expanduser("~/.codex/sessions"))
|
|
26
26
|
def sh(*a, input=None, check=True, env=None):
|
|
27
27
|
p = subprocess.run(a, capture_output=True, text=True, input=input, env=env)
|
|
28
|
-
if check and p.returncode: sys.exit(f"git-
|
|
28
|
+
if check and p.returncode: sys.exit(f"git-sci: `{' '.join(a)}` failed: {p.stderr.strip()}")
|
|
29
29
|
return p.stdout
|
|
30
30
|
git = lambda *a, **k: sh("git", *a, **k)
|
|
31
31
|
_r = {}
|
|
@@ -42,7 +42,7 @@ def changed_files():
|
|
|
42
42
|
def commit_files(files, message, author):
|
|
43
43
|
git("add", "-A", "--", *files, check=False) # pathspec may legitimately match nothing
|
|
44
44
|
c = ("-c", "commit.gpgsign=false", "commit", "-q", "--no-verify", "--allow-empty",
|
|
45
|
-
"--author", f"{author} <git-
|
|
45
|
+
"--author", f"{author} <git-sci@git-sci>", "-m", message)
|
|
46
46
|
# --only keeps unrelated pre-staged paths out; empty repo matches no pathspec -> plain commit
|
|
47
47
|
quiet("git", *c, "--only", "--", *files).returncode and git(*c)
|
|
48
48
|
return head()
|
|
@@ -58,16 +58,16 @@ def blob(data): # loose object via hashlib+zlib: no subprocess per chunk (sha1
|
|
|
58
58
|
return sha
|
|
59
59
|
refs = lambda prefix: [l.split("\t") for l in git( # [(refname, sha)]
|
|
60
60
|
"for-each-ref", "--format=%(refname)\t%(objectname)", prefix, check=False).splitlines()]
|
|
61
|
-
def
|
|
62
|
-
d = os.path.join(root(), ".git", "git-
|
|
63
|
-
if must and not os.path.isdir(d): sys.exit("git-
|
|
61
|
+
def git_sci_dir(must=True): # .git/git-sci is a per-machine cache; refs/git-sci/* are the source of truth
|
|
62
|
+
d = os.path.join(root(), ".git", "git-sci")
|
|
63
|
+
if must and not os.path.isdir(d): sys.exit("git-sci: not initialized here (run `git-sci init`)")
|
|
64
64
|
return d
|
|
65
65
|
def jread(path, default):
|
|
66
66
|
try: return json.load(open(path))
|
|
67
67
|
except (OSError, ValueError): return default
|
|
68
68
|
jwrite = lambda path, obj: json.dump(obj, open(path, "w"), indent=1)
|
|
69
|
-
state = lambda name, default: jread(os.path.join(
|
|
70
|
-
save_state = lambda name, obj: jwrite(os.path.join(
|
|
69
|
+
state = lambda name, default: jread(os.path.join(git_sci_dir(), name), default)
|
|
70
|
+
save_state = lambda name, obj: jwrite(os.path.join(git_sci_dir(), name), obj)
|
|
71
71
|
def parse_events(text): # skips torn lines rather than bricking every command
|
|
72
72
|
out = []
|
|
73
73
|
for l in text.splitlines():
|
|
@@ -75,27 +75,27 @@ def parse_events(text): # skips torn lines rather than bricking every command
|
|
|
75
75
|
except ValueError: pass
|
|
76
76
|
return out
|
|
77
77
|
def events(): # own cache first (file order), then teammates' logs; stable-sorted
|
|
78
|
-
try: evs = parse_events(open(os.path.join(
|
|
78
|
+
try: evs = parse_events(open(os.path.join(git_sci_dir(), "events.jsonl")).read())
|
|
79
79
|
except OSError: evs = []
|
|
80
|
-
for ref, sha in refs("refs/git-
|
|
80
|
+
for ref, sha in refs("refs/git-sci/log/"):
|
|
81
81
|
if ref.split("/")[-1] != user():
|
|
82
82
|
evs += parse_events(git("cat-file", "blob", sha, check=False))
|
|
83
83
|
return sorted(evs, key=lambda e: e.get("timestamp", ""))
|
|
84
84
|
def lock():
|
|
85
|
-
lk = open(os.path.join(
|
|
85
|
+
lk = open(os.path.join(git_sci_dir(False), "lock"), "a")
|
|
86
86
|
fcntl.flock(lk, fcntl.LOCK_EX)
|
|
87
87
|
return lk # held until process exit
|
|
88
|
-
def mirror_log(): # one writer per user: refs/git-
|
|
89
|
-
p = os.path.join(
|
|
90
|
-
if os.path.exists(p): git("update-ref", f"refs/git-
|
|
88
|
+
def mirror_log(): # one writer per user: refs/git-sci/log/<user> mirrors the local cache
|
|
89
|
+
p = os.path.join(git_sci_dir(), "events.jsonl")
|
|
90
|
+
if os.path.exists(p): git("update-ref", f"refs/git-sci/log/{user()}", blob(open(p).read()))
|
|
91
91
|
def record(mirror=True, **ev):
|
|
92
92
|
ev = {"id": uuid.uuid4().hex[:8], "timestamp": time.strftime("%Y-%m-%dT%H:%M:%S%z"), **ev}
|
|
93
|
-
with open(os.path.join(
|
|
93
|
+
with open(os.path.join(git_sci_dir(), "events.jsonl"), "a") as f:
|
|
94
94
|
f.write(json.dumps(ev) + "\n")
|
|
95
95
|
return mirror and mirror_log() or ev
|
|
96
|
-
def store_chunks(sid, files, u=None): # {path: text} -> refs/git-
|
|
96
|
+
def store_chunks(sid, files, u=None): # {path: text} -> refs/git-sci/sessions/<u>/<sid> tree
|
|
97
97
|
if not files: return
|
|
98
|
-
ref, ix = f"refs/git-
|
|
98
|
+
ref, ix = f"refs/git-sci/sessions/{u or user()}/{sid}", os.path.join(git_sci_dir(), "tmpindex")
|
|
99
99
|
env = {**os.environ, "GIT_INDEX_FILE": ix}
|
|
100
100
|
if os.path.exists(ix): os.remove(ix)
|
|
101
101
|
if not quiet("git", "rev-parse", "-q", "--verify", ref).returncode:
|
|
@@ -153,7 +153,7 @@ def char_edits(patch): # character-level ranges for paired -/+ lines of a unifi
|
|
|
153
153
|
elif not l.startswith("\\") and not l.startswith("diff "):
|
|
154
154
|
o, n, minus = o + 1, n + 1, []
|
|
155
155
|
return out
|
|
156
|
-
edits_msg = lambda actor, files: (f"git-
|
|
156
|
+
edits_msg = lambda actor, files: (f"git-sci: {actor} edits " + ", ".join(files[:3])
|
|
157
157
|
+ (f" +{len(files) - 3} more" if len(files) > 3 else ""))
|
|
158
158
|
def sync(exclude=()): # commit unclaimed changes as <user>/local
|
|
159
159
|
exclude = set(exclude) | {f for s in state("active.json", {}).values() for f in s["files"]}
|
|
@@ -187,11 +187,11 @@ def finish_call(sid, active, payload, harness="claude"):
|
|
|
187
187
|
h_files = [f for f in files if f not in a_files]
|
|
188
188
|
actor, pre = agent(sid, harness), head()
|
|
189
189
|
turn = state("turns.json", {}).get(session_key(sid, harness), {}).get("n", 0)
|
|
190
|
-
c = commit_files(files, f"git-
|
|
190
|
+
c = commit_files(files, f"git-sci: {actor} {tool}" if a_files else edits_msg(local(), files),
|
|
191
191
|
actor if a_files else local())
|
|
192
192
|
p = patches(pre, c, files)
|
|
193
193
|
if a_files:
|
|
194
|
-
git("update-ref", f"refs/git-
|
|
194
|
+
git("update-ref", f"refs/git-sci/ops/{c}", c) # survives the turn squash + git gc
|
|
195
195
|
record(type="edit", actor=actor, turn=f"turn{turn}", commit=c, patch_id=patch_id(c),
|
|
196
196
|
tool_calls=[call], edits=[{"file": f, "patch": p[f], "tool_call_id": call["id"],
|
|
197
197
|
"attribution": {"method": "hook"}} for f in a_files])
|
|
@@ -210,9 +210,9 @@ def squash_turn(sid, harness="claude"):
|
|
|
210
210
|
mine = [e for e in ops if e["actor"] == actor and "squashes" not in e]
|
|
211
211
|
if not rng or set(rng) - {e["commit"] for e in mine}: return
|
|
212
212
|
# commit-tree + update-ref never touch the worktree/index: edits after the last call stay dirty
|
|
213
|
-
c = git("-c", f"user.name={actor}", "-c", "user.email=git-
|
|
213
|
+
c = git("-c", f"user.name={actor}", "-c", "user.email=git-sci@git-sci", "commit-tree",
|
|
214
214
|
git("rev-parse", "HEAD^{tree}").strip(), "-p", start,
|
|
215
|
-
"-m", f"git-
|
|
215
|
+
"-m", f"git-sci: {actor} turn {t['n']} ({len(rng)} tool calls)").strip()
|
|
216
216
|
git("update-ref", "HEAD", c, head())
|
|
217
217
|
files = git("diff", "--name-only", start, c).splitlines()
|
|
218
218
|
p = patches(start, c, files)
|
|
@@ -282,8 +282,8 @@ def cmd_hook(a):
|
|
|
282
282
|
try: payload.get("cwd") and os.chdir(payload["cwd"])
|
|
283
283
|
except OSError: return
|
|
284
284
|
p = quiet("git", "rev-parse", "--show-toplevel")
|
|
285
|
-
if p.returncode or not os.path.isdir(os.path.join(p.stdout.strip(), ".git", "git-
|
|
286
|
-
return # not a git-
|
|
285
|
+
if p.returncode or not os.path.isdir(os.path.join(p.stdout.strip(), ".git", "git-sci")):
|
|
286
|
+
return # not a git-sci repo; hooks must never break the harness
|
|
287
287
|
os.chdir(p.stdout.strip())
|
|
288
288
|
lk = lock() # serialize concurrent hooks (parallel sessions/subagents)
|
|
289
289
|
event, sid, harness = (payload.get("hook_event_name", ""),
|
|
@@ -298,11 +298,11 @@ def cmd_hook(a):
|
|
|
298
298
|
sync() # everything dirty before an agent tool runs is <user>/local
|
|
299
299
|
if event == "SessionStart": # stdout lands in the agent's context
|
|
300
300
|
native = "~/.claude/projects" if harness == "claude" else "~/.codex/sessions"
|
|
301
|
-
print(f"git-
|
|
302
|
-
f".git/git-
|
|
301
|
+
print(f"git-sci: past sessions for this repo (yours + teammates') are in "
|
|
302
|
+
f".git/git-sci/sessions/, not {native}; {export_sessions()} sessions")
|
|
303
303
|
elif event == "SubagentStart":
|
|
304
|
-
print("git-
|
|
305
|
-
"use `git-
|
|
304
|
+
print("git-sci: past sessions for this repo are in .git/git-sci/sessions/; "
|
|
305
|
+
"use `git-sci sessions` to search them")
|
|
306
306
|
elif event == "UserPromptSubmit":
|
|
307
307
|
turns, tp = state("turns.json", {}), payload.get("transcript_path", "")
|
|
308
308
|
key = session_key(sid, harness)
|
|
@@ -461,14 +461,14 @@ def do_import(harnesses=("claude",)): # index every harness first: backfill run
|
|
|
461
461
|
return (f"imported {ns} sessions, {nsub} subagents; "
|
|
462
462
|
f"attributed {att}/{tot} commits in {time.time() - t0:.1f}s")
|
|
463
463
|
session_rows = lambda: [(r.split("/", 4)[4], r.split("/")[3], r) # (sid, user, ref)
|
|
464
|
-
for r, _ in refs("refs/git-
|
|
464
|
+
for r, _ in refs("refs/git-sci/sessions/")]
|
|
465
465
|
def session_text(ref, sub=None):
|
|
466
466
|
pre = f"subagents/{sub}/" if sub else ""
|
|
467
467
|
names = sorted(n for n in git("ls-tree", "-r", "--name-only", ref).splitlines()
|
|
468
468
|
if n.startswith(pre) and n.count("/") == pre.count("/"))
|
|
469
469
|
return "".join(git("cat-file", "blob", f"{ref}:{n}") for n in names)
|
|
470
|
-
def export_sessions(): # mirror session refs to .git/git-
|
|
471
|
-
base, idx = os.path.join(
|
|
470
|
+
def export_sessions(): # mirror session refs to .git/git-sci/sessions/ in ~/.claude/projects layout
|
|
471
|
+
base, idx = os.path.join(git_sci_dir(), "sessions", enc(root())), []
|
|
472
472
|
os.makedirs(base, exist_ok=True)
|
|
473
473
|
for sid, u, ref in session_rows():
|
|
474
474
|
fp = os.path.join(base, sid + ".jsonl")
|
|
@@ -482,12 +482,12 @@ def export_sessions(): # mirror session refs to .git/git-mem/sessions/ in ~/.cl
|
|
|
482
482
|
return len(idx)
|
|
483
483
|
def cmd_sessions(a):
|
|
484
484
|
if a.id == "export":
|
|
485
|
-
return print(f"git-
|
|
485
|
+
return print(f"git-sci: exported {export_sessions()} sessions to .git/git-sci/sessions/")
|
|
486
486
|
rows = session_rows()
|
|
487
487
|
if a.id:
|
|
488
488
|
for sid, u, ref in rows:
|
|
489
489
|
if sid.startswith(a.id): return print(session_text(ref), end="")
|
|
490
|
-
sys.exit("git-
|
|
490
|
+
sys.exit("git-sci: no such session")
|
|
491
491
|
pat = a.grep or (a.file and re.escape(a.file))
|
|
492
492
|
for sid, u, ref in rows:
|
|
493
493
|
if pat and not re.search(pat, session_text(ref)): continue
|
|
@@ -507,7 +507,7 @@ def chunk_prompt(text): # first user message of a stored transcript chunk (clau
|
|
|
507
507
|
and p.get("role") == "user":
|
|
508
508
|
return "".join(b.get("text", "") for b in p.get("content") or []
|
|
509
509
|
if isinstance(b, dict))
|
|
510
|
-
def stdout_file(): # file backing a redirected stdout (`git-
|
|
510
|
+
def stdout_file(): # file backing a redirected stdout (`git-sci export > dump.jsonl`)
|
|
511
511
|
try:
|
|
512
512
|
p = (fcntl.fcntl(1, fcntl.F_GETPATH, bytes(1024)).rstrip(b"\0").decode()
|
|
513
513
|
if hasattr(fcntl, "F_GETPATH") else os.readlink("/proc/self/fd/1"))
|
|
@@ -540,20 +540,20 @@ def cmd_export(a): # JSONL fine-tuning dump: one record per turn/op, prompt pai
|
|
|
540
540
|
"edits": [{"file": d["file"], "patch": d["patch"] or git(
|
|
541
541
|
"show", "--format=", e.get("commit") or "", "--", d["file"],
|
|
542
542
|
check=False)} for d in e["edits"]]}) + "\n")
|
|
543
|
-
if a.output: out.close(); print(f"git-
|
|
543
|
+
if a.output: out.close(); print(f"git-sci: exported to {a.output}")
|
|
544
544
|
def cmd_gc(a):
|
|
545
|
-
old = [r for r, sha in refs("refs/git-
|
|
545
|
+
old = [r for r, sha in refs("refs/git-sci/sessions/")
|
|
546
546
|
if int(git("show", "-s", "--format=%ct", sha).strip()) < time.time() - a.older_than * 86400]
|
|
547
547
|
for r in old: git("update-ref", "-d", r)
|
|
548
|
-
print(f"git-
|
|
548
|
+
print(f"git-sci: dropped {len(old)} session refs (git gc will prune the objects)")
|
|
549
549
|
def cfg_track(): # ask once; tty only, so never on hook runs; default yes
|
|
550
550
|
cfg = state("config.json", {})
|
|
551
551
|
if "track_sessions" not in cfg:
|
|
552
552
|
cfg["track_sessions"] = (not sys.stdin.isatty() or
|
|
553
|
-
input("git-
|
|
553
|
+
input("git-sci: track sessions in git? [Y/n] ").strip().lower() != "n")
|
|
554
554
|
save_state("config.json", cfg)
|
|
555
555
|
return cfg["track_sessions"]
|
|
556
|
-
def detect_harnesses(): # every harness with state on this machine (
|
|
556
|
+
def detect_harnesses(): # every harness with state on this machine (GIT_SCI_* overrides apply)
|
|
557
557
|
hs = [h for h, home, d in (("claude", "~/.claude", projects()),
|
|
558
558
|
("codex", "~/.codex", codex_sessions()))
|
|
559
559
|
if os.path.isdir(os.path.expanduser(home)) or os.path.isdir(d)]
|
|
@@ -562,14 +562,14 @@ def configured_harnesses():
|
|
|
562
562
|
cfg = state("config.json", {})
|
|
563
563
|
return cfg.get("harnesses") or [cfg.get("harness", "claude")]
|
|
564
564
|
hook_file = lambda h: ".claude/settings.json" if h == "claude" else ".codex/hooks.json"
|
|
565
|
-
hook_cmd = lambda h: "git-
|
|
565
|
+
hook_cmd = lambda h: "git-sci hook" if h == "claude" else "git-sci hook --harness codex"
|
|
566
566
|
def install_hooks(harness="claude"):
|
|
567
567
|
rel = hook_file(harness)
|
|
568
568
|
path = os.path.join(root(), rel)
|
|
569
569
|
os.makedirs(os.path.dirname(path), exist_ok=True)
|
|
570
570
|
cfg = jread(path, {})
|
|
571
571
|
if not isinstance(cfg.setdefault("hooks", {}), dict):
|
|
572
|
-
sys.exit(f"git-
|
|
572
|
+
sys.exit(f"git-sci: unexpected 'hooks' format in {rel}")
|
|
573
573
|
command = hook_cmd(harness)
|
|
574
574
|
hook_events = HOOK_EVENTS if harness == "claude" else CODEX_HOOK_EVENTS
|
|
575
575
|
for event in hook_events:
|
|
@@ -588,15 +588,15 @@ def remove_hooks(harness): # a restrictive re-init un-installs the excluded har
|
|
|
588
588
|
cfg["hooks"][event] = [g for g in cfg["hooks"][event] if g.get("hooks")]
|
|
589
589
|
if not cfg["hooks"][event]: del cfg["hooks"][event]
|
|
590
590
|
jwrite(path, cfg)
|
|
591
|
-
def install_push(): # travel: fetch refspec + fail-soft pre-push mirror of refs/git-
|
|
591
|
+
def install_push(): # travel: fetch refspec + fail-soft pre-push mirror of refs/git-sci/*
|
|
592
592
|
if "origin" in git("remote", check=False).split():
|
|
593
|
-
spec = "+refs/git-
|
|
593
|
+
spec = "+refs/git-sci/*:refs/git-sci/*"
|
|
594
594
|
if spec not in git("config", "--get-all", "remote.origin.fetch", check=False):
|
|
595
595
|
git("config", "--add", "remote.origin.fetch", spec)
|
|
596
596
|
hp = os.path.join(root(), ".git", "hooks", "pre-push")
|
|
597
597
|
if not os.path.exists(hp):
|
|
598
|
-
open(hp, "w").write('#!/bin/sh\n# git-
|
|
599
|
-
'git push --no-verify "$1" "+refs/git-
|
|
598
|
+
open(hp, "w").write('#!/bin/sh\n# git-sci: mirror op log + sessions, fail-soft\n'
|
|
599
|
+
'git push --no-verify "$1" "+refs/git-sci/*:refs/git-sci/*" >/dev/null 2>&1 || true\n')
|
|
600
600
|
os.chmod(hp, 0o755)
|
|
601
601
|
def agent_instructions(harnesses, choice, flags=""): # one prompt covering every harness file
|
|
602
602
|
todo = []
|
|
@@ -607,24 +607,24 @@ def agent_instructions(harnesses, choice, flags=""): # one prompt covering ever
|
|
|
607
607
|
if line not in cur: todo.append((p, cur, line, name))
|
|
608
608
|
if not todo: return
|
|
609
609
|
if choice is None and sys.stdin.isatty():
|
|
610
|
-
choice = input(f"git-
|
|
610
|
+
choice = input(f"git-sci: add one line to {'/'.join(t[3] for t in todo)} so agents find "
|
|
611
611
|
"team sessions? [Y/n] ").strip().lower() != "n"
|
|
612
612
|
if choice:
|
|
613
613
|
for p, cur, line, _ in todo:
|
|
614
614
|
open(p, "a").write(("\n" if cur and not cur.endswith("\n") else "") + line + "\n")
|
|
615
615
|
else:
|
|
616
|
-
print(f"git-
|
|
616
|
+
print(f"git-sci: run `git-sci init{flags} --instructions` later to enable team session sharing")
|
|
617
617
|
def cmd_init(a):
|
|
618
618
|
if quiet("git", "rev-parse", "--git-dir").returncode: sh("git", "init", "-q")
|
|
619
|
-
os.makedirs(os.path.join(root(), ".git", "git-
|
|
619
|
+
os.makedirs(os.path.join(root(), ".git", "git-sci"), exist_ok=True)
|
|
620
620
|
explicit = [h for h, f in (("claude", a.claude), ("codex", a.codex)) if f]
|
|
621
621
|
harnesses = explicit or detect_harnesses()
|
|
622
622
|
cfg = state("config.json", {}); cfg.pop("harness", None)
|
|
623
623
|
cfg["harnesses"] = harnesses; save_state("config.json", cfg)
|
|
624
624
|
if "origin" in git("remote", check=False).split(): # fresh clone: fetch team refs
|
|
625
|
-
git("fetch", "-q", "origin", "+refs/git-
|
|
626
|
-
p, sha = os.path.join(
|
|
627
|
-
"rev-parse", "-q", "--verify", f"refs/git-
|
|
625
|
+
git("fetch", "-q", "origin", "+refs/git-sci/*:refs/git-sci/*", check=False)
|
|
626
|
+
p, sha = os.path.join(git_sci_dir(), "events.jsonl"), git( # rebuild cache from our own ref
|
|
627
|
+
"rev-parse", "-q", "--verify", f"refs/git-sci/log/{user()}", check=False).strip()
|
|
628
628
|
if sha and not os.path.exists(p): open(p, "w").write(git("cat-file", "blob", sha))
|
|
629
629
|
installed = ", ".join(install_hooks(h) for h in harnesses); install_push()
|
|
630
630
|
for h in ("claude", "codex"):
|
|
@@ -633,22 +633,22 @@ def cmd_init(a):
|
|
|
633
633
|
# until the first prompt, then be committed as a <user>/local edit of CLAUDE.md/AGENTS.md
|
|
634
634
|
agent_instructions(harnesses, a.instructions, "".join(" --" + h for h in explicit))
|
|
635
635
|
if quiet("git", "rev-parse", "HEAD").returncode or changed_files():
|
|
636
|
-
c = commit_files(["."], "git-
|
|
636
|
+
c = commit_files(["."], "git-sci: initial state", local())
|
|
637
637
|
record(type="init", actor=local(), commit=c, edits=[])
|
|
638
|
-
print(f"git-
|
|
639
|
-
cmd_viz(argparse.Namespace(n=0, output=os.path.join(
|
|
640
|
-
print(f"open file://{
|
|
638
|
+
print(f"git-sci: initialized {root()} (hooks: {installed}); {do_import(harnesses)}")
|
|
639
|
+
cmd_viz(argparse.Namespace(n=0, output=os.path.join(git_sci_dir(), "viz.html")))
|
|
640
|
+
print(f"open file://{git_sci_dir()}/viz.html to see your history")
|
|
641
641
|
def cmd_sync(a):
|
|
642
|
-
ev = sync(); print(f"git-
|
|
642
|
+
ev = sync(); print(f"git-sci: committed local edits as op {ev['id']}" if ev else "git-sci: nothing to sync")
|
|
643
643
|
def cmd_import(a):
|
|
644
|
-
print("git-
|
|
644
|
+
print("git-sci: " + do_import(configured_harnesses()))
|
|
645
645
|
def cmd_update(a): # uv re-resolves the git source to the latest commit
|
|
646
|
-
try: p = quiet("uv", "tool", "upgrade", "git-
|
|
646
|
+
try: p = quiet("uv", "tool", "upgrade", "git-sci")
|
|
647
647
|
except OSError: p = None
|
|
648
648
|
if not p or p.returncode:
|
|
649
|
-
sys.exit("git-
|
|
650
|
-
"`uv tool install --force git+https://github.com/sundial-org/git-
|
|
651
|
-
print(f"git-
|
|
649
|
+
sys.exit("git-sci: could not update via uv; run "
|
|
650
|
+
"`uv tool install --force git+https://github.com/sundial-org/git-sci`")
|
|
651
|
+
print(f"git-sci: {(p.stderr.strip().splitlines() or ['updated'])[-1]}")
|
|
652
652
|
def cmd_log(a):
|
|
653
653
|
maybe_sync()
|
|
654
654
|
by_commit = {}
|
|
@@ -666,7 +666,7 @@ def cmd_show(a):
|
|
|
666
666
|
evs = [e for e in events() if e.get("commit") == H]
|
|
667
667
|
if not evs: # squash-merge/rebase moved the sha: match events by patch-id instead
|
|
668
668
|
pid = patch_id(H); evs = [e for e in events() if pid and e.get("patch_id") == pid]
|
|
669
|
-
if not evs: print("no git-
|
|
669
|
+
if not evs: print("no git-sci operations recorded for this commit")
|
|
670
670
|
for e in evs: print(json.dumps(e, indent=2))
|
|
671
671
|
def blame_context(chunk, tid): # first user prompt + last agent text before tool call `tid`
|
|
672
672
|
prompt = ctx = tool = None
|
|
@@ -695,7 +695,7 @@ def cmd_blame(a):
|
|
|
695
695
|
out = git("-c", "core.quotepath=false", "blame", "--porcelain",
|
|
696
696
|
"-L", f"{a.line},{a.line}", "--", f)
|
|
697
697
|
H, orig = out.split()[:2] # originating commit + the line's number in it
|
|
698
|
-
if H == "0" * 40: sys.exit(f"git-
|
|
698
|
+
if H == "0" * 40: sys.exit(f"git-sci: {f}:{a.line} is uncommitted")
|
|
699
699
|
of = next((l[9:] for l in out.splitlines() if l.startswith("filename ")), f) # pre-rename path
|
|
700
700
|
touches = lambda e: any(ed.get("file") == of for ed in e.get("edits") or [])
|
|
701
701
|
all_ev = events()
|
|
@@ -723,7 +723,7 @@ def cmd_blame(a):
|
|
|
723
723
|
print(f"{f}:{a.line} {H[:8]} " + (f"{ev['actor']}"
|
|
724
724
|
+ (f" {ev['turn']}" if ev.get("turn") else "") + f" op {ev['id']}" if ev else "untracked"))
|
|
725
725
|
print("> " + next((l[1:] for l in out.splitlines() if l.startswith("\t")), ""))
|
|
726
|
-
if not ev: return print("no git-
|
|
726
|
+
if not ev: return print("no git-sci operations recorded for this commit")
|
|
727
727
|
if "/agent:" not in ev["actor"]:
|
|
728
728
|
return print(f"human edit ({ev['actor']}): no conversation\n" + json.dumps(ev, indent=2))
|
|
729
729
|
u, sid8, tcs = ev["actor"].split("/", 1)[0], ev["actor"].rsplit("/", 1)[1], ev.get("tool_calls") or []
|
|
@@ -752,35 +752,35 @@ def cmd_undo(a):
|
|
|
752
752
|
merge = ["-m", "1"] if len(git("rev-list", "--parents", "-n1", H).split()) > 2 else []
|
|
753
753
|
r = quiet("git", "revert", "--no-commit", "--no-edit", *merge, H)
|
|
754
754
|
if r.returncode:
|
|
755
|
-
quiet("git", "revert", "--abort"); sys.exit(f"git-
|
|
755
|
+
quiet("git", "revert", "--abort"); sys.exit(f"git-sci: cannot revert {H[:8]} cleanly")
|
|
756
756
|
files, pre = changed_files(), head()
|
|
757
|
-
c = commit_files(files or ["."], f"git-
|
|
757
|
+
c = commit_files(files or ["."], f"git-sci: undo commit {H[:8]}", actor)
|
|
758
758
|
record(type="undo", actor=actor, commit=c, target_commit=H,
|
|
759
759
|
edits=[{"file": f, "patch": patches(pre, c, files)[f]} for f in files])
|
|
760
|
-
return print(f"git-
|
|
760
|
+
return print(f"git-sci: undid commit {H[:8]} in {c[:8]}")
|
|
761
761
|
target = next((e for e in reversed(events())
|
|
762
762
|
if (e["id"] == a.op or not a.op) and e.get("edits")), None)
|
|
763
|
-
if not target: sys.exit("git-
|
|
763
|
+
if not target: sys.exit("git-sci: no such operation")
|
|
764
764
|
combined = "".join(ed["patch"] for ed in target["edits"] if ed["patch"].strip())
|
|
765
765
|
if quiet("git", "apply", "-R", input=combined).returncode: # atomic: all or nothing
|
|
766
|
-
sys.exit(f"git-
|
|
767
|
-
"try `git-
|
|
766
|
+
sys.exit(f"git-sci: cannot undo op {target['id']}: its patch no longer applies cleanly; "
|
|
767
|
+
"try `git-sci undo --commit <hash>` instead")
|
|
768
768
|
files, pre = [ed["file"] for ed in target["edits"]], head()
|
|
769
|
-
c = commit_files(files, f"git-
|
|
769
|
+
c = commit_files(files, f"git-sci: undo op {target['id']}", actor)
|
|
770
770
|
record(type="undo", actor=actor, commit=c, target_op=target["id"],
|
|
771
771
|
edits=[{"file": f, "patch": patches(pre, c, files)[f]} for f in files])
|
|
772
|
-
print(f"git-
|
|
772
|
+
print(f"git-sci: undid op {target['id']} in {c[:8]}")
|
|
773
773
|
def cmd_viz(a):
|
|
774
774
|
maybe_sync()
|
|
775
|
-
log = git("log", "--exclude=refs/git-
|
|
775
|
+
log = git("log", "--exclude=refs/git-sci/*", "--all", *([f"-{a.n}"] if a.n else []), "--date=iso",
|
|
776
776
|
"--pretty=%H\x1f%h\x1f%P\x1f%D\x1f%ad\x1f%s")
|
|
777
777
|
cols = ["hash", "short", "parents", "refs", "date", "subject"]
|
|
778
778
|
commits = [dict(zip(cols, l.split("\x1f"))) for l in log.splitlines()]
|
|
779
779
|
data = json.dumps({"commits": commits, "events": events()}).replace("</", "<\\/")
|
|
780
|
-
out = a.output or os.path.join(
|
|
780
|
+
out = a.output or os.path.join(git_sci_dir(), "viz.html")
|
|
781
781
|
open(out, "w").write(VIZ.replace("__DATA__", data))
|
|
782
|
-
print(f"git-
|
|
783
|
-
VIZ = """<!doctype html><meta charset="utf-8"><title>git-
|
|
782
|
+
print(f"git-sci: wrote {out}")
|
|
783
|
+
VIZ = """<!doctype html><meta charset="utf-8"><title>git-sci</title>
|
|
784
784
|
<style>body{margin:0;font:12px/1.5 ui-monospace,Menlo,monospace;background:#fff;color:#000}
|
|
785
785
|
header{display:flex;gap:12px;align-items:center;flex-wrap:wrap;padding:8px 14px;border-bottom:1px solid #000}#st{margin-left:auto}
|
|
786
786
|
main{display:grid;grid-template-columns:1fr 1fr;height:calc(100vh - 40px)}
|
|
@@ -789,7 +789,7 @@ main{display:grid;grid-template-columns:1fr 1fr;height:calc(100vh - 40px)}
|
|
|
789
789
|
#c li:hover{background:#eee}#c li.sel{background:#000;color:#fff}
|
|
790
790
|
#d{margin:0;padding:10px 14px;overflow:auto;white-space:pre-wrap}
|
|
791
791
|
button,input{font:inherit;background:#fff;border:1px solid #000;accent-color:#000}</style>
|
|
792
|
-
<header><b>git-
|
|
792
|
+
<header><b>git-sci</b><button id=play>replay</button><input type=range id=s min=0><span id=pos></span><span id=st></span></header>
|
|
793
793
|
<main><ol id=c></ol><pre id=d>select a commit</pre></main>
|
|
794
794
|
<script>const D=__DATA__,cs=D.commits,byC={};D.events.forEach(e=>(byC[e.commit]=byC[e.commit]||[]).push(e));
|
|
795
795
|
const esc=t=>(t||'').replace(/&/g,'&').replace(/</g,'<');
|
|
@@ -803,21 +803,21 @@ function cut(){const n=+s.value;pos.textContent=n+'/'+cs.length;rows.forEach((li
|
|
|
803
803
|
function detail(x){let o=`commit ${x.hash}\\n${x.date}${x.refs?' ('+x.refs+')':''}\\n${x.subject}\\n`;
|
|
804
804
|
for(const e of byC[x.hash]||[]){o+=`\\nop ${e.id} | ${e.type} | ${e.actor}${e.turn?' | '+e.turn:''}${e.target_op?' | undoes op '+e.target_op:''}${e.target_commit?' | undoes '+e.target_commit.slice(0,8):''}\\n`;
|
|
805
805
|
for(const ed of e.edits||[])o+=` ${ed.file}${ed.tool_call_id?` (tool ${ed.tool_call_id})`:''}\\n`+(ed.patch||'').split('\\n').map(l=>' '+l).join('\\n')+'\\n';}
|
|
806
|
-
if(!(byC[x.hash]||[]).length)o+='\\n(no git-
|
|
806
|
+
if(!(byC[x.hash]||[]).length)o+='\\n(no git-sci operations recorded for this commit)';d.textContent=o;}
|
|
807
807
|
let t=null;play.onclick=()=>{if(t){clearInterval(t);t=null;return}s.value=0;cut();
|
|
808
808
|
t=setInterval(()=>{s.value=+s.value+Math.max(1,Math.ceil(cs.length/150));cut();if(+s.value>=cs.length){clearInterval(t);t=null}},80)};
|
|
809
809
|
s.oninput=cut;cut();</script>
|
|
810
810
|
"""
|
|
811
811
|
def main(argv=None):
|
|
812
|
-
ap = argparse.ArgumentParser(prog="git-
|
|
812
|
+
ap = argparse.ArgumentParser(prog="git-sci", description=__doc__)
|
|
813
813
|
sub = ap.add_subparsers(dest="cmd", required=True)
|
|
814
814
|
ps = {c: sub.add_parser(c, help=h) for c, h in [
|
|
815
|
-
("init", "git repo + .git/git-
|
|
815
|
+
("init", "git repo + .git/git-sci + hooks + session import"), ("sync", "commit pending local edits"),
|
|
816
816
|
("hook", "agent-harness hook entrypoint (JSON on stdin)"), ("show", "show operations for a commit"),
|
|
817
817
|
("import", "re-import sessions + backfill attribution"), ("gc", "drop session refs older than N days"),
|
|
818
|
-
("sessions", "list/read/export sessions in refs/git-
|
|
818
|
+
("sessions", "list/read/export sessions in refs/git-sci/sessions"), ("log", "git log with actors + turns"),
|
|
819
819
|
("undo", "undo one operation (default: latest) or a commit"), ("viz", "HTML visualization of the op log"),
|
|
820
|
-
("update", "update git-
|
|
820
|
+
("update", "update git-sci to the latest version"),
|
|
821
821
|
("export", "dump history as fine-tuning JSONL (prompt + tool_calls + edits per turn)"),
|
|
822
822
|
("blame", "the conversation behind a file:line")]}
|
|
823
823
|
ps["init"].add_argument("--claude", action="store_true",
|
|
@@ -840,7 +840,7 @@ def main(argv=None):
|
|
|
840
840
|
a = ap.parse_args(argv)
|
|
841
841
|
if a.cmd not in ("init", "hook", "update"): # hook locates its repo from the payload
|
|
842
842
|
if a.cmd == "blame": a.file = os.path.abspath(a.file) # resolve before the chdir
|
|
843
|
-
os.chdir(root());
|
|
843
|
+
os.chdir(root()); git_sci_dir(); a.lock = lock()
|
|
844
844
|
{"init": cmd_init, "hook": cmd_hook, "sync": cmd_sync, "log": cmd_log, "show": cmd_show,
|
|
845
845
|
"undo": cmd_undo, "viz": cmd_viz, "import": cmd_import, "sessions": cmd_sessions,
|
|
846
846
|
"gc": cmd_gc, "update": cmd_update, "export": cmd_export, "blame": cmd_blame}[a.cmd](a)
|
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "git-sci"
|
|
3
|
-
version = "0.1.
|
|
3
|
+
version = "0.1.1"
|
|
4
4
|
description = "Share your team's memory - you, your agents, and your teammate's agents."
|
|
5
5
|
requires-python = ">=3.9"
|
|
6
6
|
|
|
7
7
|
[project.scripts]
|
|
8
|
-
git-
|
|
8
|
+
git-sci = "git_sci:main"
|
|
9
9
|
|
|
10
10
|
[tool.flit.module]
|
|
11
|
-
name = "
|
|
11
|
+
name = "git_sci"
|
|
12
12
|
|
|
13
13
|
[build-system]
|
|
14
14
|
requires = ["flit_core>=3.2,<4"]
|