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.
Files changed (95) hide show
  1. devmemory/__about__.py +3 -0
  2. devmemory/__init__.py +14 -0
  3. devmemory/__main__.py +6 -0
  4. devmemory/adapters/__init__.py +6 -0
  5. devmemory/adapters/databricks.py +346 -0
  6. devmemory/adapters/entire.py +444 -0
  7. devmemory/adapters/git.py +408 -0
  8. devmemory/adapters/graph.py +251 -0
  9. devmemory/adapters/metrics.py +150 -0
  10. devmemory/adapters/tests.py +227 -0
  11. devmemory/analysis/__init__.py +19 -0
  12. devmemory/analysis/base.py +128 -0
  13. devmemory/analysis/chain.py +53 -0
  14. devmemory/analysis/llm.py +236 -0
  15. devmemory/analysis/rules.py +110 -0
  16. devmemory/api/__init__.py +10 -0
  17. devmemory/api/app.py +390 -0
  18. devmemory/api/mappers.py +187 -0
  19. devmemory/api/schemas.py +201 -0
  20. devmemory/cli/__init__.py +1 -0
  21. devmemory/cli/_errors.py +36 -0
  22. devmemory/cli/_render.py +79 -0
  23. devmemory/cli/analytics.py +136 -0
  24. devmemory/cli/analyze.py +58 -0
  25. devmemory/cli/app.py +163 -0
  26. devmemory/cli/checkpoint.py +199 -0
  27. devmemory/cli/compare.py +104 -0
  28. devmemory/cli/doctor.py +151 -0
  29. devmemory/cli/history.py +56 -0
  30. devmemory/cli/impact.py +95 -0
  31. devmemory/cli/init.py +91 -0
  32. devmemory/cli/mcp.py +66 -0
  33. devmemory/cli/memory.py +70 -0
  34. devmemory/cli/restore.py +91 -0
  35. devmemory/cli/search.py +48 -0
  36. devmemory/cli/serve.py +64 -0
  37. devmemory/cli/show.py +139 -0
  38. devmemory/cli/status.py +72 -0
  39. devmemory/cli/task.py +333 -0
  40. devmemory/config.py +302 -0
  41. devmemory/domain/__init__.py +5 -0
  42. devmemory/domain/enums.py +151 -0
  43. devmemory/domain/errors.py +188 -0
  44. devmemory/domain/models.py +452 -0
  45. devmemory/domain/taskloop.py +212 -0
  46. devmemory/environment.py +67 -0
  47. devmemory/logging.py +148 -0
  48. devmemory/mcp/__init__.py +12 -0
  49. devmemory/mcp/server.py +225 -0
  50. devmemory/paths.py +112 -0
  51. devmemory/pipeline/__init__.py +7 -0
  52. devmemory/pipeline/checkpoint.py +443 -0
  53. devmemory/pipeline/feature_detect.py +53 -0
  54. devmemory/pipeline/regression.py +141 -0
  55. devmemory/pipeline/runlog.py +73 -0
  56. devmemory/pipeline/status_rules.py +44 -0
  57. devmemory/py.typed +0 -0
  58. devmemory/services/__init__.py +9 -0
  59. devmemory/services/agent_context.py +287 -0
  60. devmemory/services/analysis.py +116 -0
  61. devmemory/services/analytics.py +328 -0
  62. devmemory/services/brief.py +53 -0
  63. devmemory/services/context.py +88 -0
  64. devmemory/services/databricks_sync.py +121 -0
  65. devmemory/services/features.py +85 -0
  66. devmemory/services/impact.py +47 -0
  67. devmemory/services/memory.py +212 -0
  68. devmemory/services/projects.py +226 -0
  69. devmemory/services/restore.py +194 -0
  70. devmemory/services/taskloop/__init__.py +39 -0
  71. devmemory/services/taskloop/collectors.py +263 -0
  72. devmemory/services/taskloop/engine.py +426 -0
  73. devmemory/services/taskloop/requirements.py +358 -0
  74. devmemory/services/trace.py +152 -0
  75. devmemory/services/versions.py +287 -0
  76. devmemory/storage/__init__.py +9 -0
  77. devmemory/storage/artifacts.py +113 -0
  78. devmemory/storage/db.py +205 -0
  79. devmemory/storage/graph_impacts.py +63 -0
  80. devmemory/storage/migrations/0001_init.sql +15 -0
  81. devmemory/storage/migrations/0002_versions.sql +210 -0
  82. devmemory/storage/migrations/0003_graph.sql +14 -0
  83. devmemory/storage/migrations/0004_taskloop.sql +82 -0
  84. devmemory/storage/migrations/0005_project_brief.sql +12 -0
  85. devmemory/storage/repositories.py +286 -0
  86. devmemory/storage/tasks.py +342 -0
  87. devmemory/storage/versions.py +604 -0
  88. devmemory/web/static/assets/index-CbV5njRH.js +78 -0
  89. devmemory/web/static/assets/index-DD-7ceZx.css +1 -0
  90. devmemory/web/static/index.html +18 -0
  91. devmemory_cli-0.1.0.dev0.dist-info/METADATA +174 -0
  92. devmemory_cli-0.1.0.dev0.dist-info/RECORD +95 -0
  93. devmemory_cli-0.1.0.dev0.dist-info/WHEEL +4 -0
  94. devmemory_cli-0.1.0.dev0.dist-info/entry_points.txt +3 -0
  95. 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"]