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,47 @@
|
|
|
1
|
+
"""Change-impact for a version: stored result, or computed on demand.
|
|
2
|
+
|
|
3
|
+
Thin service over :class:`GraphAdapter` + :class:`GraphImpactRepository`. When a
|
|
4
|
+
version has no stored impact and the plugin is available, it is computed once and
|
|
5
|
+
cached.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from devmemory.adapters.graph import GraphImpact, GraphStatus
|
|
11
|
+
from devmemory.services.context import ProjectContext
|
|
12
|
+
from devmemory.services.versions import get_version
|
|
13
|
+
from devmemory.storage.graph_impacts import GraphImpactRepository
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def graph_status(ctx: ProjectContext) -> GraphStatus:
|
|
17
|
+
status = ctx.graph.probe()
|
|
18
|
+
return status.model_copy(update={"detail": status.detail or _enabled_note(ctx)})
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def version_impact(
|
|
22
|
+
ctx: ProjectContext, ref: str, *, compute_if_missing: bool = True
|
|
23
|
+
) -> GraphImpact | None:
|
|
24
|
+
version = get_version(ctx, ref)
|
|
25
|
+
repo = GraphImpactRepository(ctx.db)
|
|
26
|
+
|
|
27
|
+
stored = repo.get(version.version_id)
|
|
28
|
+
if stored is not None:
|
|
29
|
+
return stored
|
|
30
|
+
if not compute_if_missing or not ctx.graph.is_available:
|
|
31
|
+
return None
|
|
32
|
+
|
|
33
|
+
impact = ctx.graph.commit_impact(version.git_commit)
|
|
34
|
+
if impact is None:
|
|
35
|
+
return None
|
|
36
|
+
computed = impact.model_copy(update={"version_id": version.version_id})
|
|
37
|
+
repo.set(version.version_id, computed)
|
|
38
|
+
return computed
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _enabled_note(ctx: ProjectContext) -> str | None:
|
|
42
|
+
if not ctx.config.graph.enabled:
|
|
43
|
+
return "graph.enabled is false; impact is computed on request only"
|
|
44
|
+
return None
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
__all__ = ["graph_status", "version_impact"]
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
"""Development memory: surface previous attempts so failed approaches aren't repeated.
|
|
2
|
+
|
|
3
|
+
Given a scope (files being touched, a feature, an intent), find the historical
|
|
4
|
+
versions that are relevant - especially the ones that failed or regressed - and
|
|
5
|
+
return them with a short "why this matched" and a recommendation.
|
|
6
|
+
|
|
7
|
+
Retrieval is deliberately simple (file overlap + feature + FTS keyword match +
|
|
8
|
+
status). Embeddings can be layered on later without changing this interface.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from pydantic import BaseModel
|
|
14
|
+
|
|
15
|
+
from devmemory.domain.enums import VersionStatus
|
|
16
|
+
from devmemory.domain.models import DevelopmentVersion
|
|
17
|
+
from devmemory.services.context import ProjectContext
|
|
18
|
+
from devmemory.storage.repositories import FeatureRepository
|
|
19
|
+
from devmemory.storage.versions import VersionRepository
|
|
20
|
+
|
|
21
|
+
_ADVERSE = {VersionStatus.REGRESSION, VersionStatus.ERROR}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class PreviousAttempt(BaseModel):
|
|
25
|
+
version_id: str
|
|
26
|
+
version_number: int
|
|
27
|
+
status: str
|
|
28
|
+
intent: str | None
|
|
29
|
+
agent: str | None
|
|
30
|
+
feature: str | None
|
|
31
|
+
git_commit: str
|
|
32
|
+
files: list[str]
|
|
33
|
+
change_summary: str
|
|
34
|
+
result: str
|
|
35
|
+
recommendation: str | None
|
|
36
|
+
is_adverse: bool
|
|
37
|
+
score: float
|
|
38
|
+
matched_on: list[str]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class MemoryQuery(BaseModel):
|
|
42
|
+
files: list[str] = []
|
|
43
|
+
feature: str | None = None
|
|
44
|
+
intent: str | None = None
|
|
45
|
+
include_successes: bool = False
|
|
46
|
+
limit: int = 10
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def previous_attempts(ctx: ProjectContext, query: MemoryQuery) -> list[PreviousAttempt]:
|
|
50
|
+
repo = VersionRepository(ctx.db)
|
|
51
|
+
project_id = ctx.config.project_id
|
|
52
|
+
want_files = {_norm(f) for f in query.files}
|
|
53
|
+
|
|
54
|
+
feature_id: str | None = None
|
|
55
|
+
if query.feature:
|
|
56
|
+
feat = FeatureRepository(ctx.db).get_by_name(project_id, query.feature)
|
|
57
|
+
feature_id = feat.feature_id if feat else None
|
|
58
|
+
|
|
59
|
+
candidates: dict[str, DevelopmentVersion] = {}
|
|
60
|
+
if query.intent:
|
|
61
|
+
for v in _by_ids(repo, repo.search_ids(project_id, query.intent, limit=40)):
|
|
62
|
+
candidates[v.version_id] = v
|
|
63
|
+
for v in repo.page(project_id, limit=500, ascending=False):
|
|
64
|
+
candidates.setdefault(v.version_id, v)
|
|
65
|
+
|
|
66
|
+
scored: list[PreviousAttempt] = []
|
|
67
|
+
for v in candidates.values():
|
|
68
|
+
attempt = _score(v, want_files, feature_id, query)
|
|
69
|
+
if attempt is not None:
|
|
70
|
+
scored.append(attempt)
|
|
71
|
+
|
|
72
|
+
scored.sort(key=lambda a: (a.score, a.version_number), reverse=True)
|
|
73
|
+
return scored[: query.limit]
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _score(
|
|
77
|
+
v: DevelopmentVersion,
|
|
78
|
+
want_files: set[str],
|
|
79
|
+
feature_id: str | None,
|
|
80
|
+
query: MemoryQuery,
|
|
81
|
+
) -> PreviousAttempt | None:
|
|
82
|
+
is_adverse = v.status in _ADVERSE or bool(v.regressions)
|
|
83
|
+
if not is_adverse and not query.include_successes:
|
|
84
|
+
return None
|
|
85
|
+
|
|
86
|
+
matched: list[str] = []
|
|
87
|
+
score = 0.0
|
|
88
|
+
|
|
89
|
+
v_files = {_norm(f.path) for f in v.changed_files}
|
|
90
|
+
overlap = want_files & v_files
|
|
91
|
+
if overlap:
|
|
92
|
+
score += 3.0 + 0.5 * len(overlap)
|
|
93
|
+
matched.append("files: " + ", ".join(sorted(overlap)[:3]))
|
|
94
|
+
|
|
95
|
+
if feature_id and v.feature_id == feature_id:
|
|
96
|
+
score += 2.5
|
|
97
|
+
matched.append(f"feature: {query.feature}")
|
|
98
|
+
|
|
99
|
+
if query.intent and v.intent:
|
|
100
|
+
shared = _keyword_overlap(query.intent, v.intent)
|
|
101
|
+
if shared:
|
|
102
|
+
score += 1.0 + 0.4 * len(shared)
|
|
103
|
+
matched.append("intent: " + ", ".join(sorted(shared)[:3]))
|
|
104
|
+
|
|
105
|
+
if is_adverse:
|
|
106
|
+
score += 1.5
|
|
107
|
+
|
|
108
|
+
if score <= 0 or not matched:
|
|
109
|
+
return None
|
|
110
|
+
|
|
111
|
+
return PreviousAttempt(
|
|
112
|
+
version_id=v.version_id,
|
|
113
|
+
version_number=v.version_number,
|
|
114
|
+
status=v.status.value,
|
|
115
|
+
intent=v.intent,
|
|
116
|
+
agent=v.agent,
|
|
117
|
+
feature=v.feature_id.split(":", 1)[-1] if v.feature_id else None,
|
|
118
|
+
git_commit=v.git_commit,
|
|
119
|
+
files=[f.path for f in v.changed_files],
|
|
120
|
+
change_summary=_change_summary(v),
|
|
121
|
+
result=_result_summary(v),
|
|
122
|
+
recommendation=_recommendation(v),
|
|
123
|
+
is_adverse=is_adverse,
|
|
124
|
+
score=round(score, 2),
|
|
125
|
+
matched_on=matched,
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _change_summary(v: DevelopmentVersion) -> str:
|
|
130
|
+
files = ", ".join(f.path for f in v.changed_files[:3])
|
|
131
|
+
more = f" (+{len(v.changed_files) - 3} more)" if len(v.changed_files) > 3 else ""
|
|
132
|
+
return f"{files}{more} +{v.lines_added}/-{v.lines_removed}" if files else v.intent or "—"
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _result_summary(v: DevelopmentVersion) -> str:
|
|
136
|
+
parts: list[str] = []
|
|
137
|
+
for r in v.regressions:
|
|
138
|
+
if r.before is not None and r.after is not None:
|
|
139
|
+
parts.append(f"{r.metric or r.kind} {r.before:g} → {r.after:g}")
|
|
140
|
+
elif r.detail:
|
|
141
|
+
parts.append(r.detail)
|
|
142
|
+
if not parts and v.tests and v.tests.ran and not v.tests.all_passed:
|
|
143
|
+
parts.append(f"{v.tests.failed} tests failed")
|
|
144
|
+
if not parts:
|
|
145
|
+
for m in v.metrics:
|
|
146
|
+
if m.before is not None and m.after is not None:
|
|
147
|
+
parts.append(f"{m.name} {m.before:g} → {m.after:g}")
|
|
148
|
+
return "; ".join(parts) or v.status.value
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _recommendation(v: DevelopmentVersion) -> str | None:
|
|
152
|
+
if v.analysis and v.analysis.recommendation:
|
|
153
|
+
return v.analysis.recommendation
|
|
154
|
+
if v.status is VersionStatus.REGRESSION or v.regressions:
|
|
155
|
+
return "This approach regressed here - avoid repeating it, or address the cause first."
|
|
156
|
+
if v.status is VersionStatus.ERROR:
|
|
157
|
+
return "This approach errored - check the fix that followed before retrying."
|
|
158
|
+
return None
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _keyword_overlap(a: str, b: str) -> set[str]:
|
|
162
|
+
return _keywords(a) & _keywords(b)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
_STOP = {
|
|
166
|
+
"the",
|
|
167
|
+
"a",
|
|
168
|
+
"an",
|
|
169
|
+
"to",
|
|
170
|
+
"of",
|
|
171
|
+
"and",
|
|
172
|
+
"or",
|
|
173
|
+
"for",
|
|
174
|
+
"in",
|
|
175
|
+
"on",
|
|
176
|
+
"with",
|
|
177
|
+
"is",
|
|
178
|
+
"add",
|
|
179
|
+
"fix",
|
|
180
|
+
"update",
|
|
181
|
+
"change",
|
|
182
|
+
"make",
|
|
183
|
+
"use",
|
|
184
|
+
"improve",
|
|
185
|
+
"implement",
|
|
186
|
+
"this",
|
|
187
|
+
"that",
|
|
188
|
+
"it",
|
|
189
|
+
"be",
|
|
190
|
+
"into",
|
|
191
|
+
"from",
|
|
192
|
+
"so",
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _keywords(text: str) -> set[str]:
|
|
197
|
+
return {
|
|
198
|
+
w
|
|
199
|
+
for w in "".join(c if c.isalnum() else " " for c in text.lower()).split()
|
|
200
|
+
if len(w) > 2 and w not in _STOP
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _by_ids(repo: VersionRepository, ids: list[str]) -> list[DevelopmentVersion]:
|
|
205
|
+
return [v for vid in ids if (v := repo.get(vid)) is not None]
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _norm(path: str) -> str:
|
|
209
|
+
return path.replace("\\", "/").lstrip("./").lower()
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
__all__ = ["MemoryQuery", "PreviousAttempt", "previous_attempts"]
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
"""Project lifecycle: ``init`` and ``status``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from pydantic import BaseModel
|
|
9
|
+
|
|
10
|
+
from devmemory.adapters.git import GitAdapter
|
|
11
|
+
from devmemory.config import DevMemoryConfig
|
|
12
|
+
from devmemory.domain.enums import FeatureStatus, VersionStatus
|
|
13
|
+
from devmemory.domain.errors import (
|
|
14
|
+
GitRepositoryNotFoundError,
|
|
15
|
+
ProjectAlreadyInitializedError,
|
|
16
|
+
)
|
|
17
|
+
from devmemory.domain.models import EntireStatus, EnvironmentInfo, Project
|
|
18
|
+
from devmemory.environment import collect_environment
|
|
19
|
+
from devmemory.logging import get_logger
|
|
20
|
+
from devmemory.paths import DEVMEMORY_DIRNAME, ProjectPaths
|
|
21
|
+
from devmemory.services.context import ProjectContext
|
|
22
|
+
from devmemory.storage.repositories import FeatureRepository, ProjectRepository
|
|
23
|
+
from devmemory.storage.versions import VersionRepository
|
|
24
|
+
|
|
25
|
+
_log = get_logger(__name__)
|
|
26
|
+
|
|
27
|
+
_SLUG_RE = re.compile(r"[^a-z0-9]+")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def slugify(text: str) -> str:
|
|
31
|
+
slug = _SLUG_RE.sub("-", text.strip().lower()).strip("-")
|
|
32
|
+
return slug or "project"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class InitReport(BaseModel):
|
|
36
|
+
project: Project
|
|
37
|
+
config_path: str
|
|
38
|
+
db_path: str
|
|
39
|
+
git_detected: bool
|
|
40
|
+
git_version: str | None
|
|
41
|
+
entire: EntireStatus
|
|
42
|
+
environment: EnvironmentInfo
|
|
43
|
+
gitignore_updated: bool
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class ProjectStatusReport(BaseModel):
|
|
47
|
+
project: Project
|
|
48
|
+
branch: str | None
|
|
49
|
+
head_sha: str | None
|
|
50
|
+
head_subject: str | None
|
|
51
|
+
working_tree_clean: bool
|
|
52
|
+
entire: EntireStatus
|
|
53
|
+
version_count: int = 0
|
|
54
|
+
latest_version_id: str | None = None
|
|
55
|
+
latest_status: str | None = None
|
|
56
|
+
latest_intent: str | None = None
|
|
57
|
+
head_has_version: bool = False
|
|
58
|
+
last_regression_id: str | None = None
|
|
59
|
+
open_features: list[str] = []
|
|
60
|
+
latest_metrics: dict[str, float | None] = {}
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def init_project(
|
|
64
|
+
repo_path: Path | str,
|
|
65
|
+
*,
|
|
66
|
+
name: str | None = None,
|
|
67
|
+
project_id: str | None = None,
|
|
68
|
+
force: bool = False,
|
|
69
|
+
entire_probe: EntireStatus | None = None,
|
|
70
|
+
) -> InitReport:
|
|
71
|
+
"""Create ``.devmemory/`` for a repository and register the project."""
|
|
72
|
+
repo_path = Path(repo_path).resolve()
|
|
73
|
+
|
|
74
|
+
git = GitAdapter(repo_path)
|
|
75
|
+
if not git.is_repository():
|
|
76
|
+
raise GitRepositoryNotFoundError(
|
|
77
|
+
f"{repo_path} is not inside a git repository.",
|
|
78
|
+
)
|
|
79
|
+
repo_root = git.repo_root()
|
|
80
|
+
paths = ProjectPaths.for_root(repo_root)
|
|
81
|
+
|
|
82
|
+
if paths.exists and not force:
|
|
83
|
+
raise ProjectAlreadyInitializedError(
|
|
84
|
+
f"DevMemory is already initialized at {paths.root}.",
|
|
85
|
+
hint="Pass --force to re-create the configuration (history is preserved).",
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
paths.ensure_scaffold()
|
|
89
|
+
|
|
90
|
+
resolved_name = name or repo_root.name
|
|
91
|
+
resolved_id = slugify(project_id or resolved_name)
|
|
92
|
+
|
|
93
|
+
config = _load_or_default_config(paths, project_id=resolved_id, project_name=resolved_name)
|
|
94
|
+
config.save(paths)
|
|
95
|
+
|
|
96
|
+
ctx = ProjectContext.for_paths(paths, config)
|
|
97
|
+
try:
|
|
98
|
+
repo = ProjectRepository(ctx.db)
|
|
99
|
+
project = repo.get_by_id(resolved_id)
|
|
100
|
+
if project is None:
|
|
101
|
+
project = repo.create(
|
|
102
|
+
project_id=resolved_id,
|
|
103
|
+
name=resolved_name,
|
|
104
|
+
repo_path=str(repo_root),
|
|
105
|
+
)
|
|
106
|
+
finally:
|
|
107
|
+
ctx.close()
|
|
108
|
+
|
|
109
|
+
gitignore_updated = _ensure_repo_gitignore(repo_root)
|
|
110
|
+
entire = entire_probe or EntireStatus()
|
|
111
|
+
|
|
112
|
+
_log.info(
|
|
113
|
+
"project.init",
|
|
114
|
+
project_id=resolved_id,
|
|
115
|
+
repo=str(repo_root),
|
|
116
|
+
force=force,
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
return InitReport(
|
|
120
|
+
project=project,
|
|
121
|
+
config_path=str(paths.config),
|
|
122
|
+
db_path=str(paths.db),
|
|
123
|
+
git_detected=True,
|
|
124
|
+
git_version=git.git_version(),
|
|
125
|
+
entire=entire,
|
|
126
|
+
environment=collect_environment(repo_root),
|
|
127
|
+
gitignore_updated=gitignore_updated,
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def project_status(
|
|
132
|
+
ctx: ProjectContext, *, entire_probe: EntireStatus | None = None
|
|
133
|
+
) -> ProjectStatusReport:
|
|
134
|
+
repo = ProjectRepository(ctx.db)
|
|
135
|
+
project = repo.get()
|
|
136
|
+
if project is None: # pragma: no cover - context load implies a project row
|
|
137
|
+
raise ProjectAlreadyInitializedError("No project row found; re-run `devmemory init`.")
|
|
138
|
+
|
|
139
|
+
versions = VersionRepository(ctx.db)
|
|
140
|
+
features = FeatureRepository(ctx.db)
|
|
141
|
+
|
|
142
|
+
head = ctx.git.head_sha()
|
|
143
|
+
subject = ctx.git.commit(head).subject if head else None
|
|
144
|
+
|
|
145
|
+
latest = versions.latest(project.project_id)
|
|
146
|
+
head_version = versions.find_by_commit(project.project_id, head) if head else None
|
|
147
|
+
last_regression = next(
|
|
148
|
+
(
|
|
149
|
+
v.version_id
|
|
150
|
+
for v in versions.page(project.project_id, limit=200, ascending=False)
|
|
151
|
+
if v.status is VersionStatus.REGRESSION
|
|
152
|
+
),
|
|
153
|
+
None,
|
|
154
|
+
)
|
|
155
|
+
open_features = [
|
|
156
|
+
f.name
|
|
157
|
+
for f in features.list_all(project.project_id)
|
|
158
|
+
if f.status not in (FeatureStatus.COMPLETE, FeatureStatus.NOT_STARTED)
|
|
159
|
+
]
|
|
160
|
+
|
|
161
|
+
return ProjectStatusReport(
|
|
162
|
+
project=project,
|
|
163
|
+
branch=ctx.git.current_branch(),
|
|
164
|
+
head_sha=head,
|
|
165
|
+
head_subject=subject,
|
|
166
|
+
working_tree_clean=not ctx.git.is_dirty(),
|
|
167
|
+
entire=entire_probe or EntireStatus(),
|
|
168
|
+
version_count=versions.count(project.project_id),
|
|
169
|
+
latest_version_id=latest.version_id if latest else None,
|
|
170
|
+
latest_status=latest.status.value if latest else None,
|
|
171
|
+
latest_intent=latest.intent if latest else None,
|
|
172
|
+
head_has_version=head_version is not None,
|
|
173
|
+
last_regression_id=last_regression,
|
|
174
|
+
open_features=open_features,
|
|
175
|
+
latest_metrics={m.name: m.after for m in latest.metrics} if latest else {},
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
# --- helpers ---------------------------------------------------------------------
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _load_or_default_config(
|
|
183
|
+
paths: ProjectPaths, *, project_id: str, project_name: str
|
|
184
|
+
) -> DevMemoryConfig:
|
|
185
|
+
if paths.config.is_file():
|
|
186
|
+
try:
|
|
187
|
+
existing = DevMemoryConfig.load(paths)
|
|
188
|
+
except Exception:
|
|
189
|
+
_log.warning("project.init.config_unreadable", path=str(paths.config))
|
|
190
|
+
else:
|
|
191
|
+
return existing
|
|
192
|
+
return DevMemoryConfig.default_for(project_id=project_id, project_name=project_name)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
_GITIGNORE_BLOCK = f"""
|
|
196
|
+
# DevMemory - local-only data (config.json is committed, the rest is not)
|
|
197
|
+
{DEVMEMORY_DIRNAME}/config.local.json
|
|
198
|
+
{DEVMEMORY_DIRNAME}/metadata.db
|
|
199
|
+
{DEVMEMORY_DIRNAME}/metadata.db-*
|
|
200
|
+
{DEVMEMORY_DIRNAME}/artifacts/
|
|
201
|
+
{DEVMEMORY_DIRNAME}/outbox/
|
|
202
|
+
{DEVMEMORY_DIRNAME}/runs/
|
|
203
|
+
{DEVMEMORY_DIRNAME}/cache/
|
|
204
|
+
"""
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _ensure_repo_gitignore(repo_root: Path) -> bool:
|
|
208
|
+
gitignore = repo_root / ".gitignore"
|
|
209
|
+
marker = f"{DEVMEMORY_DIRNAME}/metadata.db"
|
|
210
|
+
existing = gitignore.read_text(encoding="utf-8") if gitignore.is_file() else ""
|
|
211
|
+
if marker in existing:
|
|
212
|
+
return False
|
|
213
|
+
with gitignore.open("a", encoding="utf-8") as handle:
|
|
214
|
+
if existing and not existing.endswith("\n"):
|
|
215
|
+
handle.write("\n")
|
|
216
|
+
handle.write(_GITIGNORE_BLOCK)
|
|
217
|
+
return True
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
__all__ = [
|
|
221
|
+
"InitReport",
|
|
222
|
+
"ProjectStatusReport",
|
|
223
|
+
"init_project",
|
|
224
|
+
"project_status",
|
|
225
|
+
"slugify",
|
|
226
|
+
]
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
"""Restoring the working tree to an earlier development version - safely.
|
|
2
|
+
|
|
3
|
+
Restore is potentially destructive, so:
|
|
4
|
+
|
|
5
|
+
1. Resolve the target version to its git commit.
|
|
6
|
+
2. Inspect the working tree; refuse if there are uncommitted changes unless the
|
|
7
|
+
caller explicitly allows it.
|
|
8
|
+
3. Record a safety reference: a tag at the current HEAD, plus a ``git stash
|
|
9
|
+
create`` object if the tree is dirty.
|
|
10
|
+
4. Only then move HEAD - detached checkout by default, ``git reset --hard`` only
|
|
11
|
+
when explicitly asked.
|
|
12
|
+
5. Record a ``restore`` event.
|
|
13
|
+
|
|
14
|
+
``preview`` performs steps 1-2 and reports what *would* happen, touching nothing.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import json
|
|
20
|
+
import uuid
|
|
21
|
+
from datetime import UTC, datetime
|
|
22
|
+
|
|
23
|
+
from pydantic import BaseModel
|
|
24
|
+
|
|
25
|
+
from devmemory.domain.errors import DevMemoryError, RestoreSafetyError
|
|
26
|
+
from devmemory.logging import get_logger
|
|
27
|
+
from devmemory.services.context import ProjectContext
|
|
28
|
+
from devmemory.services.versions import get_version
|
|
29
|
+
|
|
30
|
+
_log = get_logger(__name__)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class RestorePreview(BaseModel):
|
|
34
|
+
version_id: str
|
|
35
|
+
target_commit: str
|
|
36
|
+
target_subject: str
|
|
37
|
+
current_commit: str | None
|
|
38
|
+
current_branch: str | None
|
|
39
|
+
already_there: bool
|
|
40
|
+
working_tree_clean: bool
|
|
41
|
+
uncommitted: list[str]
|
|
42
|
+
untracked: list[str]
|
|
43
|
+
safety_tag: str
|
|
44
|
+
warning: str
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class RestoreResult(BaseModel):
|
|
48
|
+
version_id: str
|
|
49
|
+
mode: str # 'detach' | 'hard'
|
|
50
|
+
target_commit: str
|
|
51
|
+
previous_commit: str | None
|
|
52
|
+
safety_tag: str
|
|
53
|
+
stash_ref: str | None
|
|
54
|
+
message: str
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def restore_preview(ctx: ProjectContext, ref: str) -> RestorePreview:
|
|
58
|
+
v = get_version(ctx, ref)
|
|
59
|
+
state = ctx.git.working_tree_state()
|
|
60
|
+
current = ctx.git.head_sha()
|
|
61
|
+
target_info = ctx.git.commit(v.git_commit)
|
|
62
|
+
|
|
63
|
+
return RestorePreview(
|
|
64
|
+
version_id=v.version_id,
|
|
65
|
+
target_commit=v.git_commit,
|
|
66
|
+
target_subject=target_info.subject,
|
|
67
|
+
current_commit=current,
|
|
68
|
+
current_branch=state.branch,
|
|
69
|
+
already_there=current == v.git_commit,
|
|
70
|
+
working_tree_clean=state.is_clean,
|
|
71
|
+
uncommitted=[*state.staged, *state.unstaged],
|
|
72
|
+
untracked=state.untracked,
|
|
73
|
+
safety_tag=_safety_tag_name(),
|
|
74
|
+
warning=(
|
|
75
|
+
f"This moves the working tree to the state of {v.version_id.upper()} "
|
|
76
|
+
f"(git {v.git_commit[:12]}). "
|
|
77
|
+
+ (
|
|
78
|
+
"Your uncommitted changes would be at risk."
|
|
79
|
+
if state.has_uncommitted_changes
|
|
80
|
+
else "A safety tag is created at the current HEAD first."
|
|
81
|
+
)
|
|
82
|
+
),
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def restore_version(
|
|
87
|
+
ctx: ProjectContext,
|
|
88
|
+
ref: str,
|
|
89
|
+
*,
|
|
90
|
+
mode: str = "detach",
|
|
91
|
+
allow_dirty: bool = False,
|
|
92
|
+
) -> RestoreResult:
|
|
93
|
+
if mode not in ("detach", "hard"):
|
|
94
|
+
raise DevMemoryError(f"unknown restore mode {mode!r}")
|
|
95
|
+
|
|
96
|
+
v = get_version(ctx, ref)
|
|
97
|
+
state = ctx.git.working_tree_state()
|
|
98
|
+
current = ctx.git.head_sha()
|
|
99
|
+
|
|
100
|
+
if current == v.git_commit and state.is_clean:
|
|
101
|
+
return RestoreResult(
|
|
102
|
+
version_id=v.version_id,
|
|
103
|
+
mode=mode,
|
|
104
|
+
target_commit=v.git_commit,
|
|
105
|
+
previous_commit=current,
|
|
106
|
+
safety_tag="",
|
|
107
|
+
stash_ref=None,
|
|
108
|
+
message=f"Already at {v.version_id.upper()} with a clean tree; nothing to do.",
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
if state.has_uncommitted_changes and not allow_dirty:
|
|
112
|
+
raise RestoreSafetyError(
|
|
113
|
+
f"{len(state.staged) + len(state.unstaged)} uncommitted change(s) would be at risk.",
|
|
114
|
+
hint="Commit or stash them, or pass --allow-dirty to keep a safety stash and proceed.",
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
safety_tag = _safety_tag_name()
|
|
118
|
+
ctx.git.create_tag(
|
|
119
|
+
safety_tag,
|
|
120
|
+
"HEAD",
|
|
121
|
+
message=f"devmemory: state before restoring {v.version_id}",
|
|
122
|
+
)
|
|
123
|
+
stash_ref = ctx.git.stash_create() if state.has_uncommitted_changes else None
|
|
124
|
+
|
|
125
|
+
try:
|
|
126
|
+
if mode == "hard":
|
|
127
|
+
ctx.git.reset_hard(v.git_commit)
|
|
128
|
+
else:
|
|
129
|
+
ctx.git.checkout_detached(v.git_commit)
|
|
130
|
+
except DevMemoryError:
|
|
131
|
+
_log.error("restore.failed", version=v.version_id, safety_tag=safety_tag)
|
|
132
|
+
raise
|
|
133
|
+
|
|
134
|
+
_record_restore(ctx, v.version_id, v.git_commit, current, safety_tag, stash_ref, mode)
|
|
135
|
+
_log.info(
|
|
136
|
+
"restore.done",
|
|
137
|
+
version=v.version_id,
|
|
138
|
+
commit=v.git_commit[:12],
|
|
139
|
+
mode=mode,
|
|
140
|
+
safety_tag=safety_tag,
|
|
141
|
+
)
|
|
142
|
+
recover = f"git reset --hard {safety_tag}" if mode == "hard" else "git switch -"
|
|
143
|
+
return RestoreResult(
|
|
144
|
+
version_id=v.version_id,
|
|
145
|
+
mode=mode,
|
|
146
|
+
target_commit=v.git_commit,
|
|
147
|
+
previous_commit=current,
|
|
148
|
+
safety_tag=safety_tag,
|
|
149
|
+
stash_ref=stash_ref,
|
|
150
|
+
message=(
|
|
151
|
+
f"Working tree restored to {v.version_id.upper()} ({v.git_commit[:12]}). "
|
|
152
|
+
f"Recover the previous state with `{recover}`"
|
|
153
|
+
+ (f" and `git stash apply {stash_ref}`" if stash_ref else "")
|
|
154
|
+
+ "."
|
|
155
|
+
),
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _safety_tag_name() -> str:
|
|
160
|
+
return "devmemory/safety/" + datetime.now(UTC).strftime("%Y%m%dT%H%M%SZ")
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _record_restore(
|
|
164
|
+
ctx: ProjectContext,
|
|
165
|
+
version_id: str,
|
|
166
|
+
target: str,
|
|
167
|
+
previous: str | None,
|
|
168
|
+
safety_tag: str,
|
|
169
|
+
stash_ref: str | None,
|
|
170
|
+
mode: str,
|
|
171
|
+
) -> None:
|
|
172
|
+
with ctx.db.transaction() as conn:
|
|
173
|
+
conn.execute(
|
|
174
|
+
"INSERT INTO events (event_id, project_id, version_id, type, source, payload_json, "
|
|
175
|
+
"created_at) VALUES (?, ?, ?, 'restore', 'devmemory', ?, ?)",
|
|
176
|
+
(
|
|
177
|
+
uuid.uuid4().hex,
|
|
178
|
+
ctx.config.project_id,
|
|
179
|
+
version_id,
|
|
180
|
+
json.dumps(
|
|
181
|
+
{
|
|
182
|
+
"target": target,
|
|
183
|
+
"previous": previous,
|
|
184
|
+
"safety_tag": safety_tag,
|
|
185
|
+
"stash_ref": stash_ref,
|
|
186
|
+
"mode": mode,
|
|
187
|
+
}
|
|
188
|
+
),
|
|
189
|
+
datetime.now(UTC).isoformat(),
|
|
190
|
+
),
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
__all__ = ["RestorePreview", "RestoreResult", "restore_preview", "restore_version"]
|