git-sci 0.0.1__tar.gz → 0.1.0__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.
- git_sci-0.1.0/PKG-INFO +5 -0
- git_sci-0.1.0/git_mem.py +848 -0
- git_sci-0.1.0/pyproject.toml +15 -0
- git_sci-0.0.1/LICENSE +0 -19
- git_sci-0.0.1/PKG-INFO +0 -20
- git_sci-0.0.1/README.md +0 -5
- git_sci-0.0.1/pyproject.toml +0 -23
- git_sci-0.0.1/setup.cfg +0 -4
- git_sci-0.0.1/src/git_sci.egg-info/PKG-INFO +0 -20
- git_sci-0.0.1/src/git_sci.egg-info/SOURCES.txt +0 -9
- git_sci-0.0.1/src/git_sci.egg-info/dependency_links.txt +0 -1
- git_sci-0.0.1/src/git_sci.egg-info/top_level.txt +0 -1
- git_sci-0.0.1/src/science/__init__.py +0 -0
- git_sci-0.0.1/src/science/example.py +0 -2
git_sci-0.1.0/PKG-INFO
ADDED
git_sci-0.1.0/git_mem.py
ADDED
|
@@ -0,0 +1,848 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""git-mem: an operation log for humans + agents on top of git.
|
|
3
|
+
|
|
4
|
+
Commits are attributed as <user>/<source>: <user>/local, or <user>/agent:<harness>/<session>.
|
|
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-mem/ops) and appends the turn's
|
|
7
|
+
transcript chunk to refs/git-mem/sessions/<user>/<id>; init imports existing sessions + backfills.
|
|
8
|
+
Events: .git/git-mem/events.jsonl (cache) mirrored to refs/git-mem/log/<user>, one writer each, merged
|
|
9
|
+
by timestamp at read; refs/git-mem/* are the source of truth and travel via the refspec + pre-push
|
|
10
|
+
hook init installs. .git/git-mem/lock (flock) serializes everything, like git's index.lock."""
|
|
11
|
+
import argparse, difflib, fcntl, glob, hashlib, json, os, re, subprocess, sys
|
|
12
|
+
import time, uuid, zlib
|
|
13
|
+
from datetime import datetime
|
|
14
|
+
EDIT_TOOLS = {"Edit", "Write", "MultiEdit", "NotebookEdit", "apply_patch"}
|
|
15
|
+
HOOK_EVENTS = ("SessionStart", "UserPromptSubmit", "PreToolUse", "PostToolUse", "Stop")
|
|
16
|
+
CODEX_HOOK_EVENTS = HOOK_EVENTS + ("SubagentStart", "SubagentStop")
|
|
17
|
+
CLAUDE_LINE = ("Past sessions for this repo, yours and other team members', are in "
|
|
18
|
+
".git/git-mem/sessions/ (same layout as ~/.claude/projects/, with a "
|
|
19
|
+
"sessions-index.json). Look there instead of ~/.claude/projects/.")
|
|
20
|
+
CODEX_LINE = ("Past sessions for this repo, yours and other team members', are in "
|
|
21
|
+
".git/git-mem/sessions/ (with a sessions-index.json). Look there instead of "
|
|
22
|
+
"~/.codex/sessions/, or use `git-mem sessions` to search them.")
|
|
23
|
+
projects = lambda: os.environ.get("GIT_MEM_PROJECTS", os.path.expanduser("~/.claude/projects"))
|
|
24
|
+
codex_sessions = lambda: os.environ.get(
|
|
25
|
+
"GIT_MEM_CODEX_SESSIONS", os.path.expanduser("~/.codex/sessions"))
|
|
26
|
+
def sh(*a, input=None, check=True, env=None):
|
|
27
|
+
p = subprocess.run(a, capture_output=True, text=True, input=input, env=env)
|
|
28
|
+
if check and p.returncode: sys.exit(f"git-mem: `{' '.join(a)}` failed: {p.stderr.strip()}")
|
|
29
|
+
return p.stdout
|
|
30
|
+
git = lambda *a, **k: sh("git", *a, **k)
|
|
31
|
+
_r = {}
|
|
32
|
+
def root(): # memoized per cwd: called on every event, and hooks chdir before using it
|
|
33
|
+
return _r.get(os.getcwd()) or _r.setdefault(os.getcwd(), git("rev-parse", "--show-toplevel").strip())
|
|
34
|
+
head = lambda: git("rev-parse", "HEAD").strip()
|
|
35
|
+
quiet = lambda *a, **k: subprocess.run(a, capture_output=True, text=True, **k)
|
|
36
|
+
patches = lambda pre, post, files: {f: git("diff", pre, post, "--", f) for f in files}
|
|
37
|
+
def changed_files():
|
|
38
|
+
# status.renames=false lists a rename as D+A so commit --only keeps the deletion
|
|
39
|
+
return [l[3:].strip('"') for l in
|
|
40
|
+
git("-c", "core.quotepath=false", "-c", "status.renames=false",
|
|
41
|
+
"status", "--porcelain", "-uall").splitlines()]
|
|
42
|
+
def commit_files(files, message, author):
|
|
43
|
+
git("add", "-A", "--", *files, check=False) # pathspec may legitimately match nothing
|
|
44
|
+
c = ("-c", "commit.gpgsign=false", "commit", "-q", "--no-verify", "--allow-empty",
|
|
45
|
+
"--author", f"{author} <git-mem@git-mem>", "-m", message)
|
|
46
|
+
# --only keeps unrelated pre-staged paths out; empty repo matches no pathspec -> plain commit
|
|
47
|
+
quiet("git", *c, "--only", "--", *files).returncode and git(*c)
|
|
48
|
+
return head()
|
|
49
|
+
patch_id = lambda c: (git("patch-id", "--stable", check=False,
|
|
50
|
+
input=git("show", "--format=", c, check=False)).split() or [None])[0]
|
|
51
|
+
def blob(data): # loose object via hashlib+zlib: no subprocess per chunk (sha1 repos)
|
|
52
|
+
b = data.encode() if isinstance(data, str) else data
|
|
53
|
+
hdr = b"blob %d\x00" % len(b); sha = hashlib.sha1(hdr + b).hexdigest()
|
|
54
|
+
p = os.path.join(root(), ".git", "objects", sha[:2], sha[2:])
|
|
55
|
+
if not os.path.exists(p):
|
|
56
|
+
os.makedirs(os.path.dirname(p), exist_ok=True)
|
|
57
|
+
open(p, "wb").write(zlib.compress(hdr + b))
|
|
58
|
+
return sha
|
|
59
|
+
refs = lambda prefix: [l.split("\t") for l in git( # [(refname, sha)]
|
|
60
|
+
"for-each-ref", "--format=%(refname)\t%(objectname)", prefix, check=False).splitlines()]
|
|
61
|
+
def git_mem_dir(must=True): # .git/git-mem is a per-machine cache; refs/git-mem/* are the source of truth
|
|
62
|
+
d = os.path.join(root(), ".git", "git-mem")
|
|
63
|
+
if must and not os.path.isdir(d): sys.exit("git-mem: not initialized here (run `git-mem init`)")
|
|
64
|
+
return d
|
|
65
|
+
def jread(path, default):
|
|
66
|
+
try: return json.load(open(path))
|
|
67
|
+
except (OSError, ValueError): return default
|
|
68
|
+
jwrite = lambda path, obj: json.dump(obj, open(path, "w"), indent=1)
|
|
69
|
+
state = lambda name, default: jread(os.path.join(git_mem_dir(), name), default)
|
|
70
|
+
save_state = lambda name, obj: jwrite(os.path.join(git_mem_dir(), name), obj)
|
|
71
|
+
def parse_events(text): # skips torn lines rather than bricking every command
|
|
72
|
+
out = []
|
|
73
|
+
for l in text.splitlines():
|
|
74
|
+
try: out.append(json.loads(l))
|
|
75
|
+
except ValueError: pass
|
|
76
|
+
return out
|
|
77
|
+
def events(): # own cache first (file order), then teammates' logs; stable-sorted
|
|
78
|
+
try: evs = parse_events(open(os.path.join(git_mem_dir(), "events.jsonl")).read())
|
|
79
|
+
except OSError: evs = []
|
|
80
|
+
for ref, sha in refs("refs/git-mem/log/"):
|
|
81
|
+
if ref.split("/")[-1] != user():
|
|
82
|
+
evs += parse_events(git("cat-file", "blob", sha, check=False))
|
|
83
|
+
return sorted(evs, key=lambda e: e.get("timestamp", ""))
|
|
84
|
+
def lock():
|
|
85
|
+
lk = open(os.path.join(git_mem_dir(False), "lock"), "a")
|
|
86
|
+
fcntl.flock(lk, fcntl.LOCK_EX)
|
|
87
|
+
return lk # held until process exit
|
|
88
|
+
def mirror_log(): # one writer per user: refs/git-mem/log/<user> mirrors the local cache
|
|
89
|
+
p = os.path.join(git_mem_dir(), "events.jsonl")
|
|
90
|
+
if os.path.exists(p): git("update-ref", f"refs/git-mem/log/{user()}", blob(open(p).read()))
|
|
91
|
+
def record(mirror=True, **ev):
|
|
92
|
+
ev = {"id": uuid.uuid4().hex[:8], "timestamp": time.strftime("%Y-%m-%dT%H:%M:%S%z"), **ev}
|
|
93
|
+
with open(os.path.join(git_mem_dir(), "events.jsonl"), "a") as f:
|
|
94
|
+
f.write(json.dumps(ev) + "\n")
|
|
95
|
+
return mirror and mirror_log() or ev
|
|
96
|
+
def store_chunks(sid, files, u=None): # {path: text} -> refs/git-mem/sessions/<u>/<sid> tree
|
|
97
|
+
if not files: return
|
|
98
|
+
ref, ix = f"refs/git-mem/sessions/{u or user()}/{sid}", os.path.join(git_mem_dir(), "tmpindex")
|
|
99
|
+
env = {**os.environ, "GIT_INDEX_FILE": ix}
|
|
100
|
+
if os.path.exists(ix): os.remove(ix)
|
|
101
|
+
if not quiet("git", "rev-parse", "-q", "--verify", ref).returncode:
|
|
102
|
+
git("read-tree", ref, env=env)
|
|
103
|
+
git("update-index", "--index-info", env=env,
|
|
104
|
+
input="".join(f"100644 blob {blob(c)}\t{p}\n" for p, c in files.items()))
|
|
105
|
+
git("update-ref", ref, git("commit-tree", git("write-tree", env=env).strip(), "-m", sid).strip())
|
|
106
|
+
_u = {}
|
|
107
|
+
def user(): # the git user on whose machine the change happened; owns every event
|
|
108
|
+
_u or _u.setdefault("u", (git("config", "user.name", check=False).strip()
|
|
109
|
+
or os.environ.get("USER", "unknown")).replace(" ", "-").lower())
|
|
110
|
+
return _u["u"]
|
|
111
|
+
local = lambda u=None: f"{u or user()}/local"
|
|
112
|
+
agent = lambda sid, harness="claude", u=None: f"{u or user()}/agent:{harness}/{sid[:8]}"
|
|
113
|
+
session_key = lambda sid, harness="claude": sid if harness == "claude" else f"{harness}/{sid}"
|
|
114
|
+
call_key = lambda sid, payload, harness="claude": \
|
|
115
|
+
f"{session_key(sid, harness)}/{payload.get('tool_use_id') or ''}"
|
|
116
|
+
tool_file = lambda inp: inp.get("file_path") or inp.get("notebook_path")
|
|
117
|
+
rel_to_root = lambda path: os.path.relpath(os.path.realpath(path), root())
|
|
118
|
+
def patch_files(text):
|
|
119
|
+
if not isinstance(text, str): return []
|
|
120
|
+
# Tool wrappers may carry the patch inside a JSON or JavaScript string.
|
|
121
|
+
text = text.replace("\\r\\n", "\n").replace("\\n", "\n")
|
|
122
|
+
paths = re.findall(r"^\*\*\* (?:Update|Add|Delete) File: (.+)$", text, re.M)
|
|
123
|
+
paths += re.findall(r"^\*\*\* Move to: (.+)$", text, re.M)
|
|
124
|
+
paths += [p for p in re.findall(r"^\+\+\+ (?:b/)?(.+)$", text, re.M)
|
|
125
|
+
if p != "/dev/null"]
|
|
126
|
+
cleaned = [p.strip().rstrip("\\\"'`,);") for p in paths]
|
|
127
|
+
return list(dict.fromkeys(p for p in cleaned if p and p != "/dev/null"))
|
|
128
|
+
def tool_files(tool, inp):
|
|
129
|
+
if not isinstance(inp, dict): return patch_files(inp)
|
|
130
|
+
f = tool_file(inp)
|
|
131
|
+
if f: return [f]
|
|
132
|
+
if tool == "apply_patch":
|
|
133
|
+
return patch_files(inp.get("command") or inp.get("patch") or inp.get("input") or "")
|
|
134
|
+
return []
|
|
135
|
+
def relative_tool_files(tool, inp):
|
|
136
|
+
return [rel_to_root(f) for f in tool_files(tool, inp)]
|
|
137
|
+
def char_edits(patch): # character-level ranges for paired -/+ lines of a unified patch
|
|
138
|
+
out, o, n, minus = [], 0, 0, []
|
|
139
|
+
for l in patch.splitlines():
|
|
140
|
+
if l.startswith("@@"):
|
|
141
|
+
nums = l.split("@@")[1].split()[:2]
|
|
142
|
+
if len(nums) < 2: break
|
|
143
|
+
(o, n), minus = tuple(int(x.lstrip("-+").split(",")[0]) for x in nums), []
|
|
144
|
+
elif l.startswith("-") and not l.startswith("---"):
|
|
145
|
+
minus.append((o, l[1:])); o += 1
|
|
146
|
+
elif l.startswith("+") and not l.startswith("+++"):
|
|
147
|
+
if minus:
|
|
148
|
+
ol, old = minus.pop(0)
|
|
149
|
+
out += [{"old_line": ol, "new_line": n, "old_range": [a, b], "new_range": [c, d],
|
|
150
|
+
"old_text": old[a:b], "new_text": l[1:][c:d]} for t, a, b, c, d in
|
|
151
|
+
difflib.SequenceMatcher(None, old, l[1:]).get_opcodes() if t != "equal"]
|
|
152
|
+
n += 1
|
|
153
|
+
elif not l.startswith("\\") and not l.startswith("diff "):
|
|
154
|
+
o, n, minus = o + 1, n + 1, []
|
|
155
|
+
return out
|
|
156
|
+
edits_msg = lambda actor, files: (f"git-mem: {actor} edits " + ", ".join(files[:3])
|
|
157
|
+
+ (f" +{len(files) - 3} more" if len(files) > 3 else ""))
|
|
158
|
+
def sync(exclude=()): # commit unclaimed changes as <user>/local
|
|
159
|
+
exclude = set(exclude) | {f for s in state("active.json", {}).values() for f in s["files"]}
|
|
160
|
+
files = [f for f in changed_files() if f not in exclude]
|
|
161
|
+
if not files: return None
|
|
162
|
+
pre, actor = head(), local()
|
|
163
|
+
c = commit_files(files, edits_msg(actor, files), actor)
|
|
164
|
+
p = patches(pre, c, files)
|
|
165
|
+
return record(type="edit", actor=actor, commit=c, patch_id=patch_id(c),
|
|
166
|
+
edits=[{"file": f, "patch": p[f], "char_edits": char_edits(p[f]),
|
|
167
|
+
"attribution": {"method": "hook"}} for f in files])
|
|
168
|
+
def maybe_sync(exclude=()): # skipped while an agent tool call is in flight
|
|
169
|
+
state("active.json", {}) or sync(exclude)
|
|
170
|
+
def finish_call(sid, active, payload, harness="claude"):
|
|
171
|
+
# PostToolUse: the pre->post diff is the agent's tool call
|
|
172
|
+
st = (active.pop(call_key(sid, payload, harness), None) # or an id-less claim
|
|
173
|
+
or active.pop(call_key(sid, {}, harness), None) or {"files": []})
|
|
174
|
+
save_state("active.json", active)
|
|
175
|
+
others = {f for s in active.values() for f in s["files"]}
|
|
176
|
+
files = [f for f in changed_files() if f not in others]
|
|
177
|
+
if not files: return
|
|
178
|
+
tool, inp = payload.get("tool_name", "?"), payload.get("tool_input") or {}
|
|
179
|
+
paths = relative_tool_files(tool, inp)
|
|
180
|
+
call_input = ({"file": paths[0]} if len(paths) == 1 else
|
|
181
|
+
{"files": paths} if paths else inp)
|
|
182
|
+
call = {"id": payload.get("tool_use_id"), "tool": tool, "input": call_input}
|
|
183
|
+
claimed = set(st["files"]) | set(paths)
|
|
184
|
+
# Edit/Write only touch their own file -> the rest of the diff is a concurrent local
|
|
185
|
+
# overlay; for other tools (Bash, ...) the whole diff is the agent's
|
|
186
|
+
a_files = [f for f in files if f in claimed] if tool in EDIT_TOOLS and claimed else files
|
|
187
|
+
h_files = [f for f in files if f not in a_files]
|
|
188
|
+
actor, pre = agent(sid, harness), head()
|
|
189
|
+
turn = state("turns.json", {}).get(session_key(sid, harness), {}).get("n", 0)
|
|
190
|
+
c = commit_files(files, f"git-mem: {actor} {tool}" if a_files else edits_msg(local(), files),
|
|
191
|
+
actor if a_files else local())
|
|
192
|
+
p = patches(pre, c, files)
|
|
193
|
+
if a_files:
|
|
194
|
+
git("update-ref", f"refs/git-mem/ops/{c}", c) # survives the turn squash + git gc
|
|
195
|
+
record(type="edit", actor=actor, turn=f"turn{turn}", commit=c, patch_id=patch_id(c),
|
|
196
|
+
tool_calls=[call], edits=[{"file": f, "patch": p[f], "tool_call_id": call["id"],
|
|
197
|
+
"attribution": {"method": "hook"}} for f in a_files])
|
|
198
|
+
if h_files:
|
|
199
|
+
record(type="edit", actor=local(), commit=c,
|
|
200
|
+
edits=[{"file": f, "patch": p[f], "char_edits": char_edits(p[f]),
|
|
201
|
+
"attribution": {"method": "hook"}} for f in h_files])
|
|
202
|
+
def squash_turn(sid, harness="claude"):
|
|
203
|
+
# Stop: squash the turn's tool-call commits into one commit per turn;
|
|
204
|
+
# skipped when a foreign commit landed mid-turn, and per-call commits stay either way
|
|
205
|
+
t = state("turns.json", {}).get(session_key(sid, harness))
|
|
206
|
+
if not t or quiet("git", "merge-base", "--is-ancestor", t["start"], "HEAD").returncode: return
|
|
207
|
+
start, actor = t["start"], agent(sid, harness)
|
|
208
|
+
rng = git("rev-list", f"{start}..HEAD").split()
|
|
209
|
+
ops = [e for e in events() if e.get("commit") in rng]
|
|
210
|
+
mine = [e for e in ops if e["actor"] == actor and "squashes" not in e]
|
|
211
|
+
if not rng or set(rng) - {e["commit"] for e in mine}: return
|
|
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-mem@git-mem", "commit-tree",
|
|
214
|
+
git("rev-parse", "HEAD^{tree}").strip(), "-p", start,
|
|
215
|
+
"-m", f"git-mem: {actor} turn {t['n']} ({len(rng)} tool calls)").strip()
|
|
216
|
+
git("update-ref", "HEAD", c, head())
|
|
217
|
+
files = git("diff", "--name-only", start, c).splitlines()
|
|
218
|
+
p = patches(start, c, files)
|
|
219
|
+
record(type="edit", actor=actor, turn=f"turn{t['n']}", commit=c, patch_id=patch_id(c),
|
|
220
|
+
squashes=[e["id"] for e in mine], tool_calls=[tc for e in mine for tc in e["tool_calls"]],
|
|
221
|
+
edits=[{"file": f, "patch": p[f], "attribution": {"method": "squash"}} for f in files])
|
|
222
|
+
for e in ops: # local overlays inside the agent's commits follow the squash
|
|
223
|
+
if e not in mine:
|
|
224
|
+
record(type=e["type"], actor=e["actor"], commit=c, edits=e["edits"], overlay_of=e["id"])
|
|
225
|
+
def prompt_line(l, harness="claude"):
|
|
226
|
+
try: obj = json.loads(l) # both harnesses write one JSON record per line
|
|
227
|
+
except ValueError: return False
|
|
228
|
+
if harness == "claude": # a user record of only tool_results is not a prompt, but a
|
|
229
|
+
content = (obj.get("message") or {}).get("content") # queued message may ride
|
|
230
|
+
return obj.get("type") == "user" and not (isinstance(content, list) and all(
|
|
231
|
+
isinstance(b, dict) and b.get("type") == "tool_result" for b in content))
|
|
232
|
+
p = obj.get("payload") or {}
|
|
233
|
+
if obj.get("type") == "event_msg":
|
|
234
|
+
return {"user_message": 1, "task_started": 3}.get(p.get("type"), 0)
|
|
235
|
+
return 2 * (obj.get("type") == "response_item" and p.get("type") == "message"
|
|
236
|
+
and p.get("role") == "user")
|
|
237
|
+
def prompt_boundary(sid, payload, harness="claude"):
|
|
238
|
+
# PreToolUse: a prompt can reach the transcript with no hook between turns (queued
|
|
239
|
+
# message, missed Stop); a user line past the first scan means a new turn started
|
|
240
|
+
turns = state("turns.json", {}); t = turns.get(session_key(sid, harness))
|
|
241
|
+
tp = payload.get("transcript_path") or (t or {}).get("transcript", "")
|
|
242
|
+
if not (t and tp and os.path.exists(tp)): return
|
|
243
|
+
f = open(tp, "rb"); prompts = []
|
|
244
|
+
f.seek(t.get("soffset", t.get("toffset", 0)))
|
|
245
|
+
while True:
|
|
246
|
+
pos, l = f.tell(), f.readline()
|
|
247
|
+
if not l: break
|
|
248
|
+
k = prompt_line(l, harness)
|
|
249
|
+
if k: prompts.append((pos, k))
|
|
250
|
+
end = f.tell(); kinds = {k for _, k in prompts}
|
|
251
|
+
# codex mirrors one prompt across records: prefer task_started (the earliest, as
|
|
252
|
+
# scan_codex_transcript does), else count just one of the user-record kinds
|
|
253
|
+
if 3 in kinds: prompts = [x for x in prompts if x[1] == 3]
|
|
254
|
+
elif {1, 2} <= kinds: prompts = [x for x in prompts if x[1] == 1]
|
|
255
|
+
seen = t.get("seen", 0) # the turn's own prompt is the first record seen for it,
|
|
256
|
+
bs = prompts[0 if seen else 1:] # however late the harness flushes it; the rest
|
|
257
|
+
for boundary, _ in bs: # are queued prompts, each starting a new turn
|
|
258
|
+
squash_turn(sid, harness) # close the previous turn before this turn's first commit
|
|
259
|
+
f.seek(t.get("toffset", 0)); data = f.read(boundary - f.tell())
|
|
260
|
+
if data and not t.get("stopped") and cfg_track(): # store its transcript slice too
|
|
261
|
+
store_chunks(sid, {f"turn-{t['n']:04d}.jsonl": data})
|
|
262
|
+
sync() # dirty local edits predate the queued turn: commit them outside it
|
|
263
|
+
t.update(n=t["n"] + 1, start=head(), toffset=boundary); t.pop("stopped", None)
|
|
264
|
+
t.update(soffset=end, seen=1 if bs else seen + len(prompts))
|
|
265
|
+
turns[session_key(sid, harness)] = t
|
|
266
|
+
save_state("turns.json", turns)
|
|
267
|
+
def stop_chunk(sid, payload, harness="claude"):
|
|
268
|
+
# Stop: append this turn's transcript slice to the session ref
|
|
269
|
+
turns = state("turns.json", {}); t = turns.get(session_key(sid, harness))
|
|
270
|
+
tp = payload.get("transcript_path") or (t or {}).get("transcript", "")
|
|
271
|
+
if not (t and tp and os.path.exists(tp) and cfg_track()): return
|
|
272
|
+
f = open(tp, errors="replace"); f.seek(t.get("toffset", 0)); data = f.read()
|
|
273
|
+
if data: store_chunks(sid, {f"turn-{t['n']:04d}.jsonl": data})
|
|
274
|
+
t.update(toffset=os.path.getsize(tp), stopped=True); save_state("turns.json", turns)
|
|
275
|
+
def subagent_chunk(sid, payload):
|
|
276
|
+
tp, sub = payload.get("agent_transcript_path"), payload.get("agent_id")
|
|
277
|
+
if not (tp and sub and os.path.exists(tp) and cfg_track()): return
|
|
278
|
+
store_chunks(sid, {f"subagents/{sub}/transcript.jsonl": open(tp, errors="replace").read()})
|
|
279
|
+
def cmd_hook(a):
|
|
280
|
+
try: payload = json.load(sys.stdin)
|
|
281
|
+
except (ValueError, OSError): payload = {}
|
|
282
|
+
try: payload.get("cwd") and os.chdir(payload["cwd"])
|
|
283
|
+
except OSError: return
|
|
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-mem")):
|
|
286
|
+
return # not a git-mem repo; hooks must never break the harness
|
|
287
|
+
os.chdir(p.stdout.strip())
|
|
288
|
+
lk = lock() # serialize concurrent hooks (parallel sessions/subagents)
|
|
289
|
+
event, sid, harness = (payload.get("hook_event_name", ""),
|
|
290
|
+
payload.get("session_id", "?"), a.harness)
|
|
291
|
+
active = state("active.json", {})
|
|
292
|
+
if event == "UserPromptSubmit" and not state("turns.json", {}).get(
|
|
293
|
+
session_key(sid, harness), {}).get("stopped"): # Stop never fired:
|
|
294
|
+
squash_turn(sid, harness) # squash the turn + store its transcript slice
|
|
295
|
+
stop_chunk(sid, payload, harness)
|
|
296
|
+
elif event == "PreToolUse": prompt_boundary(sid, payload, harness)
|
|
297
|
+
if event in ("SessionStart", "SubagentStart", "UserPromptSubmit", "PreToolUse"):
|
|
298
|
+
sync() # everything dirty before an agent tool runs is <user>/local
|
|
299
|
+
if event == "SessionStart": # stdout lands in the agent's context
|
|
300
|
+
native = "~/.claude/projects" if harness == "claude" else "~/.codex/sessions"
|
|
301
|
+
print(f"git-mem: past sessions for this repo (yours + teammates') are in "
|
|
302
|
+
f".git/git-mem/sessions/, not {native}; {export_sessions()} sessions")
|
|
303
|
+
elif event == "SubagentStart":
|
|
304
|
+
print("git-mem: past sessions for this repo are in .git/git-mem/sessions/; "
|
|
305
|
+
"use `git-mem sessions` to search them")
|
|
306
|
+
elif event == "UserPromptSubmit":
|
|
307
|
+
turns, tp = state("turns.json", {}), payload.get("transcript_path", "")
|
|
308
|
+
key = session_key(sid, harness)
|
|
309
|
+
turns[key] = {"n": turns.get(key, {}).get("n", 0) + 1, "start": head(),
|
|
310
|
+
"transcript": tp,
|
|
311
|
+
"toffset": os.path.getsize(tp) if os.path.exists(tp) else 0}
|
|
312
|
+
save_state("turns.json", turns)
|
|
313
|
+
elif event == "PreToolUse": # claim the file BEFORE the write exists
|
|
314
|
+
tool, inp = payload.get("tool_name", "?"), payload.get("tool_input") or {}
|
|
315
|
+
active[call_key(sid, payload, harness)] = {"files": relative_tool_files(tool, inp)}
|
|
316
|
+
save_state("active.json", active)
|
|
317
|
+
elif event == "PostToolUse":
|
|
318
|
+
finish_call(sid, active, payload, harness)
|
|
319
|
+
elif event == "Stop": # a queued prompt may end in a text-only turn: no PreToolUse
|
|
320
|
+
prompt_boundary(sid, payload, harness)
|
|
321
|
+
squash_turn(sid, harness)
|
|
322
|
+
stop_chunk(sid, payload, harness)
|
|
323
|
+
elif event == "SubagentStop":
|
|
324
|
+
subagent_chunk(sid, payload)
|
|
325
|
+
enc = lambda path: re.sub(r"[/.]", "-", path)
|
|
326
|
+
def project_dirs(): # every ~/.claude project dir that maps to this repo (worktrees too)
|
|
327
|
+
wt = [l.split(" ", 1)[1] for l in git("worktree", "list", "--porcelain", check=False)
|
|
328
|
+
.splitlines() if l.startswith("worktree ")]
|
|
329
|
+
names = {enc(p) for r in [root()] + wt for p in (r, os.path.realpath(r))}
|
|
330
|
+
return [d for d in glob.glob(os.path.join(projects(), "*")) if os.path.basename(d) in names
|
|
331
|
+
or any(os.path.basename(d).startswith(n + "--claude-worktrees-") for n in names)]
|
|
332
|
+
def repo_locations():
|
|
333
|
+
wt = [l.split(" ", 1)[1] for l in git("worktree", "list", "--porcelain", check=False)
|
|
334
|
+
.splitlines() if l.startswith("worktree ")]
|
|
335
|
+
return {os.path.realpath(p) for p in [root()] + wt}
|
|
336
|
+
def codex_meta(path):
|
|
337
|
+
try:
|
|
338
|
+
for line in open(path, errors="replace"):
|
|
339
|
+
try: obj = json.loads(line)
|
|
340
|
+
except ValueError: continue
|
|
341
|
+
if obj.get("type") == "session_meta": return obj.get("payload") or {}
|
|
342
|
+
except OSError: pass
|
|
343
|
+
return {}
|
|
344
|
+
def transcript_sources(harness="claude"):
|
|
345
|
+
if harness == "claude":
|
|
346
|
+
for d in project_dirs():
|
|
347
|
+
for path in glob.glob(os.path.join(d, "**", "*.jsonl"), recursive=True):
|
|
348
|
+
rp = os.path.relpath(path, d).split(os.sep)
|
|
349
|
+
sid, sub = (rp[0], rp[-1][:-6]) if len(rp) > 1 else (rp[0][:-6], None)
|
|
350
|
+
yield path, sid, sub
|
|
351
|
+
return
|
|
352
|
+
roots = repo_locations()
|
|
353
|
+
for path in glob.glob(os.path.join(codex_sessions(), "**", "*.jsonl"), recursive=True):
|
|
354
|
+
meta = codex_meta(path); cwd = os.path.realpath(meta.get("cwd") or "")
|
|
355
|
+
if not cwd or not any(cwd == r or cwd.startswith(r + os.sep) for r in roots): continue
|
|
356
|
+
sid = meta.get("id") or os.path.basename(path)[:-6].rsplit("-", 5)[-1]
|
|
357
|
+
source = str(meta.get("source") or "").lower()
|
|
358
|
+
yield path, sid, sid if "subagent" in source else None
|
|
359
|
+
def scan_claude_transcript(path, sid):
|
|
360
|
+
chunks, cur, calls = [], [], []
|
|
361
|
+
for line in open(path, errors="replace"):
|
|
362
|
+
if cur and "tool_result" not in line \
|
|
363
|
+
and ('"type":"user"' in line or '"type": "user"' in line):
|
|
364
|
+
chunks.append("".join(cur)); cur = []
|
|
365
|
+
cur.append(line)
|
|
366
|
+
if '"tool_use"' not in line or '"tool_result"' in line: continue
|
|
367
|
+
try: obj = json.loads(line)
|
|
368
|
+
except ValueError: continue
|
|
369
|
+
for b in (obj.get("message") or {}).get("content") or []:
|
|
370
|
+
f = (isinstance(b, dict) and b.get("type") == "tool_use"
|
|
371
|
+
and b.get("name") in EDIT_TOOLS and tool_file(b.get("input") or {}))
|
|
372
|
+
if f and obj.get("timestamp"):
|
|
373
|
+
try:
|
|
374
|
+
t = datetime.fromisoformat(obj["timestamp"].replace("Z", "+00:00"))
|
|
375
|
+
calls.append((t.timestamp(), sid, b.get("id"), f, obj.get("cwd") or ""))
|
|
376
|
+
except ValueError: pass
|
|
377
|
+
if cur: chunks.append("".join(cur))
|
|
378
|
+
return chunks, calls
|
|
379
|
+
def scan_codex_transcript(path, sid):
|
|
380
|
+
lines = open(path, errors="replace").readlines()
|
|
381
|
+
parsed = []
|
|
382
|
+
for line in lines:
|
|
383
|
+
try: parsed.append(json.loads(line))
|
|
384
|
+
except ValueError: parsed.append({})
|
|
385
|
+
task_boundaries = any(o.get("type") == "event_msg" and
|
|
386
|
+
(o.get("payload") or {}).get("type") == "task_started" for o in parsed)
|
|
387
|
+
chunks, cur, calls, cwd = [], [], [], ""
|
|
388
|
+
for line, obj in zip(lines, parsed):
|
|
389
|
+
payload = obj.get("payload") or {}
|
|
390
|
+
if obj.get("type") in ("session_meta", "turn_context"):
|
|
391
|
+
cwd = payload.get("cwd") or cwd
|
|
392
|
+
boundary = (obj.get("type") == "event_msg" and payload.get("type") == "task_started")
|
|
393
|
+
if not task_boundaries:
|
|
394
|
+
boundary = ((obj.get("type") == "response_item" and
|
|
395
|
+
payload.get("type") == "message" and payload.get("role") == "user") or
|
|
396
|
+
(obj.get("type") == "event_msg" and payload.get("type") == "user_message"))
|
|
397
|
+
if boundary and cur: chunks.append("".join(cur)); cur = []
|
|
398
|
+
cur.append(line)
|
|
399
|
+
if obj.get("type") != "response_item" or payload.get("type") not in (
|
|
400
|
+
"function_call", "custom_tool_call"): continue
|
|
401
|
+
tool = payload.get("name") or ""
|
|
402
|
+
raw = payload.get("arguments") if payload.get("arguments") is not None \
|
|
403
|
+
else payload.get("input")
|
|
404
|
+
inp = raw
|
|
405
|
+
if isinstance(raw, str):
|
|
406
|
+
try: inp = json.loads(raw)
|
|
407
|
+
except ValueError: inp = {}
|
|
408
|
+
files = tool_files(tool, inp) or patch_files(raw)
|
|
409
|
+
if not (files and obj.get("timestamp")): continue
|
|
410
|
+
try: epoch = datetime.fromisoformat(obj["timestamp"].replace("Z", "+00:00")).timestamp()
|
|
411
|
+
except ValueError: continue
|
|
412
|
+
calls += [(epoch, sid, payload.get("call_id") or payload.get("id"), f, cwd)
|
|
413
|
+
for f in files]
|
|
414
|
+
if cur: chunks.append("".join(cur))
|
|
415
|
+
return chunks, calls
|
|
416
|
+
def scan_transcript(path, sid, harness="claude"):
|
|
417
|
+
return (scan_claude_transcript(path, sid) if harness == "claude" else
|
|
418
|
+
scan_codex_transcript(path, sid))
|
|
419
|
+
def backfill(index): # attribute pre-existing commits: transcript_backfill on a files+time
|
|
420
|
+
# window match, else <author>/local with method import; commits with events are skipped
|
|
421
|
+
have, att, tot, cs, cur = {e.get("commit") for e in events()}, 0, 0, [], None
|
|
422
|
+
# -c: merges list their conflict-resolution files (result differs from every parent)
|
|
423
|
+
for l in git("log", "--name-only", "-c", "--pretty=#%H\t%an\t%at", check=False).splitlines():
|
|
424
|
+
if l.startswith("#"): cur = l[1:].split("\t") + [[]]; cs.append(cur)
|
|
425
|
+
elif l.strip() and cur: cur[3].append(l)
|
|
426
|
+
for H, an, at, files in cs:
|
|
427
|
+
if H in have: continue
|
|
428
|
+
tot += 1
|
|
429
|
+
m = {f: c for f in files for c in # nearest call in the window, across harnesses
|
|
430
|
+
[min((c for c in index.get(f, []) if abs(c[0] - int(at)) < 1800),
|
|
431
|
+
key=lambda c: abs(c[0] - int(at)), default=None)] if c}
|
|
432
|
+
u = an.replace(" ", "-").lower()
|
|
433
|
+
match = min(m.values(), key=lambda c: abs(c[0] - int(at)), default=None)
|
|
434
|
+
actor = agent(match[1], match[3], u) if match else local(u)
|
|
435
|
+
record(mirror=False, type="edit", actor=actor, commit=H, edits=[
|
|
436
|
+
{"file": f, "patch": "", "tool_call_id": m[f][2] if f in m else None,
|
|
437
|
+
"attribution": {"method": "transcript_backfill" if f in m else "import"}}
|
|
438
|
+
for f in files])
|
|
439
|
+
att += bool(m)
|
|
440
|
+
return att, tot
|
|
441
|
+
def do_import(harnesses=("claude",)): # index every harness first: backfill runs once
|
|
442
|
+
t0, seen, index, ns, nsub = time.time(), state("imported.json", {}), {}, 0, 0
|
|
443
|
+
for harness in harnesses:
|
|
444
|
+
for path, sid, sub in transcript_sources(harness):
|
|
445
|
+
st = os.stat(path)
|
|
446
|
+
if seen.get(path) == [st.st_size, int(st.st_mtime)]:
|
|
447
|
+
continue # idempotent: only new/changed transcripts are re-read
|
|
448
|
+
chunks, calls = scan_transcript(path, sid, harness)
|
|
449
|
+
for t, s, tid, f, cw in calls: # strip repo root or the transcript's own cwd,
|
|
450
|
+
pre = next((p + "/" for p in (root(), os.path.realpath(root()), cw) # clones too
|
|
451
|
+
if p and f.startswith(p + "/")), "")
|
|
452
|
+
fr = re.sub(r"^\.claude/worktrees/[^/]+/", "", f[len(pre):].lstrip("/"))
|
|
453
|
+
index.setdefault(fr, []).append((t, s, tid, harness))
|
|
454
|
+
if cfg_track():
|
|
455
|
+
pre = f"subagents/{sub}/" if sub else ""
|
|
456
|
+
store_chunks(sid, {f"{pre}turn-{i:04d}.jsonl": c for i, c in enumerate(chunks, 1)})
|
|
457
|
+
seen[path] = [st.st_size, int(st.st_mtime)]
|
|
458
|
+
ns, nsub = ns + (not sub), nsub + bool(sub)
|
|
459
|
+
save_state("imported.json", seen)
|
|
460
|
+
att, tot = backfill(index); mirror_log()
|
|
461
|
+
return (f"imported {ns} sessions, {nsub} subagents; "
|
|
462
|
+
f"attributed {att}/{tot} commits in {time.time() - t0:.1f}s")
|
|
463
|
+
session_rows = lambda: [(r.split("/", 4)[4], r.split("/")[3], r) # (sid, user, ref)
|
|
464
|
+
for r, _ in refs("refs/git-mem/sessions/")]
|
|
465
|
+
def session_text(ref, sub=None):
|
|
466
|
+
pre = f"subagents/{sub}/" if sub else ""
|
|
467
|
+
names = sorted(n for n in git("ls-tree", "-r", "--name-only", ref).splitlines()
|
|
468
|
+
if n.startswith(pre) and n.count("/") == pre.count("/"))
|
|
469
|
+
return "".join(git("cat-file", "blob", f"{ref}:{n}") for n in names)
|
|
470
|
+
def export_sessions(): # mirror session refs to .git/git-mem/sessions/ in ~/.claude/projects layout
|
|
471
|
+
base, idx = os.path.join(git_mem_dir(), "sessions", enc(root())), []
|
|
472
|
+
os.makedirs(base, exist_ok=True)
|
|
473
|
+
for sid, u, ref in session_rows():
|
|
474
|
+
fp = os.path.join(base, sid + ".jsonl")
|
|
475
|
+
open(fp, "w").write(session_text(ref))
|
|
476
|
+
for s in sorted({n.split("/")[1] for n in git("ls-tree", "-r", "--name-only", ref)
|
|
477
|
+
.splitlines() if n.startswith("subagents/")}):
|
|
478
|
+
os.makedirs(os.path.join(base, sid, "subagents"), exist_ok=True)
|
|
479
|
+
open(os.path.join(base, sid, "subagents", s + ".jsonl"), "w").write(session_text(ref, s))
|
|
480
|
+
idx.append({"id": sid, "user": u, "path": fp, "origin": "local" if u == user() else "team"})
|
|
481
|
+
jwrite(os.path.join(base, "sessions-index.json"), idx)
|
|
482
|
+
return len(idx)
|
|
483
|
+
def cmd_sessions(a):
|
|
484
|
+
if a.id == "export":
|
|
485
|
+
return print(f"git-mem: exported {export_sessions()} sessions to .git/git-mem/sessions/")
|
|
486
|
+
rows = session_rows()
|
|
487
|
+
if a.id:
|
|
488
|
+
for sid, u, ref in rows:
|
|
489
|
+
if sid.startswith(a.id): return print(session_text(ref), end="")
|
|
490
|
+
sys.exit("git-mem: no such session")
|
|
491
|
+
pat = a.grep or (a.file and re.escape(a.file))
|
|
492
|
+
for sid, u, ref in rows:
|
|
493
|
+
if pat and not re.search(pat, session_text(ref)): continue
|
|
494
|
+
print(f"{sid} {u} {len(git('ls-tree', '--name-only', ref).splitlines())} turns")
|
|
495
|
+
def chunk_prompt(text): # first user message of a stored transcript chunk (claude or codex)
|
|
496
|
+
for o in parse_events(text):
|
|
497
|
+
m, p = o.get("message") or {}, o.get("payload") or {}
|
|
498
|
+
if o.get("type") == "user" and isinstance(m, dict):
|
|
499
|
+
c = m.get("content")
|
|
500
|
+
t = c if isinstance(c, str) else "\n".join(
|
|
501
|
+
b.get("text", "") for b in c or []
|
|
502
|
+
if isinstance(b, dict) and b.get("type") == "text")
|
|
503
|
+
if t: return t
|
|
504
|
+
if o.get("type") == "event_msg" and p.get("type") == "user_message":
|
|
505
|
+
return p.get("message")
|
|
506
|
+
if o.get("type") == "response_item" and p.get("type") == "message" \
|
|
507
|
+
and p.get("role") == "user":
|
|
508
|
+
return "".join(b.get("text", "") for b in p.get("content") or []
|
|
509
|
+
if isinstance(b, dict))
|
|
510
|
+
def stdout_file(): # file backing a redirected stdout (`git-mem export > dump.jsonl`)
|
|
511
|
+
try:
|
|
512
|
+
p = (fcntl.fcntl(1, fcntl.F_GETPATH, bytes(1024)).rstrip(b"\0").decode()
|
|
513
|
+
if hasattr(fcntl, "F_GETPATH") else os.readlink("/proc/self/fd/1"))
|
|
514
|
+
return [rel_to_root(p)] if os.path.isfile(p) else []
|
|
515
|
+
except OSError: return []
|
|
516
|
+
def cmd_export(a): # JSONL fine-tuning dump: one record per turn/op, prompt paired with edits
|
|
517
|
+
maybe_sync([rel_to_root(a.output)] if a.output else stdout_file()) # never re-ingest our dump
|
|
518
|
+
rows, evs, merged = session_rows(), events(), {}
|
|
519
|
+
squashed = {i for e in evs for i in e.get("squashes", [])}
|
|
520
|
+
for e in evs: # squashed calls and overlay re-records are subsumed by their turn events
|
|
521
|
+
if e.get("type") not in ("edit", "undo") or e["id"] in squashed or "overlay_of" in e \
|
|
522
|
+
or not e.get("edits"): continue
|
|
523
|
+
key = (e["actor"], e["turn"]) if e.get("turn") else e["id"] # unsquashed turns regroup
|
|
524
|
+
m = merged.setdefault(key, {**e, "tool_calls": e.get("tool_calls", [])[:],
|
|
525
|
+
"edits": e["edits"][:]})
|
|
526
|
+
if m["id"] != e["id"]: m["tool_calls"] += e.get("tool_calls", []); m["edits"] += e["edits"]
|
|
527
|
+
out = open(a.output, "w") if a.output else sys.stdout
|
|
528
|
+
for e in merged.values():
|
|
529
|
+
sid = prompt = None
|
|
530
|
+
m = re.match(r"([^/]+)/agent:[^/]+/(.+)", e["actor"])
|
|
531
|
+
hit = m and next(((s, r) for s, u, r in rows
|
|
532
|
+
if u == m.group(1) and s.startswith(m.group(2))), None)
|
|
533
|
+
if hit:
|
|
534
|
+
sid, n = hit[0], int(e.get("turn", "turn0")[4:])
|
|
535
|
+
prompt = n and chunk_prompt(git("cat-file", "blob",
|
|
536
|
+
f"{hit[1]}:turn-{n:04d}.jsonl", check=False)) or None
|
|
537
|
+
out.write(json.dumps({"timestamp": e["timestamp"], "actor": e["actor"], "session": sid,
|
|
538
|
+
"turn": e.get("turn"), "prompt": prompt,
|
|
539
|
+
"tool_calls": e["tool_calls"],
|
|
540
|
+
"edits": [{"file": d["file"], "patch": d["patch"] or git(
|
|
541
|
+
"show", "--format=", e.get("commit") or "", "--", d["file"],
|
|
542
|
+
check=False)} for d in e["edits"]]}) + "\n")
|
|
543
|
+
if a.output: out.close(); print(f"git-mem: exported to {a.output}")
|
|
544
|
+
def cmd_gc(a):
|
|
545
|
+
old = [r for r, sha in refs("refs/git-mem/sessions/")
|
|
546
|
+
if int(git("show", "-s", "--format=%ct", sha).strip()) < time.time() - a.older_than * 86400]
|
|
547
|
+
for r in old: git("update-ref", "-d", r)
|
|
548
|
+
print(f"git-mem: dropped {len(old)} session refs (git gc will prune the objects)")
|
|
549
|
+
def cfg_track(): # ask once; tty only, so never on hook runs; default yes
|
|
550
|
+
cfg = state("config.json", {})
|
|
551
|
+
if "track_sessions" not in cfg:
|
|
552
|
+
cfg["track_sessions"] = (not sys.stdin.isatty() or
|
|
553
|
+
input("git-mem: track sessions in git? [Y/n] ").strip().lower() != "n")
|
|
554
|
+
save_state("config.json", cfg)
|
|
555
|
+
return cfg["track_sessions"]
|
|
556
|
+
def detect_harnesses(): # every harness with state on this machine (GIT_MEM_* overrides apply)
|
|
557
|
+
hs = [h for h, home, d in (("claude", "~/.claude", projects()),
|
|
558
|
+
("codex", "~/.codex", codex_sessions()))
|
|
559
|
+
if os.path.isdir(os.path.expanduser(home)) or os.path.isdir(d)]
|
|
560
|
+
return hs or ["claude"]
|
|
561
|
+
def configured_harnesses():
|
|
562
|
+
cfg = state("config.json", {})
|
|
563
|
+
return cfg.get("harnesses") or [cfg.get("harness", "claude")]
|
|
564
|
+
hook_file = lambda h: ".claude/settings.json" if h == "claude" else ".codex/hooks.json"
|
|
565
|
+
hook_cmd = lambda h: "git-mem hook" if h == "claude" else "git-mem hook --harness codex"
|
|
566
|
+
def install_hooks(harness="claude"):
|
|
567
|
+
rel = hook_file(harness)
|
|
568
|
+
path = os.path.join(root(), rel)
|
|
569
|
+
os.makedirs(os.path.dirname(path), exist_ok=True)
|
|
570
|
+
cfg = jread(path, {})
|
|
571
|
+
if not isinstance(cfg.setdefault("hooks", {}), dict):
|
|
572
|
+
sys.exit(f"git-mem: unexpected 'hooks' format in {rel}")
|
|
573
|
+
command = hook_cmd(harness)
|
|
574
|
+
hook_events = HOOK_EVENTS if harness == "claude" else CODEX_HOOK_EVENTS
|
|
575
|
+
for event in hook_events:
|
|
576
|
+
group = cfg["hooks"].setdefault(event, [])
|
|
577
|
+
if not any(h.get("command") == command for g in group for h in g.get("hooks", [])):
|
|
578
|
+
group.append({"hooks": [{"type": "command", "command": command}]})
|
|
579
|
+
jwrite(path, cfg)
|
|
580
|
+
return f"{harness} ({rel})"
|
|
581
|
+
def remove_hooks(harness): # a restrictive re-init un-installs the excluded harness's hook
|
|
582
|
+
path, command = os.path.join(root(), hook_file(harness)), hook_cmd(harness)
|
|
583
|
+
cfg = jread(path, {})
|
|
584
|
+
if not isinstance(cfg.get("hooks"), dict): return
|
|
585
|
+
for event in list(cfg["hooks"]):
|
|
586
|
+
for g in cfg["hooks"][event]:
|
|
587
|
+
g["hooks"] = [h for h in g.get("hooks", []) if h.get("command") != command]
|
|
588
|
+
cfg["hooks"][event] = [g for g in cfg["hooks"][event] if g.get("hooks")]
|
|
589
|
+
if not cfg["hooks"][event]: del cfg["hooks"][event]
|
|
590
|
+
jwrite(path, cfg)
|
|
591
|
+
def install_push(): # travel: fetch refspec + fail-soft pre-push mirror of refs/git-mem/*
|
|
592
|
+
if "origin" in git("remote", check=False).split():
|
|
593
|
+
spec = "+refs/git-mem/*:refs/git-mem/*"
|
|
594
|
+
if spec not in git("config", "--get-all", "remote.origin.fetch", check=False):
|
|
595
|
+
git("config", "--add", "remote.origin.fetch", spec)
|
|
596
|
+
hp = os.path.join(root(), ".git", "hooks", "pre-push")
|
|
597
|
+
if not os.path.exists(hp):
|
|
598
|
+
open(hp, "w").write('#!/bin/sh\n# git-mem: mirror op log + sessions, fail-soft\n'
|
|
599
|
+
'git push --no-verify "$1" "+refs/git-mem/*:refs/git-mem/*" >/dev/null 2>&1 || true\n')
|
|
600
|
+
os.chmod(hp, 0o755)
|
|
601
|
+
def agent_instructions(harnesses, choice, flags=""): # one prompt covering every harness file
|
|
602
|
+
todo = []
|
|
603
|
+
for h in harnesses:
|
|
604
|
+
name, line = ("CLAUDE.md", CLAUDE_LINE) if h == "claude" else ("AGENTS.md", CODEX_LINE)
|
|
605
|
+
p = os.path.join(root(), name)
|
|
606
|
+
cur = open(p).read() if os.path.exists(p) else ""
|
|
607
|
+
if line not in cur: todo.append((p, cur, line, name))
|
|
608
|
+
if not todo: return
|
|
609
|
+
if choice is None and sys.stdin.isatty():
|
|
610
|
+
choice = input(f"git-mem: add one line to {'/'.join(t[3] for t in todo)} so agents find "
|
|
611
|
+
"team sessions? [Y/n] ").strip().lower() != "n"
|
|
612
|
+
if choice:
|
|
613
|
+
for p, cur, line, _ in todo:
|
|
614
|
+
open(p, "a").write(("\n" if cur and not cur.endswith("\n") else "") + line + "\n")
|
|
615
|
+
else:
|
|
616
|
+
print(f"git-mem: run `git-mem init{flags} --instructions` later to enable team session sharing")
|
|
617
|
+
def cmd_init(a):
|
|
618
|
+
if quiet("git", "rev-parse", "--git-dir").returncode: sh("git", "init", "-q")
|
|
619
|
+
os.makedirs(os.path.join(root(), ".git", "git-mem"), exist_ok=True)
|
|
620
|
+
explicit = [h for h, f in (("claude", a.claude), ("codex", a.codex)) if f]
|
|
621
|
+
harnesses = explicit or detect_harnesses()
|
|
622
|
+
cfg = state("config.json", {}); cfg.pop("harness", None)
|
|
623
|
+
cfg["harnesses"] = harnesses; save_state("config.json", cfg)
|
|
624
|
+
if "origin" in git("remote", check=False).split(): # fresh clone: fetch team refs
|
|
625
|
+
git("fetch", "-q", "origin", "+refs/git-mem/*:refs/git-mem/*", check=False)
|
|
626
|
+
p, sha = os.path.join(git_mem_dir(), "events.jsonl"), git( # rebuild cache from our own ref
|
|
627
|
+
"rev-parse", "-q", "--verify", f"refs/git-mem/log/{user()}", check=False).strip()
|
|
628
|
+
if sha and not os.path.exists(p): open(p, "w").write(git("cat-file", "blob", sha))
|
|
629
|
+
installed = ", ".join(install_hooks(h) for h in harnesses); install_push()
|
|
630
|
+
for h in ("claude", "codex"):
|
|
631
|
+
if explicit and h not in explicit: remove_hooks(h)
|
|
632
|
+
# before the initial-state commit: an instruction line written after it would sit dirty
|
|
633
|
+
# until the first prompt, then be committed as a <user>/local edit of CLAUDE.md/AGENTS.md
|
|
634
|
+
agent_instructions(harnesses, a.instructions, "".join(" --" + h for h in explicit))
|
|
635
|
+
if quiet("git", "rev-parse", "HEAD").returncode or changed_files():
|
|
636
|
+
c = commit_files(["."], "git-mem: initial state", local())
|
|
637
|
+
record(type="init", actor=local(), commit=c, edits=[])
|
|
638
|
+
print(f"git-mem: initialized {root()} (hooks: {installed}); {do_import(harnesses)}")
|
|
639
|
+
cmd_viz(argparse.Namespace(n=0, output=os.path.join(git_mem_dir(), "viz.html")))
|
|
640
|
+
print(f"open file://{git_mem_dir()}/viz.html to see your history")
|
|
641
|
+
def cmd_sync(a):
|
|
642
|
+
ev = sync(); print(f"git-mem: committed local edits as op {ev['id']}" if ev else "git-mem: nothing to sync")
|
|
643
|
+
def cmd_import(a):
|
|
644
|
+
print("git-mem: " + do_import(configured_harnesses()))
|
|
645
|
+
def cmd_update(a): # uv re-resolves the git source to the latest commit
|
|
646
|
+
try: p = quiet("uv", "tool", "upgrade", "git-mem")
|
|
647
|
+
except OSError: p = None
|
|
648
|
+
if not p or p.returncode:
|
|
649
|
+
sys.exit("git-mem: could not update via uv; run "
|
|
650
|
+
"`uv tool install --force git+https://github.com/sundial-org/git-mem`")
|
|
651
|
+
print(f"git-mem: {(p.stderr.strip().splitlines() or ['updated'])[-1]}")
|
|
652
|
+
def cmd_log(a):
|
|
653
|
+
maybe_sync()
|
|
654
|
+
by_commit = {}
|
|
655
|
+
for ev in events(): by_commit.setdefault(ev.get("commit"), []).append(ev)
|
|
656
|
+
for line in git("log", f"-{a.n}", "--pretty=%H\t%h\t%ad\t%s", "--date=short").splitlines():
|
|
657
|
+
H, h, date, subj = line.split("\t", 3)
|
|
658
|
+
tags = "".join(f" [{e['actor']}" + (f" {e['turn']}" if e.get("turn") else "")
|
|
659
|
+
+ (f" pr#{e['pr']}" if e.get("pr") else "")
|
|
660
|
+
+ (" undo" if e["type"] == "undo" else "") + "]"
|
|
661
|
+
for e in by_commit.get(H, []))
|
|
662
|
+
print(f"{h} {date} {subj}{tags or ' [untracked]'}")
|
|
663
|
+
def cmd_show(a):
|
|
664
|
+
maybe_sync(); H = git("rev-parse", a.commit).strip()
|
|
665
|
+
print(git("show", "--stat", "--pretty=format:commit %h %ad%n%s%n", "--date=short", H))
|
|
666
|
+
evs = [e for e in events() if e.get("commit") == H]
|
|
667
|
+
if not evs: # squash-merge/rebase moved the sha: match events by patch-id instead
|
|
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-mem operations recorded for this commit")
|
|
670
|
+
for e in evs: print(json.dumps(e, indent=2))
|
|
671
|
+
def blame_context(chunk, tid): # first user prompt + last agent text before tool call `tid`
|
|
672
|
+
prompt = ctx = tool = None
|
|
673
|
+
for obj in parse_events(chunk):
|
|
674
|
+
p = obj.get("payload") or {}
|
|
675
|
+
m = obj.get("message") or (p if p.get("type") == "message" else {})
|
|
676
|
+
if p.get("type") in ("function_call", "custom_tool_call") and \
|
|
677
|
+
tid in (p.get("call_id"), p.get("id")):
|
|
678
|
+
tool = p.get("name")
|
|
679
|
+
if p.get("type") == "user_message" and not prompt: prompt = p.get("message")
|
|
680
|
+
c, role = m.get("content"), m.get("role")
|
|
681
|
+
for b in [{"type": "text", "text": c}] if isinstance(c, str) else \
|
|
682
|
+
(c if isinstance(c, list) else []):
|
|
683
|
+
if not isinstance(b, dict): continue
|
|
684
|
+
if b.get("type") == "tool_use" and b.get("id") == tid: tool = b.get("name")
|
|
685
|
+
elif b.get("type") in ("text", "input_text") and role == "user" and not prompt:
|
|
686
|
+
prompt = b.get("text")
|
|
687
|
+
elif b.get("type") in ("text", "output_text") and role == "assistant":
|
|
688
|
+
ctx = b.get("text")
|
|
689
|
+
if tool: break
|
|
690
|
+
return prompt, ctx, tool
|
|
691
|
+
def cmd_blame(a):
|
|
692
|
+
maybe_sync()
|
|
693
|
+
d, b = os.path.split(a.file) # blame a symlink itself: resolve only its directory
|
|
694
|
+
f = os.path.relpath(os.path.join(os.path.realpath(d or "."), b), root())
|
|
695
|
+
out = git("-c", "core.quotepath=false", "blame", "--porcelain",
|
|
696
|
+
"-L", f"{a.line},{a.line}", "--", f)
|
|
697
|
+
H, orig = out.split()[:2] # originating commit + the line's number in it
|
|
698
|
+
if H == "0" * 40: sys.exit(f"git-mem: {f}:{a.line} is uncommitted")
|
|
699
|
+
of = next((l[9:] for l in out.splitlines() if l.startswith("filename ")), f) # pre-rename path
|
|
700
|
+
touches = lambda e: any(ed.get("file") == of for ed in e.get("edits") or [])
|
|
701
|
+
all_ev = events()
|
|
702
|
+
evs = [e for e in all_ev if e.get("commit") == H]
|
|
703
|
+
if not evs: # squash-merge/rebase moved the sha: match events by patch-id instead
|
|
704
|
+
pid = patch_id(H); evs = [e for e in all_ev if pid and e.get("patch_id") == pid]
|
|
705
|
+
ev = next((e for e in evs if e.get("overlay_of") and touches(e)), # overlay owns its lines
|
|
706
|
+
next((e for e in evs if touches(e)), evs[0] if evs else None))
|
|
707
|
+
sq = next((e for e in evs if e.get("squashes")), None)
|
|
708
|
+
if sq: # re-blame the preserved per-call commits: exact call + actor by line position
|
|
709
|
+
by_id = {e["id"]: e for e in all_ev}
|
|
710
|
+
tip = next((by_id[i].get("commit") for i in reversed(sq["squashes"]) if i in by_id), None)
|
|
711
|
+
if sq.get("commit") != H: # patch-id match: translate the line into the original squash
|
|
712
|
+
ws = lambda rev: ["".join(l.split()) for l in # patch-id ignores all whitespace
|
|
713
|
+
quiet("git", "show", f"{rev}:{of}").stdout.splitlines()]
|
|
714
|
+
i, sm = int(orig) - 1, difflib.SequenceMatcher(None, ws(H), ws(sq["commit"]))
|
|
715
|
+
orig = next((j1 + i - i1 + 1 for t, i1, i2, j1, j2 in sm.get_opcodes()
|
|
716
|
+
if t == "equal" and i1 <= i < i2), None)
|
|
717
|
+
o = tip and orig and quiet("git", "-c", "core.quotepath=false", "blame", "--porcelain",
|
|
718
|
+
"-L", f"{orig},{orig}", tip, "--", of)
|
|
719
|
+
if o and not o.returncode and o.stdout:
|
|
720
|
+
c = o.stdout.split(None, 1)[0] # renamed within the turn: events use the old path
|
|
721
|
+
of = next((l[9:] for l in o.stdout.splitlines() if l.startswith("filename ")), of)
|
|
722
|
+
ev = next((e for e in all_ev if e.get("commit") == c and touches(e)), ev)
|
|
723
|
+
print(f"{f}:{a.line} {H[:8]} " + (f"{ev['actor']}"
|
|
724
|
+
+ (f" {ev['turn']}" if ev.get("turn") else "") + f" op {ev['id']}" if ev else "untracked"))
|
|
725
|
+
print("> " + next((l[1:] for l in out.splitlines() if l.startswith("\t")), ""))
|
|
726
|
+
if not ev: return print("no git-mem operations recorded for this commit")
|
|
727
|
+
if "/agent:" not in ev["actor"]:
|
|
728
|
+
return print(f"human edit ({ev['actor']}): no conversation\n" + json.dumps(ev, indent=2))
|
|
729
|
+
u, sid8, tcs = ev["actor"].split("/", 1)[0], ev["actor"].rsplit("/", 1)[1], ev.get("tool_calls") or []
|
|
730
|
+
mine = [tc for tc in tcs if of in ((tc.get("input") or {}).get("files")
|
|
731
|
+
or [(tc.get("input") or {}).get("file")])]
|
|
732
|
+
tid = next((ed["tool_call_id"] for ed in ev.get("edits") or []
|
|
733
|
+
if ed.get("file") == of and ed.get("tool_call_id")), None) \
|
|
734
|
+
or next((tc["id"] for tc in mine + tcs if tc.get("id")), None)
|
|
735
|
+
hit = next(((sid, ref) for sid, ru, ref in session_rows()
|
|
736
|
+
if ru == u and sid.startswith(sid8)), None)
|
|
737
|
+
if not (tid and hit): return print("no transcript stored for this edit")
|
|
738
|
+
sid, ref = hit
|
|
739
|
+
for n in sorted(git("ls-tree", "-r", "--name-only", ref).splitlines()):
|
|
740
|
+
chunk = git("cat-file", "blob", f"{ref}:{n}")
|
|
741
|
+
if tid in chunk: break
|
|
742
|
+
else: return print(f"tool call {tid} not found in session {sid}")
|
|
743
|
+
prompt, ctx, tool = blame_context(chunk, tid)
|
|
744
|
+
print(f"session {sid} ({n}):")
|
|
745
|
+
if prompt: print(f"user: {prompt.strip()}")
|
|
746
|
+
if ctx: print(f"agent: {ctx.strip()}")
|
|
747
|
+
print(f"[{tool or '?'} {tid}]")
|
|
748
|
+
def cmd_undo(a):
|
|
749
|
+
maybe_sync(); actor = local()
|
|
750
|
+
if a.commit is not None:
|
|
751
|
+
H = git("rev-parse", a.commit).strip()
|
|
752
|
+
merge = ["-m", "1"] if len(git("rev-list", "--parents", "-n1", H).split()) > 2 else []
|
|
753
|
+
r = quiet("git", "revert", "--no-commit", "--no-edit", *merge, H)
|
|
754
|
+
if r.returncode:
|
|
755
|
+
quiet("git", "revert", "--abort"); sys.exit(f"git-mem: cannot revert {H[:8]} cleanly")
|
|
756
|
+
files, pre = changed_files(), head()
|
|
757
|
+
c = commit_files(files or ["."], f"git-mem: undo commit {H[:8]}", actor)
|
|
758
|
+
record(type="undo", actor=actor, commit=c, target_commit=H,
|
|
759
|
+
edits=[{"file": f, "patch": patches(pre, c, files)[f]} for f in files])
|
|
760
|
+
return print(f"git-mem: undid commit {H[:8]} in {c[:8]}")
|
|
761
|
+
target = next((e for e in reversed(events())
|
|
762
|
+
if (e["id"] == a.op or not a.op) and e.get("edits")), None)
|
|
763
|
+
if not target: sys.exit("git-mem: no such operation")
|
|
764
|
+
combined = "".join(ed["patch"] for ed in target["edits"] if ed["patch"].strip())
|
|
765
|
+
if quiet("git", "apply", "-R", input=combined).returncode: # atomic: all or nothing
|
|
766
|
+
sys.exit(f"git-mem: cannot undo op {target['id']}: its patch no longer applies cleanly; "
|
|
767
|
+
"try `git-mem undo --commit <hash>` instead")
|
|
768
|
+
files, pre = [ed["file"] for ed in target["edits"]], head()
|
|
769
|
+
c = commit_files(files, f"git-mem: undo op {target['id']}", actor)
|
|
770
|
+
record(type="undo", actor=actor, commit=c, target_op=target["id"],
|
|
771
|
+
edits=[{"file": f, "patch": patches(pre, c, files)[f]} for f in files])
|
|
772
|
+
print(f"git-mem: undid op {target['id']} in {c[:8]}")
|
|
773
|
+
def cmd_viz(a):
|
|
774
|
+
maybe_sync()
|
|
775
|
+
log = git("log", "--exclude=refs/git-mem/*", "--all", *([f"-{a.n}"] if a.n else []), "--date=iso",
|
|
776
|
+
"--pretty=%H\x1f%h\x1f%P\x1f%D\x1f%ad\x1f%s")
|
|
777
|
+
cols = ["hash", "short", "parents", "refs", "date", "subject"]
|
|
778
|
+
commits = [dict(zip(cols, l.split("\x1f"))) for l in log.splitlines()]
|
|
779
|
+
data = json.dumps({"commits": commits, "events": events()}).replace("</", "<\\/")
|
|
780
|
+
out = a.output or os.path.join(git_mem_dir(), "viz.html")
|
|
781
|
+
open(out, "w").write(VIZ.replace("__DATA__", data))
|
|
782
|
+
print(f"git-mem: wrote {out}")
|
|
783
|
+
VIZ = """<!doctype html><meta charset="utf-8"><title>git-mem</title>
|
|
784
|
+
<style>body{margin:0;font:12px/1.5 ui-monospace,Menlo,monospace;background:#fff;color:#000}
|
|
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
|
+
main{display:grid;grid-template-columns:1fr 1fr;height:calc(100vh - 40px)}
|
|
787
|
+
#c{margin:0;padding:6px 0;list-style:none;overflow:auto;border-right:1px solid #000}
|
|
788
|
+
#c li{padding:1px 14px;cursor:pointer;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
|
789
|
+
#c li:hover{background:#eee}#c li.sel{background:#000;color:#fff}
|
|
790
|
+
#d{margin:0;padding:10px 14px;overflow:auto;white-space:pre-wrap}
|
|
791
|
+
button,input{font:inherit;background:#fff;border:1px solid #000;accent-color:#000}</style>
|
|
792
|
+
<header><b>git-mem</b><button id=play>replay</button><input type=range id=s min=0><span id=pos></span><span id=st></span></header>
|
|
793
|
+
<main><ol id=c></ol><pre id=d>select a commit</pre></main>
|
|
794
|
+
<script>const D=__DATA__,cs=D.commits,byC={};D.events.forEach(e=>(byC[e.commit]=byC[e.commit]||[]).push(e));
|
|
795
|
+
const esc=t=>(t||'').replace(/&/g,'&').replace(/</g,'<');
|
|
796
|
+
const cnt={};D.events.forEach(e=>cnt[e.actor]=(cnt[e.actor]||0)+Math.max((e.edits||[]).length,1));
|
|
797
|
+
const tot=Object.values(cnt).reduce((a,b)=>a+b,0)||1;
|
|
798
|
+
st.innerHTML=Object.entries(cnt).sort((a,b)=>b[1]-a[1]).map(([a,n])=>`<b>${esc(a)}</b> ${Math.round(100*n/tot)}%`).join(' | ');
|
|
799
|
+
c.innerHTML=cs.map(x=>`<li>${x.short} ${x.date.slice(0,10)} ${esc(x.subject)}${(byC[x.hash]||[]).map(e=>` <b>[${esc(e.actor)}${e.turn?' '+e.turn:''}${e.pr?' pr#'+e.pr:''}${e.type=='undo'?' undo':''}]</b>`).join('')}</li>`).join('');
|
|
800
|
+
const rows=[...c.children];rows.forEach((li,i)=>li.onclick=()=>{rows.forEach(r=>r.classList.remove('sel'));li.classList.add('sel');detail(cs[i]);});
|
|
801
|
+
s.max=cs.length;s.value=cs.length;
|
|
802
|
+
function cut(){const n=+s.value;pos.textContent=n+'/'+cs.length;rows.forEach((li,i)=>li.style.display=cs.length-1-i<n?'':'none');}
|
|
803
|
+
function detail(x){let o=`commit ${x.hash}\\n${x.date}${x.refs?' ('+x.refs+')':''}\\n${x.subject}\\n`;
|
|
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
|
+
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-mem operations recorded for this commit)';d.textContent=o;}
|
|
807
|
+
let t=null;play.onclick=()=>{if(t){clearInterval(t);t=null;return}s.value=0;cut();
|
|
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
|
+
s.oninput=cut;cut();</script>
|
|
810
|
+
"""
|
|
811
|
+
def main(argv=None):
|
|
812
|
+
ap = argparse.ArgumentParser(prog="git-mem", description=__doc__)
|
|
813
|
+
sub = ap.add_subparsers(dest="cmd", required=True)
|
|
814
|
+
ps = {c: sub.add_parser(c, help=h) for c, h in [
|
|
815
|
+
("init", "git repo + .git/git-mem + hooks + session import"), ("sync", "commit pending local edits"),
|
|
816
|
+
("hook", "agent-harness hook entrypoint (JSON on stdin)"), ("show", "show operations for a commit"),
|
|
817
|
+
("import", "re-import sessions + backfill attribution"), ("gc", "drop session refs older than N days"),
|
|
818
|
+
("sessions", "list/read/export sessions in refs/git-mem/sessions"), ("log", "git log with actors + turns"),
|
|
819
|
+
("undo", "undo one operation (default: latest) or a commit"), ("viz", "HTML visualization of the op log"),
|
|
820
|
+
("update", "update git-mem to the latest version"),
|
|
821
|
+
("export", "dump history as fine-tuning JSONL (prompt + tool_calls + edits per turn)"),
|
|
822
|
+
("blame", "the conversation behind a file:line")]}
|
|
823
|
+
ps["init"].add_argument("--claude", action="store_true",
|
|
824
|
+
help="set up Claude Code only (default: every detected harness)")
|
|
825
|
+
ps["init"].add_argument("--codex", action="store_true",
|
|
826
|
+
help="set up Codex only (default: every detected harness)")
|
|
827
|
+
ps["init"].add_argument("--instructions", "--claude-md", dest="instructions",
|
|
828
|
+
action="store_true", default=None)
|
|
829
|
+
ps["init"].add_argument("--no-instructions", "--no-claude-md", dest="instructions",
|
|
830
|
+
action="store_false")
|
|
831
|
+
ps["hook"].add_argument("--harness", choices=("claude", "codex"), default="claude")
|
|
832
|
+
ps["sessions"].add_argument("id", nargs="?"); ps["sessions"].add_argument("--grep")
|
|
833
|
+
ps["sessions"].add_argument("--file"); ps["gc"].add_argument("--older-than", type=int, required=True)
|
|
834
|
+
ps["log"].add_argument("-n", type=int, default=30); ps["show"].add_argument("commit")
|
|
835
|
+
ps["undo"].add_argument("--op", nargs="?", const="", default=None, metavar="ID")
|
|
836
|
+
ps["undo"].add_argument("--commit", nargs="?", const="HEAD", default=None, metavar="HASH")
|
|
837
|
+
ps["viz"].add_argument("-o", "--output", default=None); ps["viz"].add_argument("-n", type=int, default=0)
|
|
838
|
+
ps["export"].add_argument("-o", "--output", default=None)
|
|
839
|
+
ps["blame"].add_argument("file"); ps["blame"].add_argument("line", type=int)
|
|
840
|
+
a = ap.parse_args(argv)
|
|
841
|
+
if a.cmd not in ("init", "hook", "update"): # hook locates its repo from the payload
|
|
842
|
+
if a.cmd == "blame": a.file = os.path.abspath(a.file) # resolve before the chdir
|
|
843
|
+
os.chdir(root()); git_mem_dir(); a.lock = lock()
|
|
844
|
+
{"init": cmd_init, "hook": cmd_hook, "sync": cmd_sync, "log": cmd_log, "show": cmd_show,
|
|
845
|
+
"undo": cmd_undo, "viz": cmd_viz, "import": cmd_import, "sessions": cmd_sessions,
|
|
846
|
+
"gc": cmd_gc, "update": cmd_update, "export": cmd_export, "blame": cmd_blame}[a.cmd](a)
|
|
847
|
+
if __name__ == "__main__":
|
|
848
|
+
main()
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "git-sci"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Share your team's memory - you, your agents, and your teammate's agents."
|
|
5
|
+
requires-python = ">=3.9"
|
|
6
|
+
|
|
7
|
+
[project.scripts]
|
|
8
|
+
git-mem = "git_mem:main"
|
|
9
|
+
|
|
10
|
+
[tool.flit.module]
|
|
11
|
+
name = "git_mem"
|
|
12
|
+
|
|
13
|
+
[build-system]
|
|
14
|
+
requires = ["flit_core>=3.2,<4"]
|
|
15
|
+
build-backend = "flit_core.buildapi"
|
git_sci-0.0.1/LICENSE
DELETED
|
@@ -1,19 +0,0 @@
|
|
|
1
|
-
Copyright (c) 2018 The Python Packaging Authority
|
|
2
|
-
|
|
3
|
-
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
4
|
-
of this software and associated documentation files (the "Software"), to deal
|
|
5
|
-
in the Software without restriction, including without limitation the rights
|
|
6
|
-
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
7
|
-
copies of the Software, and to permit persons to whom the Software is
|
|
8
|
-
furnished to do so, subject to the following conditions:
|
|
9
|
-
|
|
10
|
-
The above copyright notice and this permission notice shall be included in all
|
|
11
|
-
copies or substantial portions of the Software.
|
|
12
|
-
|
|
13
|
-
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
14
|
-
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
15
|
-
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
16
|
-
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
17
|
-
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
18
|
-
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
19
|
-
SOFTWARE.
|
git_sci-0.0.1/PKG-INFO
DELETED
|
@@ -1,20 +0,0 @@
|
|
|
1
|
-
Metadata-Version: 2.4
|
|
2
|
-
Name: git-sci
|
|
3
|
-
Version: 0.0.1
|
|
4
|
-
Summary: A package for science
|
|
5
|
-
Author-email: Belinda Mo <justanexperimentpark@gmail.com>
|
|
6
|
-
License-Expression: MIT
|
|
7
|
-
Project-URL: Homepage, https://github.com/pypa/sampleproject
|
|
8
|
-
Project-URL: Issues, https://github.com/pypa/sampleproject/issues
|
|
9
|
-
Classifier: Programming Language :: Python :: 3
|
|
10
|
-
Classifier: Operating System :: OS Independent
|
|
11
|
-
Requires-Python: >=3.11
|
|
12
|
-
Description-Content-Type: text/markdown
|
|
13
|
-
License-File: LICENSE
|
|
14
|
-
Dynamic: license-file
|
|
15
|
-
|
|
16
|
-
# Science
|
|
17
|
-
|
|
18
|
-
This is a simple example package. You can use
|
|
19
|
-
[GitHub-flavored Markdown](https://guides.github.com/features/mastering-markdown/)
|
|
20
|
-
to write your content.
|
git_sci-0.0.1/README.md
DELETED
git_sci-0.0.1/pyproject.toml
DELETED
|
@@ -1,23 +0,0 @@
|
|
|
1
|
-
[project]
|
|
2
|
-
name = "git-sci"
|
|
3
|
-
version = "0.0.1"
|
|
4
|
-
authors = [
|
|
5
|
-
{ name="Belinda Mo", email="justanexperimentpark@gmail.com" },
|
|
6
|
-
]
|
|
7
|
-
description = "A package for science"
|
|
8
|
-
readme = "README.md"
|
|
9
|
-
requires-python = ">=3.11"
|
|
10
|
-
classifiers = [
|
|
11
|
-
"Programming Language :: Python :: 3",
|
|
12
|
-
"Operating System :: OS Independent",
|
|
13
|
-
]
|
|
14
|
-
license = "MIT"
|
|
15
|
-
license-files = ["LICEN[CS]E*"]
|
|
16
|
-
|
|
17
|
-
[project.urls]
|
|
18
|
-
Homepage = "https://github.com/pypa/sampleproject"
|
|
19
|
-
Issues = "https://github.com/pypa/sampleproject/issues"
|
|
20
|
-
|
|
21
|
-
[build-system]
|
|
22
|
-
requires = ["setuptools >= 77.0.3"]
|
|
23
|
-
build-backend = "setuptools.build_meta"
|
git_sci-0.0.1/setup.cfg
DELETED
|
@@ -1,20 +0,0 @@
|
|
|
1
|
-
Metadata-Version: 2.4
|
|
2
|
-
Name: git-sci
|
|
3
|
-
Version: 0.0.1
|
|
4
|
-
Summary: A package for science
|
|
5
|
-
Author-email: Belinda Mo <justanexperimentpark@gmail.com>
|
|
6
|
-
License-Expression: MIT
|
|
7
|
-
Project-URL: Homepage, https://github.com/pypa/sampleproject
|
|
8
|
-
Project-URL: Issues, https://github.com/pypa/sampleproject/issues
|
|
9
|
-
Classifier: Programming Language :: Python :: 3
|
|
10
|
-
Classifier: Operating System :: OS Independent
|
|
11
|
-
Requires-Python: >=3.11
|
|
12
|
-
Description-Content-Type: text/markdown
|
|
13
|
-
License-File: LICENSE
|
|
14
|
-
Dynamic: license-file
|
|
15
|
-
|
|
16
|
-
# Science
|
|
17
|
-
|
|
18
|
-
This is a simple example package. You can use
|
|
19
|
-
[GitHub-flavored Markdown](https://guides.github.com/features/mastering-markdown/)
|
|
20
|
-
to write your content.
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
science
|
|
File without changes
|