devmemory-cli 0.1.0.dev0__py3-none-any.whl
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.
- devmemory/__about__.py +3 -0
- devmemory/__init__.py +14 -0
- devmemory/__main__.py +6 -0
- devmemory/adapters/__init__.py +6 -0
- devmemory/adapters/databricks.py +346 -0
- devmemory/adapters/entire.py +444 -0
- devmemory/adapters/git.py +408 -0
- devmemory/adapters/graph.py +251 -0
- devmemory/adapters/metrics.py +150 -0
- devmemory/adapters/tests.py +227 -0
- devmemory/analysis/__init__.py +19 -0
- devmemory/analysis/base.py +128 -0
- devmemory/analysis/chain.py +53 -0
- devmemory/analysis/llm.py +236 -0
- devmemory/analysis/rules.py +110 -0
- devmemory/api/__init__.py +10 -0
- devmemory/api/app.py +390 -0
- devmemory/api/mappers.py +187 -0
- devmemory/api/schemas.py +201 -0
- devmemory/cli/__init__.py +1 -0
- devmemory/cli/_errors.py +36 -0
- devmemory/cli/_render.py +79 -0
- devmemory/cli/analytics.py +136 -0
- devmemory/cli/analyze.py +58 -0
- devmemory/cli/app.py +163 -0
- devmemory/cli/checkpoint.py +199 -0
- devmemory/cli/compare.py +104 -0
- devmemory/cli/doctor.py +151 -0
- devmemory/cli/history.py +56 -0
- devmemory/cli/impact.py +95 -0
- devmemory/cli/init.py +91 -0
- devmemory/cli/mcp.py +66 -0
- devmemory/cli/memory.py +70 -0
- devmemory/cli/restore.py +91 -0
- devmemory/cli/search.py +48 -0
- devmemory/cli/serve.py +64 -0
- devmemory/cli/show.py +139 -0
- devmemory/cli/status.py +72 -0
- devmemory/cli/task.py +333 -0
- devmemory/config.py +302 -0
- devmemory/domain/__init__.py +5 -0
- devmemory/domain/enums.py +151 -0
- devmemory/domain/errors.py +188 -0
- devmemory/domain/models.py +452 -0
- devmemory/domain/taskloop.py +212 -0
- devmemory/environment.py +67 -0
- devmemory/logging.py +148 -0
- devmemory/mcp/__init__.py +12 -0
- devmemory/mcp/server.py +225 -0
- devmemory/paths.py +112 -0
- devmemory/pipeline/__init__.py +7 -0
- devmemory/pipeline/checkpoint.py +443 -0
- devmemory/pipeline/feature_detect.py +53 -0
- devmemory/pipeline/regression.py +141 -0
- devmemory/pipeline/runlog.py +73 -0
- devmemory/pipeline/status_rules.py +44 -0
- devmemory/py.typed +0 -0
- devmemory/services/__init__.py +9 -0
- devmemory/services/agent_context.py +287 -0
- devmemory/services/analysis.py +116 -0
- devmemory/services/analytics.py +328 -0
- devmemory/services/brief.py +53 -0
- devmemory/services/context.py +88 -0
- devmemory/services/databricks_sync.py +121 -0
- devmemory/services/features.py +85 -0
- devmemory/services/impact.py +47 -0
- devmemory/services/memory.py +212 -0
- devmemory/services/projects.py +226 -0
- devmemory/services/restore.py +194 -0
- devmemory/services/taskloop/__init__.py +39 -0
- devmemory/services/taskloop/collectors.py +263 -0
- devmemory/services/taskloop/engine.py +426 -0
- devmemory/services/taskloop/requirements.py +358 -0
- devmemory/services/trace.py +152 -0
- devmemory/services/versions.py +287 -0
- devmemory/storage/__init__.py +9 -0
- devmemory/storage/artifacts.py +113 -0
- devmemory/storage/db.py +205 -0
- devmemory/storage/graph_impacts.py +63 -0
- devmemory/storage/migrations/0001_init.sql +15 -0
- devmemory/storage/migrations/0002_versions.sql +210 -0
- devmemory/storage/migrations/0003_graph.sql +14 -0
- devmemory/storage/migrations/0004_taskloop.sql +82 -0
- devmemory/storage/migrations/0005_project_brief.sql +12 -0
- devmemory/storage/repositories.py +286 -0
- devmemory/storage/tasks.py +342 -0
- devmemory/storage/versions.py +604 -0
- devmemory/web/static/assets/index-CbV5njRH.js +78 -0
- devmemory/web/static/assets/index-DD-7ceZx.css +1 -0
- devmemory/web/static/index.html +18 -0
- devmemory_cli-0.1.0.dev0.dist-info/METADATA +174 -0
- devmemory_cli-0.1.0.dev0.dist-info/RECORD +95 -0
- devmemory_cli-0.1.0.dev0.dist-info/WHEEL +4 -0
- devmemory_cli-0.1.0.dev0.dist-info/entry_points.txt +3 -0
- devmemory_cli-0.1.0.dev0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,408 @@
|
|
|
1
|
+
"""Git adapter - the only place DevMemory shells out to ``git``.
|
|
2
|
+
|
|
3
|
+
Design rules (see docs/IMPLEMENTATION_STRATEGY.md sec. 4):
|
|
4
|
+
|
|
5
|
+
* subprocess against the ``git`` CLI, no GitPython
|
|
6
|
+
* every call runs with an explicit ``cwd`` and a hardened environment
|
|
7
|
+
* ``-c core.autocrlf=false`` and ``--no-pager`` so output is stable on Windows
|
|
8
|
+
* NUL-delimited (``-z``) parsing wherever git offers it
|
|
9
|
+
* paths are returned POSIX-style; the working tree is never modified here
|
|
10
|
+
(restore lives in a separate, guarded adapter method added in Phase 9)
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import os
|
|
16
|
+
import shutil
|
|
17
|
+
import subprocess
|
|
18
|
+
from datetime import datetime
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
from devmemory.domain.enums import ChangeType
|
|
22
|
+
from devmemory.domain.errors import GitError, GitRepositoryNotFoundError
|
|
23
|
+
from devmemory.domain.models import (
|
|
24
|
+
ChangedFile,
|
|
25
|
+
CommitInfo,
|
|
26
|
+
DiffStat,
|
|
27
|
+
WorkingTreeState,
|
|
28
|
+
)
|
|
29
|
+
from devmemory.logging import get_logger
|
|
30
|
+
|
|
31
|
+
_log = get_logger(__name__)
|
|
32
|
+
|
|
33
|
+
_NUL = "\x00"
|
|
34
|
+
_FIELD_SEP = "\x1f" # unit separator - unlikely in commit metadata
|
|
35
|
+
|
|
36
|
+
# git show -s format: sha, parents, author name/email/date, committer name/email/date, subject, body
|
|
37
|
+
_COMMIT_FORMAT = _FIELD_SEP.join(["%H", "%P", "%an", "%ae", "%aI", "%cn", "%ce", "%cI", "%s", "%b"])
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class GitAdapter:
|
|
41
|
+
"""Read-only git operations for one repository."""
|
|
42
|
+
|
|
43
|
+
def __init__(self, repo_path: Path | str, *, git_binary: str | None = None) -> None:
|
|
44
|
+
self._cwd = Path(repo_path).resolve()
|
|
45
|
+
self._git = git_binary or shutil.which("git") or "git"
|
|
46
|
+
|
|
47
|
+
# -- process plumbing ----------------------------------------------------
|
|
48
|
+
|
|
49
|
+
def _run(self, *args: str, check: bool = True) -> subprocess.CompletedProcess[str]:
|
|
50
|
+
cmd = [
|
|
51
|
+
self._git,
|
|
52
|
+
"-c",
|
|
53
|
+
"core.autocrlf=false",
|
|
54
|
+
"-c",
|
|
55
|
+
"core.quotepath=false",
|
|
56
|
+
"--no-pager",
|
|
57
|
+
*args,
|
|
58
|
+
]
|
|
59
|
+
env = {
|
|
60
|
+
**os.environ,
|
|
61
|
+
"GIT_OPTIONAL_LOCKS": "0",
|
|
62
|
+
"GIT_TERMINAL_PROMPT": "0",
|
|
63
|
+
"GIT_PAGER": "cat",
|
|
64
|
+
"LC_ALL": "C",
|
|
65
|
+
}
|
|
66
|
+
try:
|
|
67
|
+
proc = subprocess.run( # noqa: S603 - fixed binary, arg list, no shell
|
|
68
|
+
cmd,
|
|
69
|
+
cwd=self._cwd,
|
|
70
|
+
env=env,
|
|
71
|
+
capture_output=True,
|
|
72
|
+
text=True,
|
|
73
|
+
encoding="utf-8",
|
|
74
|
+
errors="replace",
|
|
75
|
+
timeout=120,
|
|
76
|
+
)
|
|
77
|
+
except FileNotFoundError as exc:
|
|
78
|
+
raise GitError(f"git executable not found: {self._git}") from exc
|
|
79
|
+
except subprocess.TimeoutExpired as exc:
|
|
80
|
+
raise GitError(f"git {' '.join(args)} timed out") from exc
|
|
81
|
+
|
|
82
|
+
if check and proc.returncode != 0:
|
|
83
|
+
raise GitError(
|
|
84
|
+
f"git {' '.join(args)} failed ({proc.returncode}): {proc.stderr.strip()}"
|
|
85
|
+
)
|
|
86
|
+
return proc
|
|
87
|
+
|
|
88
|
+
def _out(self, *args: str) -> str:
|
|
89
|
+
return self._run(*args).stdout.strip()
|
|
90
|
+
|
|
91
|
+
def _raw(self, *args: str) -> str:
|
|
92
|
+
"""stdout with no stripping - for NUL-delimited (`-z`) output."""
|
|
93
|
+
return self._run(*args).stdout
|
|
94
|
+
|
|
95
|
+
def _run_bytes(self, *args: str) -> bytes:
|
|
96
|
+
cmd = [self._git, "-c", "core.autocrlf=false", "--no-pager", *args]
|
|
97
|
+
env = {**os.environ, "GIT_OPTIONAL_LOCKS": "0", "GIT_TERMINAL_PROMPT": "0"}
|
|
98
|
+
try:
|
|
99
|
+
proc = subprocess.run( # noqa: S603 - fixed binary, arg list, no shell
|
|
100
|
+
cmd, cwd=self._cwd, env=env, capture_output=True, timeout=300
|
|
101
|
+
)
|
|
102
|
+
except (FileNotFoundError, subprocess.SubprocessError) as exc:
|
|
103
|
+
raise GitError(f"git {' '.join(args)} failed: {exc}") from exc
|
|
104
|
+
if proc.returncode != 0:
|
|
105
|
+
raise GitError(
|
|
106
|
+
f"git {' '.join(args)} failed ({proc.returncode}): "
|
|
107
|
+
f"{proc.stderr.decode(errors='replace').strip()}"
|
|
108
|
+
)
|
|
109
|
+
return proc.stdout
|
|
110
|
+
|
|
111
|
+
# -- repository ---------------------------------------------------------
|
|
112
|
+
|
|
113
|
+
def is_repository(self) -> bool:
|
|
114
|
+
proc = self._run("rev-parse", "--is-inside-work-tree", check=False)
|
|
115
|
+
return proc.returncode == 0 and proc.stdout.strip() == "true"
|
|
116
|
+
|
|
117
|
+
def require_repository(self) -> None:
|
|
118
|
+
if not self.is_repository():
|
|
119
|
+
raise GitRepositoryNotFoundError(
|
|
120
|
+
f"{self._cwd} is not inside a git repository.",
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
def repo_root(self) -> Path:
|
|
124
|
+
self.require_repository()
|
|
125
|
+
return Path(self._out("rev-parse", "--show-toplevel"))
|
|
126
|
+
|
|
127
|
+
def git_version(self) -> str | None:
|
|
128
|
+
proc = self._run("--version", check=False)
|
|
129
|
+
return proc.stdout.strip() or None if proc.returncode == 0 else None
|
|
130
|
+
|
|
131
|
+
# -- refs & commits ---------------------------------------------------
|
|
132
|
+
|
|
133
|
+
def has_commits(self) -> bool:
|
|
134
|
+
return self._run("rev-parse", "--verify", "HEAD", check=False).returncode == 0
|
|
135
|
+
|
|
136
|
+
def current_branch(self) -> str | None:
|
|
137
|
+
"""Current branch name, or ``None`` when detached or on an unborn branch."""
|
|
138
|
+
proc = self._run("branch", "--show-current", check=False)
|
|
139
|
+
branch = proc.stdout.strip() if proc.returncode == 0 else ""
|
|
140
|
+
return branch or None
|
|
141
|
+
|
|
142
|
+
def resolve(self, rev: str) -> str:
|
|
143
|
+
"""Resolve a revision (``HEAD``, ``v1``, a short sha, ...) to a full sha."""
|
|
144
|
+
proc = self._run("rev-parse", "--verify", f"{rev}^{{commit}}", check=False)
|
|
145
|
+
if proc.returncode != 0:
|
|
146
|
+
raise GitError(f"cannot resolve revision {rev!r}")
|
|
147
|
+
return proc.stdout.strip()
|
|
148
|
+
|
|
149
|
+
def head_sha(self) -> str | None:
|
|
150
|
+
return self.resolve("HEAD") if self.has_commits() else None
|
|
151
|
+
|
|
152
|
+
def commit(self, rev: str = "HEAD") -> CommitInfo:
|
|
153
|
+
"""Full metadata for one commit, including parsed trailers."""
|
|
154
|
+
sha = self.resolve(rev)
|
|
155
|
+
raw = self._run("show", "-s", f"--format={_COMMIT_FORMAT}", sha).stdout
|
|
156
|
+
parts = raw.split(_FIELD_SEP)
|
|
157
|
+
# %b can itself contain newlines; everything after the 9th sep is the body.
|
|
158
|
+
while len(parts) < 10:
|
|
159
|
+
parts.append("")
|
|
160
|
+
fields = parts[:9]
|
|
161
|
+
body = _FIELD_SEP.join(parts[9:]).strip("\n")
|
|
162
|
+
|
|
163
|
+
return CommitInfo(
|
|
164
|
+
sha=fields[0].strip(),
|
|
165
|
+
parents=fields[1].split() if fields[1].strip() else [],
|
|
166
|
+
author_name=fields[2],
|
|
167
|
+
author_email=fields[3],
|
|
168
|
+
authored_at=_parse_iso(fields[4]),
|
|
169
|
+
committer_name=fields[5],
|
|
170
|
+
committer_email=fields[6],
|
|
171
|
+
committed_at=_parse_iso(fields[7]),
|
|
172
|
+
subject=fields[8],
|
|
173
|
+
body=body,
|
|
174
|
+
trailers=self._trailers(sha),
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
def _trailers(self, sha: str) -> dict[str, list[str]]:
|
|
178
|
+
raw = self._run(
|
|
179
|
+
"show",
|
|
180
|
+
"-s",
|
|
181
|
+
"--format=%(trailers:only=true,unfold=true,key_value_separator=%x1f)",
|
|
182
|
+
sha,
|
|
183
|
+
).stdout
|
|
184
|
+
trailers: dict[str, list[str]] = {}
|
|
185
|
+
for line in raw.splitlines():
|
|
186
|
+
if _FIELD_SEP not in line:
|
|
187
|
+
continue
|
|
188
|
+
key, _, value = line.partition(_FIELD_SEP)
|
|
189
|
+
trailers.setdefault(key.strip(), []).append(value.strip())
|
|
190
|
+
return trailers
|
|
191
|
+
|
|
192
|
+
def entire_checkpoint_trailer(self, rev: str = "HEAD") -> str | None:
|
|
193
|
+
"""The ``Entire-Checkpoint`` trailer value for a commit, if present."""
|
|
194
|
+
sha = self.resolve(rev)
|
|
195
|
+
value = self._out(
|
|
196
|
+
"show",
|
|
197
|
+
"-s",
|
|
198
|
+
"--format=%(trailers:key=Entire-Checkpoint,valueonly=true,unfold=true)",
|
|
199
|
+
sha,
|
|
200
|
+
).strip()
|
|
201
|
+
return value or None
|
|
202
|
+
|
|
203
|
+
def parent_sha(self, rev: str = "HEAD") -> str | None:
|
|
204
|
+
info = self.commit(rev)
|
|
205
|
+
return info.parent
|
|
206
|
+
|
|
207
|
+
# -- diffs -----------------------------------------------------------
|
|
208
|
+
|
|
209
|
+
def changed_files(self, base: str | None, head: str) -> list[ChangedFile]:
|
|
210
|
+
"""Files changed between ``base`` and ``head`` (or introduced by ``head``).
|
|
211
|
+
|
|
212
|
+
Parses ``git`` NUL-delimited (`-z`) output, in which name-status entries
|
|
213
|
+
are ``<code>\\0<path>\\0`` (or ``<code>\\0<old>\\0<new>\\0`` for R/C) and
|
|
214
|
+
numstat entries are ``<add>\\t<del>\\t<path>\\0`` (with an empty path and
|
|
215
|
+
two following tokens for renames).
|
|
216
|
+
"""
|
|
217
|
+
head_sha = self.resolve(head)
|
|
218
|
+
if base is None:
|
|
219
|
+
status_raw = self._raw(
|
|
220
|
+
"show", "--first-parent", "-M", "-C", "--name-status", "--format=", "-z", head_sha
|
|
221
|
+
)
|
|
222
|
+
numstat_raw = self._raw("show", "--numstat", "--format=", "-z", head_sha)
|
|
223
|
+
else:
|
|
224
|
+
base_sha = self.resolve(base)
|
|
225
|
+
status_raw = self._raw("diff", "-M", "-C", "--name-status", "-z", base_sha, head_sha)
|
|
226
|
+
numstat_raw = self._raw("diff", "--numstat", "-z", base_sha, head_sha)
|
|
227
|
+
|
|
228
|
+
stats = _parse_numstat_z(numstat_raw)
|
|
229
|
+
result: list[ChangedFile] = []
|
|
230
|
+
for code, old_path, path in _parse_name_status_z(status_raw):
|
|
231
|
+
add, dele, binary = stats.get(path, (0, 0, False))
|
|
232
|
+
result.append(
|
|
233
|
+
ChangedFile(
|
|
234
|
+
path=path,
|
|
235
|
+
old_path=old_path,
|
|
236
|
+
change_type=ChangeType.from_git_status(code),
|
|
237
|
+
additions=add,
|
|
238
|
+
deletions=dele,
|
|
239
|
+
binary=binary,
|
|
240
|
+
)
|
|
241
|
+
)
|
|
242
|
+
return result
|
|
243
|
+
|
|
244
|
+
def diff_text(self, base: str | None, head: str, *, paths: list[str] | None = None) -> str:
|
|
245
|
+
head_sha = self.resolve(head)
|
|
246
|
+
if base is None:
|
|
247
|
+
args = ["show", "--no-color", "--first-parent", "--format=", head_sha]
|
|
248
|
+
else:
|
|
249
|
+
args = ["diff", "--no-color", self.resolve(base), head_sha]
|
|
250
|
+
if paths:
|
|
251
|
+
args += ["--", *paths]
|
|
252
|
+
return self._run(*args).stdout
|
|
253
|
+
|
|
254
|
+
def diff_stat(self, base: str | None, head: str) -> DiffStat:
|
|
255
|
+
return DiffStat.from_files(self.changed_files(base, head))
|
|
256
|
+
|
|
257
|
+
def show_file(self, rev: str, path: str) -> str | None:
|
|
258
|
+
proc = self._run("show", f"{self.resolve(rev)}:{path}", check=False)
|
|
259
|
+
return proc.stdout if proc.returncode == 0 else None
|
|
260
|
+
|
|
261
|
+
# -- history --------------------------------------------------------
|
|
262
|
+
|
|
263
|
+
def commits_between(self, base: str | None, head: str, *, limit: int = 200) -> list[str]:
|
|
264
|
+
rev_range = head if base is None else f"{base}..{head}"
|
|
265
|
+
out = self._out("rev-list", f"--max-count={limit}", rev_range)
|
|
266
|
+
return out.splitlines() if out else []
|
|
267
|
+
|
|
268
|
+
def cat_ref_blob(self, ref: str, path: str) -> str | None:
|
|
269
|
+
"""Read a blob from an arbitrary ref/tree (used for Entire checkpoint refs)."""
|
|
270
|
+
proc = self._run("cat-file", "-p", f"{ref}:{path}", check=False)
|
|
271
|
+
return proc.stdout if proc.returncode == 0 else None
|
|
272
|
+
|
|
273
|
+
def list_refs(self, pattern: str) -> list[tuple[str, str]]:
|
|
274
|
+
"""``(sha, refname)`` for refs matching a glob (e.g. ``refs/entire/checkpoints/``)."""
|
|
275
|
+
out = self._out("for-each-ref", "--format=%(objectname)%09%(refname)", pattern)
|
|
276
|
+
pairs: list[tuple[str, str]] = []
|
|
277
|
+
for line in out.splitlines():
|
|
278
|
+
if "\t" in line:
|
|
279
|
+
sha, name = line.split("\t", 1)
|
|
280
|
+
pairs.append((sha.strip(), name.strip()))
|
|
281
|
+
return pairs
|
|
282
|
+
|
|
283
|
+
# -- working tree --------------------------------------------------
|
|
284
|
+
|
|
285
|
+
def working_tree_state(self) -> WorkingTreeState:
|
|
286
|
+
branch = self.current_branch()
|
|
287
|
+
state = WorkingTreeState(
|
|
288
|
+
branch=branch,
|
|
289
|
+
detached=branch is None and self.has_commits(),
|
|
290
|
+
head=self.head_sha(),
|
|
291
|
+
)
|
|
292
|
+
for entry in _split_nul(self._raw("status", "--porcelain=v1", "-z")):
|
|
293
|
+
if len(entry) < 4:
|
|
294
|
+
continue
|
|
295
|
+
x, y, name = entry[0], entry[1], entry[3:]
|
|
296
|
+
if x == "?" and y == "?":
|
|
297
|
+
state.untracked.append(name)
|
|
298
|
+
continue
|
|
299
|
+
if x not in (" ", "?"):
|
|
300
|
+
state.staged.append(name)
|
|
301
|
+
if y not in (" ", "?"):
|
|
302
|
+
state.unstaged.append(name)
|
|
303
|
+
return state
|
|
304
|
+
|
|
305
|
+
def is_dirty(self) -> bool:
|
|
306
|
+
return not self.working_tree_state().is_clean
|
|
307
|
+
|
|
308
|
+
# -- snapshots & restore (the only mutating operations) --------------
|
|
309
|
+
|
|
310
|
+
def archive_tar(self, rev: str) -> bytes:
|
|
311
|
+
"""The committed tree at ``rev`` as an uncompressed tar (bytes)."""
|
|
312
|
+
return self._run_bytes("archive", "--format=tar", self.resolve(rev))
|
|
313
|
+
|
|
314
|
+
def create_tag(self, name: str, rev: str = "HEAD", *, message: str | None = None) -> None:
|
|
315
|
+
args = ["tag", name, self.resolve(rev)]
|
|
316
|
+
if message:
|
|
317
|
+
args = ["tag", "-a", name, "-m", message, self.resolve(rev)]
|
|
318
|
+
self._run(*args)
|
|
319
|
+
|
|
320
|
+
def stash_create(self) -> str | None:
|
|
321
|
+
"""Object id of a commit capturing the current dirty state, or ``None`` if clean.
|
|
322
|
+
|
|
323
|
+
``git stash create`` records but does not touch the working tree or the
|
|
324
|
+
stash list - a pure safety reference.
|
|
325
|
+
"""
|
|
326
|
+
out = self._out("stash", "create", "devmemory: pre-restore safety")
|
|
327
|
+
return out or None
|
|
328
|
+
|
|
329
|
+
def checkout_detached(self, rev: str) -> str:
|
|
330
|
+
"""Move HEAD to ``rev`` in detached state, keeping local changes out of the way."""
|
|
331
|
+
sha = self.resolve(rev)
|
|
332
|
+
self._run("checkout", "--detach", "--force", sha)
|
|
333
|
+
return sha
|
|
334
|
+
|
|
335
|
+
def reset_hard(self, rev: str) -> str:
|
|
336
|
+
sha = self.resolve(rev)
|
|
337
|
+
self._run("reset", "--hard", sha)
|
|
338
|
+
return sha
|
|
339
|
+
|
|
340
|
+
def restore_worktree_paths(self, rev: str, paths: list[str]) -> None:
|
|
341
|
+
if paths:
|
|
342
|
+
self._run("checkout", self.resolve(rev), "--", *paths)
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
# --- helpers ----------------------------------------------------------------------
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def _parse_iso(value: str) -> datetime | None:
|
|
349
|
+
value = value.strip()
|
|
350
|
+
if not value:
|
|
351
|
+
return None
|
|
352
|
+
try:
|
|
353
|
+
return datetime.fromisoformat(value)
|
|
354
|
+
except ValueError:
|
|
355
|
+
return None
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def _split_nul(raw: str) -> list[str]:
|
|
359
|
+
return [chunk for chunk in raw.split(_NUL) if chunk != ""]
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def _parse_name_status_z(raw: str) -> list[tuple[str, str | None, str]]:
|
|
363
|
+
"""``[(code, old_path | None, path), ...]`` from ``--name-status -z`` output."""
|
|
364
|
+
tokens = _split_nul(raw)
|
|
365
|
+
entries: list[tuple[str, str | None, str]] = []
|
|
366
|
+
i = 0
|
|
367
|
+
while i < len(tokens):
|
|
368
|
+
code = tokens[i]
|
|
369
|
+
i += 1
|
|
370
|
+
if not code or i >= len(tokens):
|
|
371
|
+
break
|
|
372
|
+
if code[:1].upper() in ("R", "C") and i + 1 < len(tokens) + 1:
|
|
373
|
+
old_path = tokens[i]
|
|
374
|
+
path = tokens[i + 1] if i + 1 < len(tokens) else old_path
|
|
375
|
+
i += 2
|
|
376
|
+
entries.append((code, old_path, path))
|
|
377
|
+
else:
|
|
378
|
+
entries.append((code, None, tokens[i]))
|
|
379
|
+
i += 1
|
|
380
|
+
return entries
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def _parse_numstat_z(raw: str) -> dict[str, tuple[int, int, bool]]:
|
|
384
|
+
"""``{path: (additions, deletions, binary)}`` from ``--numstat -z`` output."""
|
|
385
|
+
tokens = _split_nul(raw)
|
|
386
|
+
stats: dict[str, tuple[int, int, bool]] = {}
|
|
387
|
+
i = 0
|
|
388
|
+
while i < len(tokens):
|
|
389
|
+
bits = tokens[i].split("\t")
|
|
390
|
+
if len(bits) < 3:
|
|
391
|
+
i += 1
|
|
392
|
+
continue
|
|
393
|
+
add_s, del_s, path = bits[0], bits[1], bits[2]
|
|
394
|
+
i += 1
|
|
395
|
+
if path == "" and i + 1 < len(tokens):
|
|
396
|
+
# Rename: the following two tokens are old, new.
|
|
397
|
+
path = tokens[i + 1]
|
|
398
|
+
i += 2
|
|
399
|
+
binary = add_s == "-" or del_s == "-"
|
|
400
|
+
stats[path] = (
|
|
401
|
+
0 if binary else int(add_s or 0),
|
|
402
|
+
0 if binary else int(del_s or 0),
|
|
403
|
+
binary,
|
|
404
|
+
)
|
|
405
|
+
return stats
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
__all__ = ["GitAdapter"]
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
"""Change-impact analysis via the Entire ``graph`` plugin (optional).
|
|
2
|
+
|
|
3
|
+
``entire graph`` builds a deterministic, no-egress local code graph. We use its
|
|
4
|
+
``commit`` analysis: an entity-level change list (added / removed / renamed /
|
|
5
|
+
signature-changed / body-changed) with a dependent count, so a signature change
|
|
6
|
+
that many callers depend on stands out.
|
|
7
|
+
|
|
8
|
+
The plugin is opt-in (`entire plugin install graph`, `graph.enabled = true`).
|
|
9
|
+
Everything here degrades to ``None`` when it is missing or slow - it never blocks
|
|
10
|
+
a checkpoint.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import os
|
|
17
|
+
import shutil
|
|
18
|
+
import subprocess
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
from pydantic import BaseModel, Field, computed_field
|
|
22
|
+
|
|
23
|
+
from devmemory.logging import get_logger
|
|
24
|
+
|
|
25
|
+
_log = get_logger(__name__)
|
|
26
|
+
|
|
27
|
+
# entity change types the plugin emits, ordered by how much they usually matter
|
|
28
|
+
_SEVERITY = {
|
|
29
|
+
"removed": 4,
|
|
30
|
+
"signature_changed": 3,
|
|
31
|
+
"renamed": 2,
|
|
32
|
+
"added": 1,
|
|
33
|
+
"body_changed": 1,
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class GraphStatus(BaseModel):
|
|
38
|
+
installed: bool = False
|
|
39
|
+
version: str | None = None
|
|
40
|
+
binary_path: str | None = None
|
|
41
|
+
detail: str | None = None
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class ChangedEntity(BaseModel):
|
|
45
|
+
path: str
|
|
46
|
+
language: str | None = None
|
|
47
|
+
file_status: str # A | M | D | R
|
|
48
|
+
change_type: str # added | removed | renamed | signature_changed | body_changed
|
|
49
|
+
kind: str # function | method | type | section | ...
|
|
50
|
+
name: str
|
|
51
|
+
dependents_count: int = 0
|
|
52
|
+
old_signature: str | None = None
|
|
53
|
+
new_signature: str | None = None
|
|
54
|
+
line: int | None = None
|
|
55
|
+
|
|
56
|
+
@property
|
|
57
|
+
def is_risky(self) -> bool:
|
|
58
|
+
return self.change_type in ("removed", "signature_changed") and self.dependents_count > 0
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class GraphImpact(BaseModel):
|
|
62
|
+
version_id: str | None = None
|
|
63
|
+
base_commit: str
|
|
64
|
+
head_commit: str
|
|
65
|
+
entities: list[ChangedEntity] = Field(default_factory=list)
|
|
66
|
+
generated_at: str | None = None
|
|
67
|
+
|
|
68
|
+
@computed_field # type: ignore[prop-decorator]
|
|
69
|
+
@property
|
|
70
|
+
def entity_count(self) -> int:
|
|
71
|
+
return len(self.entities)
|
|
72
|
+
|
|
73
|
+
@computed_field # type: ignore[prop-decorator]
|
|
74
|
+
@property
|
|
75
|
+
def max_dependents(self) -> int:
|
|
76
|
+
return max((e.dependents_count for e in self.entities), default=0)
|
|
77
|
+
|
|
78
|
+
@property
|
|
79
|
+
def hotspots(self) -> list[ChangedEntity]:
|
|
80
|
+
ranked = sorted(
|
|
81
|
+
self.entities,
|
|
82
|
+
key=lambda e: (_SEVERITY.get(e.change_type, 0), e.dependents_count),
|
|
83
|
+
reverse=True,
|
|
84
|
+
)
|
|
85
|
+
return [
|
|
86
|
+
e
|
|
87
|
+
for e in ranked
|
|
88
|
+
if e.dependents_count > 0 or e.change_type in ("removed", "signature_changed")
|
|
89
|
+
][:10]
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class GraphAdapter:
|
|
93
|
+
def __init__(
|
|
94
|
+
self,
|
|
95
|
+
repo_path: Path | str,
|
|
96
|
+
*,
|
|
97
|
+
binary: str | None = None,
|
|
98
|
+
timeout: int = 90,
|
|
99
|
+
max_seconds: int = 120,
|
|
100
|
+
) -> None:
|
|
101
|
+
self._cwd = Path(repo_path).resolve()
|
|
102
|
+
self._binary = binary or _find_binary()
|
|
103
|
+
self._timeout = timeout
|
|
104
|
+
self._max_seconds = max_seconds
|
|
105
|
+
|
|
106
|
+
@property
|
|
107
|
+
def binary_path(self) -> str | None:
|
|
108
|
+
return self._binary
|
|
109
|
+
|
|
110
|
+
@property
|
|
111
|
+
def is_available(self) -> bool:
|
|
112
|
+
return self._binary is not None
|
|
113
|
+
|
|
114
|
+
def probe(self) -> GraphStatus:
|
|
115
|
+
if self._binary is None:
|
|
116
|
+
return GraphStatus(
|
|
117
|
+
installed=False,
|
|
118
|
+
detail="entire-graph not found; run `entire plugin install graph`",
|
|
119
|
+
)
|
|
120
|
+
proc = self._run("version", "--json", timeout=15)
|
|
121
|
+
version: str | None = None
|
|
122
|
+
if proc is not None and proc.returncode == 0 and proc.stdout.strip():
|
|
123
|
+
try:
|
|
124
|
+
version = json.loads(proc.stdout).get("version")
|
|
125
|
+
except (json.JSONDecodeError, AttributeError):
|
|
126
|
+
version = None
|
|
127
|
+
return GraphStatus(installed=True, version=version, binary_path=self._binary)
|
|
128
|
+
|
|
129
|
+
def commit_impact(self, rev: str) -> GraphImpact | None:
|
|
130
|
+
"""Entity-level change list for ``rev`` versus its first parent."""
|
|
131
|
+
if self._binary is None:
|
|
132
|
+
return None
|
|
133
|
+
proc = self._run(
|
|
134
|
+
"commit",
|
|
135
|
+
"--json",
|
|
136
|
+
"--max-seconds",
|
|
137
|
+
str(self._max_seconds),
|
|
138
|
+
rev,
|
|
139
|
+
timeout=self._timeout,
|
|
140
|
+
)
|
|
141
|
+
if proc is None or proc.returncode != 0 or not proc.stdout.strip():
|
|
142
|
+
if proc is not None:
|
|
143
|
+
_log.warning("graph.commit_failed", rev=rev, stderr=proc.stderr[:400])
|
|
144
|
+
return None
|
|
145
|
+
try:
|
|
146
|
+
data = json.loads(proc.stdout)
|
|
147
|
+
except json.JSONDecodeError:
|
|
148
|
+
_log.warning("graph.bad_json", rev=rev)
|
|
149
|
+
return None
|
|
150
|
+
return _parse_commit(data)
|
|
151
|
+
|
|
152
|
+
# -- process plumbing ------------------------------------------------
|
|
153
|
+
|
|
154
|
+
def _run(self, *args: str, timeout: int = 90) -> subprocess.CompletedProcess[str] | None:
|
|
155
|
+
if self._binary is None:
|
|
156
|
+
return None
|
|
157
|
+
try:
|
|
158
|
+
return subprocess.run( # noqa: S603 - resolved binary, arg list, no shell
|
|
159
|
+
[self._binary, *args, "--repo", str(self._cwd)],
|
|
160
|
+
cwd=self._cwd,
|
|
161
|
+
capture_output=True,
|
|
162
|
+
text=True,
|
|
163
|
+
encoding="utf-8",
|
|
164
|
+
errors="replace",
|
|
165
|
+
timeout=timeout,
|
|
166
|
+
check=False,
|
|
167
|
+
)
|
|
168
|
+
except (OSError, subprocess.SubprocessError) as exc:
|
|
169
|
+
_log.warning("graph.run_failed", args=list(args), error=str(exc))
|
|
170
|
+
return None
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _parse_commit(data: object) -> GraphImpact | None:
|
|
174
|
+
if not isinstance(data, dict):
|
|
175
|
+
return None
|
|
176
|
+
files = data.get("files")
|
|
177
|
+
if not isinstance(files, list):
|
|
178
|
+
return None
|
|
179
|
+
entities: list[ChangedEntity] = []
|
|
180
|
+
for f in files:
|
|
181
|
+
if not isinstance(f, dict):
|
|
182
|
+
continue
|
|
183
|
+
path = str(f.get("path", ""))
|
|
184
|
+
language = f.get("language")
|
|
185
|
+
file_status = str(f.get("status", "M"))
|
|
186
|
+
for change in f.get("changes", []) or []:
|
|
187
|
+
if not isinstance(change, dict):
|
|
188
|
+
continue
|
|
189
|
+
entities.append(
|
|
190
|
+
ChangedEntity(
|
|
191
|
+
path=path,
|
|
192
|
+
language=language if isinstance(language, str) else None,
|
|
193
|
+
file_status=file_status,
|
|
194
|
+
change_type=str(change.get("type", "body_changed")),
|
|
195
|
+
kind=str(change.get("kind", "symbol")),
|
|
196
|
+
name=str(change.get("name", "?")),
|
|
197
|
+
dependents_count=int(change.get("dependents_count") or 0),
|
|
198
|
+
old_signature=_opt_str(change.get("old_signature")),
|
|
199
|
+
new_signature=_opt_str(change.get("new_signature")),
|
|
200
|
+
line=_opt_int(
|
|
201
|
+
change.get("after_start_line") or change.get("before_start_line")
|
|
202
|
+
),
|
|
203
|
+
)
|
|
204
|
+
)
|
|
205
|
+
return GraphImpact(
|
|
206
|
+
base_commit=str(data.get("base", "")),
|
|
207
|
+
head_commit=str(data.get("head", "")),
|
|
208
|
+
entities=entities,
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _opt_str(value: object) -> str | None:
|
|
213
|
+
return value if isinstance(value, str) and value else None
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _opt_int(value: object) -> int | None:
|
|
217
|
+
return value if isinstance(value, int) else None
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _find_binary() -> str | None:
|
|
221
|
+
found = shutil.which("entire-graph")
|
|
222
|
+
if found:
|
|
223
|
+
return found
|
|
224
|
+
|
|
225
|
+
candidates: list[Path] = []
|
|
226
|
+
home = Path.home()
|
|
227
|
+
local_appdata = os.environ.get("LOCALAPPDATA")
|
|
228
|
+
xdg_data = os.environ.get("XDG_DATA_HOME")
|
|
229
|
+
plugin_env = os.environ.get("ENTIRE_PLUGIN_DIR")
|
|
230
|
+
|
|
231
|
+
for base in (
|
|
232
|
+
Path(plugin_env) if plugin_env else None,
|
|
233
|
+
Path(local_appdata) / "entire" / "plugins" if local_appdata else None,
|
|
234
|
+
Path(xdg_data) / "entire" / "plugins" if xdg_data else None,
|
|
235
|
+
home / "AppData" / "Local" / "entire" / "plugins",
|
|
236
|
+
home / ".local" / "share" / "entire" / "plugins",
|
|
237
|
+
):
|
|
238
|
+
if base is None:
|
|
239
|
+
continue
|
|
240
|
+
candidates.append(base / "bin" / "entire-graph.exe")
|
|
241
|
+
candidates.append(base / "bin" / "entire-graph")
|
|
242
|
+
candidates.append(base / "pkg" / "graph" / "entire-graph.exe")
|
|
243
|
+
candidates.append(base / "pkg" / "graph" / "entire-graph")
|
|
244
|
+
|
|
245
|
+
for path in candidates:
|
|
246
|
+
if path.is_file():
|
|
247
|
+
return str(path)
|
|
248
|
+
return None
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
__all__ = ["ChangedEntity", "GraphAdapter", "GraphImpact", "GraphStatus"]
|