agent-memory-cli 0.1.0__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.
- agent_memory/__init__.py +9 -0
- agent_memory/__main__.py +9 -0
- agent_memory/archive.py +359 -0
- agent_memory/cli.py +437 -0
- agent_memory/debrief.py +336 -0
- agent_memory/doctor.py +710 -0
- agent_memory/git_runner.py +68 -0
- agent_memory/home.py +246 -0
- agent_memory/layout.py +198 -0
- agent_memory/org.py +218 -0
- agent_memory/publication.py +433 -0
- agent_memory/setup/__init__.py +53 -0
- agent_memory/setup/claude.py +34 -0
- agent_memory/setup/codex.py +39 -0
- agent_memory/setup/common.py +1015 -0
- agent_memory/setup/legacy.py +67 -0
- agent_memory/startup.py +254 -0
- agent_memory/status.py +137 -0
- agent_memory/sync.py +649 -0
- agent_memory/templates/org-memory/decisions.md +5 -0
- agent_memory/templates/org-memory/recent.md +16 -0
- agent_memory/templates/org-memory/rules.md +5 -0
- agent_memory/templates/project-memory/decision_log.md +3 -0
- agent_memory/templates/project-memory/known_debt.md +8 -0
- agent_memory/templates/project-memory/open_threads.md +3 -0
- agent_memory/templates/project-memory/project_facts.md +4 -0
- agent_memory/templates/workflow/SKILL.md +42 -0
- agent_memory/workflow.py +287 -0
- agent_memory_cli-0.1.0.dist-info/METADATA +261 -0
- agent_memory_cli-0.1.0.dist-info/RECORD +33 -0
- agent_memory_cli-0.1.0.dist-info/WHEEL +4 -0
- agent_memory_cli-0.1.0.dist-info/entry_points.txt +2 -0
- agent_memory_cli-0.1.0.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 Kiloloop
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""What earlier tooling installed, so ``setup`` can retire it by exact match.
|
|
4
|
+
|
|
5
|
+
This is the one module in the package that names the kernel this tool grew
|
|
6
|
+
out of: the hook commands its setup registered and the exact scripts it
|
|
7
|
+
wrote. A registration is retired when its command equals one of these
|
|
8
|
+
strings; a script is removed only when its bytes match one of these
|
|
9
|
+
templates, digest for digest. Anything else at those paths is somebody's
|
|
10
|
+
own work: it is left alone and named in the report.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import hashlib
|
|
16
|
+
from typing import Dict, Tuple
|
|
17
|
+
|
|
18
|
+
CLAUDE_LEGACY_PULL_COMMAND = ".claude/hooks/oacp-memory-pull.sh"
|
|
19
|
+
CLAUDE_LEGACY_PUSH_COMMAND = ".claude/hooks/oacp-memory-push.sh"
|
|
20
|
+
|
|
21
|
+
CLAUDE_LEGACY_PULL_SCRIPT = """\
|
|
22
|
+
#!/usr/bin/env bash
|
|
23
|
+
# Claude hook event: SessionStart (startup)
|
|
24
|
+
set -u
|
|
25
|
+
|
|
26
|
+
OACP_ROOT="${OACP_HOME:-$HOME/oacp}"
|
|
27
|
+
if [[ ! -f "$OACP_ROOT/.oacp-memory-repo" ]]; then
|
|
28
|
+
exit 0
|
|
29
|
+
fi
|
|
30
|
+
|
|
31
|
+
oacp memory pull --oacp-dir "$OACP_ROOT" || true
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
CLAUDE_LEGACY_PUSH_SCRIPT = """\
|
|
35
|
+
#!/usr/bin/env bash
|
|
36
|
+
# Claude hook event: SessionEnd / wrap-up
|
|
37
|
+
set -u
|
|
38
|
+
|
|
39
|
+
OACP_ROOT="${OACP_HOME:-$HOME/oacp}"
|
|
40
|
+
if [[ ! -f "$OACP_ROOT/.oacp-memory-repo" ]]; then
|
|
41
|
+
exit 0
|
|
42
|
+
fi
|
|
43
|
+
|
|
44
|
+
oacp memory push --oacp-dir "$OACP_ROOT" || true
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def digest(text: str) -> str:
|
|
49
|
+
return hashlib.sha256(text.encode("utf-8")).hexdigest()
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
#: Registrations retired by exact command, per hook event.
|
|
53
|
+
CLAUDE_LEGACY_REGISTRATIONS: Dict[str, Tuple[str, ...]] = {
|
|
54
|
+
"SessionStart": (CLAUDE_LEGACY_PULL_COMMAND,),
|
|
55
|
+
"SessionEnd": (CLAUDE_LEGACY_PUSH_COMMAND,),
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
#: Files removed only when their digest is one of these, per repository-relative path.
|
|
59
|
+
CLAUDE_LEGACY_FILES: Dict[str, Tuple[str, ...]] = {
|
|
60
|
+
CLAUDE_LEGACY_PULL_COMMAND: (digest(CLAUDE_LEGACY_PULL_SCRIPT),),
|
|
61
|
+
CLAUDE_LEGACY_PUSH_COMMAND: (digest(CLAUDE_LEGACY_PUSH_SCRIPT),),
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
#: The codex startup command the kernel registers, and the flag that made it pull memory.
|
|
65
|
+
#: The entry stays (it verifies protocol files and status); only the flag is retired.
|
|
66
|
+
CODEX_SESSION_INIT_PREFIX: Tuple[str, ...] = ("oacp", "session-init", "--hook")
|
|
67
|
+
CODEX_LEGACY_PULL_FLAG = "--pull-memory"
|
agent_memory/startup.py
ADDED
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 Kiloloop
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""The startup manifest: ``agent-memory startup --runtime <r>``.
|
|
4
|
+
|
|
5
|
+
A session-start hook runs this once. It optionally pulls the home first,
|
|
6
|
+
then reports which memory files a session should read, in order, with each
|
|
7
|
+
file's readability, size and modification time as the pull left them, and
|
|
8
|
+
where the sync stands.
|
|
9
|
+
It never includes a file's content and never says a file was read: the
|
|
10
|
+
states are ``readable``, ``missing`` and ``unreadable``, nothing else, and
|
|
11
|
+
the manifest carries ``content_injected: false`` to say so. The list is
|
|
12
|
+
bounded by construction (the active project files, then the curated org
|
|
13
|
+
files, both from the layout table; ``events/``, ``debriefs/`` and
|
|
14
|
+
``archive/`` are excluded), and the rendered text is cut at a character
|
|
15
|
+
budget with a notice, so a hook can never flood a session.
|
|
16
|
+
|
|
17
|
+
Output shapes: ``--json`` is the manifest itself, ``schema_version`` first;
|
|
18
|
+
the default is what the runtime's hook expects on stdout: plain text for
|
|
19
|
+
claude, whose session start takes stdout as context, and the hook JSON
|
|
20
|
+
envelope for codex, whose ``additionalContext`` carries the same text.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import datetime as dt
|
|
26
|
+
import os
|
|
27
|
+
import stat
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
from typing import Any, Dict, List, Optional, Sequence, Tuple
|
|
30
|
+
|
|
31
|
+
from . import layout, sync
|
|
32
|
+
from .git_runner import GitRunner, run_git
|
|
33
|
+
|
|
34
|
+
SCHEMA_VERSION = 1
|
|
35
|
+
RUNTIME_CLAUDE = "claude"
|
|
36
|
+
RUNTIME_CODEX = "codex"
|
|
37
|
+
RUNTIMES = (RUNTIME_CLAUDE, RUNTIME_CODEX)
|
|
38
|
+
DEFAULT_MAX_CHARS = 8000
|
|
39
|
+
#: The smallest budget the command line accepts; the notice fits in a few characters at any budget.
|
|
40
|
+
MIN_MAX_CHARS = 1
|
|
41
|
+
|
|
42
|
+
READABLE = "readable"
|
|
43
|
+
MISSING = "missing"
|
|
44
|
+
UNREADABLE = "unreadable"
|
|
45
|
+
RESULT_OK = "ok"
|
|
46
|
+
RESULT_DEGRADED = "degraded"
|
|
47
|
+
PULL_NOT_REQUESTED = "not_requested"
|
|
48
|
+
PULL_ERROR = "error"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def build_manifest(
|
|
52
|
+
home: Path,
|
|
53
|
+
*,
|
|
54
|
+
runtime: str,
|
|
55
|
+
project: Optional[str] = None,
|
|
56
|
+
pull: bool = False,
|
|
57
|
+
home_source: str = "flag",
|
|
58
|
+
project_source: Optional[str] = None,
|
|
59
|
+
notes: Sequence[str] = (),
|
|
60
|
+
runner: Optional[GitRunner] = None,
|
|
61
|
+
now: Optional[dt.datetime] = None,
|
|
62
|
+
) -> Dict[str, Any]:
|
|
63
|
+
"""The manifest for ``home``: the ordered files with their states, the sync's standing, warnings.
|
|
64
|
+
|
|
65
|
+
The pull, when requested, runs before any file is inspected, so sizes,
|
|
66
|
+
times and states describe the tree the session will read. ``notes`` are
|
|
67
|
+
warnings the caller already knows (why no project was resolved, say).
|
|
68
|
+
"""
|
|
69
|
+
if runtime not in RUNTIMES:
|
|
70
|
+
raise ValueError(f"unknown runtime {runtime!r}; one of {', '.join(RUNTIMES)}")
|
|
71
|
+
home = Path(home).expanduser().absolute()
|
|
72
|
+
warnings: List[str] = list(notes)
|
|
73
|
+
files: List[Dict[str, Any]] = []
|
|
74
|
+
|
|
75
|
+
sync_info, pull_info = _sync(home, pull, runner, warnings)
|
|
76
|
+
if project is not None:
|
|
77
|
+
try:
|
|
78
|
+
layout.validate_project_name(project)
|
|
79
|
+
except ValueError as exc:
|
|
80
|
+
warnings.append(f"project {project!r}: {exc}; its files are skipped")
|
|
81
|
+
project = None
|
|
82
|
+
if not home.is_dir():
|
|
83
|
+
warnings.append(f"memory home {home} is not a directory; every file is missing")
|
|
84
|
+
if project is not None:
|
|
85
|
+
memory = layout.project_memory_dir(home, project)
|
|
86
|
+
files.extend(_entry(home, memory / name, layout.PROJECT.name, name) for name in layout.PROJECT.files)
|
|
87
|
+
else:
|
|
88
|
+
warnings.append("no project resolved; pass --project or bind the repository with `agent-memory init --repo .`")
|
|
89
|
+
org = layout.org_memory_dir(home)
|
|
90
|
+
files.extend(_entry(home, org / name, layout.ORG.name, name) for name in layout.ORG.files)
|
|
91
|
+
for entry in files:
|
|
92
|
+
if entry["state"] != READABLE:
|
|
93
|
+
reason = f" ({entry['error']})" if entry.get("error") else ""
|
|
94
|
+
warnings.append(f"{entry['relative']}: {entry['state']}{reason}")
|
|
95
|
+
|
|
96
|
+
excluded = [f"{layout.ORG.pattern}/{sub}/" for sub in layout.ORG.dirs]
|
|
97
|
+
if project is not None:
|
|
98
|
+
memory_rel = layout.project_memory_dir(home, project).relative_to(home).as_posix()
|
|
99
|
+
excluded.extend(f"{memory_rel}/{sub}/" for sub in layout.PROJECT.dirs)
|
|
100
|
+
|
|
101
|
+
moment = now or dt.datetime.now(dt.timezone.utc)
|
|
102
|
+
return {
|
|
103
|
+
"schema_version": SCHEMA_VERSION,
|
|
104
|
+
"runtime": runtime,
|
|
105
|
+
"generated_at_utc": _iso(moment),
|
|
106
|
+
"home": str(home),
|
|
107
|
+
"home_source": home_source,
|
|
108
|
+
"project": project,
|
|
109
|
+
"project_source": project_source if project is not None else None,
|
|
110
|
+
"content_injected": False,
|
|
111
|
+
"files": files,
|
|
112
|
+
"excluded": excluded,
|
|
113
|
+
"bytes_total": sum(int(entry["bytes"]) for entry in files),
|
|
114
|
+
"sync": sync_info,
|
|
115
|
+
"pull": pull_info,
|
|
116
|
+
"warnings": warnings,
|
|
117
|
+
"result": RESULT_DEGRADED if warnings else RESULT_OK,
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _entry(home: Path, path: Path, tier: str, name: str) -> Dict[str, Any]:
|
|
122
|
+
entry: Dict[str, Any] = {
|
|
123
|
+
"tier": tier,
|
|
124
|
+
"name": name,
|
|
125
|
+
"path": str(path),
|
|
126
|
+
"relative": path.relative_to(home).as_posix(),
|
|
127
|
+
"state": MISSING,
|
|
128
|
+
"bytes": 0,
|
|
129
|
+
"modified_at_utc": None,
|
|
130
|
+
}
|
|
131
|
+
try:
|
|
132
|
+
info = os.stat(path)
|
|
133
|
+
except FileNotFoundError:
|
|
134
|
+
return entry
|
|
135
|
+
except OSError as exc:
|
|
136
|
+
entry["state"] = UNREADABLE
|
|
137
|
+
entry["error"] = exc.strerror or str(exc)
|
|
138
|
+
return entry
|
|
139
|
+
if not stat.S_ISREG(info.st_mode):
|
|
140
|
+
entry["state"] = UNREADABLE
|
|
141
|
+
entry["error"] = "not a regular file"
|
|
142
|
+
return entry
|
|
143
|
+
try:
|
|
144
|
+
with open(path, "rb") as handle:
|
|
145
|
+
handle.read(1)
|
|
146
|
+
except OSError as exc:
|
|
147
|
+
entry["state"] = UNREADABLE
|
|
148
|
+
entry["error"] = exc.strerror or str(exc)
|
|
149
|
+
return entry
|
|
150
|
+
entry["state"] = READABLE
|
|
151
|
+
entry["bytes"] = info.st_size
|
|
152
|
+
entry["modified_at_utc"] = _iso(dt.datetime.fromtimestamp(info.st_mtime, dt.timezone.utc))
|
|
153
|
+
return entry
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _sync(
|
|
157
|
+
home: Path, pull: bool, runner: Optional[GitRunner], warnings: List[str]
|
|
158
|
+
) -> Tuple[Dict[str, Any], Dict[str, Any]]:
|
|
159
|
+
marker = sync.is_configured(home)
|
|
160
|
+
info: Dict[str, Any] = {"marker": marker, "last_commit_at_utc": None}
|
|
161
|
+
pull_info: Dict[str, Any] = {"requested": pull, "status": PULL_NOT_REQUESTED, "ok": True, "lines": []}
|
|
162
|
+
if pull:
|
|
163
|
+
try:
|
|
164
|
+
outcome = sync.pull(home, runner=runner)
|
|
165
|
+
pull_info = {"requested": True, "status": outcome.status, "ok": outcome.ok, "lines": list(outcome.lines)}
|
|
166
|
+
except (sync.SyncError, OSError) as exc:
|
|
167
|
+
pull_info = {"requested": True, "status": PULL_ERROR, "ok": False, "lines": [f"memory pull: {exc}"]}
|
|
168
|
+
if not pull_info["ok"]:
|
|
169
|
+
said = pull_info["lines"][0] if pull_info["lines"] else pull_info["status"]
|
|
170
|
+
warnings.append(f"memory pull did not complete; local memory may be stale: {said}")
|
|
171
|
+
if marker and sync.is_git_repo(home, runner):
|
|
172
|
+
result = (runner or run_git)(["log", "-1", "--format=%ct"], cwd=home, timeout=None)
|
|
173
|
+
stamp = result.stdout.strip()
|
|
174
|
+
if result.ok and stamp.isdigit():
|
|
175
|
+
info["last_commit_at_utc"] = _iso(dt.datetime.fromtimestamp(int(stamp), dt.timezone.utc))
|
|
176
|
+
return info, pull_info
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
# --- rendering --------------------------------------------------------------
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def render_text(manifest: Dict[str, Any], *, max_chars: int = DEFAULT_MAX_CHARS) -> str:
|
|
183
|
+
"""The manifest as the lines a session-start hook prints; cut at ``max_chars`` with a notice."""
|
|
184
|
+
head = f"agent-memory startup ({manifest['runtime']}): home {manifest['home']} ({manifest['home_source']})"
|
|
185
|
+
if manifest["project"] is not None:
|
|
186
|
+
head += f", project {manifest['project']} ({manifest['project_source'] or 'flag'})"
|
|
187
|
+
else:
|
|
188
|
+
head += ", no project"
|
|
189
|
+
lines = [head]
|
|
190
|
+
pull = manifest["pull"]
|
|
191
|
+
if pull["requested"]:
|
|
192
|
+
if pull["lines"]:
|
|
193
|
+
lines.extend(pull["lines"])
|
|
194
|
+
elif pull["status"] == "not_configured":
|
|
195
|
+
lines.append("memory pull: sync is not enabled for this home; skipped.")
|
|
196
|
+
if manifest["sync"]["last_commit_at_utc"]:
|
|
197
|
+
lines.append(f"memory sync: last commit {manifest['sync']['last_commit_at_utc']}.")
|
|
198
|
+
|
|
199
|
+
number = 0
|
|
200
|
+
project_files = [entry for entry in manifest["files"] if entry["tier"] == layout.PROJECT.name]
|
|
201
|
+
org_files = [entry for entry in manifest["files"] if entry["tier"] == layout.ORG.name]
|
|
202
|
+
if project_files:
|
|
203
|
+
lines.append("Project memory, read in this order (states are readability only; no content is injected):")
|
|
204
|
+
for entry in project_files:
|
|
205
|
+
number += 1
|
|
206
|
+
lines.append(f" {number}. {_describe(entry)}")
|
|
207
|
+
lines.append("Org memory, curated context; consult what governs the work before doing it (not read by default):")
|
|
208
|
+
for entry in org_files:
|
|
209
|
+
number += 1
|
|
210
|
+
lines.append(f" {number}. {_describe(entry)}")
|
|
211
|
+
lines.append(f"Excluded by default: {', '.join(manifest['excluded'])}")
|
|
212
|
+
if manifest["warnings"]:
|
|
213
|
+
lines.append("Warnings:")
|
|
214
|
+
lines.extend(f" - {warning}" for warning in manifest["warnings"])
|
|
215
|
+
return _bound("\n".join(lines) + "\n", max_chars, manifest["runtime"])
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def render_codex_hook(manifest: Dict[str, Any], *, max_chars: int = DEFAULT_MAX_CHARS) -> Dict[str, Any]:
|
|
219
|
+
"""The Codex ``SessionStart`` hook envelope carrying the rendered text as ``additionalContext``."""
|
|
220
|
+
output: Dict[str, Any] = {
|
|
221
|
+
"continue": True,
|
|
222
|
+
"hookSpecificOutput": {
|
|
223
|
+
"hookEventName": "SessionStart",
|
|
224
|
+
"additionalContext": render_text(manifest, max_chars=max_chars),
|
|
225
|
+
},
|
|
226
|
+
}
|
|
227
|
+
if manifest["result"] != RESULT_OK:
|
|
228
|
+
output["systemMessage"] = "agent-memory startup completed in degraded mode; see the warnings in its context."
|
|
229
|
+
return output
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _describe(entry: Dict[str, Any]) -> str:
|
|
233
|
+
if entry["state"] == READABLE:
|
|
234
|
+
return f"{entry['relative']}: readable, {entry['bytes']} bytes, modified {entry['modified_at_utc']}"
|
|
235
|
+
reason = f" ({entry['error']})" if entry.get("error") else ""
|
|
236
|
+
return f"{entry['relative']}: {entry['state']}{reason}"
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def _bound(text: str, max_chars: int, runtime: str) -> str:
|
|
240
|
+
"""``text`` cut to at most ``max_chars`` characters, notice included; a budget below zero counts as zero."""
|
|
241
|
+
max_chars = max(0, max_chars)
|
|
242
|
+
if len(text) <= max_chars:
|
|
243
|
+
return text
|
|
244
|
+
suffix = (
|
|
245
|
+
f"\n[agent-memory: manifest text cut at {max_chars} characters; "
|
|
246
|
+
f"run `agent-memory startup --runtime {runtime} --json` for the whole manifest]\n"
|
|
247
|
+
)
|
|
248
|
+
if len(suffix) >= max_chars:
|
|
249
|
+
suffix = "[cut]\n"[:max_chars]
|
|
250
|
+
return text[: max_chars - len(suffix)] + suffix
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _iso(moment: dt.datetime) -> str:
|
|
254
|
+
return moment.astimezone(dt.timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
|
agent_memory/status.py
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 Kiloloop
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""``agent-memory status``: which home resolves, and where its sync stands.
|
|
4
|
+
|
|
5
|
+
The readout is the resolution (path, the rule that chose it, the bound
|
|
6
|
+
project), the layout (marker, allowlist, tiers), and, when the home is a sync
|
|
7
|
+
repository, its :class:`~agent_memory.sync.GitState`. The remote is contacted
|
|
8
|
+
only with ``--fetch``; otherwise ahead/behind count against the last fetched
|
|
9
|
+
upstream. Exit 0 when the tree is clean and not diverged, 1 when it is dirty
|
|
10
|
+
or diverged (or the home does not exist). Ahead and behind are reported, not
|
|
11
|
+
failed: they are what ``push`` and ``pull`` are for.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import List, Optional
|
|
19
|
+
|
|
20
|
+
from . import layout, sync
|
|
21
|
+
from .doctor import enclosing_repository, sync_state_text
|
|
22
|
+
from .git_runner import GitRunner
|
|
23
|
+
from .home import HomeResolution
|
|
24
|
+
from .sync import GitState
|
|
25
|
+
|
|
26
|
+
EXIT_OK = 0
|
|
27
|
+
EXIT_FAILED = 1
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True)
|
|
31
|
+
class Status:
|
|
32
|
+
"""Everything the verb prints, plus the exit contract."""
|
|
33
|
+
|
|
34
|
+
home: Path
|
|
35
|
+
source: str
|
|
36
|
+
project: Optional[str]
|
|
37
|
+
exists: bool
|
|
38
|
+
marker: bool = False
|
|
39
|
+
gitignore: str = ""
|
|
40
|
+
org_memory: bool = False
|
|
41
|
+
projects: int = 0
|
|
42
|
+
#: ``None`` without the marker; otherwise whether the home is a git repository.
|
|
43
|
+
repository: Optional[bool] = None
|
|
44
|
+
#: Set when the home is a repository only by sitting inside another one.
|
|
45
|
+
enclosing: Optional[Path] = None
|
|
46
|
+
git: Optional[GitState] = None
|
|
47
|
+
#: Whether the remote was contacted for this readout.
|
|
48
|
+
fetched: bool = False
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def clean(self) -> bool:
|
|
52
|
+
"""The home exists and its tree is neither dirty nor diverged."""
|
|
53
|
+
if not self.exists:
|
|
54
|
+
return False
|
|
55
|
+
return self.git is None or not (self.git.dirty or self.git.diverged)
|
|
56
|
+
|
|
57
|
+
@property
|
|
58
|
+
def exit_code(self) -> int:
|
|
59
|
+
return EXIT_OK if self.clean else EXIT_FAILED
|
|
60
|
+
|
|
61
|
+
def lines(self) -> List[str]:
|
|
62
|
+
lines = [f"home: {self.home}", f"source: {self.source}"]
|
|
63
|
+
if self.project:
|
|
64
|
+
lines.append(f"project: {self.project}")
|
|
65
|
+
if not self.exists:
|
|
66
|
+
lines.append("exists: no")
|
|
67
|
+
return lines
|
|
68
|
+
lines.extend(
|
|
69
|
+
[
|
|
70
|
+
"exists: yes",
|
|
71
|
+
f"marker: {'present' if self.marker else 'absent'}",
|
|
72
|
+
f"gitignore: {self.gitignore}",
|
|
73
|
+
f"org-memory: {'present' if self.org_memory else 'absent'}",
|
|
74
|
+
f"projects: {self.projects} with a memory dir",
|
|
75
|
+
]
|
|
76
|
+
)
|
|
77
|
+
if self.repository is None:
|
|
78
|
+
lines.append("sync: not configured")
|
|
79
|
+
elif not self.repository:
|
|
80
|
+
lines.append("sync: marker present, but the home is not a git repository")
|
|
81
|
+
elif self.enclosing is not None:
|
|
82
|
+
lines.append(f"sync: marker present, but the home is inside the repository at {self.enclosing}, not one of its own")
|
|
83
|
+
elif self.git is not None:
|
|
84
|
+
lines.append(f"sync: {sync_state_text(self.git)}")
|
|
85
|
+
if self.git.has_remote:
|
|
86
|
+
lines.append("fetch: done" if self.fetched else "fetch: skipped (pass --fetch to contact the remote)")
|
|
87
|
+
lines.append(f"tree: {'dirty' if self.git.dirty else 'clean'}")
|
|
88
|
+
return lines
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def inspect(resolution: HomeResolution, *, fetch: bool = False, runner: Optional[GitRunner] = None) -> Status:
|
|
92
|
+
"""Read the home ``resolution`` names; nothing is changed, and no network without ``fetch``."""
|
|
93
|
+
home = resolution.path
|
|
94
|
+
if not home.is_dir():
|
|
95
|
+
return Status(home, resolution.source, resolution.project, exists=False)
|
|
96
|
+
tiers = layout.allowed_memory_dirs(home)
|
|
97
|
+
org = layout.org_memory_dir(home)
|
|
98
|
+
marker = sync.is_configured(home)
|
|
99
|
+
repository: Optional[bool] = None
|
|
100
|
+
enclosing: Optional[Path] = None
|
|
101
|
+
git: Optional[GitState] = None
|
|
102
|
+
if marker:
|
|
103
|
+
repository = sync.is_git_repo(home, runner)
|
|
104
|
+
if repository:
|
|
105
|
+
enclosing = enclosing_repository(home, runner)
|
|
106
|
+
if repository and enclosing is None:
|
|
107
|
+
git = sync.git_state(home, runner=runner, fetch=fetch)
|
|
108
|
+
return Status(
|
|
109
|
+
home,
|
|
110
|
+
resolution.source,
|
|
111
|
+
resolution.project,
|
|
112
|
+
exists=True,
|
|
113
|
+
marker=marker,
|
|
114
|
+
gitignore=gitignore_state(home),
|
|
115
|
+
org_memory=org in tiers,
|
|
116
|
+
projects=len([path for path in tiers if path != org]),
|
|
117
|
+
repository=repository,
|
|
118
|
+
enclosing=enclosing,
|
|
119
|
+
git=git,
|
|
120
|
+
fetched=fetch and git is not None and git.has_remote,
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def gitignore_state(home: Path) -> str:
|
|
125
|
+
"""How the root ``.gitignore`` relates to the canonical allowlist, in a few words."""
|
|
126
|
+
path = home / layout.GITIGNORE_FILE
|
|
127
|
+
try:
|
|
128
|
+
data = path.read_bytes()
|
|
129
|
+
except FileNotFoundError:
|
|
130
|
+
return "absent"
|
|
131
|
+
except OSError as exc:
|
|
132
|
+
return f"unreadable ({exc.strerror})"
|
|
133
|
+
if data == layout.gitignore_text().encode("utf-8"):
|
|
134
|
+
return "canonical"
|
|
135
|
+
if sync.gitignore_has_managed_block(data.decode("utf-8", errors="replace")):
|
|
136
|
+
return "canonical managed block, other lines kept"
|
|
137
|
+
return "present, differs from canonical"
|